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
8 changes: 8 additions & 0 deletions docs/DETACHED_VERIFIER.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ of the same bytes. The historical JSON schema remains a broad, frozen
contract; these verifiers enforce the narrower canonical encoding profile.
JavaScript and the project-authored Python verifier apply the same rule.

Challenge issue and expiry times must use the exact UTC millisecond form
emitted by `createVerificationChallenge`, such as
`2030-01-01T00:00:02.000Z`. Calendar rollover, time-zone aliases, missing
milliseconds, and extra fractional digits are rejected before freshness is
evaluated. The historical JSON schema remains unchanged and permits a
broader date-time shape; this is the current reference verifier profile.
The local clock remains an unauthenticated input to the freshness decision.

Challenge, attestation, and witness-bundle verification reject live JavaScript
values that cannot be faithfully represented as JSON (including hidden and
symbol fields, accessors, and sparse arrays). The bundle verifier also checks
Expand Down
6 changes: 6 additions & 0 deletions docs/SESSION_ATTESTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ Official ĀRU brand authorization uses a separate trust-root system.

The verifier accepts a challenge only from its issue time through its expiration time, inclusive. An invalid verifier clock fails closed. An archived witness bundle remains evidence of what was signed, but a later verifier should not describe an expired challenge as freshly verified. Challenge creation requires a positive integer duration in milliseconds.

Challenge timestamps must have the exact UTC millisecond spelling generated
by this API. Other ISO-8601 spellings, including offsets or extra precision,
and normalized invalid dates are rejected by both project-authored verifiers.
This narrows their input profile without changing the frozen historical schema;
it does not make the machine clock a trusted time source.

The session signature covers the challenge nonce and expiration, not its
`issued_at` field. A detached verifier should retain the challenge it
generated and call `verifyWitnessBundle(bundle, { expectedChallenge })` to
Expand Down
4 changes: 4 additions & 0 deletions docs/VERIFIER_PLURALITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,10 @@ 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.
Both project-authored witness verifiers require challenge issue and expiry
in exact UTC milliseconds. This rejects normalized invalid calendar dates
and precision or time-zone aliases before checking the local replay clock.
The historical schema remains a broader frozen snapshot.

When replay succeeds, `replayable_claims_consistent` checks every authenticated
positive report for explicit claims that contradict the local result: a
Expand Down
15 changes: 13 additions & 2 deletions docs/aml-verification-challenge.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ function bytesToBase64Url(bytes) {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}

// The challenge producer emits exact UTC milliseconds. Requiring that form
// prevents Date.parse calendar rollover and submillisecond truncation from
// giving another verifier a different freshness decision.
function canonicalChallengeTime(value) {
if (typeof value !== 'string' ||
!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/.test(value)) return NaN;
const time = Date.parse(value);
return Number.isFinite(time) && new Date(time).toISOString() === value &&
!value.startsWith('0000-') ? time : NaN;
}

