From dd7c0fbabcae83f8e61085a3f0b38c40099fcdfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Jacob=20Read=20IV=20=E2=80=94=20Creator=20of=20?= =?UTF-8?q?=C4=80ML=E2=84=A2?= Date: Fri, 16 Oct 2026 19:17:46 -0700 Subject: [PATCH 1/2] Require canonical witness signature and key encodings --- docs/DETACHED_VERIFIER.md | 8 +++ docs/SESSION_ATTESTATION.md | 6 +++ docs/aml-session-attestation.js | 20 ++++++- independent/python/README.md | 5 ++ protocol/aml-session-attestation.schema.json | 6 +-- scripts/check-signed-quorum-python.mjs | 57 ++++++++++++++++++++ test/detached-witness.test.js | 36 +++++++++++++ 7 files changed, 134 insertions(+), 4 deletions(-) diff --git a/docs/DETACHED_VERIFIER.md b/docs/DETACHED_VERIFIER.md index 88eb6881..c0318b68 100644 --- a/docs/DETACHED_VERIFIER.md +++ b/docs/DETACHED_VERIFIER.md @@ -39,6 +39,14 @@ expired challenge -> FAIL - ECDSA P-256 / SHA-256 signature; - whole `aml-witness-bundle/1` integrity. +The session signature must be exactly 64 raw bytes encoded as 86 unpadded +base64url characters. Each public-key coordinate must be exactly 32 bytes +encoded as 43 unpadded base64url characters. Verification decodes and +re-encodes them to reject alternate spellings (including changed unused bits) +of the same bytes. The schema describes the text shape; the verifier checks +the canonical encoding. JavaScript and the project-authored Python verifier +apply the same rule. + 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 diff --git a/docs/SESSION_ATTESTATION.md b/docs/SESSION_ATTESTATION.md index 977a65c4..c7d649fd 100644 --- a/docs/SESSION_ATTESTATION.md +++ b/docs/SESSION_ATTESTATION.md @@ -62,6 +62,12 @@ The session signature covers the challenge nonce and expiration, not its generated and call `verifyWitnessBundle(bundle, { expectedChallenge })` to compare the entire challenge against its own copy. +The raw P-256 signature and public-key coordinates use unique unpadded +base64url encodings. The verifier rejects padding, whitespace, truncation, +and altered unused encoding bits, even if a permissive decoder would produce +the original bytes. This keeps witness verification consistent across the +JavaScript and project-authored Python implementations. + ## Try it - Detached verifier: https://aruintelligence.github.io/aml-core/detached-verifier.html diff --git a/docs/aml-session-attestation.js b/docs/aml-session-attestation.js index bf6a8c8b..a43ca97b 100644 --- a/docs/aml-session-attestation.js +++ b/docs/aml-session-attestation.js @@ -24,6 +24,16 @@ function base64UrlToBytes(value) { return Uint8Array.from(binary, (char) => char.charCodeAt(0)); } +// Web Crypto verifies decoded bytes. Require their unique unpadded base64url +// spelling so another runtime cannot reject the same purported witness. +function canonicalBytes(value, length, encodedLength, reason) { + if (typeof value !== 'string' || value.length !== encodedLength || + !/^[A-Za-z0-9_-]+$/.test(value)) throw new Error(reason); + const bytes = base64UrlToBytes(value); + if (bytes.length !== length || bytesToBase64Url(bytes) !== value) throw new Error(reason); + return bytes; +} + async function ensureSessionKey() { if (sessionKeyPair) return; if (!globalThis.crypto?.subtle) throw new Error('AML_SESSION_CRYPTO_UNAVAILABLE'); @@ -114,6 +124,12 @@ export async function verifySessionAttestation({ evidence, challenge, attestatio return fail('AML_SESSION_CHALLENGE_MISMATCH'); } checks.challenge_binding = true; + const jwk = attestation.session_public_key_jwk; + if (!jwk || jwk.kty !== 'EC' || jwk.crv !== 'P-256') { + return fail('AML_SESSION_KEY_ENCODING_INVALID'); + } + canonicalBytes(jwk.x, 32, 43, 'AML_SESSION_KEY_ENCODING_INVALID'); + canonicalBytes(jwk.y, 32, 43, 'AML_SESSION_KEY_ENCODING_INVALID'); const expectedFingerprint = await sha256Browser(attestation.session_public_key_jwk); if (expectedFingerprint !== attestation.session_key_fingerprint) { return fail('AML_SESSION_KEY_FINGERPRINT_MISMATCH'); @@ -126,10 +142,12 @@ export async function verifySessionAttestation({ evidence, challenge, attestatio ['verify'] ); const bytes = new TextEncoder().encode(canonicalJSONStringifyBrowser(sessionAttestationPayload(attestation))); + const signature = canonicalBytes(attestation.signature, 64, 86, + 'AML_SESSION_SIGNATURE_ENCODING_INVALID'); const valid = await crypto.subtle.verify( { name: 'ECDSA', hash: 'SHA-256' }, key, - base64UrlToBytes(attestation.signature), + signature, bytes ); checks.session_signature = valid; diff --git a/independent/python/README.md b/independent/python/README.md index b7b56f32..aabbc560 100644 --- a/independent/python/README.md +++ b/independent/python/README.md @@ -159,6 +159,11 @@ test fixtures only; a historical override is not a live freshness check. - public-JWK SHA-256 fingerprint; - raw 64-byte P-256 ECDSA signature over the canonical attestation payload. +The session signature and both 32-byte public-key coordinates must have their +unique unpadded base64url spelling. The JavaScript witness verifier enforces +the same byte lengths and round-trip encoding checks; a changed unused bit, +padding, or whitespace cannot be accepted by one implementation alone. + ## Deliberate separation This implementation does **not** import: diff --git a/protocol/aml-session-attestation.schema.json b/protocol/aml-session-attestation.schema.json index 1064b8f1..b2253a37 100644 --- a/protocol/aml-session-attestation.schema.json +++ b/protocol/aml-session-attestation.schema.json @@ -19,12 +19,12 @@ "properties": { "kty": { "const": "EC" }, "crv": { "const": "P-256" }, - "x": { "type": "string" }, - "y": { "type": "string" } + "x": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" }, + "y": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" } } }, "session_key_fingerprint": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, - "signature": { "type": "string", "minLength": 64 } + "signature": { "type": "string", "pattern": "^[A-Za-z0-9_-]{86}$" } }, "additionalProperties": false } diff --git a/scripts/check-signed-quorum-python.mjs b/scripts/check-signed-quorum-python.mjs index 9b4aa06f..80c4cb56 100644 --- a/scripts/check-signed-quorum-python.mjs +++ b/scripts/check-signed-quorum-python.mjs @@ -277,6 +277,29 @@ try { }; const replay = { bundle, challenge }; await compare('replayed-signed-witness', witnessQuorum, witnessPolicy, true, replay); + const alphabetWitness = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + const alternateWitness = value => value.slice(0, -1) + + alphabetWitness[alphabetWitness.indexOf(value.at(-1)) ^ 1]; + const aliasedBundle = structuredClone(bundle); + aliasedBundle.attestation.signature = alternateWitness(bundle.attestation.signature); + assert.deepEqual(Buffer.from(aliasedBundle.attestation.signature, 'base64url'), + Buffer.from(bundle.attestation.signature, 'base64url')); + aliasedBundle.integrity.value = await sha256Browser(witnessBundlePayload(aliasedBundle)); + const aliasedReports = await Promise.all([witnessKeyA, witnessKeyB].map((key, index) => + signVerificationReport(witnessVerificationReport(aliasedBundle, witness, + { verifier: `aliased-witness-${index}` }), key))); + const aliasedQuorum = await createSignedVerificationQuorum(aliasedReports, { threshold: 2 }); + const aliasedPolicy = { ...witnessPolicy, artifact_hash: aliasedBundle.integrity.value }; + const aliasedDecision = await verifySignedVerificationQuorum(aliasedQuorum, { + policy: aliasedPolicy, witnessBundle: aliasedBundle, expectedChallenge: challenge + }); + assert.equal(aliasedDecision.valid, true); + assert.equal(aliasedDecision.threshold_met, true); + assert.equal(aliasedDecision.policy_met, true); + assert.equal(aliasedDecision.witness_met, false); + assert.equal(aliasedDecision.witness_evaluation.reason, 'AML_SESSION_SIGNATURE_ENCODING_INVALID'); + await compare('signed-witness-alternate-signature-rejected', aliasedQuorum, aliasedPolicy, + false, { bundle: aliasedBundle, challenge }); const futureBundle = structuredClone(bundle); futureBundle.created_at = new Date(Date.now() + 30000).toISOString(); futureBundle.integrity.value = await sha256Browser(witnessBundlePayload(futureBundle)); @@ -588,6 +611,38 @@ try { { encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } }); assert.equal(brokenSignatureRun.status, 1, brokenSignatureRun.stderr); assert.equal(JSON.parse(brokenSignatureRun.stdout).valid, false); + // Preserve the signed bytes while changing only their textual spelling. + // A permissive base64 decoder must not make JS accept what Python rejects. + for (const [name, change, jsReason, pyReason] of [ + ['unused-signature-bits', value => { + const signature = value.attestation.signature; + value.attestation.signature = alternateWitness(signature); + assert.deepEqual(Buffer.from(value.attestation.signature, 'base64url'), + Buffer.from(signature, 'base64url')); + }, 'AML_SESSION_SIGNATURE_ENCODING_INVALID', 'AML_PY_SIGNATURE_ENCODING_UNSUPPORTED'], + ['padded-signature', value => { + value.attestation.signature += '=='; + }, 'AML_SESSION_SIGNATURE_ENCODING_INVALID', 'AML_PY_SIGNATURE_ENCODING_UNSUPPORTED'], + ['alternate-key-coordinate', value => { + const jwk = value.attestation.session_public_key_jwk; + const x = jwk.x; + jwk.x = alternateWitness(x); + assert.deepEqual(Buffer.from(jwk.x, 'base64url'), Buffer.from(x, 'base64url')); + }, 'AML_SESSION_KEY_ENCODING_INVALID', 'AML_PY_KEY_FINGERPRINT_MISMATCH'] + ]) { + const altered = structuredClone(golden); + change(altered); + altered.integrity.value = await sha256Browser(witnessBundlePayload(altered)); + const jsResult = await verifyWitnessBundle(altered, { + expectedChallenge: golden.challenge, now: Date.parse(fixedTime) + }); + assert.equal(jsResult.reason, jsReason, name); + const pyRun = spawnSync('python3', [witnessPython, + save(`${name}-bundle.json`, altered), '--challenge', heldChallenge, '--now', fixedTime], + { encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } }); + assert.equal(pyRun.status, 1, `${name}: ${pyRun.stderr}`); + assert.equal(JSON.parse(pyRun.stdout).reason, pyReason, name); + } const brokenAttestation = structuredClone(golden); brokenAttestation.attestation = []; brokenAttestation.integrity.value = await sha256Browser(witnessBundlePayload(brokenAttestation)); @@ -695,6 +750,8 @@ try { 'missing_held_challenge', 'matching_held_challenge', 'mismatched_held_challenge', 'duplicate_witness_key', 'malformed_witness_signature', 'malformed_witness_attestation', + 'witness_unused_signature_bits', 'witness_padded_signature', + 'witness_alternate_key_coordinate', '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', diff --git a/test/detached-witness.test.js b/test/detached-witness.test.js index 9b669e02..68323b5c 100644 --- a/test/detached-witness.test.js +++ b/test/detached-witness.test.js @@ -134,6 +134,42 @@ test('session attestation rejects a verifier challenge mismatch', async () => { assert.equal(check.reason, 'AML_SESSION_CHALLENGE_MISMATCH'); }); +test('witness replay rejects alternate spellings of signed P-256 bytes', async () => { + const { bundle, evidence, challenge, attestation } = await createFixture(); + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + const alternate = value => value.slice(0, -1) + + alphabet[alphabet.indexOf(value.at(-1)) ^ 1]; + const signatureAlias = alternate(attestation.signature); + assert.deepEqual(Buffer.from(signatureAlias, 'base64url'), + Buffer.from(attestation.signature, 'base64url')); + for (const [name, signature] of [ + ['unused-bits', signatureAlias], + ['padding', `${attestation.signature}==`], + ['whitespace', `${attestation.signature.slice(0, 10)} ${attestation.signature.slice(10)}`], + ['truncated', attestation.signature.slice(0, -1)], + ['null', null] + ]) { + const altered = structuredClone(bundle); + altered.attestation.signature = signature; + altered.integrity.value = await sha256Browser(witnessBundlePayload(altered)); + const direct = await verifySessionAttestation({ evidence, challenge, + attestation: altered.attestation }); + const replay = await verifyWitnessBundle(altered, { expectedChallenge: challenge }); + assert.equal(direct.reason, 'AML_SESSION_SIGNATURE_ENCODING_INVALID', name); + assert.equal(replay.reason, direct.reason, name); + assert.equal(replay.checks.session_signature, false, name); + } + const keyAlias = structuredClone(bundle); + const jwk = keyAlias.attestation.session_public_key_jwk; + const alternateX = alternate(jwk.x); + assert.deepEqual(Buffer.from(alternateX, 'base64url'), Buffer.from(jwk.x, 'base64url')); + jwk.x = alternateX; + keyAlias.attestation.session_key_fingerprint = await sha256Browser(jwk); + keyAlias.integrity.value = await sha256Browser(witnessBundlePayload(keyAlias)); + assert.equal((await verifyWitnessBundle(keyAlias, { expectedChallenge: challenge })).reason, + 'AML_SESSION_KEY_ENCODING_INVALID'); +}); + test('expired challenge fails detached verification', async () => { const { bundle } = await createFixture(); const future = Date.parse(bundle.challenge.expires_at) + 1; From 542fecc649aed41adf27a62c90860a4c9ac37472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Jacob=20Read=20IV=20=E2=80=94=20Creator=20of=20?= =?UTF-8?q?=C4=80ML=E2=84=A2?= Date: Fri, 16 Oct 2026 19:19:44 -0700 Subject: [PATCH 2/2] Preserve frozen attestation schema snapshot --- docs/DETACHED_VERIFIER.md | 6 +++--- protocol/aml-session-attestation.schema.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/DETACHED_VERIFIER.md b/docs/DETACHED_VERIFIER.md index c0318b68..90a22c9e 100644 --- a/docs/DETACHED_VERIFIER.md +++ b/docs/DETACHED_VERIFIER.md @@ -43,9 +43,9 @@ The session signature must be exactly 64 raw bytes encoded as 86 unpadded base64url characters. Each public-key coordinate must be exactly 32 bytes encoded as 43 unpadded base64url characters. Verification decodes and re-encodes them to reject alternate spellings (including changed unused bits) -of the same bytes. The schema describes the text shape; the verifier checks -the canonical encoding. JavaScript and the project-authored Python verifier -apply the same rule. +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, attestation, and witness-bundle verification reject live JavaScript values that cannot be faithfully represented as JSON (including hidden and diff --git a/protocol/aml-session-attestation.schema.json b/protocol/aml-session-attestation.schema.json index b2253a37..1064b8f1 100644 --- a/protocol/aml-session-attestation.schema.json +++ b/protocol/aml-session-attestation.schema.json @@ -19,12 +19,12 @@ "properties": { "kty": { "const": "EC" }, "crv": { "const": "P-256" }, - "x": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" }, - "y": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" } + "x": { "type": "string" }, + "y": { "type": "string" } } }, "session_key_fingerprint": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, - "signature": { "type": "string", "pattern": "^[A-Za-z0-9_-]{86}$" } + "signature": { "type": "string", "minLength": 64 } }, "additionalProperties": false }