diff --git a/docs/VERIFIER_PLURALITY.md b/docs/VERIFIER_PLURALITY.md index a364a071..a6bdf58e 100644 --- a/docs/VERIFIER_PLURALITY.md +++ b/docs/VERIFIER_PLURALITY.md @@ -81,11 +81,11 @@ independently retained inputs when reviewing an archived decision. Reordering JSON keys or changing whitespace leaves the fingerprints unchanged; changing an input value changes its fingerprint. A rejected malformed quorum may have no fingerprints, and a live API call with non-JSON witness values has no -portable input binding. The output is neither signed nor timestamped: anyone -holding the inputs can generate a new output, and witness freshness is checked -at the time of replay. A matching fingerprint establishes content identity -under this canonicalization, not custody, signer identity, approval, or the -truth of a report. +portable input binding. The output is unsigned: anyone holding the inputs can +generate a new output, and witness freshness is checked at the time of replay. +A matching fingerprint establishes content identity under this +canonicalization, not custody, signer identity, approval, or the truth of a +report. The CLI requires strict UTF-8 and rejects duplicate JSON object names in every input file, including escaped spellings of the same name and duplicates nested @@ -400,6 +400,13 @@ bundle to pass with the retained challenge **now**, and requires its integrity hash to match the signed quorum's artifact hash and type. When these inputs are supplied, the CLI also requires `witness_met` for exit `0`. A changed challenge, tampered bundle, mismatched artifact, or expired challenge fails this check. +For a structurally checkable quorum, `witness_evaluation.evaluated_at` records +the single UTC millisecond instant used for this freshness check, and +`clock_source: "local_system"` identifies the machine clock. Both fields are +present for a failed witness replay too. They are unsigned observations, not +a trusted timestamp, proof of when a signer checked, or evidence that the +challenge is still fresh when someone later reads the result. Save the input +fingerprints and rerun verification for a current decision. Use `verifySignedVerificationQuorum(quorum, { policy, witnessBundle, expectedChallenge })` for the same check in browser JavaScript. Supplying only one of the bundle and challenge is a usage error. diff --git a/docs/aml-signed-verification-quorum.js b/docs/aml-signed-verification-quorum.js index 63a1460c..4d4b839d 100644 --- a/docs/aml-signed-verification-quorum.js +++ b/docs/aml-signed-verification-quorum.js @@ -399,12 +399,17 @@ export async function verifySignedVerificationQuorum(quorum, { } } if (witnessBundle !== undefined) { - const witness = await verifyWitnessBundle(witnessBundle, { expectedChallenge }); + // Snapshot the local clock once for the freshness decision. The timestamp + // describes this replay, not when a signer checked or a trusted time source. + const replayNow = Date.now(); + const witness = await verifyWitnessBundle(witnessBundle, { expectedChallenge, now: replayNow }); const artifactMatches = rebuilt.artifact_type === 'aml-witness-bundle/1' && rebuilt.artifact_hash === witnessBundle?.integrity?.value; result.witness_met = valid && witness.valid && witness.checks?.verifier_held_challenge === true && artifactMatches; result.witness_evaluation = { + evaluated_at: new Date(replayNow).toISOString(), + clock_source: 'local_system', local_bundle_valid: witness.valid, reason: witness.reason, retained_challenge_matched: witness.checks?.verifier_held_challenge === true, diff --git a/independent/python/README.md b/independent/python/README.md index 7b21773d..1768e1df 100644 --- a/independent/python/README.md +++ b/independent/python/README.md @@ -85,9 +85,14 @@ and each supplied held policy, bundle, and challenge. These are SHA-256 hashes of complete `sorted-json-v1` JSON values, not hashes of the source file bytes. The JavaScript and Python comparison harness checks their agreement, including changed held policies and challenges. An unsupported input has no portable -input binding. The output is unsigned and replay time affects witness freshness; -retain the inputs and evaluate the decision again when needed. These hashes -identify evaluated content, not an independent certification. +input binding. For a structurally checkable quorum with witness inputs, +`witness_evaluation.evaluated_at` is the UTC millisecond instant sampled once +from the verifier's local clock and used for challenge freshness. The +`clock_source` is `local_system`. A failed witness replay records this instant +too. It is an unsigned observation, not a trusted timestamp or evidence of +when the signer checked; retain the inputs and evaluate again for a current +decision. These hashes identify evaluated content, not an independent +certification. To turn independently retained fingerprints into an acceptance gate, pass a strict `aml-signed-quorum-input-expectations/1` JSON file with one or more diff --git a/independent/python/verify_signed_quorum.py b/independent/python/verify_signed_quorum.py index f7fdeae4..8fdf51bc 100644 --- a/independent/python/verify_signed_quorum.py +++ b/independent/python/verify_signed_quorum.py @@ -9,6 +9,7 @@ import argparse import base64 import binascii +import datetime as dt import hashlib import json import re @@ -378,7 +379,12 @@ def verify_quorum(quorum, policy=None, witness_bundle=None, expected_challenge=N **({"trusted_dissent": trusted_dissent} if veto_dissent else {}), } if with_witness: - witness_valid, witness_reason = verify_bundle(witness_bundle, expected_challenge=expected_challenge) + # Use a single millisecond snapshot for freshness and its reported + # observation time. This local clock is not an authenticated clock. + replay_now = dt.datetime.now(dt.timezone.utc) + replay_now = replay_now.replace(microsecond=replay_now.microsecond // 1000 * 1000) + witness_valid, witness_reason = verify_bundle( + witness_bundle, now=replay_now, expected_challenge=expected_challenge) bundle_hash = (witness_bundle.get("integrity", {}).get("value") if isinstance(witness_bundle, dict) and isinstance(witness_bundle.get("integrity"), dict) else None) @@ -386,6 +392,8 @@ def verify_quorum(quorum, policy=None, witness_bundle=None, expected_challenge=N artifact_hash == bundle_hash) result["witness_met"] = valid and witness_valid and witness_artifact_matches result["witness_evaluation"] = { + "evaluated_at": replay_now.isoformat(timespec="milliseconds").replace("+00:00", "Z"), + "clock_source": "local_system", "local_bundle_valid": witness_valid, "reason": witness_reason, "artifact_matches": witness_artifact_matches, } diff --git a/scripts/check-consumer-compatibility.mjs b/scripts/check-consumer-compatibility.mjs index a1e83121..3afc5dfd 100644 --- a/scripts/check-consumer-compatibility.mjs +++ b/scripts/check-consumer-compatibility.mjs @@ -212,6 +212,11 @@ try { !witnessBound.replayable_claims_consistent) { throw new Error('Installed-package signed quorum CLI did not recheck the retained witness'); } + if (witnessBound.witness_evaluation?.clock_source !== 'local_system' || + new Date(witnessBound.witness_evaluation.evaluated_at).toISOString() !== + witnessBound.witness_evaluation.evaluated_at) { + throw new Error('Installed-package signed quorum CLI omitted its local replay time'); + } const fingerprint = value => createHash('sha256') .update(canonicalJSONStringifyBrowser(value)).digest('hex'); if (witnessBound.input_fingerprints?.canonicalization !== 'sorted-json-v1' || diff --git a/scripts/check-signed-quorum-python.mjs b/scripts/check-signed-quorum-python.mjs index dc5b0126..b2b52d1d 100644 --- a/scripts/check-signed-quorum-python.mjs +++ b/scripts/check-signed-quorum-python.mjs @@ -38,6 +38,7 @@ async function compare(name, quorum, policy, expected, { bundle, challenge, expe if (bundle && challenge) args.push('--bundle', save(`${name}-bundle.json`, bundle), '--challenge', save(`${name}-challenge.json`, challenge)); if (expectedInputs) args.push('--expect-inputs', save(`${name}-expected-inputs.json`, expectedInputs)); + const replayStarted = Date.now(); const run = spawnSync('python3', args, { encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } }); @@ -48,12 +49,21 @@ async function compare(name, quorum, policy, expected, { bundle, challenge, expe policy, expectedInputFingerprints: expectedInputs, ...(bundle ? { witnessBundle: bundle, expectedChallenge: challenge } : {}) }); + const replayFinished = Date.now(); for (const field of ['valid', 'threshold_met', ...(policy ? ['policy_met'] : []), ...(bundle ? ['witness_met', 'replayable_claims_consistent'] : []), ...(expectedInputs ? ['inputs_met'] : [])]) { assert.equal(py[field], js[field], `${name}: ${field}: ${run.stderr} ${run.stdout}`); } if (bundle) { + for (const result of [py, js]) { + assert.equal(result.witness_evaluation.clock_source, 'local_system', name); + const observed = result.witness_evaluation.evaluated_at; + assert.match(observed, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/, name); + assert.ok(Date.parse(observed) >= replayStarted && Date.parse(observed) <= replayFinished, + `${name}: freshness time must come from the replay interval`); + assert.equal(new Date(Date.parse(observed)).toISOString(), observed, name); + } assert.equal(py.witness_evaluation.local_bundle_valid, js.witness_evaluation.local_bundle_valid, name); assert.equal(py.witness_evaluation.artifact_matches, js.witness_evaluation.artifact_matches, name); assert.deepEqual(py.replayable_claims_evaluation, js.replayable_claims_evaluation, name); diff --git a/test/browser-signed-quorum-cli.test.js b/test/browser-signed-quorum-cli.test.js index 4c726a25..7967ba8d 100644 --- a/test/browser-signed-quorum-cli.test.js +++ b/test/browser-signed-quorum-cli.test.js @@ -287,9 +287,15 @@ test('signed quorum rechecks the named witness against a separately held challen const bundlePath = write('bundle.json', bundle); const challengePath = write('held-challenge.json', challenge); + const replayStarted = Date.now(); const replayed = await verifySignedVerificationQuorum(quorum, { witnessBundle: bundle, expectedChallenge: challenge }); + assert.equal(replayed.witness_evaluation.clock_source, 'local_system'); + assert.ok(Date.parse(replayed.witness_evaluation.evaluated_at) >= replayStarted); + assert.ok(Date.parse(replayed.witness_evaluation.evaluated_at) <= Date.now()); + assert.equal(new Date(Date.parse(replayed.witness_evaluation.evaluated_at)).toISOString(), + replayed.witness_evaluation.evaluated_at); assert.equal(replayed.valid, true); assert.equal(replayed.threshold_met, true); assert.equal(replayed.witness_met, true); @@ -303,8 +309,31 @@ test('signed quorum rechecks the named witness against a separately held challen assert.equal(accepted.status, 0, accepted.stderr); assert.equal(JSON.parse(accepted.stdout).witness_met, true); assert.equal(JSON.parse(accepted.stdout).replayable_claims_consistent, true); + assert.equal(JSON.parse(accepted.stdout).witness_evaluation.clock_source, 'local_system'); + assert.ok(Date.parse(JSON.parse(accepted.stdout).witness_evaluation.evaluated_at) >= replayStarted); + assert.ok(Date.parse(JSON.parse(accepted.stdout).witness_evaluation.evaluated_at) <= Date.now()); assert.deepEqual(JSON.parse(accepted.stdout).input_fingerprints, replayed.input_fingerprints); + // Prove the printed instant is the same single instant used for freshness. + const originalNow = Date.now; + const afterExpiry = Date.parse(challenge.expires_at) + 1; + let clockReads = 0; + Date.now = () => { clockReads += 1; return afterExpiry; }; + try { + const expired = await verifySignedVerificationQuorum(quorum, { + witnessBundle: bundle, expectedChallenge: challenge + }); + assert.equal(clockReads, 1); + assert.equal(expired.valid, true); + assert.equal(expired.threshold_met, true); + assert.equal(expired.witness_met, false); + assert.equal(expired.witness_evaluation.reason, 'AML_CHALLENGE_EXPIRED'); + assert.equal(expired.witness_evaluation.evaluated_at, new Date(afterExpiry).toISOString()); + assert.equal(expired.witness_evaluation.clock_source, 'local_system'); + } finally { + Date.now = originalNow; + } + const completePolicy = { schema: 'aml-signed-quorum-held-policy/4', prototype: true, artifact_type: quorum.artifact_type, artifact_hash: quorum.artifact_hash,