export function createVerificationChallenge({ ttl_ms = 120000 } = {}) {
if (!Number.isSafeInteger(ttl_ms) || ttl_ms <= 0) {
throw new Error('AML_CHALLENGE_TTL_INVALID');
Expand Down Expand Up @@ -40,8 +51,8 @@ export function verifyVerificationChallenge(challenge, { now = Date.now() } = {}
if (typeof challenge.nonce !== 'string' || challenge.nonce.length < 32) {
return { valid: false, reason: 'AML_CHALLENGE_NONCE_INVALID' };
}
const issued = typeof challenge.issued_at === 'string' ? Date.parse(challenge.issued_at) : NaN;
const expires = typeof challenge.expires_at === 'string' ? Date.parse(challenge.expires_at) : NaN;
const issued = canonicalChallengeTime(challenge.issued_at);
const expires = canonicalChallengeTime(challenge.expires_at);
if (typeof now !== 'number' || !Number.isFinite(now) ||
!Number.isFinite(issued) || !Number.isFinite(expires) || expires <= issued) {
return { valid: false, reason: 'AML_CHALLENGE_TIME_INVALID' };
Expand Down
2 changes: 2 additions & 0 deletions independent/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ test fixtures only; a historical override is not a live freshness check.
- browser-evidence SHA-256;
- whole witness-bundle SHA-256;
- challenge schema, nonce, issue time and expiry;
- challenge issue and expiry in exact UTC milliseconds, rejecting calendar
rollover, offset aliases, and extra fractional precision before freshness;
- equality with a separately retained, fresh challenge when supplied;
- challenge/evidence equality inside the session attestation;
- canonical UTC millisecond evidence, attestation, and bundle times (and a
Expand Down
13 changes: 12 additions & 1 deletion independent/python/verify_witness.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,14 +164,25 @@ def parse_time(value):
return parsed


def canonical_challenge_time(value):
if not isinstance(value, str) or not re.fullmatch(
r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z", value):
raise ValueError("AML_PY_CHALLENGE_TIME_INVALID")
parsed = parse_time(value)
if parsed.isoformat(timespec="milliseconds").replace("+00:00", "Z") != value:
raise ValueError("AML_PY_CHALLENGE_TIME_INVALID")
return parsed


def verify_challenge(challenge, now):
if not isinstance(challenge, dict) or challenge.get("schema") != "aml-verification-challenge/1":
return False, "AML_PY_CHALLENGE_INVALID"
nonce = challenge.get("nonce")
if not isinstance(nonce, str) or len(nonce) < 32:
return False, "AML_PY_CHALLENGE_NONCE_INVALID"
try:
issued, expires = parse_time(challenge.get("issued_at")), parse_time(challenge.get("expires_at"))
issued = canonical_challenge_time(challenge.get("issued_at"))
expires = canonical_challenge_time(challenge.get("expires_at"))
if expires <= issued or now.tzinfo is None:
return False, "AML_PY_CHALLENGE_TIME_INVALID"
except (ValueError, TypeError, OverflowError):
Expand Down
50 changes: 50 additions & 0 deletions scripts/check-signed-quorum-python.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,27 @@ try {
};
const replay = { bundle, challenge };
await compare('replayed-signed-witness', witnessQuorum, witnessPolicy, true, replay);
const timeAliasBundle = structuredClone(bundle);
timeAliasBundle.challenge.issued_at =
new Date(Date.parse(challenge.issued_at) + 3600000).toISOString().replace('Z', '+01:00');
assert.equal(Date.parse(timeAliasBundle.challenge.issued_at), Date.parse(challenge.issued_at));
timeAliasBundle.integrity.value = await sha256Browser(witnessBundlePayload(timeAliasBundle));
const timeAliasReports = await Promise.all([witnessKeyA, witnessKeyB].map((key, index) =>
signVerificationReport(witnessVerificationReport(timeAliasBundle, witness,
{ verifier: `time-alias-${index}` }), key)));
const timeAliasQuorum = await createSignedVerificationQuorum(timeAliasReports, { threshold: 2 });
const timeAliasPolicy = { ...witnessPolicy, artifact_hash: timeAliasBundle.integrity.value };
const timeAliasDecision = await verifySignedVerificationQuorum(timeAliasQuorum, {
policy: timeAliasPolicy, witnessBundle: timeAliasBundle,
expectedChallenge: timeAliasBundle.challenge
});
assert.equal(timeAliasDecision.valid, true);
assert.equal(timeAliasDecision.threshold_met, true);
assert.equal(timeAliasDecision.policy_met, true);
assert.equal(timeAliasDecision.witness_met, false);
assert.equal(timeAliasDecision.witness_evaluation.reason, 'AML_CHALLENGE_TIME_INVALID');
await compare('signed-challenge-time-alias-rejected', timeAliasQuorum,
timeAliasPolicy, false, { bundle: timeAliasBundle, challenge: timeAliasBundle.challenge });
const alphabetWitness = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const alternateWitness = value => value.slice(0, -1) +
alphabetWitness[alphabetWitness.indexOf(value.at(-1)) ^ 1];
Expand Down Expand Up @@ -587,6 +608,33 @@ try {
{ encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
assert.equal(heldRun.status, 0, heldRun.stderr);
assert.equal(JSON.parse(heldRun.stdout).valid, true);
assert.equal((await verifyWitnessBundle(golden, {
expectedChallenge: golden.challenge, now: Date.parse(fixedTime)
})).valid, true);
// The old JS parser normalized November 31 to December 1. The signed
// session and bundle remain intact when only the unsigned challenge issue
// spelling changes, so this used to yield a different decision in Python.
for (const [name, field, value] of [
['challenge-calendar-rollover', 'issued_at', '2029-11-31T00:00:02.000Z'],
['challenge-offset-alias', 'issued_at', '2030-01-01T01:00:02.000+01:00'],
['challenge-submillisecond', 'issued_at', '2030-01-01T00:00:02.0001Z'],
['challenge-missing-milliseconds', 'expires_at', '2030-01-01T00:10:02Z']
]) {
const altered = structuredClone(golden);
altered.challenge[field] = value;
altered.integrity.value = await sha256Browser(witnessBundlePayload(altered));
const held = save(`${name}-challenge.json`, altered.challenge);
const pathToBundle = save(`${name}-bundle.json`, altered);
const jsResult = await verifyWitnessBundle(altered, {
expectedChallenge: altered.challenge, now: Date.parse(fixedTime)
});
assert.equal(jsResult.reason, 'AML_CHALLENGE_TIME_INVALID', name);
const pyRun = spawnSync('python3', [witnessPython, pathToBundle,
'--challenge', held, '--now', fixedTime],
{ encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
assert.equal(pyRun.status, 1, `${name}: ${pyRun.stderr}`);
assert.equal(JSON.parse(pyRun.stdout).reason, 'AML_PY_CHALLENGE_TIME_INVALID', name);
}
const changedHeld = { ...golden.challenge,
issued_at: '2030-01-01T00:00:01.000Z' };
const mismatchedHeld = spawnSync('python3', [witnessPython, goldenBundle, '--now', fixedTime,
Expand Down Expand Up @@ -752,6 +800,8 @@ try {
'malformed_witness_signature', 'malformed_witness_attestation',
'witness_unused_signature_bits', 'witness_padded_signature',
'witness_alternate_key_coordinate',
'challenge_calendar_rollover', 'challenge_offset_alias',
'challenge_submillisecond', 'challenge_missing_milliseconds',
'before_challenge_issue', 'after_challenge_expiry',
'fixed_vector_bundle_calendar', 'fixed_vector_bundle_before_attestation',
'fixed_vector_bundle_after_expiry', 'fixed_vector_bundle_after_replay',
Expand Down
23 changes: 23 additions & 0 deletions test/detached-witness.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,29 @@ test('invalid verifier clocks and malformed time fields fail closed', async () =
'AML_CHALLENGE_TIME_INVALID');
});

test('challenge times use exact UTC milliseconds across verifiers', async () => {
const { bundle, challenge } = await createFixture();
const offsetAlias = new Date(Date.parse(challenge.issued_at) + 3600000)
.toISOString().replace('Z', '+01:00');
for (const [name, field, value] of [
['invalid-calendar', 'issued_at', '2029-11-31T00:00:00.000Z'],
['rollover-hour', 'issued_at', '2030-01-01T24:00:00.000Z'],
['year-zero', 'issued_at', '0000-01-01T00:00:00.000Z'],
['offset-alias', 'issued_at', offsetAlias],
['submillisecond', 'issued_at', challenge.issued_at.replace('Z', '1Z')],
['missing-milliseconds', 'expires_at', challenge.expires_at.replace(/\.[0-9]{3}Z$/, 'Z')]
]) {
const altered = { ...challenge, [field]: value };
assert.equal(verifyVerificationChallenge(altered).reason, 'AML_CHALLENGE_TIME_INVALID', name);
const resealed = structuredClone(bundle);
resealed.challenge = altered;
resealed.integrity.value = await sha256Browser(witnessBundlePayload(resealed));
const result = await verifyWitnessBundle(resealed, { expectedChallenge: altered });
assert.equal(result.reason, 'AML_CHALLENGE_TIME_INVALID', name);
}
assert.equal(verifyVerificationChallenge(challenge).valid, true);
});

test('challenge creation rejects invalid durations before producing an artifact', () => {
for (const ttl_ms of [0, -1, 0.5, '60000', NaN, Infinity, Number.MAX_SAFE_INTEGER]) {
assert.throws(() => createVerificationChallenge({ ttl_ms }), /AML_CHALLENGE_TTL_INVALID/);
Expand Down
Loading