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
17 changes: 12 additions & 5 deletions docs/VERIFIER_PLURALITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion docs/aml-signed-verification-quorum.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 8 additions & 3 deletions independent/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion independent/python/verify_signed_quorum.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import argparse
import base64
import binascii
import datetime as dt
import hashlib
import json
import re
Expand Down Expand Up @@ -378,14 +379,21 @@ 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)
witness_artifact_matches = (artifact_type == "aml-witness-bundle/1" and
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,
}
Expand Down
5 changes: 5 additions & 0 deletions scripts/check-consumer-compatibility.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' ||
Expand Down
10 changes: 10 additions & 0 deletions scripts/check-signed-quorum-python.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
});
Expand All @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions test/browser-signed-quorum-cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down
Loading