diff --git a/CHANGELOG.md b/CHANGELOG.md index b11e48d3..cf3ed8e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - 記録キューの排出が完了しないまま export され、content とチェーンが食い違う (検証すると invalid になる) proof が生成される問題を修正した。排出待ちを進捗ベースにし、排出できなかった場合は export を中止して通知する (#250) - 検証 (Web) がフォルダ読み込み時にスクリーンショットのチェーン裏付け検査を行わず、ZIP で開いた場合と結論が変わる問題を修正した。あわせて、チェーンに記録があるのに manifest に存在しないスクリーンショット (剥ぎ取り) を Web でも警告するようにし、「0 枚」と「未検査」を区別して表示する (#251) - 検証 (Web) が `sessionStartToken` と署名チェックポイントの `sessionId` を突合しておらず、別セッションのトークンを流用した proof を「整合性: 証明済み」と表示していた問題を修正した。あわせて署名チェックポイントの不備をハッシュチェーンの失敗として誤って帰属していた表示も是正した (#252) +- 試験束縛の検証で提出時刻の time-box を失格条件として扱っていた問題を修正した ([ADR-0013](docs/adr/0013-exam-schedule-advisory-keep-manifest-format.md) は advisory と定めている)。締切を過ぎた提出というだけで束縛が invalid になり、root アンカー gate の試験免除が外れ保証等級まで下がるうえ、提出時刻を渡さない検証 (Web) と CLI で結論が食い違っていた。time-box を `valid` から外し、CLI の表示も PASS/FAIL ではなく advisory と分かる文言に改めた (#220) - 単一タブ export の事前認証を best-effort 化し、class / assignment モードで Turnstile が不達のときに提出用 ZIP を一切出力できなくなる問題を修正した (失敗 attestation はチェーンに記録される) (#244) ### セキュリティ diff --git a/docs/system-spec.md b/docs/system-spec.md index dd5e33da..3f643e5e 100644 --- a/docs/system-spec.md +++ b/docs/system-spec.md @@ -523,7 +523,7 @@ interface ExamProofBlock { 2. `packageHash` 再計算 = `proof.exam.packageHash` → この問題に束縛 3. root 再計算 (`fingerprintHash, nonce, packageHash, proof.exam.startToken`) = `initialEventChainHash` → **T0 以降に開始** (token は proof 同梱なので out-of-band 不要) 4. `startToken` で package を復号 → 平文 `problemContentHash` = `proof.exam.problemContentHash` → 答案はこの問題のもの -5. **time-box** (advisory): `releaseTime ≤ 提出時刻 ≤ deadline`。実際の提出時刻は外部 (Moodle)。verify-cli は `--submitted-at` で渡せ、未指定なら window 表示のみ (`withinWindow=null`) +5. **time-box** (advisory): `releaseTime ≤ 提出時刻 ≤ deadline`。実際の提出時刻は外部 (Moodle)。verify-cli は `--submitted-at` で渡せ、未指定なら window 表示のみ (`withinWindow=null`)。**`withinWindow=false` は `verifyExamBinding` の `valid` には合流しない** — 提出窓の管理は Moodle が唯一の正で、遅延提出は失格条件ではない ([ADR-0013](adr/0013-exam-schedule-advisory-keep-manifest-format.md) / §4.7) 配線: **verify-cli** は `--exam-package ` で渡す (任意、未指定なら root 束縛のみ表示し「package 未提供」を明示)。**verify(web)** は試験束縛カードで root 束縛を常時表示し、「問題パッケージを読み込む」で `.tcexam` を取り込み当該タブを再検証して完全束縛を表示する。package 指定で束縛が失敗すれば全体を invalid とする。 diff --git a/packages/shared/src/__tests__/examBinding.test.ts b/packages/shared/src/__tests__/examBinding.test.ts index 0d25b4e9..861f0881 100644 --- a/packages/shared/src/__tests__/examBinding.test.ts +++ b/packages/shared/src/__tests__/examBinding.test.ts @@ -122,14 +122,26 @@ describe('verifyExamBinding', () => { expect(result.valid).toBe(true); }); - it('fails when the submission is after the deadline', async () => { + // time-box は advisory (ADR-0013)。遅延提出の是非は Moodle 側の運用判断であって、 + // 暗号的束縛 (署名 / packageHash / root / 内容ハッシュ) の検証失敗ではない。 + it('keeps the binding valid for a late submission (time-box is advisory, ADR-0013)', async () => { const { proof, manifest, registry } = await makeExamProof(); const result = await verifyExamBinding(proof as unknown as BindingArg, manifest, { examAuthorityRegistry: registry, submissionTimeMs: Date.parse('2026-06-06T09:00:00.000Z'), }); expect(result.timeBox?.withinWindow).toBe(false); - expect(result.valid).toBe(false); + expect(result.valid).toBe(true); + }); + + it('keeps the binding valid for a submission before the release time (ADR-0013)', async () => { + const { proof, manifest, registry } = await makeExamProof(); + const result = await verifyExamBinding(proof as unknown as BindingArg, manifest, { + examAuthorityRegistry: registry, + submissionTimeMs: Date.parse('2026-06-05T23:00:00.000Z'), + }); + expect(result.timeBox?.withinWindow).toBe(false); + expect(result.valid).toBe(true); }); it('fails when the package signature cannot be verified (unknown authority)', async () => { diff --git a/packages/shared/src/exam/examPackage.ts b/packages/shared/src/exam/examPackage.ts index 8ae69b07..2a57b6f9 100644 --- a/packages/shared/src/exam/examPackage.ts +++ b/packages/shared/src/exam/examPackage.ts @@ -608,12 +608,10 @@ export async function verifyExamBinding( withinWindow, }; + // time-box は advisory (ADR-0013)。valid には合流させない — 遅延提出 (や早期提出) の + // 扱いは提出窓を管理する Moodle 側の運用判断であって、暗号的束縛の検証失敗ではない。 result.valid = - result.packageSignatureValid && - result.packageHashMatches && - result.rootMatches && - result.problemContentHashMatches && - withinWindow !== false; // submission 提供時のみ window 違反で fail + result.packageSignatureValid && result.packageHashMatches && result.rootMatches && result.problemContentHashMatches; return result; } diff --git a/packages/verify-cli/src/__tests__/output.test.ts b/packages/verify-cli/src/__tests__/output.test.ts index f62a6bd4..8295cb8a 100644 --- a/packages/verify-cli/src/__tests__/output.test.ts +++ b/packages/verify-cli/src/__tests__/output.test.ts @@ -69,6 +69,31 @@ function examBindingFailed(): CLIExamResult { }; } +/** 束縛は全て通っているが提出が窓の外だった proof (time-box は advisory / ADR-0013)。 */ +function examBindingLate(): CLIExamResult { + return { + present: true, + examId: 'exam-1', + problemId: 'p1', + variant: null, + packageProvided: true, + rootBindingValid: true, + binding: { + valid: true, + packageSignatureValid: true, + packageHashMatches: true, + rootMatches: true, + problemContentHashMatches: true, + timeBox: { + releaseTime: '2026-06-06T00:00:00.000Z', + deadline: '2026-06-06T03:00:00.000Z', + windowCoherent: true, + withinWindow: false, + }, + }, + }; +} + /** チェーン検証が通ったときに shared が返す (成功) メッセージ。 */ const CHAIN_SUCCESS_MESSAGE = 'All hashes verified successfully (including PoSW)'; @@ -155,6 +180,23 @@ describe('formatResult — 総合 FAILED の理由表示 (#217)', () => { }); }); +describe('formatResult — time-box は advisory (ADR-0013 / #220)', () => { + it('does not print FAIL on the time-box line of a late submission', () => { + const text = plain(formatResult(output({ exam: examBindingLate() }))); + + const windowLine = text.split('\n').find((line) => line.includes('Submitted within window')); + expect(windowLine).toBeDefined(); + expect(windowLine).not.toContain('FAIL'); + }); + + it('marks a missed submission window as advisory rather than a verification failure', () => { + const text = plain(formatResult(output({ exam: examBindingLate() }))); + + const windowLine = text.split('\n').find((line) => line.includes('Submitted within window')) ?? ''; + expect(windowLine).toMatch(/advisory/i); + }); +}); + describe('formatResult — PoSW が再計算されなかったとき (fast モード)', () => { it('states next to the PASSED header that the PoSW was not recomputed', () => { const text = plain( diff --git a/packages/verify-cli/src/output.ts b/packages/verify-cli/src/output.ts index 514f0fae..00e42723 100644 --- a/packages/verify-cli/src/output.ts +++ b/packages/verify-cli/src/output.ts @@ -139,8 +139,14 @@ function formatExamSection(exam: CLIExamResult, lines: string[]): void { lines.push(`Time-box: ${tb.releaseTime} … ${tb.deadline}`); if (tb.withinWindow === null) { lines.push(c('dim', ' (submission time not provided — pass --submitted-at to check the window)')); + } else if (tb.withinWindow) { + lines.push(' Submitted within window: yes'); } else { - lines.push(` Submitted within window: ${passFail(tb.withinWindow)}`); + // time-box は advisory (ADR-0013)。束縛検証の合否ではないので PASS/FAIL の語彙を使わない + // (総合 PASS の下に FAIL と出ると、採点者が失格条件と読み違える)。 + lines.push( + ` Submitted within window: ${c('yellow', 'no')} ${c('dim', '(advisory — the submission window is managed by Moodle, not by this proof)')}` + ); } } if (!b.valid && b.reason) {