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 @@ -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 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
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 @@ -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
Expand Down
20 changes: 19 additions & 1 deletion docs/aml-session-attestation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand All @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions independent/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
57 changes: 57 additions & 0 deletions scripts/check-signed-quorum-python.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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',
Expand Down
36 changes: 36 additions & 0 deletions test/detached-witness.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading