diff --git a/docs/cfav-retired-saml-signing-key-evidence.md b/docs/cfav-retired-saml-signing-key-evidence.md new file mode 100644 index 0000000..d8c4b69 --- /dev/null +++ b/docs/cfav-retired-saml-signing-key-evidence.md @@ -0,0 +1,30 @@ +# CFAV: Retired SAML Signing Key Trust-Edge Evidence (Issue #90) + +## Scope + +This is a **non-production synthetic acceptance fixture** for existing TrustDecay / SCIM-RE / RAP / Execution Exchange. It is **not a SAML XML signature implementation, live SAML relying-party test, post-quantum cryptanalysis, or production certification**. + +It uses actual RSA-2048/SHA-256 test-only signatures over a JSON assertion analog, two simulated relying parties with different pinned keys, the existing TTP `resolveTrustRoute()` on a revoked trust path, and independently pinned Ed25519 observer signatures over test observations. Keys are ephemeral and never committed. + +Run: `node --test packages/trust-routing-engine/tests/retired-key-assurance.test.mjs` or `npm run test:trust-routing`. + +## Evidence semantics + +- `NATIVE_REJECTED`: a host-pinned test observer signed an observation that the synthetic target rejected K1. This does not prove a real vendor SAML SP rejected XML. +- `NATIVE_ACCEPTANCE_UNCONTROLLED`: an inventoried SP accepted a K1-signed test assertion; no blocked effect was observed. Do not claim it is governed. +- `RESIDUAL_TRUST_BLOCKED_AT_EFFECT`: simulated native SP accepted K1, and the **same assertion challenge** was blocked at the protected effect by a test call through existing TrustRoute. Residual native trust remains and must be disclosed. +- `UNPROTECTED_EFFECT`: signed observation that the protected consequence executed; negative counterexample to safety. +- `UNKNOWN`: no appropriately scoped signed and fresh observation. Never silently treat unknown as safe. + +Receipts prove what a verifier reported, not independently that a real SP behaved as reported. A caller cannot earn verification by setting a `verified: true` boolean. The assessment requires a host-pinned observer key. The trusted host must independently control both observation collection and key trust. Synthetic signing material and `pinnedObserverKeys` shown in test code are **not** production key custody. + +## Next controlled integration gate + +1. Inventory actual `(issuer ID, signing-key SHA-256 SPKI fingerprint, relying-party ID, owner, configuration source)` trust edges, including manual pins and unobserved applications; do not treat inventory completeness as assumed. +2. Deploy two isolated, approved SAML XML service-provider test environments with synthetic users, test-only certificates and a properly configured assertion validator. +3. Produce independent native SP evidence for active K2 acceptance, K1 rejection in dynamic SP, K1 acceptance at deliberately pinned SP, and positive/negative audience, destination, InResponseTo, Conditions, subject confirmation, replay and issuer tests. +4. Protect a specific downstream action with the actual non-demo BlockSiFr authority path. Test native bypass routes and side effects with target-side audit instrumentation. Record genuine DENY and physical effect outcome separately. +5. Ensure signed observer evidence is rooted in an enrolled, rotated and revocable production trust registry. Test stale, missing, tampered, wrong-tenant, replayed, and revoked observer evidence. +6. Produce a report of `N` known edges, `R` native rejections, `B` residual native accepts with protected downstream blocks, `E` observed executions, and `U` unknowns. Never claim full coverage when `U > 0` or discovery is incomplete. + +**Acceptance claim allowed today:** Source-verified test harness demonstrating the difference between key retirement, native relying-party trust, and protected downstream execution. **Not allowed:** “Every relying party rejects a retired SAML key.” diff --git a/packages/trust-routing-engine/src/retired-key-assurance.js b/packages/trust-routing-engine/src/retired-key-assurance.js new file mode 100644 index 0000000..26179b6 --- /dev/null +++ b/packages/trust-routing-engine/src/retired-key-assurance.js @@ -0,0 +1,87 @@ +import { createPublicKey, verify } from 'node:crypto' + +// Read-only evidence assessment. This does not verify SAML XML, authenticate a user, +// mutate relying-party configuration, or authorize an execution. +const signedFields = [ + 'runId', 'observerId', 'issuerId', 'relyingPartyId', 'keyFingerprint', + 'challengeId', 'kind', 'outcome', 'observedAt' +] +const outcomes = { + native_assertion: new Set(['ACCEPTED', 'REJECTED']), + protected_effect: new Set(['EXECUTED', 'BLOCKED']) +} +const fingerprintPattern = /^[a-f0-9]{64}$/ + +export function canonicalObservation(observation) { + if (!observation || signedFields.some(k => typeof observation[k] !== 'string' || !observation[k])) { + throw new Error('incomplete_observation') + } + if (!fingerprintPattern.test(observation.keyFingerprint)) throw new Error('invalid_key_fingerprint') + if (!outcomes[observation.kind]?.has(observation.outcome)) throw new Error('invalid_outcome') + return Buffer.from(JSON.stringify(Object.fromEntries(signedFields.map(k => [k, observation[k]]))), 'utf8') +} + +function verifyObservation(observation, { issuerId, keyFingerprint, runId, pinnedObserverKeys, nowMs, maxAgeMs }) { + try { + if (observation.issuerId !== issuerId || observation.keyFingerprint !== keyFingerprint || observation.runId !== runId) return false + const observedAt = Date.parse(observation.observedAt) + if (!Number.isFinite(observedAt) || observedAt > nowMs || nowMs - observedAt > maxAgeMs) return false + const publicKey = pinnedObserverKeys[observation.observerId] + if (!publicKey || typeof observation.signature !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(observation.signature)) return false + return verify(null, canonicalObservation(observation), createPublicKey(publicKey), Buffer.from(observation.signature, 'base64')) + } catch { + return false + } +} + +/** + * Classifies observed trust edges without claiming that an observation is genuine + * merely because a caller says so. Observer keys must be pinned by the trusted host. + * An accepted native assertion is NEVER reported as native retirement success. + */ +export function assessRetiredKeyCoverage({ + issuerId, keyFingerprint, runId, relyingPartyIds, observations = [], + pinnedObserverKeys, assessedAt, maxObservationAgeSeconds = 3600 +}) { + if (!issuerId || !runId || !fingerprintPattern.test(keyFingerprint ?? '')) throw new Error('invalid_assessment_scope') + if (!Array.isArray(relyingPartyIds) || relyingPartyIds.length === 0 || + relyingPartyIds.some(id => typeof id !== 'string' || !id) || + new Set(relyingPartyIds).size !== relyingPartyIds.length) throw new Error('invalid_relying_party_inventory') + if (!Array.isArray(observations) || !pinnedObserverKeys || typeof pinnedObserverKeys !== 'object') throw new Error('invalid_evidence_input') + const nowMs = Date.parse(assessedAt) + if (!Number.isFinite(nowMs) || !Number.isFinite(maxObservationAgeSeconds) || maxObservationAgeSeconds <= 0) throw new Error('invalid_evaluation_time') + const verified = observations.filter(o => relyingPartyIds.includes(o?.relyingPartyId) && verifyObservation(o, { + issuerId, keyFingerprint, runId, pinnedObserverKeys, nowMs, + maxAgeMs: maxObservationAgeSeconds * 1000 + })) + const edges = relyingPartyIds.map(relyingPartyId => { + const party = verified.filter(o => o.relyingPartyId === relyingPartyId) + const native = party.filter(o => o.kind === 'native_assertion') + const effects = party.filter(o => o.kind === 'protected_effect') + // Any observed acceptance defeats the claim that this SP rejects K1. + const nativeOutcome = native.some(o => o.outcome === 'ACCEPTED') ? 'ACCEPTED' : + native.some(o => o.outcome === 'REJECTED') ? 'REJECTED' : 'UNKNOWN' + // A block only constrains the same accepted assertion challenge, not any other test. + const acceptedChallenges = new Set(native.filter(o => o.outcome === 'ACCEPTED').map(o => o.challengeId)) + const effectOutcome = effects.some(o => o.outcome === 'EXECUTED') ? 'EXECUTED' : + effects.some(o => o.outcome === 'BLOCKED' && acceptedChallenges.has(o.challengeId)) ? 'BLOCKED' : 'UNKNOWN' + let verdict = 'UNKNOWN' + if (effectOutcome === 'EXECUTED') verdict = 'UNPROTECTED_EFFECT' + else if (nativeOutcome === 'ACCEPTED' && effectOutcome === 'BLOCKED') verdict = 'RESIDUAL_TRUST_BLOCKED_AT_EFFECT' + else if (nativeOutcome === 'ACCEPTED') verdict = 'NATIVE_ACCEPTANCE_UNCONTROLLED' + else if (nativeOutcome === 'REJECTED') verdict = 'NATIVE_REJECTED' + return { + relyingPartyId, nativeOutcome, effectOutcome, verdict, + evidenceChallengeIds: [...new Set(party.map(o => o.challengeId))].sort() + } + }) + return { + evidenceState: 'SIGNED_OBSERVATION_ASSESSMENT_NOT_NATIVE_SAML_CERTIFICATION', + issuerId, keyFingerprint, runId, knownRelyingParties: edges.length, + verifiedObservations: verified.length, + rejectedObservations: observations.length - verified.length, + allKnownPartiesNativeReject: edges.every(e => e.verdict === 'NATIVE_REJECTED'), + allKnownPartiesEffectConstrained: edges.every(e => ['NATIVE_REJECTED', 'RESIDUAL_TRUST_BLOCKED_AT_EFFECT'].includes(e.verdict)), + edges + } +} diff --git a/packages/trust-routing-engine/tests/retired-key-assurance.test.mjs b/packages/trust-routing-engine/tests/retired-key-assurance.test.mjs new file mode 100644 index 0000000..53a6d7b --- /dev/null +++ b/packages/trust-routing-engine/tests/retired-key-assurance.test.mjs @@ -0,0 +1,139 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createHash, generateKeyPairSync, sign, verify } from 'node:crypto' +import { assessRetiredKeyCoverage, canonicalObservation } from '../src/retired-key-assurance.js' +import { resolveTrustRoute, computeBindingHash } from '../src/resolve.js' + +// Synthetic protocol-level payloads, not SAML XML signatures or a live SP. +// An SP's decision is established by verifying real RSA signatures against the +// public key(s) the controlled test-SP is configured to trust. +const key1 = generateKeyPairSync('rsa', { modulusLength: 2048 }) +const key2 = generateKeyPairSync('rsa', { modulusLength: 2048 }) +const observer = generateKeyPairSync('ed25519') +const publicKeyPEM = observer.publicKey.export({ type: 'spki', format: 'pem' }) +const fingerprint = createHash('sha256').update(key1.publicKey.export({ type: 'spki', format: 'der' })).digest('hex') +const issuerId = 'synthetic-idp-1' +const runId = 'cfav-issue-90-local-fixture' +const assessedAt = '2026-11-11T15:00:00.000Z' +const scopes = { issuerId, keyFingerprint: fingerprint, runId, + relyingPartyIds: ['SP-A-DYNAMIC', 'SP-B-PINNED', 'SP-C-UNKNOWN'], + pinnedObserverKeys: { 'staging-observer-1': publicKeyPEM }, assessedAt } + +function assertion(privateKey, audience) { + const payload = { issuerId, audience, subject: 'synthetic-user', + issuedAt: '2026-11-11T14:59:00.000Z', expiresAt: '2026-11-11T15:04:00.000Z', nonce: 'synthetic-unique-1' } + const bytes = Buffer.from(JSON.stringify(payload)) + return { payload, bytes, signature: sign('RSA-SHA256', bytes, privateKey) } +} +function targetAccepts(signed, audience, trustedPublicKeys) { + if (signed.payload.issuerId !== issuerId || signed.payload.audience !== audience) return false + if (Date.parse(signed.payload.expiresAt) <= Date.parse(assessedAt)) return false + return trustedPublicKeys.some(key => verify('RSA-SHA256', signed.bytes, key, signed.signature)) +} +function observed(relyingPartyId, kind, outcome, challengeId) { + const o = { runId, observerId: 'staging-observer-1', issuerId, relyingPartyId, + keyFingerprint: fingerprint, challengeId, kind, outcome, observedAt: assessedAt } + return { ...o, signature: sign(null, canonicalObservation(o), observer.privateKey).toString('base64') } +} +function evaluate(observations) { + return assessRetiredKeyCoverage({ ...scopes, observations }) +} +function protectedRouteDecision() { + const request = { subject: 'synthetic-user', action: 'record.write', resource: 'sp-b:protected-record', + paramsHash: 'sha256:synthetic', timestamp: assessedAt, context: {}, delegationHopCount: 0 } + request.bindingHash = computeBindingHash({ subject: request.subject, action: request.action, + resource: request.resource, paramsHash: request.paramsHash, timestampBucket: assessedAt.slice(0, 16) }) + return resolveTrustRoute({ request, candidates: [{ issuerType: 'workload', issuerId, + proofRef: 'synthetic-proof', trustScore: 0.99, lastVerifiedAt: assessedAt, + freshnessSeconds: 0, revoked: true, action: 'record.write', + delegationAllowed: false, maxHops: 0, grantId: 'synthetic-grant', minTrustScore: 0.65 }] }) +} + +test('real RSA signatures: dynamic SP rejects retired K1, pinned SP accepts K1', () => { + const oldForA = assertion(key1.privateKey, 'SP-A-DYNAMIC') + const newForA = assertion(key2.privateKey, 'SP-A-DYNAMIC') + const oldForB = assertion(key1.privateKey, 'SP-B-PINNED') + assert.equal(targetAccepts(oldForA, 'SP-A-DYNAMIC', [key2.publicKey]), false) + assert.equal(targetAccepts(newForA, 'SP-A-DYNAMIC', [key2.publicKey]), true) + assert.equal(targetAccepts(oldForB, 'SP-B-PINNED', [key1.publicKey]), true) + assert.equal(targetAccepts(oldForB, 'SP-A-DYNAMIC', [key1.publicKey]), false) +}) + +test('retired key is denied for protected effect despite pinned SP accepting it', () => { + const oldForB = assertion(key1.privateKey, 'SP-B-PINNED') + assert.equal(targetAccepts(oldForB, 'SP-B-PINNED', [key1.publicKey]), true) + const d = protectedRouteDecision() + assert.equal(d.decision, 'DENY') + assert.deepEqual(d.reasonCodes, ['revoked_subject']) + let protectedEffectOccurred = false + if (d.decision === 'PERMIT') protectedEffectOccurred = true + assert.equal(protectedEffectOccurred, false) + const report = evaluate([ + observed('SP-A-DYNAMIC', 'native_assertion', 'REJECTED', 'A3'), + observed('SP-B-PINNED', 'native_assertion', 'ACCEPTED', 'A4'), + observed('SP-B-PINNED', 'protected_effect', 'BLOCKED', 'A4') + ]) + assert.equal(report.allKnownPartiesNativeReject, false) + assert.equal(report.allKnownPartiesEffectConstrained, false) // SP-C still unknown + assert.equal(report.edges[0].verdict, 'NATIVE_REJECTED') + assert.equal(report.edges[1].verdict, 'RESIDUAL_TRUST_BLOCKED_AT_EFFECT') + assert.equal(report.edges[2].verdict, 'UNKNOWN') +}) + +test('a blocked effect from an unrelated challenge cannot establish protection', () => { + const report = evaluate([ + observed('SP-B-PINNED', 'native_assertion', 'ACCEPTED', 'A4'), + observed('SP-B-PINNED', 'protected_effect', 'BLOCKED', 'different-challenge') + ]) + assert.equal(report.edges[1].verdict, 'NATIVE_ACCEPTANCE_UNCONTROLLED') +}) + +test('accepted native assertion without observed downstream block is uncontrolled', () => { + const report = evaluate([observed('SP-B-PINNED', 'native_assertion', 'ACCEPTED', 'A4')]) + assert.equal(report.edges[1].verdict, 'NATIVE_ACCEPTANCE_UNCONTROLLED') +}) + +test('confirmed protected effect after K1 retirement is critical counterexample', () => { + const report = evaluate([ + observed('SP-B-PINNED', 'native_assertion', 'ACCEPTED', 'A4'), + observed('SP-B-PINNED', 'protected_effect', 'EXECUTED', 'A6') + ]) + assert.equal(report.edges[1].verdict, 'UNPROTECTED_EFFECT') +}) + +test('forged and mutated observation cannot advance evidence state', () => { + const valid = observed('SP-A-DYNAMIC', 'native_assertion', 'REJECTED', 'A3') + const report = evaluate([{ ...valid, outcome: 'ACCEPTED' }, { ...valid, signature: 'AAAA' }]) + assert.equal(report.verifiedObservations, 0) + assert.equal(report.rejectedObservations, 2) + assert.equal(report.edges[0].verdict, 'UNKNOWN') +}) + +test('different issuer, run, party, key or stale observation are not accepted', () => { + const valid = observed('SP-A-DYNAMIC', 'native_assertion', 'REJECTED', 'A3') + const old = { ...valid, observedAt: '2026-11-10T15:00:00.000Z' } + old.signature = sign(null, canonicalObservation(old), observer.privateKey).toString('base64') + const report = evaluate([ + observed('SP-A-DYNAMIC', 'native_assertion', 'REJECTED', 'A3'), + observed('SP-X-NOT-IN-INVENTORY', 'native_assertion', 'REJECTED', 'bad-party'), + { ...valid, issuerId: 'other-issuer' }, + { ...valid, runId: 'other-run' }, + { ...valid, keyFingerprint: '0'.repeat(64) }, + old + ]) + assert.equal(report.verifiedObservations, 1) // un-inventoried SP observations cannot inflate verified coverage + assert.equal(report.rejectedObservations, 5) + assert.equal(report.knownRelyingParties, 3) + assert.equal(report.edges[0].verdict, 'NATIVE_REJECTED') +}) + +test('absence of known relying-party inventory never passes', () => { + assert.throws(() => assessRetiredKeyCoverage({ ...scopes, relyingPartyIds: [] }), /invalid_relying_party_inventory/) + assert.throws(() => assessRetiredKeyCoverage({ ...scopes, relyingPartyIds: ['SP-A-DYNAMIC', 'SP-A-DYNAMIC'] }), /invalid_relying_party_inventory/) +}) + +test('observer key must be pinned by host, not supplied by observation', () => { + const report = assessRetiredKeyCoverage({ ...scopes, pinnedObserverKeys: {}, + observations: [observed('SP-A-DYNAMIC', 'native_assertion', 'REJECTED', 'A3')] }) + assert.equal(report.edges[0].verdict, 'UNKNOWN') +})