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 integrations/computeid-agentpassport-trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ Expected output: `Result: FAIL (7 checks, 1 failure(s), 0 skipped)` — see CONF

A reviewer can reproduce, from this repository alone, with no live dependency on ComputeID's service beyond registering one fresh passport: that a ComputeID `/verify` response is independently checkable via `offline-verifier.js` and the included `ca-cert.pem` with zero network calls once captured (both the classical RSA-SHA256 and ML-DSA-65 signatures are recomputed from raw key/signature/payload bytes in the bundle, not read from the service's own claimed result — verified adversarially against a tampered payload); that converting real evidence into a TRACE record produces the result in CONFORMANCE.md; and that the hash-chained ComputeID audit log is independently verifiable via `verify-audit-chain.js` (requires live database access — documented as the one check that cannot be reproduced from a static bundle alone).

## What the CA receipt binds, and what it does not

`offline-verifier.js` reads `passport_id`, `status`, `issued_at` and `expires_at` only from the CA-signed `verification_receipt.receipt_payload`, after its signature verifies against `ca-cert.pem`, and requires them to equal the bundle's own `passport_id` and `status` and the unsigned copies in `verification_receipt`. Freshness and revocation are never taken from unsigned fields.

The receipt ComputeID issues today signs `expires_at`, `issued_at`, `key_id` (the CA key: first 16 hex of sha256 over the CA public key PEM), `passport_id`, `signature_valid` and `status`. It does not sign `public_key` or `pq_public_key`, so the passport keys are self-embedded in the bundle and a bundle carrying someone else's keys with a genuine receipt cannot be told apart from the real one. `issuer_trusted`, and so `overall_pass`, is therefore `false` for every current ComputeID bundle, with the reason in `verification_reasons.receipt_binding`. It becomes `true` once the signed receipt carries `public_key` and `pq_public_key` equal to the bundle's.

