Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

### セキュリティ
Expand Down
2 changes: 1 addition & 1 deletion docs/system-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file.tcexam>` で渡す (任意、未指定なら root 束縛のみ表示し「package 未提供」を明示)。**verify(web)** は試験束縛カードで root 束縛を常時表示し、「問題パッケージを読み込む」で `.tcexam` を取り込み当該タブを再検証して完全束縛を表示する。package 指定で束縛が失敗すれば全体を invalid とする。

Expand Down
16 changes: 14 additions & 2 deletions packages/shared/src/__tests__/examBinding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 3 additions & 5 deletions packages/shared/src/exam/examPackage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
42 changes: 42 additions & 0 deletions packages/verify-cli/src/__tests__/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)';

Expand Down Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion packages/verify-cli/src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading