diff --git a/integrations/computeid-agentpassport-trace/README.md b/integrations/computeid-agentpassport-trace/README.md index 6194b69..70dbae8 100644 --- a/integrations/computeid-agentpassport-trace/README.md +++ b/integrations/computeid-agentpassport-trace/README.md @@ -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 ` (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. diff --git a/integrations/computeid-agentpassport-trace/offline-verifier.js b/integrations/computeid-agentpassport-trace/offline-verifier.js index cf81198..80280c9 100644 --- a/integrations/computeid-agentpassport-trace/offline-verifier.js +++ b/integrations/computeid-agentpassport-trace/offline-verifier.js @@ -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'); @@ -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 && @@ -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 && @@ -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.', @@ -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-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-ca-cert.pem] [--now ]'); + process.exit(1); + } + const result = verify(bundlePath, caCertPath, nowArg ? { now: nowArg } : {}); + console.log(JSON.stringify(result, null, 2)); +} diff --git a/integrations/computeid-agentpassport-trace/package.json b/integrations/computeid-agentpassport-trace/package.json index b8b598d..c703a60 100644 --- a/integrations/computeid-agentpassport-trace/package.json +++ b/integrations/computeid-agentpassport-trace/package.json @@ -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", diff --git a/integrations/computeid-agentpassport-trace/test/offline-verifier.test.js b/integrations/computeid-agentpassport-trace/test/offline-verifier.test.js new file mode 100644 index 0000000..6d83c40 --- /dev/null +++ b/integrations/computeid-agentpassport-trace/test/offline-verifier.test.js @@ -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/); +}); diff --git a/integrations/wcm-azure-skr/README.md b/integrations/wcm-azure-skr/README.md index 7e7266d..efd1ee9 100644 --- a/integrations/wcm-azure-skr/README.md +++ b/integrations/wcm-azure-skr/README.md @@ -59,8 +59,17 @@ python wcm_azure_skr.py --describe-claims --authority https://... ignored ``` TDX maps to `x-ms-attestation-type: tdxvm` and gets the compliance-status -condition. Its measurement claim names are **not asserted here** and must be -supplied like any other. +condition and its own debug gate, `tdx_td_attributes_debug equals false` +([MAA TDX EAT profile](https://learn.microsoft.com/azure/attestation/trust-domain-extensions-eat-profile), +[tdxvm sample token](https://learn.microsoft.com/azure/attestation/attestation-token-examples)). +Every branch is built from its own attestation type, so a branch never carries +the other TEE's claims (which MAA does not issue on that token, so the branch +would never match). A TEE-specific `--measurement-claim` such as +`x-ms-sevsnpvm-hostdata` is refused when the manifest also allows the other TEE; +from Python, pass a mapping `{"sevsnpvm": ..., "tdxvm": ...}`. Every documented +TDX measurement register (`tdx_mrtd`, `tdx_rtmr0..3`, `tdx_mrconfigid`, +`tdx_mrowner`, `tdx_mrownerconfig`) is 96 hex and `tdx_report_data` is 128, so +none can carry a 64-hex WCM measurement and the width check refuses them. `nvidia-cc-gpu` has no attestation-type value: MAA's CVM attestation describes the virtual machine, and there is no claim meaning "the GPU is in CC mode". GPU @@ -72,7 +81,8 @@ binding stays with the WCM broker's GPU check, and a GPU-only requirement raises |---|---| | `required_hw_platform: [amd-sev-snp]` | `x-ms-attestation-type equals sevsnpvm` | | `required_assurance_tier: hardware-attested` | `x-ms-compliance-status equals azure-compliant-cvm` | -| (always, on SNP) | `x-ms-sevsnpvm-is-debuggable equals false` | +| (always, on each SNP branch) | `x-ms-sevsnpvm-is-debuggable equals false` | +| (always, on each TDX branch) | `tdx_td_attributes_debug equals false` | | `accepted_measurements`, status not `revoked` | one `anyOf` branch per measurement | | `authority` | pinned on every branch | diff --git a/integrations/wcm-azure-skr/test_wcm_azure_skr.py b/integrations/wcm-azure-skr/test_wcm_azure_skr.py index 6d9758d..b37ac05 100644 --- a/integrations/wcm-azure-skr/test_wcm_azure_skr.py +++ b/integrations/wcm-azure-skr/test_wcm_azure_skr.py @@ -324,3 +324,92 @@ def test_a_token_with_no_nonce_is_refused() -> None: evidence_from_maa_claims( good_claims(**{"x-ms-runtime": {}}), challenge(), serving_image_measurement=SERVING ) + + +BOTH_ORDERS = pytest.mark.parametrize( + "order", [["amd-sev-snp", "intel-tdx"], ["intel-tdx", "amd-sev-snp"]] +) +OWN_DEBUG_CLAIM = {"sevsnpvm": "x-ms-sevsnpvm-is-debuggable", "tdxvm": "tdx_td_attributes_debug"} +FOREIGN_PREFIX = {"sevsnpvm": "tdx_", "tdxvm": "x-ms-sevsnpvm-"} + + +def _branch_type(branch: dict) -> str: + return next(c["equals"] for c in branch["allOf"] if c["claim"] == "x-ms-attestation-type") + + +@BOTH_ORDERS +def test_every_branch_has_its_own_debug_gate_whatever_the_platform_order(order: list) -> None: + """The gate used to follow required_hw_platform[0]: [tdx, snp] emitted none at all.""" + result = build_release_policy( + make_manifest(required_hw_platform=order), authority=AUTHORITY, allow_unbound_workload=True + ) + + assert {_branch_type(b) for b in result["anyOf"]} == {"sevsnpvm", "tdxvm"} + for branch in result["anyOf"]: + gate = {"claim": OWN_DEBUG_CLAIM[_branch_type(branch)], "equals": "false"} + assert gate in branch["allOf"] + + +@BOTH_ORDERS +def test_each_branch_carries_only_claims_of_its_own_tee(order: list) -> None: + """[snp, tdx] used to put x-ms-sevsnpvm-* on tdxvm branches, which never match.""" + result = build_release_policy( + make_manifest(required_hw_platform=order), + authority=AUTHORITY, + allow_unbound_workload=True, + ) + + for branch in result["anyOf"]: + foreign = FOREIGN_PREFIX[_branch_type(branch)] + assert all(not c["claim"].startswith(foreign) for c in branch["allOf"]) + + +@BOTH_ORDERS +def test_snp_only_measurement_claim_is_refused_when_tdx_is_allowed(order: list) -> None: + with pytest.raises(SkrPolicyError, match="only issued on sevsnpvm tokens"): + build_release_policy( + make_manifest(required_hw_platform=order), + authority=AUTHORITY, + measurement_claim="x-ms-sevsnpvm-hostdata", + ) + + +def test_mapping_must_name_a_claim_for_every_allowed_tee() -> None: + with pytest.raises(SkrPolicyError, match="no measurement_claim for tdxvm"): + build_release_policy( + make_manifest(required_hw_platform=["amd-sev-snp", "intel-tdx"]), + authority=AUTHORITY, + measurement_claim={"sevsnpvm": "x-ms-sevsnpvm-hostdata"}, + ) + + +def test_tdx_measurement_claims_are_384_bit_and_refused_for_a_wcm_hash() -> None: + """Every documented TDX measurement register is 96 or 128 hex; none fits a HashValue.""" + with pytest.raises(SkrPolicyError, match="different chains"): + build_release_policy( + make_manifest(required_hw_platform=["amd-sev-snp", "intel-tdx"]), + authority=AUTHORITY, + measurement_claim={"sevsnpvm": "x-ms-sevsnpvm-hostdata", "tdxvm": "tdx_mrconfigid"}, + ) + + +def test_tdx_debug_gate_can_be_dropped_deliberately() -> None: + result = build_release_policy( + make_manifest(required_hw_platform=["intel-tdx"]), + authority=AUTHORITY, + allow_unbound_workload=True, + require_not_debuggable=False, + ) + + assert all(c["claim"] != "tdx_td_attributes_debug" for c in result["anyOf"][0]["allOf"]) + + +def test_debuggable_tdx_guest_cannot_produce_hardware_attested_evidence() -> None: + claims = { + "x-ms-attestation-type": "tdxvm", + "x-ms-compliance-status": "azure-compliant-cvm", + "tdx_td_attributes_debug": True, + "x-ms-runtime": {"nonce": "a" * 64}, + } + with pytest.raises(SkrPolicyError, match="debuggable"): + evidence_from_maa_claims(claims, challenge(), serving_image_measurement=SERVING) diff --git a/integrations/wcm-azure-skr/wcm_azure_skr.py b/integrations/wcm-azure-skr/wcm_azure_skr.py index e4699fe..2395d42 100644 --- a/integrations/wcm-azure-skr/wcm_azure_skr.py +++ b/integrations/wcm-azure-skr/wcm_azure_skr.py @@ -34,10 +34,20 @@ get the platform conditions alone; the returned policy is then annotated as not binding a workload, and the CLI prints that to stderr. -**Claims used, and where they come from.** Only SEV-SNP claims that Microsoft -documents for MAA are emitted by default. TDX is supported for attestation type -and compliance status; its measurement claim names are not asserted here and -must be supplied through ``measurement_claim`` like any other. +**Claims used, and where they come from.** Every claim is one Microsoft +documents for MAA. SEV-SNP claims are from the MAA claim sets page +(https://learn.microsoft.com/azure/attestation/claim-sets). TDX claims are from +the MAA TDX EAT profile +(https://learn.microsoft.com/azure/attestation/trust-domain-extensions-eat-profile) +and its ``tdxvm`` sample token +(https://learn.microsoft.com/azure/attestation/attestation-token-examples). + +**Each branch carries only claims of its own TEE.** A token has one +``x-ms-attestation-type``; MAA issues ``x-ms-sevsnpvm-*`` claims only on a +``sevsnpvm`` token and ``tdx_*`` claims only on a ``tdxvm`` token. A condition +naming the other TEE's claim is never met, so a branch mixing them never +matches. The debug gate is therefore per branch: ``x-ms-sevsnpvm-is-debuggable`` +on SNP, ``tdx_td_attributes_debug`` on TDX. Usage:: @@ -70,6 +80,7 @@ __all__ = [ "ATTESTATION_TYPE_BY_PLATFORM", "MAA_CLAIMS", + "DEBUG_CLAIM_BY_ATTESTATION_TYPE", "SKR_POLICY_VERSION", "SkrPolicyError", "build_release_policy", @@ -89,10 +100,9 @@ #: MAA claims this module reads or emits, with what each one is. #: -#: Restricted to SEV-SNP CVM claims Microsoft documents. A claim not listed here -#: is not emitted by default and must be named explicitly by the caller, because -#: a policy referencing a claim MAA does not issue never matches and presents as -#: a broken CVM. +#: Restricted to CVM claims Microsoft documents (see the module docstring for the +#: pages). A claim not listed here is refused, because a policy referencing a +#: claim MAA does not issue never matches and presents as a broken CVM. MAA_CLAIMS = { "x-ms-attestation-type": "sevsnpvm or tdxvm; which TEE produced the token", "x-ms-compliance-status": "azure-compliant-cvm when the platform met Azure's CVM baseline", @@ -101,8 +111,40 @@ "x-ms-sevsnpvm-hostdata": "256-bit host-supplied data, 64 hex characters", "x-ms-sevsnpvm-idkeydigest": "digest of the key that signed the guest's ID block", "x-ms-sevsnpvm-guestsvn": "guest security version number", + "tdx_td_attributes_debug": "true when the TD runs in TD debug mode (host VMM can read it)", + "tdx_mrtd": "384-bit measurement of the TD's initial contents, 96 hex characters", + "tdx_rtmr0": "384-bit runtime measurement register 0, 96 hex characters", + "tdx_rtmr1": "384-bit runtime measurement register 1, 96 hex characters", + "tdx_rtmr2": "384-bit runtime measurement register 2, 96 hex characters", + "tdx_rtmr3": "384-bit runtime measurement register 3, 96 hex characters", + "tdx_mrconfigid": "384-bit software-defined configuration ID, 96 hex characters", + "tdx_mrowner": "384-bit software-defined owner ID, 96 hex characters", + "tdx_mrownerconfig": "384-bit owner-defined configuration ID, 96 hex characters", + "tdx_report_data": "512-bit TD report data, 128 hex characters", } +#: Attestation type -> the claim that says the guest was launched debuggable. +#: SNP: MAA claim sets page. TDX: MAA TDX EAT profile; the tdxvm sample token on +#: the token examples page carries ``"tdx_td_attributes_debug": false``. +DEBUG_CLAIM_BY_ATTESTATION_TYPE = { + "sevsnpvm": "x-ms-sevsnpvm-is-debuggable", + "tdxvm": "tdx_td_attributes_debug", +} + +#: Claim-name prefix -> the only attestation type whose tokens carry it. +_TEE_CLAIM_PREFIX = { + "x-ms-sevsnpvm-": "sevsnpvm", + "tdx_": "tdxvm", +} + + +def _claim_tee(claim: str) -> str | None: + """The attestation type a claim belongs to, or None if it is TEE-neutral.""" + for prefix, attestation_type in _TEE_CLAIM_PREFIX.items(): + if claim.startswith(prefix): + return attestation_type + return None + #: Claims whose width makes them incompatible with a WCM HashValue, and why. #: build_release_policy checks this before emitting a condition, so the failure #: arrives at generation time rather than as a policy that never matches. @@ -110,6 +152,15 @@ "x-ms-sevsnpvm-launchmeasurement": 96, "x-ms-sevsnpvm-hostdata": 64, "x-ms-sevsnpvm-idkeydigest": 96, + "tdx_mrtd": 96, + "tdx_rtmr0": 96, + "tdx_rtmr1": 96, + "tdx_rtmr2": 96, + "tdx_rtmr3": 96, + "tdx_mrconfigid": 96, + "tdx_mrowner": 96, + "tdx_mrownerconfig": 96, + "tdx_report_data": 128, } @@ -158,11 +209,68 @@ def _usable_measurements(manifest: WeightCustodyManifest) -> list[str]: ] +def _measurement_claims_by_type( + measurement_claim: str | Mapping[str, str], attestation_types: Sequence[str] +) -> dict[str, str]: + """Resolve ``measurement_claim`` to one claim per attestation type, or refuse.""" + if isinstance(measurement_claim, str): + requested = {attestation_type: measurement_claim for attestation_type in attestation_types} + else: + requested = dict(measurement_claim) + unknown = sorted(set(requested) - set(DEBUG_CLAIM_BY_ATTESTATION_TYPE)) + if unknown: + raise SkrPolicyError( + f"measurement_claim keys {unknown} are not MAA attestation types; use " + f"{sorted(DEBUG_CLAIM_BY_ATTESTATION_TYPE)}" + ) + resolved: dict[str, str] = {} + for attestation_type in attestation_types: + claim = requested.get(attestation_type) + if claim is None: + raise SkrPolicyError( + f"no measurement_claim for {attestation_type}. The manifest allows it, and " + "a branch without a measurement condition would release the key to any " + "compliant CVM of that type. Pass a mapping with a claim for every " + "attestation type." + ) + if claim not in MAA_CLAIMS: + raise SkrPolicyError( + f"{claim!r} is not in MAA_CLAIMS. A policy referencing a " + "claim MAA does not issue never matches, and presents as a broken CVM " + "rather than as a policy error. Add it to MAA_CLAIMS with a description " + "once you have confirmed Azure issues it." + ) + tee = _claim_tee(claim) + if tee is not None and tee != attestation_type: + raise SkrPolicyError( + f"{claim} is only issued on {tee} tokens, so it can never match on the " + f"{attestation_type} branch this manifest requires. Pass a mapping from " + "attestation type to a claim of that type." + ) + resolved[attestation_type] = claim + return resolved + + +def _check_measurement_width(claim: str, measurements: Sequence[str]) -> None: + width = _CLAIM_HEX_WIDTH.get(claim) + for measurement in measurements: + digest = measurement.split(":", 1)[1] + if width is not None and len(digest) != width: + raise SkrPolicyError( + f"measurement {measurement} has {len(digest)} hex characters but " + f"{claim} carries {width}. These are values from " + "different chains; comparing them would produce a policy that " + "never matches. On Azure the WCM path binds a SHA-256 PCR 23 " + "digest (see wcm.azure_vtpm), which is not the SNP launch " + "measurement." + ) + + def build_release_policy( manifest: WeightCustodyManifest, *, authority: str, - measurement_claim: str | None = None, + measurement_claim: str | Mapping[str, str] | None = None, allow_unbound_workload: bool = False, require_not_debuggable: bool = True, ) -> dict[str, Any]: @@ -175,7 +283,10 @@ def build_release_policy( ``measurement_claim`` names the MAA claim carrying the value the manifest's ``accepted_measurements`` hold. See the module docstring for why this cannot - be inferred. + be inferred. When the manifest allows more than one TEE, pass a mapping from + attestation type (``sevsnpvm``, ``tdxvm``) to claim: a TEE-specific claim + such as ``x-ms-sevsnpvm-hostdata`` is only issued on that TEE's tokens, so it + cannot bind the other TEE's branch. """ if not authority.startswith("https://"): raise SkrPolicyError( @@ -207,46 +318,32 @@ def build_release_policy( "allow_unbound_workload=True to emit platform conditions only and " "accept that any compliant CVM in this authority can obtain the key." ) + claim_by_type: dict[str, str] = {} else: - if measurement_claim not in MAA_CLAIMS: - raise SkrPolicyError( - f"{measurement_claim!r} is not in MAA_CLAIMS. A policy referencing a " - "claim MAA does not issue never matches, and presents as a broken CVM " - "rather than as a policy error. Add it to MAA_CLAIMS with a description " - "once you have confirmed Azure issues it." - ) - width = _CLAIM_HEX_WIDTH.get(measurement_claim) - for measurement in measurements: - digest = measurement.split(":", 1)[1] - if width is not None and len(digest) != width: - raise SkrPolicyError( - f"measurement {measurement} has {len(digest)} hex characters but " - f"{measurement_claim} carries {width}. These are values from " - "different chains; comparing them would produce a policy that " - "never matches. On Azure the WCM path binds a SHA-256 PCR 23 " - "digest (see wcm.azure_vtpm), which is not the SNP launch " - "measurement." - ) - - base_conditions: list[dict[str, Any]] = [ - {"claim": "x-ms-attestation-type", "equals": attestation_types[0]} - ] - if manifest.release_policy.required_assurance_tier is AssuranceTier.hardware_attested: - base_conditions.append( - {"claim": "x-ms-compliance-status", "equals": "azure-compliant-cvm"} - ) - if require_not_debuggable and attestation_types[0] == "sevsnpvm": - # A debuggable guest can be inspected by the host, which defeats the - # software-adversary half of WCM's guarantee before any key moves. - base_conditions.append({"claim": "x-ms-sevsnpvm-is-debuggable", "equals": "false"}) + claim_by_type = _measurement_claims_by_type(measurement_claim, attestation_types) + for claim in dict.fromkeys(claim_by_type.values()): + _check_measurement_width(claim, measurements) branches: list[dict[str, Any]] = [] for attestation_type in attestation_types: - conditions = [dict(condition) for condition in base_conditions] - conditions[0] = {"claim": "x-ms-attestation-type", "equals": attestation_type} + # Built from this branch's own attestation type. Deriving any of it from + # attestation_types[0] made the debug gate depend on the order of + # required_hw_platform and put SNP-only claims on TDX branches. + conditions: list[dict[str, Any]] = [ + {"claim": "x-ms-attestation-type", "equals": attestation_type} + ] + if manifest.release_policy.required_assurance_tier is AssuranceTier.hardware_attested: + conditions.append({"claim": "x-ms-compliance-status", "equals": "azure-compliant-cvm"}) + if require_not_debuggable: + # A debuggable guest can be inspected by the host, which defeats the + # software-adversary half of WCM's guarantee before any key moves. + conditions.append( + {"claim": DEBUG_CLAIM_BY_ATTESTATION_TYPE[attestation_type], "equals": "false"} + ) if measurement_claim is None: branches.append({"authority": authority, "allOf": conditions}) continue + claim = claim_by_type[attestation_type] # One branch per accepted measurement: SKR's grammar has anyOf at the # branch level and allOf inside, with no disjunction over a single claim. for measurement in measurements: @@ -254,7 +351,7 @@ def build_release_policy( { "authority": authority, "allOf": conditions - + [{"claim": measurement_claim, "equals": measurement.split(":", 1)[1]}], + + [{"claim": claim, "equals": measurement.split(":", 1)[1]}], } ) @@ -312,7 +409,7 @@ def evidence_from_maa_claims( "meet Azure's CVM baseline, so this is not hardware-attested evidence and " "must not be built into a CompositeEvidence that says it is." ) - if str(claims.get("x-ms-sevsnpvm-is-debuggable", "false")).lower() == "true": + if str(claims.get(DEBUG_CLAIM_BY_ATTESTATION_TYPE[attestation_type], "false")).lower() == "true": raise SkrPolicyError( "the guest was launched debuggable, so the host can inspect it. Evidence " "from a debuggable guest does not support a hardware-attested assurance "