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
10 changes: 10 additions & 0 deletions docs/VERIFIER_PLURALITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ objects. A later edit requires a new verification call. The CLI reads saved
files; this behavior does not authenticate who supplied them or prevent a
file from changing before it is read.

The browser receipt, evidence, session attestation, and witness bundle helpers
also copy ordinary JSON inputs before their first asynchronous check. Direct
verification results describe those call-time bytes. Evidence and bundle
creators embed detached copies of the inputs they checked, so later caller
edits cannot change the generated packet. Receipt sealing preserves its
published object identity and rejects a payload edited while hashing; it
does not attach a seal to stale bytes. Recheck any packet after changing it.
These rules prevent a live-object race, not a compromised page or false
claims in the supplied JSON.

From a checkout or installed preview package, the offline command is:

```bash
Expand Down
10 changes: 7 additions & 3 deletions docs/aml-browser-evidence.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { assertBrowserJSON, canonicalJSONStringifyBrowser, sha256Browser, verifyBrowserReceipt } from './aml-browser-integrity.js';
import { assertBrowserJSON, canonicalJSONStringifyBrowser, sha256Browser,
snapshotBrowserJSON, verifyBrowserReceipt } from './aml-browser-integrity.js';

let evidenceRevision = 0;

Expand All @@ -20,6 +21,9 @@ export async function createBrowserEvidence(
{ url = globalThis.location?.href || 'about:blank' } = {}
) {
if (!receipt?.integrity) throw new Error('AML_BROWSER_EVIDENCE_REQUIRES_SEALED_RECEIPT');
try { receipt = snapshotBrowserJSON(receipt); }
catch { throw new Error('AML_BROWSER_EVIDENCE_RECEIPT_INVALID'); }
url = snapshotBrowserJSON(url);
const receiptCheck = await verifyBrowserReceipt(receipt);
if (!receiptCheck.valid) throw new Error('AML_BROWSER_EVIDENCE_RECEIPT_INVALID');

Expand All @@ -31,7 +35,7 @@ export async function createBrowserEvidence(
url,
generated_at: new Date().toISOString(),
receipt,
zone_violations: currentViolations()
zone_violations: snapshotBrowserJSON(currentViolations())
};

evidence.integrity = {
Expand Down Expand Up @@ -59,7 +63,7 @@ export async function createBrowserEvidence(

export async function verifyBrowserEvidence(evidence) {
try {
assertBrowserJSON(evidence);
evidence = snapshotBrowserJSON(evidence);
if (!evidence?.integrity || evidence.integrity.schema !== 'aml-integrity/1') {
return { valid: false, reason: 'AML_BROWSER_EVIDENCE_UNSEALED' };
}
Expand Down
10 changes: 8 additions & 2 deletions docs/aml-browser-integrity.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,14 @@ export function browserReceiptPayload(receipt) {
}

export async function sealBrowserReceipt(receipt) {
const payload = browserReceiptPayload(receipt);
const payload = snapshotBrowserJSON(browserReceiptPayload(receipt));
const value = await sha256Browser(payload);
// The live receipt is published before its digest resolves. Preserve that
// object identity, but never attach a seal for stale payload bytes.
if (canonicalJSONStringifyBrowser(browserReceiptPayload(receipt)) !==
canonicalJSONStringifyBrowser(payload)) {
throw new Error('AML_BROWSER_RECEIPT_CHANGED_DURING_SEAL');
}
receipt.integrity = {
schema: 'aml-integrity/1',
algorithm: 'SHA-256',
Expand All @@ -128,7 +134,7 @@ export async function sealBrowserReceipt(receipt) {

export async function verifyBrowserReceipt(receipt) {
try {
assertBrowserJSON(receipt);
receipt = snapshotBrowserJSON(receipt);
if (!receipt?.integrity || receipt.integrity.schema !== 'aml-integrity/1') {
return { valid: false, reason: 'AML_BROWSER_RECEIPT_UNSEALED' };
}
Expand Down
22 changes: 20 additions & 2 deletions docs/aml-session-attestation.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// This proves possession of the ephemeral session key and exact payload binding.
// It does NOT prove human identity, organization identity, authorization, intent truth, safety, or policy quality.

import { assertBrowserJSON, canonicalJSONStringifyBrowser, sha256Browser } from './aml-browser-integrity.js';
import { assertBrowserJSON, canonicalJSONStringifyBrowser, sha256Browser,
snapshotBrowserJSON } from './aml-browser-integrity.js';
import { verifyBrowserEvidence } from './aml-browser-evidence.js';
import { verifyVerificationChallenge } from './aml-verification-challenge.js';

Expand Down Expand Up @@ -56,6 +57,7 @@ export async function createSessionAttestation({
evidence = globalThis.__AML_EVIDENCE__,
challenge
} = {}) {
({ evidence, challenge } = snapshotBrowserJSON({ evidence, challenge }));
const evidenceCheck = await verifyBrowserEvidence(evidence);
if (!evidenceCheck.valid) throw new Error('AML_SESSION_EVIDENCE_INVALID');
const challengeCheck = verifyVerificationChallenge(challenge);
Expand Down Expand Up @@ -100,13 +102,29 @@ export async function verifySessionAttestation({ evidence, challenge, attestatio
};
const fail = (reason, extra = {}) => ({ valid: false, reason, checks: { ...checks }, ...extra });
try {
const evidenceCheck = await verifyBrowserEvidence(evidence);
// Capture all three inputs before the first await, but retain the usual
// evidence -> challenge -> attestation error order for malformed inputs.
const capture = value => {
try { return { value: snapshotBrowserJSON(value) }; }
catch (error) { return { error }; }
};
const evidenceInput = capture(evidence);
const challengeInput = capture(challenge);
const attestationInput = capture(attestation);
const evidenceCheck = evidenceInput.error
? { valid: false, reason: evidenceInput.error?.message || 'AML_BROWSER_EVIDENCE_VERIFY_ERROR' }
: await verifyBrowserEvidence(evidenceInput.value);
checks.receipt_integrity = evidenceCheck.receipt?.valid === true;
if (!evidenceCheck.valid) return fail('AML_SESSION_EVIDENCE_INVALID', { evidence: evidenceCheck });
checks.evidence_integrity = true;
if (challengeInput.error) return fail(challengeInput.error?.message || 'AML_CHALLENGE_INVALID');
challenge = challengeInput.value;
evidence = evidenceInput.value;
const challengeCheck = verifyVerificationChallenge(challenge, { now });
if (!challengeCheck.valid) return fail(challengeCheck.reason);
checks.challenge_freshness = true;
if (attestationInput.error) return fail(attestationInput.error?.message || 'AML_SESSION_VERIFY_ERROR');
attestation = attestationInput.value;
assertBrowserJSON(attestation);
if (!attestation || attestation.schema !== 'aml-session-attestation/1') {
return fail('AML_SESSION_ATTESTATION_INVALID');
Expand Down
12 changes: 10 additions & 2 deletions docs/aml-witness-bundle.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// ĀML witness bundle — SHIPPED reference prototype.
// Packages evidence + verifier challenge + ephemeral-session attestation into one portable JSON object.

import { assertBrowserJSON, canonicalJSONStringifyBrowser, sha256Browser } from './aml-browser-integrity.js';
import { assertBrowserJSON, canonicalJSONStringifyBrowser, sha256Browser,
snapshotBrowserJSON } from './aml-browser-integrity.js';
import { verifySessionAttestation } from './aml-session-attestation.js';
import { verifyVerificationChallenge } from './aml-verification-challenge.js';

Expand Down Expand Up @@ -41,6 +42,7 @@ function bundleTimeFailure(bundle, now) {
}

export async function createWitnessBundle({ evidence, challenge, attestation }) {
({ evidence, challenge, attestation } = snapshotBrowserJSON({ evidence, challenge, attestation }));
const attestationCheck = await verifySessionAttestation({ evidence, challenge, attestation });
if (!attestationCheck.valid) throw new Error(attestationCheck.reason);
const createdNow = Date.now();
Expand Down Expand Up @@ -75,7 +77,12 @@ export async function verifyWitnessBundle(bundle, { now = Date.now(), expectedCh
};
const fail = (reason, extra = {}) => ({ valid: false, reason, checks: { ...checks }, ...extra });
try {
assertBrowserJSON(bundle);
let heldInputError;
if (expectedChallenge !== undefined) {
try { expectedChallenge = snapshotBrowserJSON(expectedChallenge); }
catch (error) { heldInputError = error; }
}
bundle = snapshotBrowserJSON(bundle);
if (!bundle?.integrity || bundle.integrity.schema !== 'aml-integrity/1') {
return fail('AML_WITNESS_BUNDLE_UNSEALED');
}
Expand All @@ -95,6 +102,7 @@ export async function verifyWitnessBundle(bundle, { now = Date.now(), expectedCh
}
checks.bundle_integrity = true;
if (expectedChallenge !== undefined) {
if (heldInputError) return fail(heldInputError?.message || 'AML_CHALLENGE_INVALID');
const heldChallenge = verifyVerificationChallenge(expectedChallenge, { now });
if (!heldChallenge.valid) return fail(heldChallenge.reason);
if (canonicalJSONStringifyBrowser(bundle.challenge) !== canonicalJSONStringifyBrowser(expectedChallenge)) {
Expand Down
38 changes: 37 additions & 1 deletion test/browser-packet-json-integrity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,42 @@ test('browser evidence rejects receipt and packet changes invisible to their has
assert.equal((await verifyBrowserEvidence(evidence)).reason, 'AML_BROWSER_EVIDENCE_INTEGRITY_INVALID');
delete evidence.integrity.audit_note;
receipt.decisions.audit_note = 'added';
assert.equal((await verifyBrowserEvidence(evidence)).valid, false);
// The evidence retains the sealed receipt bytes, not the caller's later edit.
assert.equal((await verifyBrowserEvidence(evidence)).valid, true);
await assert.rejects(createBrowserEvidence(receipt), /AML_BROWSER_EVIDENCE_RECEIPT_INVALID/);
});

test('receipt and evidence verification use their call-time bytes', async () => {
const receipt = await sealBrowserReceipt(makeReceipt());
const receiptCheck = verifyBrowserReceipt(receipt);
receipt.decisions[0].id = 'changed';
assert.equal((await receiptCheck).valid, true);
assert.equal((await verifyBrowserReceipt(receipt)).reason, 'AML_BROWSER_RECEIPT_HASH_MISMATCH');

const sealed = await sealBrowserReceipt(makeReceipt());
const evidence = await createBrowserEvidence(sealed);
const evidenceCheck = verifyBrowserEvidence(evidence);
evidence.zone_violations.push({ reason: 'changed' });
assert.equal((await evidenceCheck).valid, true);
assert.equal((await verifyBrowserEvidence(evidence)).reason, 'AML_BROWSER_EVIDENCE_HASH_MISMATCH');
});

test('receipt sealing rejects a payload changed while its digest is pending', async () => {
const receipt = makeReceipt();
const pending = sealBrowserReceipt(receipt);
receipt.decisions[0].id = 'changed';
await assert.rejects(pending, /AML_BROWSER_RECEIPT_CHANGED_DURING_SEAL/);
assert.equal(receipt.integrity, undefined);
});

test('evidence creation detaches its receipt and zone violations', async () => {
const receipt = await sealBrowserReceipt(makeReceipt());
globalThis.__AML_ZONE_VIOLATIONS__ = [{ reason: 'first' }];
const pending = createBrowserEvidence(receipt);
receipt.decisions[0].id = 'changed';
const evidence = await pending;
assert.equal(evidence.receipt.decisions[0].id, 'one');
globalThis.__AML_ZONE_VIOLATIONS__[0].reason = 'second';
assert.equal(evidence.zone_violations[0].reason, 'first');
assert.equal((await verifyBrowserEvidence(evidence)).valid, true);
});
75 changes: 74 additions & 1 deletion test/detached-witness.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,75 @@ test('detached witness bundle verifies under Node WebCrypto', async () => {
assert.equal(bundleCheck.reason, 'AML_WITNESS_BUNDLE_VALID');
});

test('direct session and bundle verification bind all inputs before Web Crypto yields', async () => {
const source = await createFixture();
const evidence = structuredClone(source.evidence);
const challenge = structuredClone(source.challenge);
const attestation = structuredClone(source.attestation);
const sessionCheck = verifySessionAttestation({ evidence, challenge, attestation });
evidence.receipt.decisions[0].purpose = 'changed after call';
challenge.nonce = 'A'.repeat(43);
attestation.signature = 'A'.repeat(86);
const session = await sessionCheck;
assert.equal(session.valid, true);
assert.equal(session.evidence_hash, source.evidence.integrity.value);
assert.equal((await verifySessionAttestation({ evidence, challenge, attestation })).valid, false);

const bundle = structuredClone(source.bundle);
const heldChallenge = structuredClone(source.challenge);
const pending = verifyWitnessBundle(bundle, { expectedChallenge: heldChallenge });
bundle.evidence.receipt.decisions[0].purpose = 'changed after call';
bundle.attestation.signature = 'A'.repeat(86);
heldChallenge.issued_at = '2030-01-01T00:00:00.000Z';
const replay = await pending;
assert.equal(replay.valid, true);
assert.equal(replay.checks.verifier_held_challenge, true);
assert.equal((await verifyWitnessBundle(bundle, { expectedChallenge: heldChallenge })).valid, false);
});

test('witness creators package the inputs checked at call time', async () => {
const source = await createFixture();
const evidence = structuredClone(source.evidence);
const challenge = structuredClone(source.challenge);
const pendingAttestation = createSessionAttestation({ evidence, challenge });
evidence.receipt.decisions[0].purpose = 'changed after call';
challenge.nonce = 'A'.repeat(43);
const attestation = await pendingAttestation;
assert.equal(attestation.challenge_nonce, source.challenge.nonce);
assert.equal((await verifySessionAttestation({ evidence: source.evidence,
challenge: source.challenge, attestation })).valid, true);

const bundleEvidence = structuredClone(source.evidence);
const bundleChallenge = structuredClone(source.challenge);
const bundleAttestation = structuredClone(attestation);
const pendingBundle = createWitnessBundle({ evidence: bundleEvidence,
challenge: bundleChallenge, attestation: bundleAttestation });
bundleEvidence.receipt.decisions[0].purpose = 'changed after call';
bundleChallenge.nonce = 'A'.repeat(43);
bundleAttestation.signature = 'A'.repeat(86);
const bundle = await pendingBundle;
assert.equal(bundle.challenge.nonce, source.challenge.nonce);
assert.equal((await verifyWitnessBundle(bundle, { expectedChallenge: source.challenge })).valid, true);
});

test('hidden call-time inputs cannot become valid by deletion during replay', async () => {
const source = await createFixture();
const held = structuredClone(source.challenge);
Object.defineProperty(held, 'unbound', { value: true, configurable: true });
const pending = verifyWitnessBundle(source.bundle, { expectedChallenge: held });
delete held.unbound;
assert.equal((await pending).reason, 'AML_CANONICAL_JSON_NON_JSON_OBJECT');
assert.equal((await verifyWitnessBundle(source.bundle, { expectedChallenge: held })).valid, true);

const attestation = structuredClone(source.attestation);
Object.defineProperty(attestation.session_public_key_jwk, 'unbound',
{ value: true, configurable: true });
const pendingSession = verifySessionAttestation({ evidence: source.evidence,
challenge: source.challenge, attestation });
delete attestation.session_public_key_jwk.unbound;
assert.equal((await pendingSession).reason, 'AML_CANONICAL_JSON_NON_JSON_OBJECT');
});

test('sequential and concurrent attestations use isolated keys and remain replayable', async () => {
const { evidence, challenge, attestation: first } = await createFixture();
const second = await createSessionAttestation({ evidence, challenge });
Expand Down Expand Up @@ -385,6 +454,8 @@ test('a hidden challenge field fails direct challenge, signature, and bundle che
assert.equal(canonicalJSONStringifyBrowser(bundle), canonical);
assert.equal(verifyVerificationChallenge(challenge).reason, 'AML_CANONICAL_JSON_NON_JSON_OBJECT');
assert.equal((await verifySessionAttestation({ evidence, challenge, attestation })).valid, false);
assert.equal((await verifyWitnessBundle(bundle)).valid, true);
Object.defineProperty(bundle.challenge, 'unbound_claim', { value: 'added after signing' });
assert.equal((await verifyWitnessBundle(bundle)).reason, 'AML_CANONICAL_JSON_NON_JSON_OBJECT');
});

Expand All @@ -395,6 +466,8 @@ test('an unsigned hidden attestation field fails direct signature and bundle che
assert.equal(canonicalJSONStringifyBrowser(bundle), canonical);
assert.equal((await verifySessionAttestation({ evidence, challenge, attestation })).reason,
'AML_CANONICAL_JSON_NON_JSON_OBJECT');
assert.equal((await verifyWitnessBundle(bundle)).valid, true);
Object.defineProperty(bundle.attestation.session_public_key_jwk, 'unbound_claim', { value: 'added' });
assert.equal((await verifyWitnessBundle(bundle)).reason, 'AML_CANONICAL_JSON_NON_JSON_OBJECT');
await assert.rejects(createWitnessBundle({ evidence, challenge, attestation }),
/AML_CANONICAL_JSON_NON_JSON_OBJECT/);
Expand Down Expand Up @@ -427,7 +500,7 @@ test('verifier-held challenge detects a changed issued_at even after the bundle
const { bundle, challenge } = await createFixture();
const heldChallenge = structuredClone(challenge);
assert.equal((await verifyWitnessBundle(bundle, { expectedChallenge: heldChallenge })).valid, true);
challenge.issued_at = new Date(Date.parse(challenge.issued_at) - 1000).toISOString();
bundle.challenge.issued_at = new Date(Date.parse(challenge.issued_at) - 1000).toISOString();
bundle.integrity.value = await sha256Browser(witnessBundlePayload(bundle));
// The ephemeral signature binds the nonce and expiration, not issued_at.
assert.equal((await verifyWitnessBundle(bundle)).valid, true);
Expand Down
Loading