Pin the clock with `--now <iso-8601>` (or `verify(path, ca, { now })`) to evaluate a captured bundle inside its receipt window. Tests: `npm test` (Node's built-in runner).

## Maintainer

trustedaicompute-ops (GitHub org) — contact via computeid-backend issues.
150 changes: 127 additions & 23 deletions integrations/computeid-agentpassport-trace/offline-verifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@
// did. Both are now genuinely recomputed here using node:crypto (RSA-PSS)
// and @noble/post-quantum (ML-DSA-65), against the raw public_key,
// signature, and signed_payload bytes in the bundle.
//
// FIXED (receipt binding): the CA-signed receipt_payload was signature
// checked but never parsed, so freshness came from the unsigned
// receipt.expires_at, revocation from the unsigned bundle.status, and a
// bundle carrying an attacker's own keys plus another passport's genuine
// receipt passed. passport_id, status, issued_at and expires_at are now read
// ONLY from the verified receipt_payload and must equal the bundle's claims.
// The receipt ComputeID issues today signs expires_at, issued_at, key_id
// (the CA key: first 16 hex of sha256 over ca-cert.pem's public key PEM),
// passport_id, signature_valid and status. It does not sign either passport
// key, so public_key and pq_public_key are self-embedded and nothing ties
// them to the CA. issuer_trusted is therefore false, with the reason, until
// the receipt signs public_key and pq_public_key.

const fs = require('fs');
const crypto = require('crypto');
Expand Down Expand Up @@ -58,24 +71,82 @@ function checkMlDsaSignature(bundle) {
// Verifies the receipt's RSA-SHA256 signature was made by the private key
// corresponding to the PUBLICLY PUBLISHED CA certificate — not just
// trusting that the receipt says it came from ComputeID.
function checkIssuerTrusted(receipt, caCertPem) {
// Returns the parsed receipt_payload only when its signature verifies against
// the CA certificate; otherwise signed is null. Nothing outside the returned
// object may be used for a trust decision.
function checkReceiptSignature(receipt, caCertPem) {
if (!receipt || !receipt.receipt_signature || !receipt.receipt_payload || !caCertPem) {
return { issuer_trusted: false, reason: 'missing receipt signature, payload, or CA certificate' };
return { signed: null, reason: 'missing receipt signature, payload, or CA certificate' };
}
try {
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(receipt.receipt_payload);
verifier.end();
const valid = verifier.verify(caCertPem, receipt.receipt_signature, 'base64');
return { issuer_trusted: valid, reason: valid ? 'receipt signature verified against published CA certificate' : 'signature does not match published CA certificate — issuer NOT proven' };
if (!valid) {
return { signed: null, reason: 'signature does not match published CA certificate, issuer NOT proven' };
}
const signed = JSON.parse(receipt.receipt_payload);
if (!signed || typeof signed !== 'object' || Array.isArray(signed)) {
return { signed: null, reason: 'receipt_payload verified but is not a JSON object' };
}
return { signed, reason: 'receipt signature verified against published CA certificate' };
} catch (err) {
return { issuer_trusted: false, reason: 'verification error: ' + err.message };
return { signed: null, reason: 'verification error: ' + err.message };
}
}

function verify(evidenceBundlePath, caCertPath) {
// key_id in the receipt names the CA key: first 16 hex of sha256 over the CA
// public key in SPKI PEM form (matches every ComputeID fixture in evidence/).
function caKeyId(caCertPem) {
const pem = new crypto.X509Certificate(caCertPem).publicKey.export({ type: 'spki', format: 'pem' });
return crypto.createHash('sha256').update(pem).digest('hex').slice(0, 16);
}

// The bundle's claims must equal what the CA signed. Unsigned copies inside
// verification_receipt must equal it too: a mismatch means someone edited them.
function checkReceiptBinding(bundle, receipt, signed, caCertPem) {
const problems = [];
if (typeof signed.passport_id !== 'string' || signed.passport_id !== bundle.passport_id) {
problems.push('signed passport_id ' + JSON.stringify(signed.passport_id) + ' != bundle passport_id ' + JSON.stringify(bundle.passport_id));
}
if (typeof signed.status !== 'string' || signed.status !== bundle.status) {
problems.push('signed status ' + JSON.stringify(signed.status) + ' != bundle status ' + JSON.stringify(bundle.status));
}
for (const field of ['passport_id', 'status', 'issued_at', 'expires_at', 'key_id', 'signature_valid']) {
if (receipt[field] !== undefined && receipt[field] !== signed[field]) {
problems.push('unsigned verification_receipt.' + field + ' ' + JSON.stringify(receipt[field]) + ' != signed ' + JSON.stringify(signed[field]));
}
}
if (signed.key_id !== undefined && signed.key_id !== caKeyId(caCertPem)) {
problems.push('signed key_id ' + JSON.stringify(signed.key_id) + ' does not name the supplied CA key');
}
if (problems.length) {
return { bound: false, reason: 'signed receipt does not match the bundle: ' + problems.join('; ') };
}
// Key binding. Only an exact copy of the bundle's own key fields inside the
// signed payload counts; no other field is taken as a binding.
const missing = ['public_key', 'pq_public_key'].filter((k) => signed[k] === undefined);
if (missing.length) {
return {
bound: false,
reason: 'receipt_payload binds no passport key (' + missing.join(', ') + ' not signed; signed fields: ' +
Object.keys(signed).sort().join(', ') + '). The keys are self-embedded in the bundle, so the CA receipt ' +
'does not prove these keys belong to this passport.',
};
}
const mismatched = ['public_key', 'pq_public_key'].filter((k) => signed[k] !== bundle[k]);
if (mismatched.length) {
return { bound: false, reason: 'bundle ' + mismatched.join(', ') + ' differs from the key the CA signed' };
}
return { bound: true, reason: 'receipt binds passport_id, status and both passport keys' };
}

// options.now pins the clock (Date or ISO string) for reproducible runs.
function verify(evidenceBundlePath, caCertPath, options = {}) {
const raw = fs.readFileSync(evidenceBundlePath, 'utf8');
const bundle = JSON.parse(raw);
const now = options.now !== undefined ? new Date(options.now) : new Date();

const structure_valid =
!!bundle.public_key && !!bundle.signature && !!bundle.signed_payload &&
Expand All @@ -86,20 +157,35 @@ function verify(evidenceBundlePath, caCertPath) {
const classical_signature_valid = classicalResult.valid;
const ml_dsa_signature_valid = mlDsaResult.valid;

const not_revoked = bundle.status === 'active' && bundle.revoked_at === null;
const hardware_attestation_present = false;

const receipt = bundle.verification_receipt || {};
const receipt_expires_at = receipt.expires_at || null;
const credential_fresh = receipt_expires_at
? new Date() < new Date(receipt_expires_at)
: false;

let issuerTrustedResult = { issuer_trusted: false, reason: 'CA certificate not provided' };
let receiptResult = { signed: null, reason: 'CA certificate not provided' };
let bindingResult = { bound: false, reason: 'no verified receipt to bind against' };
if (caCertPath) {
const caCertPem = fs.readFileSync(caCertPath, 'utf8');
issuerTrustedResult = checkIssuerTrusted(receipt, caCertPem);
receiptResult = checkReceiptSignature(receipt, caCertPem);
if (receiptResult.signed) {
bindingResult = checkReceiptBinding(bundle, receipt, receiptResult.signed, caCertPem);
}
}
const signed = receiptResult.signed;
// Everything below comes from the CA-signed payload, never from the
// unsigned verification_receipt copies or the bundle's own status field.
// bundle.revoked_at is unsigned and can only make the result stricter.
const not_revoked = !!signed && signed.status === 'active' && bundle.status === 'active' &&
bundle.revoked_at === null;
const receipt_expires_at = signed && typeof signed.expires_at === 'string' ? signed.expires_at : null;
const receipt_issued_at = signed && typeof signed.issued_at === 'string' ? signed.issued_at : null;
const expiresMs = receipt_expires_at ? Date.parse(receipt_expires_at) : NaN;
const issuedMs = receipt_issued_at ? Date.parse(receipt_issued_at) : NaN;
const credential_fresh = Number.isFinite(expiresMs) && now.getTime() < expiresMs &&
(!receipt_issued_at || (Number.isFinite(issuedMs) && issuedMs <= now.getTime()));

const issuerTrustedResult = {
issuer_trusted: !!signed && bindingResult.bound,
reason: !signed ? receiptResult.reason : receiptResult.reason + '; ' + bindingResult.reason,
};

const overall_pass =
structure_valid &&
Expand All @@ -124,6 +210,10 @@ function verify(evidenceBundlePath, caCertPath) {
classical_signature: classicalResult.reason,
ml_dsa_signature: mlDsaResult.reason,
issuer_trusted: issuerTrustedResult.reason,
receipt_binding: bindingResult.reason,
credential_fresh: receipt_expires_at
? 'window taken from the CA-signed receipt_payload, evaluated at ' + now.toISOString()
: 'no CA-verified receipt expires_at, so freshness is not established',
},
overall_pass,
credential_fresh_note: 'credential_fresh reflects the verification RECEIPT freshness window (5 minutes from issuance), not a passport-level expiry policy. Passports themselves do not expire today — only the receipt attesting to a specific verification check does.',
Expand All @@ -132,19 +222,33 @@ function verify(evidenceBundlePath, caCertPath) {
},
receipt_evidence: {
receipt_algorithm: receipt.receipt_algorithm || null,
receipt_key_id: receipt.key_id || null,
receipt_issued_at: receipt.issued_at || null,
receipt_key_id: signed && signed.key_id !== undefined ? signed.key_id : null,
receipt_issued_at: receipt_issued_at,
receipt_expires_at: receipt_expires_at,
receipt_signed_fields: signed ? Object.keys(signed).sort() : [],
},
};
}

const bundlePath = process.argv[2];
const caCertPath = process.argv[3];
if (!bundlePath) {
console.error('Usage: node offline-verifier.js <path-to-evidence-bundle.json> [path-to-ca-cert.pem]');
process.exit(1);
}
module.exports = { verify, checkReceiptBinding };

const result = verify(bundlePath, caCertPath);
console.log(JSON.stringify(result, null, 2));
if (require.main === module) {
const args = process.argv.slice(2);
let nowArg;
const nowIndex = args.indexOf('--now');
if (nowIndex !== -1) {
nowArg = args[nowIndex + 1];
args.splice(nowIndex, 2);
if (!nowArg || Number.isNaN(Date.parse(nowArg))) {
console.error('--now needs an ISO 8601 timestamp');
process.exit(1);
}
}
const [bundlePath, caCertPath] = args;
if (!bundlePath) {
console.error('Usage: node offline-verifier.js <path-to-evidence-bundle.json> [path-to-ca-cert.pem] [--now <iso-8601>]');
process.exit(1);
}
const result = verify(bundlePath, caCertPath, nowArg ? { now: nowArg } : {});
console.log(JSON.stringify(result, null, 2));
}
3 changes: 3 additions & 0 deletions integrations/computeid-agentpassport-trace/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "1.0.0",
"description": "ComputeID AgentPassport TRACE v0.2 adapter and offline verification tools",
"main": "convert-to-trace.js",
"scripts": {
"test": "node --test test/offline-verifier.test.js"
},
"license": "Apache-2.0",
"dependencies": {
"canonicalize": "2.1.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Tests for offline-verifier.js receipt binding. Run: node --test test/
//
// These exercise the CA receipt, not ML-DSA. If @noble/post-quantum is not
// installed, ML-DSA verification is replaced by a stub that accepts every
// signature, so a pass here says nothing about the ML-DSA check.
const test = require('node:test');
const assert = require('node:assert');
const Module = require('module');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');

const NOBLE = '@noble/post-quantum/ml-dsa.js';
let mlDsaStubbed = false;
try {
require.resolve(NOBLE);
} catch {
mlDsaStubbed = true;
const stubPath = path.join(__dirname, '__ml_dsa_stub__.js');
const origResolve = Module._resolveFilename;
Module._resolveFilename = function (request, ...rest) {
return request === NOBLE ? stubPath : origResolve.call(this, request, ...rest);
};
const stub = new Module(stubPath);
stub.filename = stubPath;
stub.loaded = true;
stub.exports = { ml_dsa65: { verify: () => true } };
require.cache[stubPath] = stub;
}

const { verify, checkReceiptBinding } = require('../offline-verifier.js');

const ROOT = path.join(__dirname, '..');
const CA = path.join(ROOT, 'ca-cert.pem');
const FIXTURE = path.join(ROOT, 'evidence', 'opaque-diligence-demo.json');
const INSIDE_WINDOW = '2026-09-05T13:40:00Z';

function withBundle(mutate) {
const bundle = JSON.parse(fs.readFileSync(FIXTURE, 'utf8'));
mutate(bundle);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'computeid-verifier-'));
const file = path.join(dir, 'bundle.json');
fs.writeFileSync(file, JSON.stringify(bundle));
return file;
}

test('ML-DSA backend', (t) => {
t.diagnostic(mlDsaStubbed ? 'ML-DSA STUBBED (accepts all): @noble/post-quantum not installed' : 'real @noble/post-quantum');
});

test('genuine fixture: expired at real time, overall false', () => {
const r = verify(FIXTURE, CA);
assert.strictEqual(r.outcomes.credential_fresh, false);
assert.strictEqual(r.overall_pass, false);
});

test('genuine fixture inside its signed window: fresh, not revoked, but keys unbound', () => {
const r = verify(FIXTURE, CA, { now: INSIDE_WINDOW });
assert.strictEqual(r.outcomes.classical_signature_valid, true);
assert.strictEqual(r.outcomes.credential_fresh, true);
assert.strictEqual(r.outcomes.not_revoked, true);
assert.strictEqual(r.outcomes.issuer_trusted, false);
assert.match(r.verification_reasons.receipt_binding, /binds no passport key/);
assert.strictEqual(r.overall_pass, false);
assert.strictEqual(r.receipt_evidence.receipt_expires_at, '2026-09-05T13:42:51.013Z');
assert.strictEqual(r.receipt_evidence.receipt_key_id, 'ebb276c2f18ed34f');
});

test('edited unsigned receipt.expires_at is rejected and not used for freshness', () => {
const file = withBundle((b) => { b.verification_receipt.expires_at = '2099-01-01T00:00:00Z'; });
const r = verify(file, CA, { now: '2030-01-01T00:00:00Z' });
assert.strictEqual(r.outcomes.credential_fresh, false);
assert.strictEqual(r.outcomes.issuer_trusted, false);
assert.match(r.verification_reasons.receipt_binding, /unsigned verification_receipt\.expires_at/);
assert.strictEqual(r.receipt_evidence.receipt_expires_at, '2026-09-05T13:42:51.013Z');
assert.strictEqual(r.overall_pass, false);
});

test('attacker key and passport with a foreign CA receipt is rejected', () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const file = withBundle((b) => {
b.passport_id = 'attacker-passport';
b.name = 'evil';
b.public_key = publicKey.export({ type: 'spki', format: 'pem' });
b.signed_payload = JSON.stringify({ capabilities: ['admin'], name: 'evil', organization: 'Evil' });
b.signature = crypto.sign('sha256', Buffer.from(b.signed_payload), privateKey).toString('base64');
});
const r = verify(file, CA, { now: INSIDE_WINDOW });
assert.strictEqual(r.outcomes.classical_signature_valid, true, 'attacker self-signature is valid by construction');
assert.strictEqual(r.outcomes.issuer_trusted, false);
assert.match(r.verification_reasons.receipt_binding, /signed passport_id .* != bundle passport_id "attacker-passport"/);
assert.strictEqual(r.overall_pass, false);
});

test('attacker key keeping the victim passport_id is rejected for missing key binding', () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const file = withBundle((b) => {
b.public_key = publicKey.export({ type: 'spki', format: 'pem' });
b.signature = crypto.sign('sha256', Buffer.from(b.signed_payload), privateKey).toString('base64');
});
const r = verify(file, CA, { now: INSIDE_WINDOW });
assert.strictEqual(r.outcomes.issuer_trusted, false);
assert.match(r.verification_reasons.receipt_binding, /binds no passport key/);
assert.strictEqual(r.overall_pass, false);
});

test('unsigned bundle.status is not trusted for revocation', () => {
const file = withBundle((b) => { b.status = 'active'; b.verification_receipt.status = 'revoked'; });
const r = verify(file, CA, { now: INSIDE_WINDOW });
assert.strictEqual(r.outcomes.issuer_trusted, false);
assert.match(r.verification_reasons.receipt_binding, /verification_receipt\.status/);
});

test('without a CA certificate nothing from the receipt is used', () => {
const r = verify(FIXTURE, undefined, { now: INSIDE_WINDOW });
assert.strictEqual(r.outcomes.credential_fresh, false);
assert.strictEqual(r.outcomes.not_revoked, false);
assert.strictEqual(r.outcomes.issuer_trusted, false);
});

test('a receipt that signs both passport keys binds them; a different key fails', () => {
const bundle = JSON.parse(fs.readFileSync(FIXTURE, 'utf8'));
const caPem = fs.readFileSync(CA, 'utf8');
const signed = {
...JSON.parse(bundle.verification_receipt.receipt_payload),
public_key: bundle.public_key,
pq_public_key: bundle.pq_public_key,
};
assert.strictEqual(checkReceiptBinding(bundle, {}, signed, caPem).bound, true);
const other = { ...bundle, public_key: 'x' };
const res = checkReceiptBinding(other, {}, signed, caPem);
assert.strictEqual(res.bound, false);
assert.match(res.reason, /public_key differs/);
});
Loading
Loading