Skip to content
Draft
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
30 changes: 30 additions & 0 deletions docs/cfav-retired-saml-signing-key-evidence.md
Original file line number Diff line number Diff line change
@@ -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.”
87 changes: 87 additions & 0 deletions packages/trust-routing-engine/src/retired-key-assurance.js
Original file line number Diff line number Diff line change
@@ -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
}
}
139 changes: 139 additions & 0 deletions packages/trust-routing-engine/tests/retired-key-assurance.test.mjs
Original file line number Diff line number Diff line change
@@ -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')
})
Loading