diff --git a/Cargo.lock b/Cargo.lock index 90497b20e..5e608adb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5565,10 +5565,12 @@ dependencies = [ "http", "jsonschema 0.18.3", "jsonwebtoken", + "p256", "rand_core 0.6.4", "rcgen", "registry-evidence-verifier", "registry-platform-audit", + "registry-platform-config", "registry-platform-crypto", "registry-platform-httpsec", "registry-platform-httputil", @@ -5607,6 +5609,7 @@ dependencies = [ "chrono", "ed25519-dalek", "getrandom 0.4.3", + "p256", "registry-evidence", "registry-evidence-verifier", "registry-mint", @@ -5636,6 +5639,7 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "p256", "registry-evidence-client", "registry-evidence-verifier", "registry-platform-crypto", @@ -5655,6 +5659,7 @@ dependencies = [ "ed25519-dalek", "getrandom 0.4.3", "jsonschema 0.18.3", + "p256", "pyo3", "pyo3-build-config", "registry-evidence-client", @@ -5675,6 +5680,7 @@ dependencies = [ "base64", "chrono", "jsonschema 0.18.3", + "p256", "registry-platform-crypto", "registry-platform-sdjwt", "schemars 1.2.1", @@ -5695,9 +5701,9 @@ dependencies = [ "base64", "chrono", "clap", - "ed25519-dalek", "getrandom 0.4.3", "inquire", + "p256", "registry-platform-crypto", "rhai", "rustix", @@ -5762,9 +5768,11 @@ dependencies = [ "ed25519-dalek", "http", "jsonwebtoken", + "p256", "registry-evidence", "registry-platform-audit", "registry-platform-canonical-json", + "registry-platform-config", "registry-platform-crypto", "registry-platform-oidc", "reqwest 0.12.28", @@ -5843,6 +5851,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "time", + "zeroize", ] [[package]] @@ -5858,10 +5867,12 @@ dependencies = [ "pkcs1", "proptest", "registry-platform-canonical-json", + "reqwest 0.12.28", "serde", "serde_json", "sha2 0.11.0", "subtle", + "tempfile", "thiserror 2.0.18", "tokio", "url", diff --git a/crates/registry-evidence-client-node/Cargo.toml b/crates/registry-evidence-client-node/Cargo.toml index be6e74098..b41eae530 100644 --- a/crates/registry-evidence-client-node/Cargo.toml +++ b/crates/registry-evidence-client-node/Cargo.toml @@ -35,6 +35,7 @@ napi-build.workspace = true base64.workspace = true ed25519-dalek.workspace = true getrandom.workspace = true +p256.workspace = true # The committed policy fixture stands for a relying party's own verification # policy document, so the tests validate it against the frozen contract that # governs one. `jsonschema` compiles that contract and `serde_norway` reads it. diff --git a/crates/registry-evidence-client-node/README.md b/crates/registry-evidence-client-node/README.md index f0f4ed97d..539661ef2 100644 --- a/crates/registry-evidence-client-node/README.md +++ b/crates/registry-evidence-client-node/README.md @@ -13,7 +13,7 @@ in CI. ## JS surface ```js -const client = new EvidenceClient({ baseUrl, trustedJwks, token, ... }); +const client = new EvidenceClient({ baseUrl, trustedJwks, revokedKeyIds, token }); const prepared = client.prepare(spec); // synchronous, no I/O const definitions = await client.discover(); @@ -79,7 +79,7 @@ cargo test -p registry-evidence-client-node --test golden_fixture -- --ignored r never by hand-editing the fixture files. The JS tests under `__test__/` take the opposite approach for their own live round trip: `helpers/live-signing.js` -signs a fresh Evidence payload with Node's built-in `crypto` (Ed25519) for +signs a fresh Evidence payload with Node's built-in `crypto` (P-256/ES256) for whatever nonce the prepared request actually generated, because neither `registry-evidence-verifier` nor `registry-evidence-client` exposes its test signer outside `cfg(test)`. @@ -110,6 +110,10 @@ workspace-wide forbid would reject outright. - `trustedRootCertificates` accepts a PEM-encoded string only, not a Buffer or DER bytes. +- `trustedJwks` and `revokedKeyIds` are both required trust inputs. + `revokedKeyIds` contains current service-key RFC 7638 thumbprints and + overrides a matching key even when it remains in `trustedJwks` or in an + older prepared request's policy. - Exactly two token providers are supported: `token: { static: "..." }` and `token: { privateKeyJwt: { tokenEndpoint, clientId, clientKey, ... } }`. A caller-supplied custom token provider is out of scope for this binding. diff --git a/crates/registry-evidence-client-node/__test__/construction.test.js b/crates/registry-evidence-client-node/__test__/construction.test.js index c9d38cd5b..15a7d0a3e 100644 --- a/crates/registry-evidence-client-node/__test__/construction.test.js +++ b/crates/registry-evidence-client-node/__test__/construction.test.js @@ -11,13 +11,16 @@ function validConfig(overrides = {}) { trustedJwks: { keys: [ { - kty: 'OKP', - crv: 'Ed25519', - kid: 'construction-test-key', - x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + kty: 'EC', + crv: 'P-256', + kid: '_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo', + alg: 'ES256', + x: '3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4', + y: 'GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU', }, ], }, + revokedKeyIds: [], token: { static: 'construction-test-token' }, ...overrides, }; @@ -45,6 +48,16 @@ test('an empty trusted key set is refused', () => { assertConfigurationRefusal(() => new EvidenceClient(validConfig({ trustedJwks: { keys: [] } }))); }); +test('the current revoked key list is required', () => { + const config = validConfig(); + delete config.revokedKeyIds; + assertConfigurationRefusal(() => new EvidenceClient(config)); +}); + +test('a malformed revoked key identifier is refused', () => { + assertConfigurationRefusal(() => new EvidenceClient(validConfig({ revokedKeyIds: ['not-a-thumbprint'] }))); +}); + test('a base URL with an empty path segment is refused', () => { assertConfigurationRefusal( () => new EvidenceClient(validConfig({ baseUrl: 'https://evidence.example.org/prefix//suffix' })), diff --git a/crates/registry-evidence-client-node/__test__/discovery.test.js b/crates/registry-evidence-client-node/__test__/discovery.test.js index febdb88a7..ada7bd5ff 100644 --- a/crates/registry-evidence-client-node/__test__/discovery.test.js +++ b/crates/registry-evidence-client-node/__test__/discovery.test.js @@ -48,6 +48,7 @@ function clientAgainst(stub, bounds = {}) { return new EvidenceClient({ baseUrl: stub.baseUrl, trustedJwks: GOLDEN_JWKS, + revokedKeyIds: [], token: { static: 'discovery-test-token' }, ...bounds, }); diff --git a/crates/registry-evidence-client-node/__test__/errors.test.js b/crates/registry-evidence-client-node/__test__/errors.test.js index 53da02190..346481e44 100644 --- a/crates/registry-evidence-client-node/__test__/errors.test.js +++ b/crates/registry-evidence-client-node/__test__/errors.test.js @@ -10,10 +10,12 @@ const { generateSigningKey, signEvidence, requestSpec, evidenceFor } = require(' const DUMMY_JWKS = { keys: [ { - kty: 'OKP', - crv: 'Ed25519', - kid: 'errors-test-key', - x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + kty: 'EC', + crv: 'P-256', + kid: '_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo', + alg: 'ES256', + x: '3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4', + y: 'GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU', }, ], }; @@ -25,6 +27,7 @@ async function clientAndPrepared(stub) { const client = new EvidenceClient({ baseUrl: stub.baseUrl, trustedJwks: DUMMY_JWKS, + revokedKeyIds: [], token: { static: 'errors-test-token' }, }); return { client, prepared: client.prepare(requestSpec()) }; @@ -170,6 +173,7 @@ test('a response over maxResponseBytes is refused as a transport failure, not a const client = new EvidenceClient({ baseUrl: stub.baseUrl, trustedJwks: DUMMY_JWKS, + revokedKeyIds: [], token: { static: 'errors-test-token' }, maxResponseBytes: 16, }); @@ -202,6 +206,7 @@ test('verifyAsOf refuses a non-finite or unrepresentable asOfMillis as a configu const client = new EvidenceClient({ baseUrl: stub.baseUrl, trustedJwks: signingKey.jwks, + revokedKeyIds: [], token: { static: 'as-of-millis-token' }, }); const prepared = client.prepare(spec); diff --git a/crates/registry-evidence-client-node/__test__/happy-path.test.js b/crates/registry-evidence-client-node/__test__/happy-path.test.js index caefdfb58..fe1222385 100644 --- a/crates/registry-evidence-client-node/__test__/happy-path.test.js +++ b/crates/registry-evidence-client-node/__test__/happy-path.test.js @@ -40,6 +40,7 @@ test('a prepared request round-trips through send and verify against a live stub const client = new EvidenceClient({ baseUrl: stub.baseUrl, trustedJwks: signingKey.jwks, + revokedKeyIds: [], token: { static: 'happy-path-token' }, }); @@ -67,6 +68,7 @@ test('requestAndVerify performs the same round trip in one call', async () => { const client = new EvidenceClient({ baseUrl: stub.baseUrl, trustedJwks: signingKey.jwks, + revokedKeyIds: [], token: { static: 'happy-path-token' }, }); @@ -92,6 +94,7 @@ test('a second send on the same prepared request is refused locally, and the stu const client = new EvidenceClient({ baseUrl: stub.baseUrl, trustedJwks: signingKey.jwks, + revokedKeyIds: [], token: { static: 'one-send-guard-token' }, }); diff --git a/crates/registry-evidence-client-node/__test__/helpers/live-signing.js b/crates/registry-evidence-client-node/__test__/helpers/live-signing.js index 2a0793692..e8af2e245 100644 --- a/crates/registry-evidence-client-node/__test__/helpers/live-signing.js +++ b/crates/registry-evidence-client-node/__test__/helpers/live-signing.js @@ -12,7 +12,7 @@ const EVIDENCE_SCHEMA_V1 = 'registry.assertion-evidence/v1'; const EVIDENCE_JWS_MEDIA_TYPE = 'application/jose+json'; /** - * A fresh Ed25519 signing key for one stub deployment. + * A fresh P-256 signing key for one stub deployment. * * Neither crate that can sign a real Evidence response * (`registry-evidence-verifier`, `registry-evidence-client`) exposes its test @@ -22,20 +22,24 @@ const EVIDENCE_JWS_MEDIA_TYPE = 'application/jose+json'; * `registry-platform-crypto` on the Rust side. This key is generated fresh * per test and never written anywhere. */ -function generateSigningKey(kid) { - const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); +function generateSigningKey() { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); const jwk = publicKey.export({ format: 'jwk' }); + const thumbprintInput = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }); + const kid = crypto.createHash('sha256').update(thumbprintInput).digest('base64url'); return { kid, privateKey, - jwks: { keys: [{ ...jwk, kid, alg: 'EdDSA' }] }, + jwks: { keys: [{ ...jwk, kid, alg: 'ES256' }] }, }; } /** Sign an Evidence payload as a flattened JWS, matching the wire format `verify_flattened_jws` expects. */ function signEvidence(evidence, signingKey) { const protectedHeader = { - alg: 'EdDSA', + alg: 'ES256', kid: signingKey.kid, typ: EVIDENCE_JWS_TYP, cty: EVIDENCE_JWS_CTY, @@ -43,7 +47,10 @@ function signEvidence(evidence, signingKey) { const protectedSegment = Buffer.from(JSON.stringify(protectedHeader)).toString('base64url'); const payloadSegment = Buffer.from(JSON.stringify(evidence)).toString('base64url'); const signingInput = `${protectedSegment}.${payloadSegment}`; - const signature = crypto.sign(null, Buffer.from(signingInput), signingKey.privateKey); + const signature = crypto.sign('sha256', Buffer.from(signingInput), { + key: signingKey.privateKey, + dsaEncoding: 'ieee-p1363', + }); return { protected: protectedSegment, payload: payloadSegment, diff --git a/crates/registry-evidence-client-node/index.d.ts b/crates/registry-evidence-client-node/index.d.ts index f20c13095..04c7812ba 100644 --- a/crates/registry-evidence-client-node/index.d.ts +++ b/crates/registry-evidence-client-node/index.d.ts @@ -3,9 +3,9 @@ /** A relying party's connection to one Evidence deployment. */ export declare class EvidenceClient { /** - * Build a client for one deployment. `trustedJwks` is mandatory; a key set - * the verifier could never use is refused, exactly as the Rust - * configuration refuses it. + * Build a client for one deployment. `trustedJwks` and `revokedKeyIds` are + * mandatory trust inputs. A key set or revoked-key list the verifier could + * never use is refused, exactly as the Rust configuration refuses it. * * `maxResponseBytes` bounds the signed response `send` reads. * `maxMetadataBytes` bounds the documents `discover` and `fetchJwks` read, diff --git a/crates/registry-evidence-client-node/src/convert.rs b/crates/registry-evidence-client-node/src/convert.rs index 1b6f33388..2b3cd81e3 100644 --- a/crates/registry-evidence-client-node/src/convert.rs +++ b/crates/registry-evidence-client-node/src/convert.rs @@ -87,6 +87,25 @@ fn required_u64(object: &Map, field: &str) -> Result, + field: &str, +) -> Result, ConversionError> { + let values = object + .get(field) + .and_then(Value::as_array) + .ok_or_else(|| ConversionError::new(format!("`{field}` must be an array of strings")))?; + values + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| ConversionError::new(format!("`{field}` must contain only strings"))) + }) + .collect() +} + fn optional_string( object: &Map, field: &str, @@ -418,10 +437,13 @@ pub fn config_from_json(value: &Value) -> Result Value { serde_json::json!({ "keys": [{ - "kty": "OKP", - "crv": "Ed25519", - "kid": "test-key", - "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "kty": "EC", + "crv": "P-256", + "kid": "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo", + "alg": "ES256", + "x": "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4", + "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU", }], }) } @@ -818,6 +842,7 @@ mod tests { serde_json::json!({ "baseUrl": "https://evidence.example.org", "trustedJwks": one_key_jwks_json(), + "revokedKeyIds": [], "token": { "static": "header-safe-token" }, }) } @@ -835,6 +860,7 @@ mod tests { let config_json = serde_json::json!({ "baseUrl": "https://evidence.example.org", "trustedJwks": one_key_jwks_json(), + "revokedKeyIds": [], "token": { "privateKeyJwt": { "tokenEndpoint": "https://issuer.example.org/token", @@ -855,6 +881,7 @@ mod tests { let config_json = serde_json::json!({ "baseUrl": "https://evidence.example.org", "trustedJwks": one_key_jwks_json(), + "revokedKeyIds": [], "token": { "static": "header-safe-token", "privateKeyJwt": { @@ -916,6 +943,7 @@ mod tests { let config_json = serde_json::json!({ "baseUrl": "https://evidence.example.org", "trustedJwks": one_key_jwks_json(), + "revokedKeyIds": [], "token": { "privateKeyJwt": { "tokenEndpoint": "https://issuer.example.org/token", @@ -940,6 +968,7 @@ mod tests { let config_json = serde_json::json!({ "baseUrl": "https://evidence.example.org", "trustedJwks": one_key_jwks_json(), + "revokedKeyIds": [], "token": { "privateKeyJwt": { "tokenEndpoint": "https://issuer.example.org/token", @@ -1235,6 +1264,7 @@ mod tests { let config_json = serde_json::json!({ "baseUrl": "https://evidence.example.org", "trustedJwks": one_key_jwks_json(), + "revokedKeyIds": [], "token": { "privateKeyJwt": { "tokenEndpoint": "https://issuer.example.org/token", diff --git a/crates/registry-evidence-client-node/src/lib.rs b/crates/registry-evidence-client-node/src/lib.rs index 774e6bb7b..d56a6a4e1 100644 --- a/crates/registry-evidence-client-node/src/lib.rs +++ b/crates/registry-evidence-client-node/src/lib.rs @@ -226,9 +226,9 @@ pub struct EvidenceClient { #[napi] impl EvidenceClient { - /// Build a client for one deployment. `trustedJwks` is mandatory; a key set - /// the verifier could never use is refused, exactly as the Rust - /// configuration refuses it. + /// Build a client for one deployment. `trustedJwks` and `revokedKeyIds` are + /// mandatory trust inputs. A key set or revoked-key list the verifier could + /// never use is refused, exactly as the Rust configuration refuses it. /// /// `maxResponseBytes` bounds the signed response `send` reads. /// `maxMetadataBytes` bounds the documents `discover` and `fetchJwks` read, diff --git a/crates/registry-evidence-client-node/tests/fixtures/jwks.json b/crates/registry-evidence-client-node/tests/fixtures/jwks.json index a2bf16137..ee8904275 100644 --- a/crates/registry-evidence-client-node/tests/fixtures/jwks.json +++ b/crates/registry-evidence-client-node/tests/fixtures/jwks.json @@ -1,11 +1,12 @@ { "keys": [ { - "alg": "EdDSA", - "crv": "Ed25519", - "kid": "evidence-node-fixture-key-1", - "kty": "OKP", - "x": "34jRMEq83DuDYLQbJLPH52qmMl1WWM54R6sDokOThsc" + "alg": "ES256", + "crv": "P-256", + "kid": "CJWjQNpBEQhXN4QzR4UwHlktswaNrK2WrFNnOkPrEEQ", + "kty": "EC", + "x": "h0bZZ3A3lttrLpOb6DL01uOgug4SWBR0gP3Nn_7yYkM", + "y": "ie2RcuvxkfDUiUCQ0bP1YTo-g5MuBCFwEIOEbOKElj4" } ] } diff --git a/crates/registry-evidence-client-node/tests/fixtures/policy.json b/crates/registry-evidence-client-node/tests/fixtures/policy.json index 62e8f3321..a51543bfa 100644 --- a/crates/registry-evidence-client-node/tests/fixtures/policy.json +++ b/crates/registry-evidence-client-node/tests/fixtures/policy.json @@ -20,6 +20,7 @@ "form": "boolean" } ], + "revokedKeyIds": [], "maximumAssertionLifetimeSeconds": 2592000, "clockSkewSeconds": 30 } diff --git a/crates/registry-evidence-client-node/tests/fixtures/response.jws.json b/crates/registry-evidence-client-node/tests/fixtures/response.jws.json index be7b94f1a..2d7f0f2da 100644 --- a/crates/registry-evidence-client-node/tests/fixtures/response.jws.json +++ b/crates/registry-evidence-client-node/tests/fixtures/response.jws.json @@ -1,5 +1,5 @@ { - "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2aWRlbmNlLW5vZGUtZml4dHVyZS1rZXktMSIsInR5cCI6ImV2aWRlbmNlK2p3cyIsImN0eSI6ImFwcGxpY2F0aW9uL2V2aWRlbmNlK2pzb24ifQ", + "protected": "eyJhbGciOiJFUzI1NiIsImtpZCI6IkNKV2pRTnBCRVFoWE40UXpSNFV3SGxrdHN3YU5ySzJXckZObk9rUHJFRVEiLCJ0eXAiOiJldmlkZW5jZStqd3MiLCJjdHkiOiJhcHBsaWNhdGlvbi9ldmlkZW5jZStqc29uIn0", "payload": "eyJzY2hlbWEiOiJyZWdpc3RyeS5hc3NlcnRpb24tZXZpZGVuY2UvdjEiLCJhc3N1cmFuY2VQcm9maWxlIjoibG9jYWwiLCJyZXF1ZXN0Tm9uY2UiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBIiwiaWQiOiJ1cm46ZXhhbXBsZTpldmlkZW5jZTpub2RlLWZpeHR1cmUiLCJ0eXBlIjoiRXZpZGVuY2UiLCJzdXBwb3J0c1JlcXVpcmVtZW50IjoidXJuOmV4YW1wbGU6cmVxdWlyZW1lbnQ6djEiLCJpc0NvbmZvcm1hbnRUbyI6InVybjpleGFtcGxlOmV2aWRlbmNlLXR5cGU6djEiLCJpc3N1ZWRCeSI6InVybjpleGFtcGxlOmlzc3VlciIsInByb3ZpZGVkQnkiOiJ1cm46ZXhhbXBsZTpwcm92aWRlciIsImlzc3VlZEF0IjoiMjAyNi0wOC0wMVQwMDowMDowMFoiLCJvYnNlcnZlZEF0IjoiMjAyNi0wOC0wMVQwMDowMDowMFoiLCJ2YWxpZFVudGlsIjoiMjAyNi0wOC0zMVQwMDowMDowMFoiLCJwdXJwb3NlIjoiZXhhbXBsZS1wdXJwb3NlIiwiYXVkaWVuY2UiOiJ1cm46ZXhhbXBsZTphdWRpZW5jZSIsImNvbmZpZ3VyYXRpb25SZXZpc2lvbiI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwic3ViamVjdHMiOlt7InJvbGUiOiJzdWJqZWN0IiwiYmluZGluZyI6InVybjpldmlkZW5jZTpzdWJqZWN0OnYxX0FBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEifV0sInN1cHBvcnRlZFZhbHVlcyI6W3sicHJvdmlkZXNWYWx1ZUZvciI6InVybjpleGFtcGxlOmNvbmNlcHQ6c3RhdHVzLWhvbGRzIiwidmFsdWUiOnRydWV9XX0", - "signature": "8-ktnYTjflZ6Ctw35M6bfySjhG_J48joahbJeWOb73GkB58vfl2wFmTJDuVsu0qxblNhEbJoC9cESE2HCEy6Ag" + "signature": "pOOJCnN2_-HRugVcsfh-tQIG3UnyNguDCmet7z8jLXO-z6dPHbEFZeqquLYJeFBpE-28vPrN4mmEbvJJl-Up5g" } diff --git a/crates/registry-evidence-client-node/tests/golden_fixture.rs b/crates/registry-evidence-client-node/tests/golden_fixture.rs index 30de59189..aa4e5ebfb 100644 --- a/crates/registry-evidence-client-node/tests/golden_fixture.rs +++ b/crates/registry-evidence-client-node/tests/golden_fixture.rs @@ -17,7 +17,7 @@ use std::{fs, path::Path}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::{DateTime, Duration as ChronoDuration, Utc}; -use ed25519_dalek::SigningKey; +use p256::{ecdsa::SigningKey, elliptic_curve::rand_core::OsRng}; use registry_evidence_client::{ AssuranceProfile, Evidence, EvidenceObjectType, EvidenceVerificationPolicyDocument, ExpectedFormDocument, ExpectedOutputDocument, ExpectedScalarFormDocument, @@ -35,8 +35,6 @@ use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; /// `prepare`, so there is nothing independent to match it against. const FIXTURE_NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; -const ACTIVE_KEY_ID: &str = "evidence-node-fixture-key-1"; - /// The instant the committed response is signed for, shared by the generator /// and by every check that reads the result, so nothing has to re-derive it /// from the committed bytes. @@ -61,23 +59,29 @@ fn fixture_issued_at() -> DateTime { .expect("the fixture instant parses") } -/// A fresh Ed25519 signer under the fixture's key id. The private half never +/// A fresh P-256 signer under its RFC 7638 thumbprint key id. The private half never /// leaves the process that made it: regeneration commits only the public key, /// and the real-clock check below discards the whole pair when it returns. fn fixture_signer() -> LocalJwkSigner { - let mut seed = [0_u8; 32]; - getrandom::fill(&mut seed).expect("the host supplies randomness"); - let signing_key = SigningKey::from_bytes(&seed); - let private_jwk_json = serde_json::json!({ - "kty": "OKP", - "crv": "Ed25519", - "alg": "EdDSA", - "kid": ACTIVE_KEY_ID, - "x": URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), - "d": URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), - }); - let private_jwk = - PrivateJwk::parse(&private_jwk_json.to_string()).expect("the generated key parses"); + let signing_key = SigningKey::random(&mut OsRng); + let public = signing_key.verifying_key().to_encoded_point(false); + let mut private_jwk = PrivateJwk { + kty: "EC".to_owned(), + kid: None, + alg: Some("ES256".to_owned()), + crv: Some("P-256".to_owned()), + d: Some(URL_SAFE_NO_PAD.encode(signing_key.to_bytes())), + x: public.x().map(|value| URL_SAFE_NO_PAD.encode(value)), + y: public.y().map(|value| URL_SAFE_NO_PAD.encode(value)), + n: None, + e: None, + p: None, + q: None, + dp: None, + dq: None, + qi: None, + }; + private_jwk.kid = Some(private_jwk.public().jkt().expect("the thumbprint computes")); LocalJwkSigner::new(private_jwk).expect("the generated key signs") } @@ -153,6 +157,7 @@ fn fixture_policy_document(evidence: &Evidence) -> EvidenceVerificationPolicyDoc form: ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::Boolean), }) .collect(), + revoked_key_ids: Vec::new(), maximum_assertion_lifetime_seconds: (FIXTURE_LIFETIME_DAYS * 24 * 60 * 60) as u64, clock_skew_seconds: 30, } @@ -169,7 +174,7 @@ struct ProtectedHeader<'a> { async fn sign(evidence: &Evidence, signer: &LocalJwkSigner) -> FlattenedJws { let payload = serde_json::to_vec(evidence).expect("evidence serializes"); let protected = serde_json::to_vec(&ProtectedHeader { - alg: "EdDSA", + alg: "ES256", kid: signer.key_id(), typ: EVIDENCE_JWS_TYP, cty: EVIDENCE_JWS_CTY, @@ -273,14 +278,16 @@ fn the_committed_fixture_verifies_at_its_pinned_instant() { ) .expect("the policy fixture parses"); - let policy = policy_document.into_policy(fixture_issued_at() + ChronoDuration::days(1)); + let policy = policy_document + .try_into_policy(fixture_issued_at() + ChronoDuration::days(1)) + .expect("the committed policy states bounds its contract allows"); let evidence = verify_flattened_jws(&jws_bytes, &jwks, &policy).expect("the fixture verifies"); assert_fixture_shape(&evidence); } /// The real-clock half of the same coverage. A response signed now and verified -/// now keeps the wall-clock path through `into_policy` and +/// now keeps the wall-clock path through `try_into_policy` and /// `verify_flattened_jws` exercised, without any committed file having to stay /// current for years to do it. #[tokio::test] @@ -293,7 +300,9 @@ async fn a_freshly_signed_response_verifies_against_the_real_clock() { let jws_bytes = serde_json::to_vec(&sign(&evidence, &signer).await) .expect("the signed response serializes"); - let policy = policy_document.into_policy(Utc::now()); + let policy = policy_document + .try_into_policy(Utc::now()) + .expect("the fixture policy states bounds its contract allows"); let verified = verify_flattened_jws(&jws_bytes, &public_jwks(&signer), &policy) .expect("a freshly signed response verifies"); diff --git a/crates/registry-evidence-client-py/Cargo.toml b/crates/registry-evidence-client-py/Cargo.toml index 50814c620..d4d585472 100644 --- a/crates/registry-evidence-client-py/Cargo.toml +++ b/crates/registry-evidence-client-py/Cargo.toml @@ -48,6 +48,7 @@ pyo3 = { workspace = true, features = ["auto-initialize"] } base64.workspace = true ed25519-dalek.workspace = true getrandom.workspace = true +p256.workspace = true # The committed policy fixture stands for a relying party's own verification # policy document, so the tests validate it against the frozen contract that # governs one. `jsonschema` compiles that contract and `serde_norway` reads it. diff --git a/crates/registry-evidence-client-py/README.md b/crates/registry-evidence-client-py/README.md index 9a99480bc..337830afe 100644 --- a/crates/registry-evidence-client-py/README.md +++ b/crates/registry-evidence-client-py/README.md @@ -21,7 +21,7 @@ Python threads keep running. ```python from registry_evidence_client import EvidenceClient -client = EvidenceClient(base_url, trusted_jwks, token, ...) +client = EvidenceClient(base_url, trusted_jwks, revoked_key_ids, token) prepared = client.prepare(spec) # synchronous, no I/O definitions = client.discover() @@ -36,6 +36,11 @@ verified = client.verify_as_of(prepared, response, as_of_unix_seconds) key, `"private_key_jwt"`. There is no caller-supplied token provider; that is out of scope for this binding, same as the Node binding. +`trusted_jwks` and `revoked_key_ids` are both required trust inputs. +`revoked_key_ids` contains current service-key RFC 7638 thumbprints and +overrides a matching key even when it remains in `trusted_jwks` or in an older +prepared request's policy. + ## Design notes ### Error mapping diff --git a/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi b/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi index 62188503c..0545127a7 100644 --- a/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi +++ b/crates/registry-evidence-client-py/python/registry_evidence_client/__init__.pyi @@ -128,6 +128,7 @@ class EvidenceClient: self, base_url: str, trusted_jwks: Any, + revoked_key_ids: Sequence[str], token: Any, request_timeout_seconds: Optional[float] = ..., connect_timeout_seconds: Optional[float] = ..., diff --git a/crates/registry-evidence-client-py/src/convert.rs b/crates/registry-evidence-client-py/src/convert.rs index 6cc01bd3e..5d2d5d869 100644 --- a/crates/registry-evidence-client-py/src/convert.rs +++ b/crates/registry-evidence-client-py/src/convert.rs @@ -585,6 +585,7 @@ fn token_provider_from_json(value: &Value) -> Result, Con pub fn config_from_parts( base_url: &str, trusted_jwks: &Value, + revoked_key_ids: Vec, token: &Value, request_timeout_seconds: Option, connect_timeout_seconds: Option, @@ -604,7 +605,8 @@ pub fn config_from_parts( let token_provider = token_provider_from_json(token)?; - let mut config = EvidenceClientConfig::new(base_url, token_provider, trusted_jwks); + let mut config = + EvidenceClientConfig::new(base_url, token_provider, trusted_jwks, revoked_key_ids); if let Some(seconds) = request_timeout_seconds { let timeout = duration_from_seconds(seconds, "`request_timeout_seconds`") @@ -1076,6 +1078,7 @@ mod tests { let config = config_from_parts( "https://evidence.example/", &serde_json::json!({ "keys": [] }), + Vec::new(), &Value::String("a-static-token".to_owned()), None, None, @@ -1100,6 +1103,7 @@ mod tests { config_from_parts( "https://evidence.example/", &serde_json::json!({ "keys": [] }), + Vec::new(), &token, Some(5.5), Some(1.0), @@ -1116,6 +1120,7 @@ mod tests { let error = config_from_parts( "not a url", &serde_json::json!({ "keys": [] }), + Vec::new(), &Value::String("token".to_owned()), None, None, @@ -1133,6 +1138,7 @@ mod tests { let error = config_from_parts( "https://evidence.example/", &serde_json::json!({ "keys": [] }), + Vec::new(), &serde_json::json!({ "static": "token" }), None, None, @@ -1157,6 +1163,7 @@ mod tests { let error = config_from_parts( "https://evidence.example/", &serde_json::json!({ "keys": [] }), + Vec::new(), &token, None, None, @@ -1407,6 +1414,7 @@ mod tests { let error = config_from_parts( "https://evidence.example/", &serde_json::json!({ "keys": [] }), + Vec::new(), &Value::String(format!("{CANARY}\n")), None, None, @@ -1442,6 +1450,7 @@ mod tests { let error = config_from_parts( "https://evidence.example/", &serde_json::json!({ "keys": [] }), + Vec::new(), &token, None, None, diff --git a/crates/registry-evidence-client-py/src/lib.rs b/crates/registry-evidence-client-py/src/lib.rs index b3c79f04d..a48daecd6 100644 --- a/crates/registry-evidence-client-py/src/lib.rs +++ b/crates/registry-evidence-client-py/src/lib.rs @@ -307,10 +307,11 @@ struct EvidenceClient { impl EvidenceClient { /// Build a client for one deployment. /// - /// `trusted_jwks` is mandatory: a key set the verifier could never use is - /// refused, exactly as the wrapped Rust configuration refuses it. `token` - /// is either a static bearer string or the private-key-JWT provider's own - /// settings; there is no caller-supplied token provider in this binding. + /// `trusted_jwks` and `revoked_key_ids` are mandatory trust inputs: a key + /// set or revoked-key list the verifier could never use is refused, exactly + /// as the wrapped Rust configuration refuses it. `token` is either a static + /// bearer string or the private-key-JWT provider's own settings; there is no + /// caller-supplied token provider in this binding. /// /// `max_response_bytes` bounds the signed response `send` reads. /// `max_metadata_bytes` bounds the documents `discover` and `fetch_jwks` @@ -319,6 +320,7 @@ impl EvidenceClient { #[pyo3(signature = ( base_url, trusted_jwks, + revoked_key_ids, token, request_timeout_seconds=None, connect_timeout_seconds=None, @@ -332,6 +334,7 @@ impl EvidenceClient { py: Python<'_>, base_url: &str, trusted_jwks: &Bound<'_, PyAny>, + revoked_key_ids: Vec, token: &Bound<'_, PyAny>, request_timeout_seconds: Option, connect_timeout_seconds: Option, @@ -347,6 +350,7 @@ impl EvidenceClient { let config = config_from_parts( base_url, &trusted_jwks_json, + revoked_key_ids, &token_json, request_timeout_seconds, connect_timeout_seconds, diff --git a/crates/registry-evidence-client-py/tests/fixtures/jwks.json b/crates/registry-evidence-client-py/tests/fixtures/jwks.json index fd4cba6c7..6080a91c7 100644 --- a/crates/registry-evidence-client-py/tests/fixtures/jwks.json +++ b/crates/registry-evidence-client-py/tests/fixtures/jwks.json @@ -1,11 +1,12 @@ { "keys": [ { - "alg": "EdDSA", - "crv": "Ed25519", - "kid": "evidence-python-fixture-key-1", - "kty": "OKP", - "x": "uv8kus_GPzFwIEsLwrmF1TPFSC9CMkk51U8pxkO2SeM" + "alg": "ES256", + "crv": "P-256", + "kid": "JT1F_hSwUOPmolCSjv4Xiqn8Nh2fELjJnj-6uygiQ9w", + "kty": "EC", + "x": "vO5Qlv7KmONHNfqNQ2Ka5KwF1JG3_hIttdZ3WW04Kq8", + "y": "wYL2xh2g9rQEEnY9VRN2La5otbFRsDqgb_nJy3IKTNM" } ] } diff --git a/crates/registry-evidence-client-py/tests/fixtures/policy.json b/crates/registry-evidence-client-py/tests/fixtures/policy.json index 62e8f3321..a51543bfa 100644 --- a/crates/registry-evidence-client-py/tests/fixtures/policy.json +++ b/crates/registry-evidence-client-py/tests/fixtures/policy.json @@ -20,6 +20,7 @@ "form": "boolean" } ], + "revokedKeyIds": [], "maximumAssertionLifetimeSeconds": 2592000, "clockSkewSeconds": 30 } diff --git a/crates/registry-evidence-client-py/tests/fixtures/response.jws.json b/crates/registry-evidence-client-py/tests/fixtures/response.jws.json index 018dd97a9..d045a4ab6 100644 --- a/crates/registry-evidence-client-py/tests/fixtures/response.jws.json +++ b/crates/registry-evidence-client-py/tests/fixtures/response.jws.json @@ -1,5 +1,5 @@ { - "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV2aWRlbmNlLXB5dGhvbi1maXh0dXJlLWtleS0xIiwidHlwIjoiZXZpZGVuY2UrandzIiwiY3R5IjoiYXBwbGljYXRpb24vZXZpZGVuY2UranNvbiJ9", + "protected": "eyJhbGciOiJFUzI1NiIsImtpZCI6IkpUMUZfaFN3VU9QbW9sQ1NqdjRYaXFuOE5oMmZFTGpKbmotNnV5Z2lROXciLCJ0eXAiOiJldmlkZW5jZStqd3MiLCJjdHkiOiJhcHBsaWNhdGlvbi9ldmlkZW5jZStqc29uIn0", "payload": "eyJzY2hlbWEiOiJyZWdpc3RyeS5hc3NlcnRpb24tZXZpZGVuY2UvdjEiLCJhc3N1cmFuY2VQcm9maWxlIjoibG9jYWwiLCJyZXF1ZXN0Tm9uY2UiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBIiwiaWQiOiJ1cm46ZXhhbXBsZTpldmlkZW5jZTpweXRob24tZml4dHVyZSIsInR5cGUiOiJFdmlkZW5jZSIsInN1cHBvcnRzUmVxdWlyZW1lbnQiOiJ1cm46ZXhhbXBsZTpyZXF1aXJlbWVudDp2MSIsImlzQ29uZm9ybWFudFRvIjoidXJuOmV4YW1wbGU6ZXZpZGVuY2UtdHlwZTp2MSIsImlzc3VlZEJ5IjoidXJuOmV4YW1wbGU6aXNzdWVyIiwicHJvdmlkZWRCeSI6InVybjpleGFtcGxlOnByb3ZpZGVyIiwiaXNzdWVkQXQiOiIyMDI2LTA4LTAxVDAwOjAwOjAwWiIsIm9ic2VydmVkQXQiOiIyMDI2LTA4LTAxVDAwOjAwOjAwWiIsInZhbGlkVW50aWwiOiIyMDI2LTA4LTMxVDAwOjAwOjAwWiIsInB1cnBvc2UiOiJleGFtcGxlLXB1cnBvc2UiLCJhdWRpZW5jZSI6InVybjpleGFtcGxlOmF1ZGllbmNlIiwiY29uZmlndXJhdGlvblJldmlzaW9uIjoic2hhMjU2OjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJzdWJqZWN0cyI6W3sicm9sZSI6InN1YmplY3QiLCJiaW5kaW5nIjoidXJuOmV2aWRlbmNlOnN1YmplY3Q6djFfQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSJ9XSwic3VwcG9ydGVkVmFsdWVzIjpbeyJwcm92aWRlc1ZhbHVlRm9yIjoidXJuOmV4YW1wbGU6Y29uY2VwdDpzdGF0dXMtaG9sZHMiLCJ2YWx1ZSI6dHJ1ZX1dfQ", - "signature": "I_pS9ltaQMAKwXq3O6FryRm9QpNlrE_3NLOq3zebvoW1HyHi8uD__ho13XVvf78f7AVP4Zyg5BdR2mJSl-xDDA" + "signature": "B1hxozg8nL8Xph7lzSGzGbVK8dNWsnIIdmgmC_UNwC-y8E_MabNUAJDUO202-6zqhDfQRT-w61sLXdX37sktmw" } diff --git a/crates/registry-evidence-client-py/tests/golden_fixture.rs b/crates/registry-evidence-client-py/tests/golden_fixture.rs index 3dfdc56c8..88d4d3dda 100644 --- a/crates/registry-evidence-client-py/tests/golden_fixture.rs +++ b/crates/registry-evidence-client-py/tests/golden_fixture.rs @@ -22,7 +22,7 @@ use std::{fs, path::Path}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::{DateTime, Duration as ChronoDuration, Utc}; -use ed25519_dalek::SigningKey; +use p256::{ecdsa::SigningKey, elliptic_curve::rand_core::OsRng}; // This file is a separate integration-test crate, and Cargo auto-links two // different crates under the identical name `registry_evidence_client` here: // this package's own compiled library (its `[lib] name`) and the wrapped SDK @@ -51,8 +51,6 @@ use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; /// `prepare`, so there is nothing independent to match it against. const FIXTURE_NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; -const ACTIVE_KEY_ID: &str = "evidence-python-fixture-key-1"; - /// The instant the committed response is signed for, shared by the generator /// and by every check that reads the result, so nothing has to re-derive it /// from the committed bytes. @@ -77,23 +75,29 @@ fn fixture_issued_at() -> DateTime { .expect("the fixture instant parses") } -/// A fresh Ed25519 signer under the fixture's key id. The private half never +/// A fresh P-256 signer under its RFC 7638 thumbprint key id. The private half never /// leaves the process that made it: regeneration commits only the public key, /// and the real-clock check below discards the whole pair when it returns. fn fixture_signer() -> LocalJwkSigner { - let mut seed = [0_u8; 32]; - getrandom::fill(&mut seed).expect("the host supplies randomness"); - let signing_key = SigningKey::from_bytes(&seed); - let private_jwk_json = serde_json::json!({ - "kty": "OKP", - "crv": "Ed25519", - "alg": "EdDSA", - "kid": ACTIVE_KEY_ID, - "x": URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), - "d": URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), - }); - let private_jwk = - PrivateJwk::parse(&private_jwk_json.to_string()).expect("the generated key parses"); + let signing_key = SigningKey::random(&mut OsRng); + let public = signing_key.verifying_key().to_encoded_point(false); + let mut private_jwk = PrivateJwk { + kty: "EC".to_owned(), + kid: None, + alg: Some("ES256".to_owned()), + crv: Some("P-256".to_owned()), + d: Some(URL_SAFE_NO_PAD.encode(signing_key.to_bytes())), + x: public.x().map(|value| URL_SAFE_NO_PAD.encode(value)), + y: public.y().map(|value| URL_SAFE_NO_PAD.encode(value)), + n: None, + e: None, + p: None, + q: None, + dp: None, + dq: None, + qi: None, + }; + private_jwk.kid = Some(private_jwk.public().jkt().expect("the thumbprint computes")); LocalJwkSigner::new(private_jwk).expect("the generated key signs") } @@ -169,6 +173,7 @@ fn fixture_policy_document(evidence: &Evidence) -> EvidenceVerificationPolicyDoc form: ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::Boolean), }) .collect(), + revoked_key_ids: Vec::new(), maximum_assertion_lifetime_seconds: (FIXTURE_LIFETIME_DAYS * 24 * 60 * 60) as u64, clock_skew_seconds: 30, } @@ -185,7 +190,7 @@ struct ProtectedHeader<'a> { async fn sign(evidence: &Evidence, signer: &LocalJwkSigner) -> FlattenedJws { let payload = serde_json::to_vec(evidence).expect("evidence serializes"); let protected = serde_json::to_vec(&ProtectedHeader { - alg: "EdDSA", + alg: "ES256", kid: signer.key_id(), typ: EVIDENCE_JWS_TYP, cty: EVIDENCE_JWS_CTY, @@ -289,14 +294,16 @@ fn the_committed_fixture_verifies_at_its_pinned_instant() { ) .expect("the policy fixture parses"); - let policy = policy_document.into_policy(fixture_issued_at() + ChronoDuration::days(1)); + let policy = policy_document + .try_into_policy(fixture_issued_at() + ChronoDuration::days(1)) + .expect("the committed policy states bounds its contract allows"); let evidence = verify_flattened_jws(&jws_bytes, &jwks, &policy).expect("the fixture verifies"); assert_fixture_shape(&evidence); } /// The real-clock half of the same coverage. A response signed now and verified -/// now keeps the wall-clock path through `into_policy` and +/// now keeps the wall-clock path through `try_into_policy` and /// `verify_flattened_jws` exercised, without any committed file having to stay /// current for years to do it. #[tokio::test] @@ -309,7 +316,9 @@ async fn a_freshly_signed_response_verifies_against_the_real_clock() { let jws_bytes = serde_json::to_vec(&sign(&evidence, &signer).await) .expect("the signed response serializes"); - let policy = policy_document.into_policy(Utc::now()); + let policy = policy_document + .try_into_policy(Utc::now()) + .expect("the fixture policy states bounds its contract allows"); let verified = verify_flattened_jws(&jws_bytes, &public_jwks(&signer), &policy) .expect("a freshly signed response verifies"); diff --git a/crates/registry-evidence-client-py/tests/happy_path.rs b/crates/registry-evidence-client-py/tests/happy_path.rs index 7b410a228..cb8f55ceb 100644 --- a/crates/registry-evidence-client-py/tests/happy_path.rs +++ b/crates/registry-evidence-client-py/tests/happy_path.rs @@ -35,22 +35,24 @@ use std::{fs, path::Path}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::{DateTime, Duration as ChronoDuration, Utc}; -use ed25519_dalek::{Signer, SigningKey}; use evidence_client_sdk::{ AssuranceProfile, Evidence, EvidenceObjectType, JwksDocument, PublicValue, SubjectBinding, SupportedValue, }; +use p256::{ + ecdsa::{signature::Signer, Signature, SigningKey}, + elliptic_curve::rand_core::OsRng, +}; use pyo3::prelude::*; use registry_evidence_verifier::{ EVIDENCE_JWS_CTY, EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_JWS_TYP, EVIDENCE_SCHEMA_V1, }; +use registry_platform_crypto::PublicJwk; use wiremock::{ matchers::{method, path as path_matcher}, Mock, MockServer, ResponseTemplate, }; -const KEY_ID: &str = "evidence-python-live-key-1"; - /// The instant the golden fixture is signed for, restated from /// `tests/golden_fixture.rs` rather than shared with it: every file under /// `tests/` compiles as its own crate. @@ -83,25 +85,35 @@ fn request_spec_json() -> serde_json::Value { }) } -/// A fresh Ed25519 key, generated and discarded within one test. Distinct +/// A fresh P-256 key, generated and discarded within one test. Distinct /// from the golden fixture's committed key: these tests need to sign a /// response for a nonce that does not exist until `prepare()` runs, so they /// cannot use a response signed ahead of time. fn fresh_signing_key() -> SigningKey { - let mut seed = [0_u8; 32]; - getrandom::fill(&mut seed).expect("the host supplies randomness"); - SigningKey::from_bytes(&seed) + SigningKey::random(&mut OsRng) +} + +fn public_jwk(signing_key: &SigningKey) -> PublicJwk { + let point = signing_key.verifying_key().to_encoded_point(false); + let mut key = PublicJwk { + kty: "EC".to_owned(), + kid: None, + alg: Some("ES256".to_owned()), + crv: Some("P-256".to_owned()), + x: point.x().map(|value| URL_SAFE_NO_PAD.encode(value)), + y: point.y().map(|value| URL_SAFE_NO_PAD.encode(value)), + n: None, + e: None, + }; + key.kid = Some(key.jkt().expect("the thumbprint computes")); + key } fn trusted_jwks_json(signing_key: &SigningKey) -> serde_json::Value { let jwks = JwksDocument { - keys: vec![serde_json::json!({ - "kty": "OKP", - "crv": "Ed25519", - "alg": "EdDSA", - "kid": KEY_ID, - "x": URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), - })], + keys: vec![ + serde_json::to_value(public_jwk(signing_key)).expect("the public key serializes") + ], }; serde_json::to_value(jwks).expect("the key set serializes") } @@ -156,13 +168,16 @@ struct FlattenedJwsBody { /// Sign synchronously with the raw key, unlike the golden fixture's own /// signer: mounting has to happen after `prepare()` names a nonce, and by /// then this test is past the one async setup step it allows itself (see the -/// module doc comment), so the signature is computed with `ed25519_dalek` +/// module doc comment), so the signature is computed with `p256` /// directly rather than through the async `SigningProvider` trait. fn sign(evidence: &Evidence, signing_key: &SigningKey) -> Vec { let payload = serde_json::to_vec(evidence).expect("evidence serializes"); + let key_id = public_jwk(signing_key) + .kid + .expect("the key identifier is derived"); let protected = serde_json::to_vec(&ProtectedHeader { - alg: "EdDSA", - kid: KEY_ID, + alg: "ES256", + kid: &key_id, typ: EVIDENCE_JWS_TYP, cty: EVIDENCE_JWS_CTY, }) @@ -171,7 +186,7 @@ fn sign(evidence: &Evidence, signing_key: &SigningKey) -> Vec { let protected = URL_SAFE_NO_PAD.encode(protected); let payload = URL_SAFE_NO_PAD.encode(payload); let signing_input = format!("{protected}.{payload}"); - let signature = signing_key.sign(signing_input.as_bytes()); + let signature: Signature = signing_key.sign(signing_input.as_bytes()); serde_json::to_vec(&FlattenedJwsBody { protected, @@ -222,6 +237,7 @@ fn round_trip_through_send_and_verify() { .call1(( base_url.as_str(), python_json(py, &trusted_jwks), + Vec::::new(), "test-token", )) .expect("the client is constructed"); @@ -297,6 +313,7 @@ fn request_and_verify_performs_the_same_round_trip() { .call1(( base_url.as_str(), python_json(py, &trusted_jwks), + Vec::::new(), "test-token", )) .expect("the client is constructed"); @@ -352,6 +369,7 @@ fn a_second_send_is_refused_without_reaching_the_deployment() { .call1(( base_url.as_str(), python_json(py, &trusted_jwks), + Vec::::new(), "test-token", )) .expect("the client is constructed"); @@ -460,6 +478,7 @@ fn a_stale_fixture_response_fails_verification_against_a_live_prepared_request() .call1(( base_url.as_str(), python_json(py, &trusted_jwks), + Vec::::new(), "test-token", )) .expect("the client is constructed"); diff --git a/crates/registry-evidence-client-py/tests/python/helpers/fixtures.py b/crates/registry-evidence-client-py/tests/python/helpers/fixtures.py index b6dfb6951..2ddc8e036 100644 --- a/crates/registry-evidence-client-py/tests/python/helpers/fixtures.py +++ b/crates/registry-evidence-client-py/tests/python/helpers/fixtures.py @@ -22,11 +22,12 @@ VALID_JWKS = { "keys": [ { - "kty": "OKP", - "crv": "Ed25519", - "alg": "EdDSA", - "kid": "construction-test-key", - "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "kid": "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo", + "x": "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4", + "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU", } ] } diff --git a/crates/registry-evidence-client-py/tests/python/test_concurrency.py b/crates/registry-evidence-client-py/tests/python/test_concurrency.py index 442b8ec35..32bc876f6 100644 --- a/crates/registry-evidence-client-py/tests/python/test_concurrency.py +++ b/crates/registry-evidence-client-py/tests/python/test_concurrency.py @@ -61,7 +61,7 @@ def test_two_concurrent_calls_overlap_instead_of_serializing(self): # elapsed time it measures is the two `discover()` calls themselves, # not construction plus those calls. clients = [ - revc.EvidenceClient(server.base_url, fixtures.VALID_JWKS, "test-token") + revc.EvidenceClient(server.base_url, fixtures.VALID_JWKS, [], "test-token") for _ in range(2) ] @@ -144,7 +144,7 @@ def test_construction_releases_the_gil(self): base_url = "http://127.0.0.1:1" def build_client() -> None: - revc.EvidenceClient(base_url, fixtures.VALID_JWKS, "test-token") + revc.EvidenceClient(base_url, fixtures.VALID_JWKS, [], "test-token") def spin_observer( counter: list[int], diff --git a/crates/registry-evidence-client-py/tests/python/test_construction.py b/crates/registry-evidence-client-py/tests/python/test_construction.py index 020fa11bd..1b2a88428 100644 --- a/crates/registry-evidence-client-py/tests/python/test_construction.py +++ b/crates/registry-evidence-client-py/tests/python/test_construction.py @@ -26,7 +26,7 @@ class ConstructionTest(unittest.TestCase): def test_a_non_https_non_loopback_base_url_is_refused(self): with self.assertRaises(revc.ConfigurationError) as raised: - revc.EvidenceClient("http://example.org", fixtures.VALID_JWKS, "test-token") + revc.EvidenceClient("http://example.org", fixtures.VALID_JWKS, [], "test-token") error = raised.exception self.assertEqual(error.kind, "configuration") # A caller branches on `kind`, never by parsing `str(error)`: the @@ -35,13 +35,23 @@ def test_a_non_https_non_loopback_base_url_is_refused(self): def test_an_empty_key_set_is_refused(self): with self.assertRaises(revc.ConfigurationError) as raised: - revc.EvidenceClient("https://example.org", {"keys": []}, "test-token") + revc.EvidenceClient("https://example.org", {"keys": []}, [], "test-token") + self.assertEqual(raised.exception.kind, "configuration") + + def test_a_malformed_revoked_key_identifier_is_refused(self): + with self.assertRaises(revc.ConfigurationError) as raised: + revc.EvidenceClient( + "https://example.org", + fixtures.VALID_JWKS, + ["not-a-thumbprint"], + "test-token", + ) self.assertEqual(raised.exception.kind, "configuration") def test_a_base_url_with_an_empty_path_segment_is_refused(self): with self.assertRaises(revc.ConfigurationError) as raised: revc.EvidenceClient( - "https://example.org/a//b", fixtures.VALID_JWKS, "test-token" + "https://example.org/a//b", fixtures.VALID_JWKS, [], "test-token" ) self.assertEqual(raised.exception.kind, "configuration") @@ -53,7 +63,7 @@ def test_a_cyclic_mapping_is_refused_as_a_configuration_error(self): cyclic = {} cyclic["self"] = cyclic with self.assertRaises(revc.ConfigurationError) as raised: - revc.EvidenceClient("https://example.org", cyclic, "test-token") + revc.EvidenceClient("https://example.org", cyclic, [], "test-token") self.assertEqual(raised.exception.kind, "configuration") def test_a_loopback_http_base_url_is_accepted(self): @@ -61,13 +71,13 @@ def test_a_loopback_http_base_url_is_accepted(self): # the specific rules, not "any base URL fails". Port 1 is never # connected to here; construction never performs I/O. client = revc.EvidenceClient( - "http://127.0.0.1:1", fixtures.VALID_JWKS, "test-token" + "http://127.0.0.1:1", fixtures.VALID_JWKS, [], "test-token" ) self.assertIsInstance(client, revc.EvidenceClient) def test_every_exception_carries_every_stable_attribute(self): with self.assertRaises(revc.EvidenceClientError) as raised: - revc.EvidenceClient("http://example.org", fixtures.VALID_JWKS, "test-token") + revc.EvidenceClient("http://example.org", fixtures.VALID_JWKS, [], "test-token") for attribute in ( "kind", "status", diff --git a/crates/registry-evidence-client-py/tests/python/test_discovery.py b/crates/registry-evidence-client-py/tests/python/test_discovery.py index e2cc287b9..fc50f64f0 100644 --- a/crates/registry-evidence-client-py/tests/python/test_discovery.py +++ b/crates/registry-evidence-client-py/tests/python/test_discovery.py @@ -47,7 +47,7 @@ def setUp(self) -> None: def _client(self, **bounds): return revc.EvidenceClient( - self.server.base_url, fixtures.VALID_JWKS, "test-token", **bounds + self.server.base_url, fixtures.VALID_JWKS, [], "test-token", **bounds ) def _serve_definitions(self) -> bytes: diff --git a/crates/registry-evidence-client-py/tests/python/test_errors.py b/crates/registry-evidence-client-py/tests/python/test_errors.py index 053ceede2..6dd499dc6 100644 --- a/crates/registry-evidence-client-py/tests/python/test_errors.py +++ b/crates/registry-evidence-client-py/tests/python/test_errors.py @@ -39,7 +39,7 @@ def setUp(self) -> None: def _client(self, **kwargs): return revc.EvidenceClient( - self.server.base_url, fixtures.VALID_JWKS, "test-token", **kwargs + self.server.base_url, fixtures.VALID_JWKS, [], "test-token", **kwargs ) def _send(self, client): diff --git a/crates/registry-evidence-client-py/tests/python/test_one_send_guard.py b/crates/registry-evidence-client-py/tests/python/test_one_send_guard.py index dd2edac29..1861e0dcd 100644 --- a/crates/registry-evidence-client-py/tests/python/test_one_send_guard.py +++ b/crates/registry-evidence-client-py/tests/python/test_one_send_guard.py @@ -43,7 +43,7 @@ def setUp(self) -> None: def test_a_second_send_is_refused_without_reaching_the_network(self): client = revc.EvidenceClient( - self.server.base_url, fixtures.VALID_JWKS, "test-token" + self.server.base_url, fixtures.VALID_JWKS, [], "test-token" ) prepared = client.prepare(fixtures.request_spec()) diff --git a/crates/registry-evidence-client-py/tests/python/test_package_layout.py b/crates/registry-evidence-client-py/tests/python/test_package_layout.py index 0348ec155..cda4f1ab2 100644 --- a/crates/registry-evidence-client-py/tests/python/test_package_layout.py +++ b/crates/registry-evidence-client-py/tests/python/test_package_layout.py @@ -59,7 +59,7 @@ ) try: - revc.EvidenceClient("not-a-url", {"keys": []}, "test-token") + revc.EvidenceClient("not-a-url", {"keys": []}, [], "test-token") except revc.ConfigurationError as error: findings["refusal_kind"] = error.kind diff --git a/crates/registry-evidence-client-py/tests/python/test_raw_response.py b/crates/registry-evidence-client-py/tests/python/test_raw_response.py index 2f18df1f3..f8c45edb4 100644 --- a/crates/registry-evidence-client-py/tests/python/test_raw_response.py +++ b/crates/registry-evidence-client-py/tests/python/test_raw_response.py @@ -49,7 +49,7 @@ def _response(self, headers: dict[str, str]) -> revc.RawEvidenceResponse: headers={"Content-Type": EVIDENCE_JWS_MEDIA_TYPE, **headers}, body=SIGNED_BODY, ) - client = revc.EvidenceClient(server.base_url, fixtures.VALID_JWKS, "test-token") + client = revc.EvidenceClient(server.base_url, fixtures.VALID_JWKS, [], "test-token") return client.send(client.prepare(fixtures.request_spec())) def test_the_body_is_exactly_the_bytes_the_deployment_served(self): diff --git a/crates/registry-evidence-client/Cargo.toml b/crates/registry-evidence-client/Cargo.toml index 83dfb8260..fd227999c 100644 --- a/crates/registry-evidence-client/Cargo.toml +++ b/crates/registry-evidence-client/Cargo.toml @@ -33,6 +33,7 @@ zeroize.workspace = true [dev-dependencies] ed25519-dalek.workspace = true +p256.workspace = true registry-evidence.workspace = true registry-mint.workspace = true tempfile.workspace = true diff --git a/crates/registry-evidence-client/README.md b/crates/registry-evidence-client/README.md index 5cee52c8a..7052e8142 100644 --- a/crates/registry-evidence-client/README.md +++ b/crates/registry-evidence-client/README.md @@ -45,18 +45,21 @@ use registry_evidence_client::{ }; /// `trusted_jwks` is the key set the integrator reviewed and pinned out of band. -/// The prepared request carries the nonce and the closed policy that will judge -/// the answer. +/// `revoked_key_ids` is the current emergency denylist of service-key RFC 7638 +/// thumbprints. The prepared request carries the nonce and the closed policy +/// that will judge the answer. async fn accept( base_url: url::Url, access_token: &str, trusted_jwks: registry_evidence_client::JwksDocument, + revoked_key_ids: Vec, prepared: &PreparedEvidenceRequest, ) -> Result { let client = EvidenceClient::new(EvidenceClientConfig::new( base_url, Arc::new(StaticToken::new(access_token)?), trusted_jwks, + revoked_key_ids, ))?; client.request_and_verify(prepared).await } @@ -68,6 +71,10 @@ async fn accept( uses the key set pinned at construction. Nothing here fetches keys at verification time, because a key set taken from the same origin as the response it would verify establishes nothing about that response. +- The current revoked-key list is a separate required trust input. It overrides + both a key retained in the pinned set and the revocation list captured in an + older prepared request, so an emergency revocation takes effect without + preparing a replacement request. - One prepared request is one exchange, enforced rather than advised. Neither this crate nor its HTTP client retries anything, and a second `send` with the same prepared request fails locally before any I/O: a second attempt is a second diff --git a/crates/registry-evidence-client/src/client.rs b/crates/registry-evidence-client/src/client.rs index 6be89bbd2..d2def6e42 100644 --- a/crates/registry-evidence-client/src/client.rs +++ b/crates/registry-evidence-client/src/client.rs @@ -166,7 +166,7 @@ impl EvidenceClient { &self, spec: EvidenceRequestSpec, ) -> Result { - PreparedEvidenceRequest::new(spec) + PreparedEvidenceRequest::new_with_revoked_key_ids(spec, self.config.revoked_key_ids.clone()) } /// Read the request shapes this requester is entitled to send. @@ -311,7 +311,7 @@ impl EvidenceClient { response: &RawEvidenceResponse, now: DateTime, ) -> Result { - let policy_document = match prepared.subject_expectations() { + let mut policy_document = match prepared.subject_expectations() { SubjectExpectations::Pinned(_) => prepared.policy_document().clone(), // Adopt the response's own role-bound bindings as expectations, then // let the ordinary verifier apply the whole policy. Nothing else is @@ -322,7 +322,23 @@ impl EvidenceClient { prepared.policy_with_subjects(untrusted_subject_bindings(&response.body)) } }; - let policy = policy_document.into_policy(now); + // The client's current trusted denylist wins over the retained policy + // and the pinned JWKS. Reconstructing a client after an emergency + // revocation must refuse an older response even when both retained + // artifacts still name the compromised key. + policy_document + .revoked_key_ids + .clone_from(&self.config.revoked_key_ids); + // `prepare` bounded the two time expectations by the same contract the + // verifier enforces, so this refusal is unreachable from a prepared + // request. It stays a refusal rather than an assumption: honouring an + // out-of-contract lifetime would accept assertions this relying party + // must refuse. + let policy = policy_document.try_into_policy(now).map_err(|_| { + EvidenceClientError::configuration( + "the prepared policy states a time bound the verification policy contract forbids", + ) + })?; let evidence = verify_flattened_jws(&response.body, &self.config.trusted_jwks, &policy) .map_err(EvidenceClientError::Verification)?; Ok(VerifiedEvidence { @@ -577,6 +593,7 @@ mod tests { Url::parse(base_url).expect("the base URL parses"), Arc::new(StaticToken::new("test-token").expect("the credential is accepted")), fixture.trusted_jwks.clone(), + Vec::new(), ) } @@ -840,6 +857,33 @@ mod tests { ); } + #[test] + fn a_revoked_identifier_overrides_the_still_pinned_key() { + let fixture = signed_evidence(); + let key_id = fixture.trusted_jwks.keys[0]["kid"] + .as_str() + .expect("the fixture key has an identifier") + .to_owned(); + let client = EvidenceClient::new(EvidenceClientConfig::new( + Url::parse("https://evidence.example.org").expect("the base URL parses"), + Arc::new(StaticToken::new("test-token").expect("the credential is accepted")), + fixture.trusted_jwks.clone(), + vec![key_id.clone()], + )) + .expect("the denylisted cached key is valid configuration"); + let prepared = client + .prepare(spec(SubjectExpectations::AcceptFirstUse)) + .expect("the request is prepared"); + assert_eq!(prepared.policy_document().revoked_key_ids, [key_id]); + let response = raw(fixture.sign(prepared.request_nonce())); + assert_eq!( + client + .verify_as_of(&prepared, &response, fixture.now) + .expect_err("the revoked key is refused before cached selection"), + EvidenceClientError::Verification(VerificationError::Key) + ); + } + /// First-use acceptance defers which subject an assertion is about. It does /// not defer which roles were asked about, so a response that renames a role, /// adds one, or drops one is refused rather than adopted. diff --git a/crates/registry-evidence-client/src/config.rs b/crates/registry-evidence-client/src/config.rs index e2044573e..3165a1df0 100644 --- a/crates/registry-evidence-client/src/config.rs +++ b/crates/registry-evidence-client/src/config.rs @@ -6,7 +6,10 @@ use std::{fmt, sync::Arc, time::Duration}; -use registry_evidence_verifier::{model::JwksDocument, verifier::trusted_keys_are_usable}; +use registry_evidence_verifier::{ + model::JwksDocument, + verifier::{revoked_key_ids_are_usable, trusted_keys_are_usable}, +}; use registry_platform_httputil::DEFAULT_OUTBOUND_CONNECT_TIMEOUT; use url::Url; use zeroize::Zeroizing; @@ -50,6 +53,7 @@ pub struct EvidenceClientConfig { pub(crate) base_url: Url, pub(crate) token_provider: Arc, pub(crate) trusted_jwks: JwksDocument, + pub(crate) revoked_key_ids: Vec, pub(crate) request_timeout: Duration, pub(crate) connect_timeout: Duration, pub(crate) user_agent: Option, @@ -70,11 +74,13 @@ impl EvidenceClientConfig { base_url: Url, token_provider: Arc, trusted_jwks: JwksDocument, + revoked_key_ids: Vec, ) -> Self { Self { base_url, token_provider, trusted_jwks, + revoked_key_ids, request_timeout: DEFAULT_REQUEST_TIMEOUT, connect_timeout: DEFAULT_CONNECT_TIMEOUT, user_agent: None, @@ -138,6 +144,12 @@ impl EvidenceClientConfig { &self.trusted_jwks } + /// Emergency service-key denylist applied before the pinned key set. + #[must_use] + pub fn revoked_key_ids(&self) -> &[String] { + &self.revoked_key_ids + } + #[must_use] pub fn max_response_bytes(&self) -> u64 { self.max_response_bytes @@ -192,6 +204,11 @@ impl EvidenceClientConfig { "the pinned key set must be one the verifier can use", )); } + if revoked_key_ids_are_usable(&self.revoked_key_ids).is_err() { + return Err(EvidenceClientError::configuration( + "the revoked key identifiers must be unique RFC 7638 thumbprints within the verifier bound", + )); + } if self.max_response_bytes == 0 || self.max_metadata_bytes == 0 { return Err(EvidenceClientError::configuration( "the response bounds must allow at least one byte", @@ -233,6 +250,7 @@ mod tests { Url::parse(base_url).expect("the test URL parses"), Arc::new(StaticToken::new("test-token").expect("the credential is accepted")), one_key(), + Vec::new(), ) } @@ -241,10 +259,12 @@ mod tests { fn one_key() -> JwksDocument { JwksDocument { keys: vec![serde_json::json!({ - "kty": "OKP", - "crv": "Ed25519", - "kid": "test-key", - "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "kty": "EC", + "crv": "P-256", + "kid": "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo", + "alg": "ES256", + "x": "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4", + "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU", })], } } @@ -363,6 +383,26 @@ mod tests { ); } + #[test] + fn an_unusable_revocation_list_is_refused_at_construction() { + for revoked_key_ids in [ + vec!["not-a-thumbprint".to_owned()], + vec![ + "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo".to_owned(), + "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo".to_owned(), + ], + ] { + let mut config = config("https://evidence.example.org"); + config.revoked_key_ids = revoked_key_ids; + assert_eq!( + config.validate().expect_err("the denylist is refused"), + EvidenceClientError::configuration( + "the revoked key identifiers must be unique RFC 7638 thumbprints within the verifier bound" + ) + ); + } + } + /// Emptiness is only one of the ways a pinned set can be unusable, and every /// other way costs the adopter the same: a client that constructs, then /// refuses every response for a reason that reads as a deployment fault. The diff --git a/crates/registry-evidence-client/src/fixtures.rs b/crates/registry-evidence-client/src/fixtures.rs index 0ff6c05ee..a1966f731 100644 --- a/crates/registry-evidence-client/src/fixtures.rs +++ b/crates/registry-evidence-client/src/fixtures.rs @@ -16,7 +16,7 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::{DateTime, SecondsFormat, TimeDelta, Utc}; -use ed25519_dalek::SigningKey; +use p256::{ecdsa::SigningKey, elliptic_curve::rand_core::OsRng}; use registry_evidence_verifier::{ model::JwksDocument, EVIDENCE_JWS_CTY, EVIDENCE_JWS_TYP, EVIDENCE_SCHEMA_V1, }; @@ -55,25 +55,26 @@ pub(crate) struct SignedEvidenceFixture { /// A fresh issuer. Two fixtures never share a key or a key identifier, so a /// response from one is a response signed by a key the other never pinned. pub(crate) fn signed_evidence() -> SignedEvidenceFixture { - let mut seed = [0u8; 32]; - getrandom::fill(&mut seed).expect("the test host supplies randomness"); - let signing_key = SigningKey::from_bytes(&seed); - let public = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()); - let private = URL_SAFE_NO_PAD.encode(signing_key.to_bytes()); - let key_id = format!("fixture-key-{}", &public[..8]); - - let signing_key = PrivateJwk::parse( - &json!({ - "kty": "OKP", - "crv": "Ed25519", - "alg": "EdDSA", - "kid": key_id, - "x": public, - "d": private, - }) - .to_string(), - ) - .expect("the fixture key parses"); + let signing_key = SigningKey::random(&mut OsRng); + let public = signing_key.verifying_key().to_encoded_point(false); + let mut signing_key = PrivateJwk { + kty: "EC".to_owned(), + kid: None, + alg: Some("ES256".to_owned()), + crv: Some("P-256".to_owned()), + d: Some(URL_SAFE_NO_PAD.encode(signing_key.to_bytes())), + x: public.x().map(|value| URL_SAFE_NO_PAD.encode(value)), + y: public.y().map(|value| URL_SAFE_NO_PAD.encode(value)), + n: None, + e: None, + p: None, + q: None, + dp: None, + dq: None, + qi: None, + }; + let key_id = signing_key.public().jkt().expect("the thumbprint computes"); + signing_key.kid = Some(key_id.clone()); let trusted_jwks = JwksDocument { keys: vec![ serde_json::to_value(signing_key.public()).expect("the published key serializes") @@ -151,7 +152,7 @@ impl SignedEvidenceFixture { fn sign_payload(&self, payload: &Value) -> Vec { let protected = URL_SAFE_NO_PAD.encode( json!({ - "alg": "EdDSA", + "alg": "ES256", "kid": self.key_id, "typ": EVIDENCE_JWS_TYP, "cty": EVIDENCE_JWS_CTY, diff --git a/crates/registry-evidence-client/src/prepare.rs b/crates/registry-evidence-client/src/prepare.rs index a28d6aaf1..420cd5517 100644 --- a/crates/registry-evidence-client/src/prepare.rs +++ b/crates/registry-evidence-client/src/prepare.rs @@ -15,7 +15,8 @@ use std::{ use registry_evidence_verifier::{ verifier::{ EvidenceVerificationPolicyDocument, ExpectedFormDocument, ExpectedOutputDocument, - ExpectedSubjectDocument, + ExpectedSubjectDocument, MAXIMUM_ASSERTION_LIFETIME_SECONDS, MAXIMUM_CLOCK_SKEW_SECONDS, + MINIMUM_ASSERTION_LIFETIME_SECONDS, }, AssuranceProfile, }; @@ -47,11 +48,6 @@ pub const MAXIMUM_SELECTOR_STRING_BYTES: usize = 512; pub const MINIMUM_SELECTOR_INTEGER: i64 = -9_007_199_254_740_991; /// Largest selector integer the request contract accepts. pub const MAXIMUM_SELECTOR_INTEGER: i64 = 9_007_199_254_740_991; -/// Longest maximum assertion lifetime a policy may state, per the -/// verification policy contract the deployment applies. -pub const MAXIMUM_ASSERTION_LIFETIME_SECONDS: u64 = 31_536_000; -/// Largest clock skew tolerance a policy may state, per the same contract. -pub const MAXIMUM_CLOCK_SKEW_SECONDS: u64 = 300; /// Largest list cardinality, minimum or maximum, a list-form expected output /// may state, per the same contract. pub const MAXIMUM_LIST_ITEMS: usize = 64; @@ -166,7 +162,10 @@ pub struct PreparedEvidenceRequest { impl PreparedEvidenceRequest { /// Validate a specification, generate its nonce, and close its policy. - pub(crate) fn new(spec: EvidenceRequestSpec) -> Result { + pub(crate) fn new_with_revoked_key_ids( + spec: EvidenceRequestSpec, + revoked_key_ids: Vec, + ) -> Result { validate(&spec)?; let nonce = RequestNonce::generate()?; @@ -208,6 +207,7 @@ impl PreparedEvidenceRequest { request_nonce: nonce.as_str().to_owned(), expected_subjects, expected_outputs: spec.expected_outputs, + revoked_key_ids, maximum_assertion_lifetime_seconds: spec.maximum_assertion_lifetime_seconds, clock_skew_seconds: spec.clock_skew_seconds, }; @@ -219,6 +219,11 @@ impl PreparedEvidenceRequest { }) } + #[cfg(test)] + fn new(spec: EvidenceRequestSpec) -> Result { + Self::new_with_revoked_key_ids(spec, Vec::new()) + } + /// The nonce this request carries. Retain it with the transaction record: /// re-verifying the stored response later needs the nonce from the request, /// not from the response. @@ -410,8 +415,10 @@ fn validate(spec: &EvidenceRequestSpec) -> Result<(), EvidenceClientError> { // Ties the message below to the constant, so the constant cannot drift // from the number the message states. + const _: () = assert!(MINIMUM_ASSERTION_LIFETIME_SECONDS == 1); const _: () = assert!(MAXIMUM_ASSERTION_LIFETIME_SECONDS == 31_536_000); - if !(1..=MAXIMUM_ASSERTION_LIFETIME_SECONDS).contains(&spec.maximum_assertion_lifetime_seconds) + if !(MINIMUM_ASSERTION_LIFETIME_SECONDS..=MAXIMUM_ASSERTION_LIFETIME_SECONDS) + .contains(&spec.maximum_assertion_lifetime_seconds) { return Err(EvidenceClientError::configuration( "the maximum assertion lifetime must be within 1..=31536000 seconds", @@ -611,6 +618,7 @@ mod tests { "concept": "urn:example:client:concept:status-holds", "form": "boolean", }], + "revokedKeyIds": [], "maximumAssertionLifetimeSeconds": 300, "clockSkewSeconds": 60, }) diff --git a/crates/registry-evidence-client/tests/against_a_real_deployment.rs b/crates/registry-evidence-client/tests/against_a_real_deployment.rs index fdd9bc326..4c98a8fbe 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -35,6 +35,7 @@ use std::{ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::Utc; +use p256::{ecdsa::SigningKey, elliptic_curve::rand_core::OsRng}; use registry_evidence::{runtime::EvidenceRuntime, server}; use registry_evidence_client::{ AssuranceProfile, ConceptForm, DefinitionCardinality, DefinitionKind, EvidenceClient, @@ -47,7 +48,7 @@ use registry_mint::{ config::MintConfig, server::{self as mint_server, MintService}, }; -use registry_platform_crypto::{sign, PrivateJwk}; +use registry_platform_crypto::{sign, PrivateJwk, PublicJwk}; use serde_json::{json, Value}; use url::Url; use wiremock::{ @@ -59,7 +60,7 @@ use wiremock::{ const TOKEN_AUDIENCE: &str = "evidence-fixture"; const CONFIGURED_TAG: &str = "fixture-agency"; const REQUIREMENT: &str = "urn:example:fixture:requirement:adult-status:v1"; -const SIGNING_KEY_ID: &str = "fixture-key-2026-01"; +const FIXTURE_SIGNING_KEY_ID: &str = "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"; /// Vocabulary this suite chooses. const RELYING_AUDIENCE: &str = "https://relying.invalid/procedure"; @@ -72,7 +73,6 @@ const SOURCE_BEARER: &str = "source-bearer-canary"; /// credentials it issues with. const CLIENT_ID: &str = "client-suite-relying-party"; const CLIENT_KEY_ID: &str = "client-suite-client-key"; -const ISSUER_KEY_ID: &str = "client-suite-issuer-key"; /// The shortest access token lifetime the authorization server accepts. The /// refresh margin case needs a margin wider than a whole credential's life. @@ -275,7 +275,10 @@ async fn the_published_key_set_is_the_deployments_own() { assert_eq!(&published, deployment.runtime.jwks()); assert_eq!(published.keys.len(), 1); - assert_eq!(published.keys[0]["kid"], json!(SIGNING_KEY_ID)); + let published_key: PublicJwk = + serde_json::from_value(published.keys[0].clone()).expect("the published key parses"); + let thumbprint = published_key.jkt().expect("the thumbprint computes"); + assert_eq!(published_key.kid.as_deref(), Some(thumbprint.as_str())); assert_eq!( published.keys[0].get("d"), None, @@ -441,6 +444,7 @@ async fn a_response_under_the_wrong_media_type_is_refused() { Url::parse(&replay.uri())?, Arc::new(StaticToken::new(deployment.token())?), deployment.runtime.jwks().clone(), + Vec::new(), ))?; // A prepared request is good for one send, and the one above is spent, // so the replay leg carries its own. The media type is refused before @@ -819,6 +823,7 @@ impl Deployment { self.base_url.clone(), token_provider, self.runtime.jwks().clone(), + Vec::new(), )) .expect("the client configuration is usable") } @@ -828,6 +833,7 @@ impl Deployment { self.base_url.clone(), Arc::new(StaticToken::new(access_token).expect("the credential is header-safe")), self.runtime.jwks().clone(), + Vec::new(), ); if let Some(max_response_bytes) = max_response_bytes { config = config.with_max_response_bytes(max_response_bytes); @@ -852,7 +858,7 @@ impl Deployment { "aud": TOKEN_AUDIENCE, "sub": PRINCIPAL, "iat": now - 1, - "exp": now + 3600, + "exp": now + 60, "evidence_tags": requester_tags, "evidence_audience": RELYING_AUDIENCE, }); @@ -937,7 +943,25 @@ async fn start_trusting(source_answer: Value, external_issuer: Option<&str>) -> fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) .expect("the secret root is owner-only"); copy_tree(&fixture_root(), &bundle_root); - rewrite_for_local_profile(&bundle_root, &source.uri(), &issuer); + let (signing_key, signing_public) = service_key(); + let signing_key_id = signing_public + .kid + .as_deref() + .expect("the service key has a thumbprint"); + rewrite_for_local_profile(&bundle_root, &source.uri(), &issuer, signing_key_id); + fs::remove_file( + bundle_root + .join("public-keys") + .join(format!("{FIXTURE_SIGNING_KEY_ID}.jwk.json")), + ) + .expect("remove the tracked fixture public key"); + fs::write( + bundle_root + .join("public-keys") + .join(format!("{signing_key_id}.jwk.json")), + serde_json::to_vec(&signing_public).expect("the public key serializes"), + ) + .expect("write the staged Evidence public key"); write_secret( &secret_root, @@ -949,7 +973,7 @@ async fn start_trusting(source_answer: Value, external_issuer: Option<&str>) -> "subject-binding-key", "subject-binding-secret-canary-32-bytes-minimum", ); - write_secret(&secret_root, "signing-key", &private_jwk(SIGNING_KEY_ID)); + write_secret(&secret_root, "signing-key", &signing_key); write_secret(&secret_root, "source-a-token", SOURCE_BEARER); fs::write( &runtime_path, @@ -1134,7 +1158,18 @@ async fn start_token_issuer() -> TokenIssuer { fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) .expect("the issuer secret root is owner-only"); fs::create_dir(root.join("clients")).expect("create the client registry"); - write_secret(&secret_root, "signing.jwk", &private_jwk(ISSUER_KEY_ID)); + fs::create_dir(root.join("public-keys")).expect("create the issuer public-key directory"); + let (issuer_signing_key, issuer_public_key) = service_key(); + let issuer_key_id = issuer_public_key + .kid + .as_deref() + .expect("the Mint service key has a thumbprint"); + write_secret(&secret_root, "signing.jwk", &issuer_signing_key); + fs::write( + root.join(format!("public-keys/{issuer_key_id}.jwk.json")), + serde_json::to_vec(&issuer_public_key).expect("the issuer public key serializes"), + ) + .expect("write the issuer public key"); write_secret( &secret_root, "audit-hash-key", @@ -1164,13 +1199,20 @@ validationMode: supervised-local-development issuer: {origin} listener: {{address: 127.0.0.1, port: {port}}} signing: - algorithm: EdDSA - activeKeyId: {ISSUER_KEY_ID} - activeKeyFile: secrets/signing.jwk + algorithm: ES256 + activePublicJwkFile: public-keys/{issuer_key_id}.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: local-jwk + privateKeyRef: secret:file/signing.jwk +secretProviders: + file: + root: {secret_root} audit: path: audit/decisions.jsonl maximumFileBytes: 1073741824 - hashKeyFile: secrets/audit-hash-key + hashKeyRef: secret:file/audit-hash-key hashKeyVersion: 1 accessTokens: audiences: [{TOKEN_AUDIENCE}] @@ -1183,10 +1225,12 @@ accessTokens: grantAuthority: evidence_authority clientAssertion: audience: {token_endpoint} + maximumLifetimeSeconds: 300 algorithms: [EdDSA] clients: directory: clients -"# +"#, + secret_root = secret_root.display(), ), ) .expect("write the issuer configuration"); @@ -1236,7 +1280,12 @@ fn fixture_root() -> PathBuf { /// The local profile is what permits a loopback token issuer. Every other /// security decision in the bundle, including authentication, authorization, /// selector validation, subject binding, signing, and audit, is unchanged. -fn rewrite_for_local_profile(bundle_root: &Path, source_origin: &str, issuer_origin: &str) { +fn rewrite_for_local_profile( + bundle_root: &Path, + source_origin: &str, + issuer_origin: &str, + signing_key_id: &str, +) { let configuration_path = bundle_root.join("evidence.yaml"); let mut document = fs::read_to_string(&configuration_path).expect("the staged configuration is readable"); @@ -1264,6 +1313,18 @@ fn rewrite_for_local_profile(bundle_root: &Path, source_origin: &str, issuer_ori &format!("jwksUri: {issuer_origin}/.well-known/jwks.json"), 1, ); + replace_exact( + &mut document, + "algorithms: [ES256]", + "algorithms: [EdDSA, ES256]", + 1, + ); + replace_exact( + &mut document, + &format!("activePublicJwkFile: public-keys/{FIXTURE_SIGNING_KEY_ID}.jwk.json"), + &format!("activePublicJwkFile: public-keys/{signing_key_id}.jwk.json"), + 1, + ); fs::write(&configuration_path, document).expect("the local configuration is written"); } @@ -1288,6 +1349,9 @@ listener: secretProviders: file: root: {secrets} +signer: + kind: local-jwk + privateKeyRef: secret:file/signing-key auditStorage: path: {audit} maximumFileBytes: 10485760 @@ -1301,8 +1365,8 @@ outboundTls: ) } -/// A fresh Ed25519 private JWK under the identifier the reader expects. -fn private_jwk(key_id: &str) -> String { +/// A fresh Ed25519 private JWK for an externally owned client or token issuer. +fn private_client_jwk(key_id: &str) -> String { let mut seed = [0u8; 32]; getrandom::fill(&mut seed).expect("the test host supplies randomness"); let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed); @@ -1318,7 +1382,38 @@ fn private_jwk(key_id: &str) -> String { } fn generate_key(key_id: &str) -> PrivateJwk { - PrivateJwk::parse(&private_jwk(key_id)).expect("the generated key parses") + PrivateJwk::parse(&private_client_jwk(key_id)).expect("the generated key parses") +} + +/// A fresh ES256 service key whose identifier is its RFC 7638 thumbprint. +fn service_key() -> (String, PublicJwk) { + let signing_key = SigningKey::random(&mut OsRng); + let point = signing_key.verifying_key().to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(point.x().expect("the public point has x")); + let y = URL_SAFE_NO_PAD.encode(point.y().expect("the public point has y")); + let mut public = PublicJwk { + kty: "EC".to_owned(), + kid: None, + alg: Some("ES256".to_owned()), + crv: Some("P-256".to_owned()), + x: Some(x.clone()), + y: Some(y.clone()), + n: None, + e: None, + }; + let key_id = public.jkt().expect("the thumbprint computes"); + public.kid = Some(key_id.clone()); + let private = json!({ + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "kid": key_id, + "d": URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), + "x": x, + "y": y, + }) + .to_string(); + (private, public) } fn replace_exact(document: &mut String, from: &str, to: &str, expected: usize) { diff --git a/crates/registry-evidence-verifier/Cargo.toml b/crates/registry-evidence-verifier/Cargo.toml index f87ceb805..daab017f8 100644 --- a/crates/registry-evidence-verifier/Cargo.toml +++ b/crates/registry-evidence-verifier/Cargo.toml @@ -15,6 +15,7 @@ workspace = true base64.workspace = true chrono.workspace = true jsonschema.workspace = true +p256.workspace = true registry-platform-crypto.workspace = true registry-platform-sdjwt.workspace = true schemars.workspace = true diff --git a/crates/registry-evidence-verifier/README.md b/crates/registry-evidence-verifier/README.md index 12812f8d8..b1e44ab3d 100644 --- a/crates/registry-evidence-verifier/README.md +++ b/crates/registry-evidence-verifier/README.md @@ -10,7 +10,11 @@ Portable verification core for signed Evidence Version 1 responses. - `verifier::EvidenceVerificationPolicy` and its declarative `EvidenceVerificationPolicyDocument` form, including expected subjects, expected output value forms, accepted assurance profile, request nonce echo, - accepted assertion lifetime, and clock skew. + accepted assertion lifetime, and clock skew. The accepted lifetime and the + clock skew are the two policy bounds that would fail open, so reading a + document and every conversion to a policy refuse a value the verification + policy contract forbids: an unusable policy is refused as an input, never + reported as a verification outcome. - `model` wire types: the closed `Evidence` payload, its public value forms, the flattened JWS response, the unsigned envelope, and the JWKS document. - `contracts::evidence_schema` and `contracts::evidence_contract_accepts`: the diff --git a/crates/registry-evidence-verifier/src/fixtures.rs b/crates/registry-evidence-verifier/src/fixtures.rs index 0b8e1f15e..28830e7b9 100644 --- a/crates/registry-evidence-verifier/src/fixtures.rs +++ b/crates/registry-evidence-verifier/src/fixtures.rs @@ -8,13 +8,10 @@ //! bytes, the signing input, the SD-JWT VC issuance shape, and the bound on the //! number of published keys. //! -//! They deliberately omit the runtime's issuer-side configuration guards: key -//! identifier validation, the check that the published key repeats the -//! provider's algorithm and key identifier, and the startup sign-and-verify -//! self-test. Each of those refuses a misconfigured deployment before it signs -//! anything, and a fixture signer is built in-process from a known good test -//! key, so their absence cannot weaken what these tests prove. The runtime -//! signer is verified against this crate by the runtime's own suite. +//! They deliberately omit the runtime's provider-readiness and startup +//! sign-and-verify probes. A fixture signer is built in-process from a known +//! good test key, so their absence cannot weaken what these tests prove. The +//! runtime signer is verified against this crate by the runtime's own suite. use std::{collections::BTreeSet, sync::Arc}; @@ -66,10 +63,15 @@ impl EvidenceSigner { provider: Arc, configured_active_key_id: &str, ) -> Result { - if provider.algorithm() != SigningAlgorithm::EdDsa { + if provider.algorithm() != SigningAlgorithm::Es256 { return Err(FixtureSigningError::Algorithm); } - if provider.key_id() != configured_active_key_id { + let public = provider.public_jwk(); + if provider.key_id() != configured_active_key_id + || public.algorithm().ok() != Some(SigningAlgorithm::Es256) + || public.kid.as_deref() != Some(provider.key_id()) + || public.jkt().ok().as_deref() != Some(provider.key_id()) + { return Err(FixtureSigningError::ActiveKeyId); } Ok(Self { provider }) @@ -86,7 +88,7 @@ impl EvidenceSigner { let payload = serde_json::to_vec(evidence).map_err(|_| FixtureSigningError::Serialization)?; let protected = serde_json::to_vec(&ProtectedHeader { - alg: "EdDSA", + alg: "ES256", kid: self.provider.key_id(), typ: EVIDENCE_JWS_TYP, cty: EVIDENCE_JWS_CTY, @@ -132,14 +134,14 @@ pub fn jwks_document( if keys.len() == MAX_PUBLISHED_KEYS { return Err(FixtureSigningError::PublishedKey); } - if key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) { + if key.algorithm().ok() != Some(SigningAlgorithm::Es256) { return Err(FixtureSigningError::Algorithm); } let key_id = key .kid .as_deref() .ok_or(FixtureSigningError::PublishedKey)?; - if !seen.insert(key_id.to_owned()) { + if key.jkt().ok().as_deref() != Some(key_id) || !seen.insert(key_id.to_owned()) { return Err(FixtureSigningError::PublishedKey); } keys.push(serde_json::to_value(key).map_err(|_| FixtureSigningError::Serialization)?); @@ -149,30 +151,42 @@ pub fn jwks_document( #[cfg(test)] mod tests { + use p256::{elliptic_curve::sec1::ToEncodedPoint, SecretKey}; + use super::*; - const PUBLIC_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"evidence-key-1"}"#; + fn public_jwk(index: u8) -> PublicJwk { + let mut scalar = [0_u8; 32]; + scalar[31] = index + .checked_add(1) + .expect("test key index remains bounded"); + let secret = SecretKey::from_slice(&scalar).expect("test scalar is valid"); + let encoded = secret.public_key().to_encoded_point(false); + let mut key = PublicJwk { + kty: "EC".to_owned(), + kid: None, + alg: Some("ES256".to_owned()), + crv: Some("P-256".to_owned()), + x: Some(URL_SAFE_NO_PAD.encode(encoded.x().expect("x coordinate"))), + y: Some(URL_SAFE_NO_PAD.encode(encoded.y().expect("y coordinate"))), + n: None, + e: None, + }; + key.kid = Some(key.jkt().expect("thumbprint computes")); + key + } /// The fixture key set publishes the same maximum number of keys as the /// runtime, so a trusted set built here cannot exceed what a deployment can /// serve. #[test] fn published_key_set_stops_at_the_runtime_bound() { - let active: PublicJwk = serde_json::from_str(PUBLIC_JWK).expect("test key parses"); - - let retired = (0..MAX_PUBLISHED_KEYS - 1).map(|index| { - let mut key = active.clone(); - key.kid = Some(format!("retired-evidence-key-{index:02}")); - key - }); + let active = public_jwk(0); + let retired = (1..MAX_PUBLISHED_KEYS as u8).map(public_jwk); let boundary = jwks_document(active.clone(), retired).expect("the bound itself is allowed"); assert_eq!(boundary.keys.len(), MAX_PUBLISHED_KEYS); - let too_many = (0..MAX_PUBLISHED_KEYS).map(|index| { - let mut key = active.clone(); - key.kid = Some(format!("excess-evidence-key-{index:02}")); - key - }); + let too_many = (1..=MAX_PUBLISHED_KEYS as u8).map(public_jwk); assert!(matches!( jwks_document(active.clone(), too_many), Err(FixtureSigningError::PublishedKey) diff --git a/crates/registry-evidence-verifier/src/model.rs b/crates/registry-evidence-verifier/src/model.rs index 947429b28..212e21de2 100644 --- a/crates/registry-evidence-verifier/src/model.rs +++ b/crates/registry-evidence-verifier/src/model.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use p256::ecdsa::VerifyingKey; use schemars::JsonSchema; use serde::{de, Deserialize, Deserializer, Serialize}; use serde_json::{Number, Value}; @@ -11,7 +12,7 @@ use utoipa::ToSchema; use crate::AssuranceProfile; -/// Caller-supplied Ed25519 holder public key. `deny_unknown_fields` is the +/// Caller-supplied P-256 holder public key. `deny_unknown_fields` is the /// primary defence against private key members: a body carrying `d` or any /// other unexpected member fails to parse. #[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] @@ -20,24 +21,25 @@ pub struct HolderPublicKey { pub kty: String, pub crv: String, pub x: String, + pub y: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub alg: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub kid: Option, } -/// Exact byte length of a raw Ed25519 public key. +/// Exact byte length of each P-256 affine coordinate. const HOLDER_KEY_DECODED_LENGTH: usize = 32; const MAX_HOLDER_KEY_ID_BYTES: usize = 256; impl HolderPublicKey { - /// Accept only a public OKP Ed25519 JWK whose coordinate is the canonical - /// unpadded base64url encoding of exactly 32 bytes. + /// Accept only a public EC P-256 JWK whose coordinates are canonical + /// unpadded base64url encodings of exactly 32 bytes and form a curve point. pub fn is_acceptable(&self) -> bool { - if self.kty != "OKP" || self.crv != "Ed25519" { + if self.kty != "EC" || self.crv != "P-256" { return false; } - if self.alg.as_deref().is_some_and(|alg| alg != "EdDSA") { + if self.alg.as_deref().is_some_and(|alg| alg != "ES256") { return false; } if self @@ -47,9 +49,20 @@ impl HolderPublicKey { { return false; } - URL_SAFE_NO_PAD - .decode(&self.x) - .is_ok_and(|decoded| decoded.len() == HOLDER_KEY_DECODED_LENGTH) + let Ok(x) = URL_SAFE_NO_PAD.decode(&self.x) else { + return false; + }; + let Ok(y) = URL_SAFE_NO_PAD.decode(&self.y) else { + return false; + }; + if x.len() != HOLDER_KEY_DECODED_LENGTH || y.len() != HOLDER_KEY_DECODED_LENGTH { + return false; + } + let mut encoded = Vec::with_capacity(65); + encoded.push(0x04); + encoded.extend_from_slice(&x); + encoded.extend_from_slice(&y); + VerifyingKey::from_sec1_bytes(&encoded).is_ok() } } @@ -334,10 +347,11 @@ mod tests { }], }; let holder_key = HolderPublicKey { - kty: "OKP".to_owned(), - crv: "Ed25519".to_owned(), + kty: "EC".to_owned(), + crv: "P-256".to_owned(), x: "protected-holder-coordinate-canary".to_owned(), - alg: Some("EdDSA".to_owned()), + y: "protected-holder-y-coordinate-canary".to_owned(), + alg: Some("ES256".to_owned()), kid: Some("protected-holder-key-id-canary".to_owned()), }; let bucket = BucketValue { diff --git a/crates/registry-evidence-verifier/src/sdjwt_vc.rs b/crates/registry-evidence-verifier/src/sdjwt_vc.rs index 106f82017..9c81fbf84 100644 --- a/crates/registry-evidence-verifier/src/sdjwt_vc.rs +++ b/crates/registry-evidence-verifier/src/sdjwt_vc.rs @@ -59,7 +59,7 @@ pub enum SdJwtVcMappingError { Subjects, #[error("an evidence claim is not representable as JSON")] Claim, - #[error("the holder public key is not an acceptable Ed25519 public JWK")] + #[error("the holder public key is not an acceptable P-256 public JWK")] HolderKey, #[error("the SD-JWT VC structured projection is inconsistent with the evidence value")] StructuredProjection, @@ -191,7 +191,7 @@ fn confirmation(key: &HolderPublicKey) -> Result HolderPublicKey { HolderPublicKey { - kty: "OKP".to_string(), - crv: "Ed25519".to_string(), - x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo".to_string(), + kty: "EC".to_string(), + crv: "P-256".to_string(), + x: "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4".to_string(), + y: "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU".to_string(), alg: None, kid: None, } @@ -583,17 +584,18 @@ mod tests { let evidence = evidence(); let mut key = holder_key(); key.kid = Some("holder-1".to_string()); - key.alg = Some("EdDSA".to_string()); + key.alg = Some("ES256".to_string()); let confirmation = issuance_input(&evidence, Some(&key), &BTreeMap::new()) .expect("evidence maps") .cnf .expect("confirmation is present"); - assert_eq!(confirmation.jwk.kty, "OKP"); - assert_eq!(confirmation.jwk.crv.as_deref(), Some("Ed25519")); + assert_eq!(confirmation.jwk.kty, "EC"); + assert_eq!(confirmation.jwk.crv.as_deref(), Some("P-256")); assert_eq!(confirmation.jwk.x.as_deref(), Some(key.x.as_str())); + assert_eq!(confirmation.jwk.y.as_deref(), Some(key.y.as_str())); assert_eq!(confirmation.jwk.kid.as_deref(), Some("holder-1")); - assert_eq!(confirmation.jwk.alg.as_deref(), Some("EdDSA")); + assert_eq!(confirmation.jwk.alg.as_deref(), Some("ES256")); assert!(confirmation.kid.is_none()); } @@ -601,11 +603,11 @@ mod tests { fn rejects_unacceptable_holder_keys() { let evidence = evidence(); let mut wrong_curve = holder_key(); - wrong_curve.crv = "P-256".to_string(); + wrong_curve.crv = "P-384".to_string(); let mut wrong_algorithm = holder_key(); - wrong_algorithm.alg = Some("ES256".to_string()); + wrong_algorithm.alg = Some("EdDSA".to_string()); let mut wrong_key_type = holder_key(); - wrong_key_type.kty = "EC".to_string(); + wrong_key_type.kty = "OKP".to_string(); let mut short_coordinate = holder_key(); short_coordinate.x = "11qYAYKxCrfVS_7TyWQHOg".to_string(); let mut padded_coordinate = holder_key(); @@ -628,9 +630,10 @@ mod tests { #[test] fn rejects_private_key_members_before_mapping() { let body = serde_json::json!({ - "kty": "OKP", - "crv": "Ed25519", - "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "kty": "EC", + "crv": "P-256", + "x": "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4", + "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU", "d": "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A", }); assert!(serde_json::from_value::(body).is_err()); diff --git a/crates/registry-evidence-verifier/src/verifier.rs b/crates/registry-evidence-verifier/src/verifier.rs index b1873774e..2fba2fac1 100644 --- a/crates/registry-evidence-verifier/src/verifier.rs +++ b/crates/registry-evidence-verifier/src/verifier.rs @@ -16,7 +16,7 @@ use thiserror::Error; use crate::{ contracts::evidence_contract_accepts, - model::{Evidence, FlattenedJws, JwksDocument}, + model::{Evidence, FlattenedJws, HolderPublicKey, JwksDocument}, sdjwt_vc::evidence_payload_from_claims, AssuranceProfile, EVIDENCE_JWS_CTY, EVIDENCE_JWS_TYP, EVIDENCE_SCHEMA_V1, EVIDENCE_SD_JWT_VC_TYP, @@ -33,11 +33,87 @@ const MAX_DISCLOSURE_BYTES: usize = 8 * 1024; const MINIMUM_SALT_BYTES: usize = 16; const MAXIMUM_SALT_BYTES: usize = 64; +/// Shortest maximum assertion lifetime a policy may state, per the +/// verification policy contract. +/// +/// This bound and the bounds below it are the contract constraints on a policy +/// that fail open, so they are enforced here. A pattern, +/// length, or uniqueness violation elsewhere in a policy fails closed: the +/// payload is itself contract-checked, so an out-of-contract expectation is one +/// no conformant payload can match and verification refuses the response. A +/// lifetime or skew wider than the contract allows fails the other way, making +/// this verifier accept assertions a conformant relying party must refuse. The +/// failure-class vocabulary is frozen and has no class for an unusable policy, +/// so a forbidden bound is refused where a policy is read or built, never +/// reported as a verification outcome. +pub const MINIMUM_ASSERTION_LIFETIME_SECONDS: u64 = 1; +/// Longest maximum assertion lifetime a policy may state, per the same +/// contract. +pub const MAXIMUM_ASSERTION_LIFETIME_SECONDS: u64 = 31_536_000; +/// Largest clock skew tolerance a policy may state, per the same contract. +/// Omitting the tolerance means zero, which is always inside the bound. +pub const MAXIMUM_CLOCK_SKEW_SECONDS: u64 = 300; +/// Smallest list cardinality a policy may state. +pub const MINIMUM_EXPECTED_LIST_ITEMS: usize = 1; +/// Largest list cardinality a policy may state. +pub const MAXIMUM_EXPECTED_LIST_ITEMS: usize = 64; + +/// A policy stating a bound the verification policy contract forbids. +/// +/// This is a refusal to use the policy at all, not a verification outcome. Both +/// Variants carry the stated value, which is a relying party's own expectation +/// and never comes from a response. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum PolicyBoundsError { + #[error("maximumAssertionLifetimeSeconds must be {MINIMUM_ASSERTION_LIFETIME_SECONDS} to {MAXIMUM_ASSERTION_LIFETIME_SECONDS}, not {0}")] + AssertionLifetime(u64), + #[error("clockSkewSeconds must be at most {MAXIMUM_CLOCK_SKEW_SECONDS}, not {0}")] + ClockSkew(u64), + #[error("minimumItems must be {MINIMUM_EXPECTED_LIST_ITEMS} to {MAXIMUM_EXPECTED_LIST_ITEMS}, not {0}")] + MinimumItems(usize), + #[error("maximumItems must be {MINIMUM_EXPECTED_LIST_ITEMS} to {MAXIMUM_EXPECTED_LIST_ITEMS}, not {0}")] + MaximumItems(usize), +} + +fn checked_assertion_lifetime(seconds: u64) -> Result { + if !(MINIMUM_ASSERTION_LIFETIME_SECONDS..=MAXIMUM_ASSERTION_LIFETIME_SECONDS).contains(&seconds) + { + return Err(PolicyBoundsError::AssertionLifetime(seconds)); + } + Ok(Duration::from_secs(seconds)) +} + +fn checked_clock_skew(seconds: u64) -> Result { + if seconds > MAXIMUM_CLOCK_SKEW_SECONDS { + return Err(PolicyBoundsError::ClockSkew(seconds)); + } + Ok(Duration::from_secs(seconds)) +} + +fn checked_minimum_items(items: usize) -> Result { + if !(MINIMUM_EXPECTED_LIST_ITEMS..=MAXIMUM_EXPECTED_LIST_ITEMS).contains(&items) { + return Err(PolicyBoundsError::MinimumItems(items)); + } + Ok(items) +} + +fn checked_maximum_items(items: usize) -> Result { + if !(MINIMUM_EXPECTED_LIST_ITEMS..=MAXIMUM_EXPECTED_LIST_ITEMS).contains(&items) { + return Err(PolicyBoundsError::MaximumItems(items)); + } + Ok(items) +} + /// Complete relying-procedure expectations for strict verification. /// /// Every expectation comes from independent trusted state such as the relying /// procedure, a previously trusted binding, or a trusted requirement contract. /// Copying values out of the JWS under verification proves nothing. +/// +/// The two time bounds are private, so the only ways to a policy are the +/// checked conversions on this type and on +/// [`EvidenceVerificationPolicyDocument`], and no caller can state a bound the +/// contract forbids. #[derive(Debug, Clone)] pub struct EvidenceVerificationPolicy { pub assurance_profile: AssuranceProfile, @@ -55,10 +131,15 @@ pub struct EvidenceVerificationPolicy { pub expected_subjects: Vec, /// Expected concept identifiers, value forms, and cardinalities. pub expected_outputs: Vec, - /// Longest acceptable `validUntil - issuedAt` interval. - pub maximum_assertion_lifetime: Duration, + /// Service key thumbprints that fail closed even if present in a stale or + /// otherwise trusted JWKS document. + pub revoked_key_ids: Vec, + /// Longest acceptable `validUntil - issuedAt` interval. Read it with + /// [`EvidenceVerificationPolicy::maximum_assertion_lifetime`]. + maximum_assertion_lifetime: Duration, pub now: DateTime, - pub clock_skew: Duration, + /// Read it with [`EvidenceVerificationPolicy::clock_skew`]. + clock_skew: Duration, } /// Closed wire document for independently retained verification expectations. @@ -67,6 +148,9 @@ pub struct EvidenceVerificationPolicy { /// durations. This document is the serializable form used by offline operator /// boundaries, including the local pre-response context. It never learns /// expectations from the response it is asked to verify. +/// +/// Reading one refuses the two time bounds the contract forbids, so an +/// out-of-contract document never reaches verification. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EvidenceVerificationPolicyDocument { @@ -82,11 +166,31 @@ pub struct EvidenceVerificationPolicyDocument { pub request_nonce: String, pub expected_subjects: Vec, pub expected_outputs: Vec, + pub revoked_key_ids: Vec, + #[serde(deserialize_with = "read_assertion_lifetime_seconds")] pub maximum_assertion_lifetime_seconds: u64, - #[serde(default)] + #[serde(default, deserialize_with = "read_clock_skew_seconds")] pub clock_skew_seconds: u64, } +fn read_assertion_lifetime_seconds<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let seconds = u64::deserialize(deserializer)?; + checked_assertion_lifetime(seconds).map_err(serde::de::Error::custom)?; + Ok(seconds) +} + +fn read_clock_skew_seconds<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let seconds = u64::deserialize(deserializer)?; + checked_clock_skew(seconds).map_err(serde::de::Error::custom)?; + Ok(seconds) +} + #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExpectedSubjectDocument { @@ -144,13 +248,42 @@ pub struct ExpectedListFormDocument { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExpectedListDocument { + #[serde(deserialize_with = "read_minimum_items")] pub minimum_items: usize, + #[serde(deserialize_with = "read_maximum_items")] pub maximum_items: usize, } +fn read_minimum_items<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let items = usize::deserialize(deserializer)?; + checked_minimum_items(items).map_err(serde::de::Error::custom) +} + +fn read_maximum_items<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let items = usize::deserialize(deserializer)?; + checked_maximum_items(items).map_err(serde::de::Error::custom) +} + impl EvidenceVerificationPolicyDocument { - pub fn into_policy(self, now: DateTime) -> EvidenceVerificationPolicy { - EvidenceVerificationPolicy { + /// The runtime-facing policy for one verification instant, or a refusal when + /// the document states a bound the contract forbids. + /// + /// Reading a document already refuses bounded fields, so this is what holds + /// them for a document built in code, where the public fields accept any value. + pub fn try_into_policy( + self, + now: DateTime, + ) -> Result { + let maximum_assertion_lifetime = + checked_assertion_lifetime(self.maximum_assertion_lifetime_seconds)?; + let clock_skew = checked_clock_skew(self.clock_skew_seconds)?; + Ok(EvidenceVerificationPolicy { assurance_profile: self.expected_assurance_profile, issued_by: self.issued_by, provided_by: self.provided_by, @@ -171,22 +304,25 @@ impl EvidenceVerificationPolicyDocument { expected_outputs: self .expected_outputs .into_iter() - .map(|output| ExpectedOutput { - concept: output.concept, - form: expected_value_form_document(output.form), + .map(|output| { + Ok(ExpectedOutput { + concept: output.concept, + form: expected_value_form_document(output.form)?, + }) }) - .collect(), - maximum_assertion_lifetime: Duration::from_secs( - self.maximum_assertion_lifetime_seconds, - ), + .collect::, PolicyBoundsError>>()?, + revoked_key_ids: self.revoked_key_ids, + maximum_assertion_lifetime, now, - clock_skew: Duration::from_secs(self.clock_skew_seconds), - } + clock_skew, + }) } } -fn expected_value_form_document(document: ExpectedFormDocument) -> ExpectedValueForm { - match document { +fn expected_value_form_document( + document: ExpectedFormDocument, +) -> Result { + Ok(match document { ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::Boolean) => { ExpectedValueForm::Boolean } @@ -209,13 +345,26 @@ fn expected_value_form_document(document: ExpectedFormDocument) -> ExpectedValue ExpectedValueForm::Structured } ExpectedFormDocument::List(wrapper) => ExpectedValueForm::List { - minimum_items: wrapper.list.minimum_items, - maximum_items: wrapper.list.maximum_items, + minimum_items: checked_minimum_items(wrapper.list.minimum_items)?, + maximum_items: checked_maximum_items(wrapper.list.maximum_items)?, }, - } + }) } impl EvidenceVerificationPolicy { + /// Longest acceptable `validUntil - issuedAt` interval, within the bounds + /// the verification policy contract states. + #[must_use] + pub fn maximum_assertion_lifetime(&self) -> Duration { + self.maximum_assertion_lifetime + } + + /// Accepted clock skew tolerance, within the same contract's bound. + #[must_use] + pub fn clock_skew(&self) -> Duration { + self.clock_skew + } + /// Build expectations from evidence accepted in an original trusted /// transaction, for later re-verification of the stored response. /// @@ -225,14 +374,21 @@ impl EvidenceVerificationPolicy { /// values back as expectations proves nothing. The expected nonce comes /// from the independently retained original request, never from the /// response. + /// + /// The two time bounds are the relying party's own, stated in seconds as the + /// contract states them, and a value the contract forbids is refused rather + /// than honoured. pub fn from_accepted_transaction( evidence: &Evidence, retained_request_nonce: &str, - maximum_assertion_lifetime: Duration, + maximum_assertion_lifetime_seconds: u64, now: DateTime, - clock_skew: Duration, - ) -> Self { - Self { + clock_skew_seconds: u64, + ) -> Result { + let maximum_assertion_lifetime = + checked_assertion_lifetime(maximum_assertion_lifetime_seconds)?; + let clock_skew = checked_clock_skew(clock_skew_seconds)?; + Ok(Self { assurance_profile: evidence.assurance_profile, issued_by: evidence.issued_by.clone(), provided_by: evidence.provided_by.clone(), @@ -258,10 +414,11 @@ impl EvidenceVerificationPolicy { form: expected_form_of(&value.value), }) .collect(), + revoked_key_ids: Vec::new(), maximum_assertion_lifetime, now, clock_skew, - } + }) } } @@ -451,19 +608,25 @@ pub fn verify_flattened_jws_report( parse_json_strict(&protected_bytes).map_err(|_| VerificationError::ProtectedHeader)?; let protected: ProtectedHeader = serde_json::from_value(protected_strict).map_err(|_| VerificationError::ProtectedHeader)?; - if protected.alg != "EdDSA" + if protected.alg != "ES256" || protected.typ != EVIDENCE_JWS_TYP || protected.cty != EVIDENCE_JWS_CTY - || protected.kid.is_empty() - || protected.kid.len() > 256 - || protected.kid.chars().any(char::is_control) + || !key_identifier_is_thumbprint(&protected.kid) { return Err(VerificationError::ProtectedHeader); } + validate_revocations(&policy.revoked_key_ids)?; + if policy + .revoked_key_ids + .iter() + .any(|kid| kid == &protected.kid) + { + return Err(VerificationError::Key); + } let keys = trusted_keys(trusted_jwks)?; let key = keys.get(&protected.kid).ok_or(VerificationError::Key)?; - if key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) { + if key.algorithm().ok() != Some(SigningAlgorithm::Es256) { return Err(VerificationError::Key); } let signature = decode_bounded( @@ -552,18 +715,20 @@ pub fn verify_sd_jwt_vc_report( parse_json_strict(&header_bytes).map_err(|_| VerificationError::ProtectedHeader)?; let header: SdJwtHeader = serde_json::from_value(header_strict).map_err(|_| VerificationError::ProtectedHeader)?; - if header.alg != "EdDSA" + if header.alg != "ES256" || header.typ != EVIDENCE_SD_JWT_VC_TYP - || header.kid.is_empty() - || header.kid.len() > 256 - || header.kid.chars().any(char::is_control) + || !key_identifier_is_thumbprint(&header.kid) { return Err(VerificationError::ProtectedHeader); } + validate_revocations(&policy.revoked_key_ids)?; + if policy.revoked_key_ids.iter().any(|kid| kid == &header.kid) { + return Err(VerificationError::Key); + } let keys = trusted_keys(trusted_jwks)?; let key = keys.get(&header.kid).ok_or(VerificationError::Key)?; - if key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) { + if key.algorithm().ok() != Some(SigningAlgorithm::Es256) { return Err(VerificationError::Key); } let signature = decode_bounded( @@ -764,7 +929,7 @@ fn resolve_disclosures( Ok(resolved) } -/// The confirmation, when present, carries exactly one Ed25519 public key and +/// The confirmation, when present, carries exactly one P-256 public key and /// no private material. fn validate_confirmation(confirmation: &Value) -> Result<(), VerificationError> { let Some(members) = confirmation.as_object() else { @@ -774,12 +939,9 @@ fn validate_confirmation(confirmation: &Value) -> Result<(), VerificationError> return Err(VerificationError::Payload); } let jwk = members.get("jwk").ok_or(VerificationError::Payload)?; - let key: PublicJwk = + let key: HolderPublicKey = serde_json::from_value(jwk.clone()).map_err(|_| VerificationError::Payload)?; - if key.kty != "OKP" - || key.crv.as_deref() != Some("Ed25519") - || key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) - { + if !key.is_acceptable() { return Err(VerificationError::Payload); } Ok(()) @@ -791,13 +953,19 @@ fn trusted_keys(jwks: &JwksDocument) -> Result, Veri } let mut output = BTreeMap::new(); for value in &jwks.keys { + let members = value.as_object().ok_or(VerificationError::Key)?; + let exact_members = ["alg", "crv", "kid", "kty", "x", "y"] + .into_iter() + .collect::>(); + if members.keys().map(String::as_str).collect::>() != exact_members { + return Err(VerificationError::Key); + } let key: PublicJwk = serde_json::from_value(value.clone()).map_err(|_| VerificationError::Key)?; let kid = key.kid.clone().ok_or(VerificationError::Key)?; - if kid.is_empty() - || kid.len() > 256 - || kid.chars().any(char::is_control) - || key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) + if !key_identifier_is_thumbprint(&kid) + || key.algorithm().ok() != Some(SigningAlgorithm::Es256) + || key.jkt().ok().as_deref() != Some(kid.as_str()) || output.insert(kid, key).is_some() { return Err(VerificationError::Key); @@ -806,6 +974,38 @@ fn trusted_keys(jwks: &JwksDocument) -> Result, Veri Ok(output) } +/// Validate a relying party's emergency service-key denylist. +/// +/// This is public so clients can reject unusable pinned trust configuration at +/// construction rather than deferring the same failure to every verification. +/// An identifier may deliberately still appear in a cached JWKS: revocation is +/// checked first and overrides that cached key. +pub fn revoked_key_ids_are_usable(revoked_key_ids: &[String]) -> Result<(), VerificationError> { + if revoked_key_ids.len() > MAX_TRUSTED_KEYS + || revoked_key_ids + .iter() + .any(|kid| !key_identifier_is_thumbprint(kid)) + || revoked_key_ids.iter().collect::>().len() != revoked_key_ids.len() + { + return Err(VerificationError::Key); + } + Ok(()) +} + +fn validate_revocations(revoked_key_ids: &[String]) -> Result<(), VerificationError> { + revoked_key_ids_are_usable(revoked_key_ids) +} + +fn key_identifier_is_thumbprint(kid: &str) -> bool { + kid.len() == 43 + && kid + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + && URL_SAFE_NO_PAD + .decode(kid) + .is_ok_and(|decoded| decoded.len() == 32 && URL_SAFE_NO_PAD.encode(&decoded) == kid) +} + /// Compare every policy expectation after signature and schema verification. /// /// Every mismatch, including the expected nonce, expected role-bound subject @@ -965,9 +1165,11 @@ fn decode_bounded( #[cfg(test)] mod tests { - use std::sync::Arc; + use std::{collections::BTreeSet, sync::Arc}; + use p256::elliptic_curve::rand_core::OsRng; use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; + use serde::Deserialize; use serde_json::{json, Value}; use super::*; @@ -977,8 +1179,214 @@ mod tests { SupportedValue, }; - const PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"evidence-key-1"}"#; - const RETIRED_PRIVATE_JWK: &str = r#"{"crv":"Ed25519","d":"f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys","x":"pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec","kty":"OKP","alg":"EdDSA","kid":"retired-evidence-key"}"#; + const KEY_ID: &str = "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"; + const RETIRED_KEY_ID: &str = "xx0BcA-wMohw8atYDJOe6peGModklG2wRHBlXHMvl0M"; + const PRIVATE_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"}"#; + const RETIRED_PRIVATE_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE","x":"axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpY","y":"T-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU","alg":"ES256","kid":"xx0BcA-wMohw8atYDJOe6peGModklG2wRHBlXHMvl0M"}"#; + + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + struct ExternalVectorFixture { + fixture: String, + synthetic_only: bool, + compatibility_claim: String, + purpose: String, + issuer_public_jwk: PublicJwk, + vectors: Vec, + } + + #[derive(Debug, Deserialize)] + #[serde(deny_unknown_fields)] + struct ExternalVector { + id: String, + standard: String, + provenance: ExternalVectorProvenance, + serialized: String, + expected: ExternalVectorExpected, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct ExternalVectorProvenance { + source: String, + revision: String, + location: String, + derivation: String, + serialized_sha256: String, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct ExternalVectorExpected { + protected_typ: String, + issuer: String, + #[serde(default)] + vct: Option, + disclosure_names: Vec, + evidence_profile_rejection: String, + } + + #[derive(Debug, Eq, PartialEq)] + struct ExternalPresentation { + protected_typ: String, + issuer: String, + vct: Option, + disclosure_names: Vec, + } + + fn verify_external_presentation( + serialized: &str, + public_key: &PublicJwk, + ) -> Result { + let without_trailing_tilde = serialized + .strip_suffix('~') + .ok_or("presentation lacks the no-KB-JWT trailing tilde")?; + let mut presentation_parts = without_trailing_tilde.split('~'); + let jwt = presentation_parts.next().ok_or("issuer JWT is absent")?; + let disclosures = presentation_parts.collect::>(); + if disclosures.is_empty() || disclosures.iter().any(|value| value.is_empty()) { + return Err("presentation disclosures are absent or empty"); + } + + let jwt_parts = jwt.split('.').collect::>(); + let [protected, payload, encoded_signature] = jwt_parts.as_slice() else { + return Err("issuer JWT is not compact JWS"); + }; + let header_bytes = URL_SAFE_NO_PAD + .decode(protected) + .map_err(|_| "protected header is not base64url")?; + let payload_bytes = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| "payload is not base64url")?; + let signature = URL_SAFE_NO_PAD + .decode(encoded_signature) + .map_err(|_| "signature is not base64url")?; + let header = parse_json_strict(&header_bytes).map_err(|_| "header is not strict JSON")?; + let payload_value = + parse_json_strict(&payload_bytes).map_err(|_| "payload is not strict JSON")?; + if header.get("alg").and_then(Value::as_str) != Some("ES256") { + return Err("external vector is not ES256"); + } + verify( + format!("{protected}.{payload}").as_bytes(), + &signature, + public_key, + ) + .map_err(|_| "external ES256 signature does not verify")?; + + let payload = payload_value + .as_object() + .ok_or("external payload is not an object")?; + if payload.get("_sd_alg").and_then(Value::as_str) != Some("sha-256") { + return Err("external vector does not select sha-256 disclosures"); + } + let mut embedded_digests = BTreeSet::new(); + collect_external_sd_digests(&payload_value, &mut embedded_digests); + let mut disclosure_names = Vec::with_capacity(disclosures.len()); + for disclosure in disclosures { + let digest = URL_SAFE_NO_PAD.encode(Sha256::digest(disclosure.as_bytes())); + if !embedded_digests.contains(&digest) { + return Err("presented disclosure digest is not signed"); + } + let disclosure_bytes = URL_SAFE_NO_PAD + .decode(disclosure) + .map_err(|_| "disclosure is not base64url")?; + let disclosure_value = parse_json_strict(&disclosure_bytes) + .map_err(|_| "disclosure is not strict JSON")?; + let disclosure_array = disclosure_value + .as_array() + .filter(|members| members.len() == 3) + .ok_or("disclosure is not a property disclosure")?; + disclosure_names.push( + disclosure_array[1] + .as_str() + .ok_or("disclosure name is not a string")? + .to_owned(), + ); + } + + Ok(ExternalPresentation { + protected_typ: header + .get("typ") + .and_then(Value::as_str) + .ok_or("protected typ is absent")? + .to_owned(), + issuer: payload + .get("iss") + .and_then(Value::as_str) + .ok_or("issuer is absent")? + .to_owned(), + vct: payload + .get("vct") + .and_then(Value::as_str) + .map(str::to_owned), + disclosure_names, + }) + } + + fn collect_external_sd_digests(value: &Value, output: &mut BTreeSet) { + match value { + Value::Object(members) => { + if members.len() == 1 { + if let Some(digest) = members.get("...").and_then(Value::as_str) { + output.insert(digest.to_owned()); + } + } + if let Some(digests) = members.get("_sd").and_then(Value::as_array) { + output.extend(digests.iter().filter_map(Value::as_str).map(str::to_owned)); + } + for member in members.values() { + collect_external_sd_digests(member, output); + } + } + Value::Array(members) => { + for member in members { + collect_external_sd_digests(member, output); + } + } + _ => {} + } + } + + fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } + + fn mutate_external_signature(serialized: &str) -> String { + let mut presentation_parts = serialized + .strip_suffix('~') + .expect("fixture has trailing tilde") + .split('~') + .map(str::to_owned) + .collect::>(); + let mut jwt_parts = presentation_parts[0] + .split('.') + .map(str::to_owned) + .collect::>(); + let mut signature = URL_SAFE_NO_PAD + .decode(&jwt_parts[2]) + .expect("fixture signature decodes"); + signature[0] ^= 1; + jwt_parts[2] = URL_SAFE_NO_PAD.encode(signature); + presentation_parts[0] = jwt_parts.join("."); + format!("{}~", presentation_parts.join("~")) + } + + fn mutate_first_external_disclosure(serialized: &str) -> String { + let mut parts = serialized + .strip_suffix('~') + .expect("fixture has trailing tilde") + .split('~') + .map(str::to_owned) + .collect::>(); + let disclosure = parts.get_mut(1).expect("fixture has a disclosure"); + let last = disclosure.pop().expect("fixture disclosure is nonempty"); + disclosure.push(if last == 'A' { 'B' } else { 'A' }); + format!("{}~", parts.join("~")) + } async fn sign_with_protected_header( private_jwk: &str, @@ -1056,7 +1464,7 @@ mod tests { let private = PrivateJwk::parse(PRIVATE_JWK).expect("key parses"); let provider: Arc = Arc::new(LocalJwkSigner::new(private).expect("signer builds")); - let signer = EvidenceSigner::initialize(provider, "evidence-key-1") + let signer = EvidenceSigner::initialize(provider, KEY_ID) .await .expect("signer initializes"); let jws = signer.sign_json(&evidence).await.expect("evidence signs"); @@ -1073,10 +1481,11 @@ mod tests { EvidenceVerificationPolicy::from_accepted_transaction( evidence, &evidence.request_nonce, - Duration::from_secs(48 * 60 * 60), + 48 * 60 * 60, now, - Duration::from_secs(30), + 30, ) + .expect("the fixture policy states bounds the contract allows") } async fn signed_fixture() -> (Vec, JwksDocument, EvidenceVerificationPolicy) { @@ -1087,6 +1496,108 @@ mod tests { .await } + #[test] + fn external_rfc9901_and_draft18_vectors_verify_shared_cryptography_and_preserve_profile_boundary( + ) { + let fixture: ExternalVectorFixture = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/fixtures/conformance/external-sd-jwt-vectors.yaml" + )) + .expect("external vector fixture parses"); + assert_eq!( + fixture.fixture, + "registry.evidence.external-sd-jwt-vectors/v1" + ); + assert!(fixture.synthetic_only); + assert_eq!(fixture.compatibility_claim, "none"); + assert!(fixture.purpose.contains("shared ES256 and RFC 9901")); + assert_eq!(fixture.vectors.len(), 2); + + let mut evidence_jwk = fixture.issuer_public_jwk.clone(); + evidence_jwk.kid = Some( + evidence_jwk + .jkt() + .expect("external key thumbprint computes"), + ); + let evidence_jwks = jwks_document(evidence_jwk, []).expect("strict Evidence JWKS builds"); + let evidence_policy = policy_for( + &fixture_evidence(), + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ); + + for vector in &fixture.vectors { + let (expected_standard, expected_source, expected_revision, expected_sha256) = + match vector.id.as_str() { + "rfc-9901-section-5-single-disclosure" => ( + "RFC 9901", + "https://www.rfc-editor.org/rfc/rfc9901.txt", + "RFC 9901, November 2025", + "ded07ccce2201ac557def085e1f514f2669e1274914c33efdd7459a04bae50f2", + ), + "sd-jwt-vc-draft-18-figure-10" => ( + "draft-ietf-oauth-sd-jwt-vc-18", + "https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-18.txt", + "draft-ietf-oauth-sd-jwt-vc-18; oauth-wg tag commit 69e50ea623367c212c12c680e35e256b640b5f6b", + "d76ee28606ccc124fb90567f2511ddd5f2cddf2ee3f2ff7eeebfa51b3e759ad2", + ), + other => panic!("unexpected external vector {other}"), + }; + assert_eq!(vector.standard, expected_standard); + assert_eq!(vector.provenance.source, expected_source); + assert_eq!(vector.provenance.revision, expected_revision); + assert!(!vector.provenance.location.is_empty()); + assert!(!vector.provenance.derivation.is_empty()); + assert_eq!(vector.provenance.serialized_sha256, expected_sha256); + assert_eq!(sha256_hex(vector.serialized.as_bytes()), expected_sha256); + + let verified = + verify_external_presentation(&vector.serialized, &fixture.issuer_public_jwk) + .expect("authoritative external vector verifies"); + assert_eq!( + verified, + ExternalPresentation { + protected_typ: vector.expected.protected_typ.clone(), + issuer: vector.expected.issuer.clone(), + vct: vector.expected.vct.clone(), + disclosure_names: vector.expected.disclosure_names.clone(), + } + ); + + assert!(verify_external_presentation( + &mutate_external_signature(&vector.serialized), + &fixture.issuer_public_jwk, + ) + .is_err()); + assert!(verify_external_presentation( + &mutate_first_external_disclosure(&vector.serialized), + &fixture.issuer_public_jwk, + ) + .is_err()); + + assert_eq!( + vector.expected.evidence_profile_rejection, + "protected-header" + ); + assert_eq!( + verify_sd_jwt_vc( + vector.serialized.as_bytes(), + &evidence_jwks, + &evidence_policy, + ), + Err(VerificationError::ProtectedHeader), + "external standards vectors must not silently widen the Evidence profile", + ); + } + } + + #[test] + fn verifier_requires_canonical_sha256_thumbprint_encoding() { + assert!(key_identifier_is_thumbprint(&"A".repeat(43))); + assert!(!key_identifier_is_thumbprint(&format!( + "{}B", + "A".repeat(42) + ))); + } + #[tokio::test] async fn signed_false_round_trips_and_verifies() { let (jws, jwks, policy) = signed_fixture().await; @@ -1177,8 +1688,8 @@ mod tests { let base = serde_json::to_string(&fixture_evidence()).expect("Evidence serializes"); assert_eq!(base.matches("\"value\":false").count(), 1); let header = json!({ - "alg": "EdDSA", - "kid": "evidence-key-1", + "alg": "ES256", + "kid": KEY_ID, "typ": EVIDENCE_JWS_TYP, "cty": EVIDENCE_JWS_CTY }); @@ -1316,6 +1827,7 @@ mod tests { "add an unprotected header", "add jku, x5u, jwk, x5c, crit, or b64", "unknown kid", + "revoked kid, even when the key remains in a cached JWKS", "algorithm mismatch", "signed payload violates the Evidence JSON Schema", "duplicate evidence object beside payload", @@ -1325,8 +1837,8 @@ mod tests { let evidence = fixture_evidence(); let base_header = json!({ - "alg": "EdDSA", - "kid": "evidence-key-1", + "alg": "ES256", + "kid": KEY_ID, "typ": EVIDENCE_JWS_TYP, "cty": EVIDENCE_JWS_CTY }); @@ -1335,6 +1847,12 @@ mod tests { let jwks = jwks_document(public, []).expect("JWKS builds"); let (_, _, policy) = signed_fixture().await; assert!(verify_flattened_jws(&valid, &jwks, &policy).is_ok()); + let mut revoked = policy.clone(); + revoked.revoked_key_ids = vec![KEY_ID.to_owned()]; + assert_eq!( + verify_flattened_jws(&valid, &jwks, &revoked), + Err(VerificationError::Key) + ); let mut missing_signature: Value = serde_json::from_slice(&valid).expect("JWS parses"); missing_signature @@ -1351,7 +1869,7 @@ mod tests { ); for extra in [ - ("header", json!({"kid": "evidence-key-1"})), + ("header", json!({"kid": KEY_ID})), ( "evidence", serde_json::to_value(&evidence).expect("Evidence serializes"), @@ -1398,14 +1916,14 @@ mod tests { for (header, expected) in [ ( json!({ - "alg": "EdDSA", "kid": "unknown-key", "typ": EVIDENCE_JWS_TYP, + "alg": "ES256", "kid": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "typ": EVIDENCE_JWS_TYP, "cty": EVIDENCE_JWS_CTY }), VerificationError::Key, ), ( json!({ - "alg": "HS256", "kid": "evidence-key-1", "typ": EVIDENCE_JWS_TYP, + "alg": "HS256", "kid": KEY_ID, "typ": EVIDENCE_JWS_TYP, "cty": EVIDENCE_JWS_CTY }), VerificationError::ProtectedHeader, @@ -1436,8 +1954,8 @@ mod tests { let mut mutated = fixture_evidence(); mutated.request_nonce = "B".repeat(43); let header = json!({ - "alg": "EdDSA", - "kid": "evidence-key-1", + "alg": "ES256", + "kid": KEY_ID, "typ": EVIDENCE_JWS_TYP, "cty": EVIDENCE_JWS_CTY }); @@ -1559,6 +2077,7 @@ mod tests { binding: DEBUG_BINDING_CANARY.to_string(), }], expected_outputs: Vec::new(), + revoked_key_ids: Vec::new(), maximum_assertion_lifetime_seconds: 48 * 60 * 60, clock_skew_seconds: 30, }; @@ -1566,6 +2085,275 @@ mod tests { assert!(!rendered.contains(DEBUG_BINDING_CANARY), "{rendered}"); } + /// A valid policy document whose two contract-bounded time fields the + /// caller sets, so a bound test states only what it is about. + fn policy_document_with_time_bounds( + maximum_assertion_lifetime_seconds: u64, + clock_skew_seconds: u64, + ) -> EvidenceVerificationPolicyDocument { + EvidenceVerificationPolicyDocument { + expected_assurance_profile: AssuranceProfile::EvidenceGrade, + issued_by: "urn:example:issuer".to_string(), + provided_by: "urn:example:provider".to_string(), + requirement: "urn:example:requirement:v1".to_string(), + evidence_type: "urn:example:type:v1".to_string(), + purpose: "casework".to_string(), + audience: "urn:example:audience".to_string(), + configuration_revision: format!("sha256:{}", "0".repeat(64)), + request_nonce: FIXTURE_NONCE.to_string(), + expected_subjects: Vec::new(), + expected_outputs: Vec::new(), + revoked_key_ids: Vec::new(), + maximum_assertion_lifetime_seconds, + clock_skew_seconds, + } + } + + fn policy_document_with_list_bounds( + minimum_items: usize, + maximum_items: usize, + ) -> EvidenceVerificationPolicyDocument { + let mut document = policy_document_with_time_bounds(48 * 60 * 60, 30); + document.expected_outputs.push(ExpectedOutputDocument { + concept: "urn:example:concept".to_string(), + form: ExpectedFormDocument::List(ExpectedListFormDocument { + list: ExpectedListDocument { + minimum_items, + maximum_items, + }, + }), + }); + document + } + + /// The bounds this crate enforces are the contract's, not a second opinion + /// about them. + #[test] + fn the_enforced_time_bounds_are_the_contract_bounds() { + let contract: serde_norway::Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/contracts/verification-policy.schema.yaml" + )) + .expect("the verification policy contract is YAML"); + let bound = |field: &str, bound: &str| -> u64 { + serde_norway::from_value(contract["properties"][field][bound].clone()) + .unwrap_or_else(|error| panic!("the contract states {field} {bound}: {error}")) + }; + assert_eq!( + bound("maximumAssertionLifetimeSeconds", "minimum"), + MINIMUM_ASSERTION_LIFETIME_SECONDS + ); + assert_eq!( + bound("maximumAssertionLifetimeSeconds", "maximum"), + MAXIMUM_ASSERTION_LIFETIME_SECONDS + ); + assert_eq!(bound("clockSkewSeconds", "minimum"), 0); + assert_eq!( + bound("clockSkewSeconds", "maximum"), + MAXIMUM_CLOCK_SKEW_SECONDS + ); + + let list = + &contract["$defs"]["expected-form"]["oneOf"][1]["properties"]["list"]["properties"]; + let list_bound = |field: &str, bound: &str| -> usize { + serde_norway::from_value(list[field][bound].clone()) + .unwrap_or_else(|error| panic!("the contract states {field} {bound}: {error}")) + }; + for field in ["minimumItems", "maximumItems"] { + assert_eq!(list_bound(field, "minimum"), MINIMUM_EXPECTED_LIST_ITEMS); + assert_eq!(list_bound(field, "maximum"), MAXIMUM_EXPECTED_LIST_ITEMS); + } + } + + /// A policy document is an input, and one that states a time bound the + /// contract forbids is unusable rather than merely unsatisfied. Reading it + /// has to refuse it: the failure-class vocabulary is frozen, so verification + /// has no class to report it under, and honouring it would make this + /// verifier accept assertions a conformant relying party must refuse. + #[test] + fn a_policy_document_stating_a_forbidden_time_bound_is_refused_when_read() { + let refused = |document: EvidenceVerificationPolicyDocument| { + let bytes = serde_json::to_vec(&document).expect("the document serializes"); + serde_json::from_slice::(&bytes) + .expect_err("a document outside the contract bounds is refused") + .to_string() + }; + for (label, document) in [ + ( + "a zero lifetime", + policy_document_with_time_bounds(MINIMUM_ASSERTION_LIFETIME_SECONDS - 1, 0), + ), + ( + "a lifetime past the ceiling", + policy_document_with_time_bounds(MAXIMUM_ASSERTION_LIFETIME_SECONDS + 1, 0), + ), + ] { + let message = refused(document); + assert!( + message.contains("maximumAssertionLifetimeSeconds"), + "{label} is refused for the field it states: {message}" + ); + } + let message = refused(policy_document_with_time_bounds( + MAXIMUM_ASSERTION_LIFETIME_SECONDS, + MAXIMUM_CLOCK_SKEW_SECONDS + 1, + )); + assert!(message.contains("clockSkewSeconds"), "{message}"); + } + + #[test] + fn a_policy_document_at_the_contract_bounds_is_read() { + for (lifetime, skew) in [ + (MINIMUM_ASSERTION_LIFETIME_SECONDS, 0), + ( + MAXIMUM_ASSERTION_LIFETIME_SECONDS, + MAXIMUM_CLOCK_SKEW_SECONDS, + ), + ] { + let bytes = serde_json::to_vec(&policy_document_with_time_bounds(lifetime, skew)) + .expect("the document serializes"); + let read: EvidenceVerificationPolicyDocument = serde_json::from_slice(&bytes) + .unwrap_or_else(|error| panic!("{lifetime}s and {skew}s skew are read: {error}")); + assert_eq!(read.maximum_assertion_lifetime_seconds, lifetime); + assert_eq!(read.clock_skew_seconds, skew); + } + + for (minimum_items, maximum_items) in [ + (MINIMUM_EXPECTED_LIST_ITEMS, MINIMUM_EXPECTED_LIST_ITEMS), + (MAXIMUM_EXPECTED_LIST_ITEMS, MAXIMUM_EXPECTED_LIST_ITEMS), + ] { + let bytes = serde_json::to_vec(&policy_document_with_list_bounds( + minimum_items, + maximum_items, + )) + .expect("the document serializes"); + serde_json::from_slice::(&bytes).unwrap_or_else( + |error| panic!("{minimum_items}..={maximum_items} items are read: {error}"), + ); + } + } + + #[test] + fn a_policy_document_stating_a_forbidden_list_bound_is_refused_when_read() { + for (label, minimum_items, maximum_items) in [ + ("zero minimum", 0, 1), + ( + "minimum past the ceiling", + MAXIMUM_EXPECTED_LIST_ITEMS + 1, + MAXIMUM_EXPECTED_LIST_ITEMS, + ), + ("zero maximum", 1, 0), + ( + "maximum past the ceiling", + MINIMUM_EXPECTED_LIST_ITEMS, + MAXIMUM_EXPECTED_LIST_ITEMS + 1, + ), + ] { + let bytes = serde_json::to_vec(&policy_document_with_list_bounds( + minimum_items, + maximum_items, + )) + .expect("the document serializes"); + assert!( + serde_json::from_slice::(&bytes).is_err(), + "{label} was accepted" + ); + } + } + + /// The document fields are public, so a caller can build one in code + /// without going through a reader. The conversion to a policy is the second + /// place the bounds hold. + #[test] + fn a_policy_document_built_in_code_cannot_widen_the_contract_bounds() { + let now = "2026-08-02T12:00:00Z".parse().expect("time parses"); + let refusal = |lifetime, skew| { + policy_document_with_time_bounds(lifetime, skew) + .try_into_policy(now) + .map(|_| ()) + }; + assert_eq!( + refusal(MAXIMUM_ASSERTION_LIFETIME_SECONDS + 1, 0), + Err(PolicyBoundsError::AssertionLifetime( + MAXIMUM_ASSERTION_LIFETIME_SECONDS + 1 + )) + ); + assert_eq!(refusal(0, 0), Err(PolicyBoundsError::AssertionLifetime(0))); + assert_eq!( + refusal( + MAXIMUM_ASSERTION_LIFETIME_SECONDS, + MAXIMUM_CLOCK_SKEW_SECONDS + 1 + ), + Err(PolicyBoundsError::ClockSkew(MAXIMUM_CLOCK_SKEW_SECONDS + 1)) + ); + let policy = policy_document_with_time_bounds( + MAXIMUM_ASSERTION_LIFETIME_SECONDS, + MAXIMUM_CLOCK_SKEW_SECONDS, + ) + .try_into_policy(now) + .expect("a document at the bounds converts"); + assert_eq!( + policy.maximum_assertion_lifetime(), + Duration::from_secs(MAXIMUM_ASSERTION_LIFETIME_SECONDS) + ); + assert_eq!( + policy.clock_skew(), + Duration::from_secs(MAXIMUM_CLOCK_SKEW_SECONDS) + ); + + assert_eq!( + policy_document_with_list_bounds(0, 1) + .try_into_policy(now) + .map(|_| ()), + Err(PolicyBoundsError::MinimumItems(0)) + ); + assert_eq!( + policy_document_with_list_bounds(1, MAXIMUM_EXPECTED_LIST_ITEMS + 1) + .try_into_policy(now) + .map(|_| ()), + Err(PolicyBoundsError::MaximumItems( + MAXIMUM_EXPECTED_LIST_ITEMS + 1 + )) + ); + } + + /// Re-verifying a retained response is the third way to a policy, and it + /// never reads a document, so it carries the same bounds itself. + #[test] + fn an_accepted_transaction_cannot_widen_the_contract_bounds() { + let evidence = fixture_evidence(); + let now = "2026-08-02T12:00:00Z".parse().expect("time parses"); + let policy_for_bounds = |lifetime, skew| { + EvidenceVerificationPolicy::from_accepted_transaction( + &evidence, + &evidence.request_nonce, + lifetime, + now, + skew, + ) + }; + assert_eq!( + policy_for_bounds(MAXIMUM_ASSERTION_LIFETIME_SECONDS + 1, 0).map(|_| ()), + Err(PolicyBoundsError::AssertionLifetime( + MAXIMUM_ASSERTION_LIFETIME_SECONDS + 1 + )) + ); + assert_eq!( + policy_for_bounds(0, 0).map(|_| ()), + Err(PolicyBoundsError::AssertionLifetime(0)) + ); + assert_eq!( + policy_for_bounds(48 * 60 * 60, MAXIMUM_CLOCK_SKEW_SECONDS + 1).map(|_| ()), + Err(PolicyBoundsError::ClockSkew(MAXIMUM_CLOCK_SKEW_SECONDS + 1)) + ); + let policy = policy_for_bounds(MINIMUM_ASSERTION_LIFETIME_SECONDS, 0) + .expect("a transaction at the bounds builds a policy"); + assert_eq!( + policy.maximum_assertion_lifetime(), + Duration::from_secs(MINIMUM_ASSERTION_LIFETIME_SECONDS) + ); + assert_eq!(policy.clock_skew(), Duration::ZERO); + } + #[tokio::test] async fn expected_output_contract_is_exact_after_signature_verification() { let (jws, jwks, policy) = signed_fixture().await; @@ -1666,8 +2454,8 @@ mod tests { async fn retired_public_key_verifies_only_while_published_and_payload_is_current() { let evidence = fixture_evidence(); let header = json!({ - "alg": "EdDSA", - "kid": "retired-evidence-key", + "alg": "ES256", + "kid": RETIRED_KEY_ID, "typ": EVIDENCE_JWS_TYP, "cty": EVIDENCE_JWS_CTY }); @@ -1704,18 +2492,13 @@ mod tests { let active = LocalJwkSigner::new(private) .expect("active signer builds") .public_jwk(); - let retired = (0..32).map(|index| { - let mut key = active.clone(); - key.kid = Some(format!("retired-evidence-key-{index:02}")); - key - }); + let retired = (0..32).map(|_| generated_public_jwk()); let maximum = jwks_document(active.clone(), retired).expect("maximum JWKS builds"); assert_eq!(maximum.keys.len(), MAX_TRUSTED_KEYS); assert!(verify_flattened_jws(&jws, &maximum, &policy).is_ok()); let mut excess = maximum; - let mut extra = active; - extra.kid = Some("retired-evidence-key-excess".to_owned()); + let extra = generated_public_jwk(); excess .keys .push(serde_json::to_value(extra).expect("extra key serializes")); @@ -1798,7 +2581,7 @@ mod tests { let private = PrivateJwk::parse(PRIVATE_JWK).expect("key parses"); let provider: Arc = Arc::new(LocalJwkSigner::new(private).expect("signer builds")); - EvidenceSigner::initialize(provider, "evidence-key-1") + EvidenceSigner::initialize(provider, KEY_ID) .await .expect("signer initializes") } @@ -1849,6 +2632,17 @@ mod tests { assert_eq!(evidence, fixture_evidence()); } + #[tokio::test] + async fn revoked_key_rejects_sd_jwt_even_when_cached_jwks_still_contains_it() { + let (serialized, jwks, mut policy) = issued_sd_jwt_vc().await; + policy.revoked_key_ids = vec![KEY_ID.to_owned()]; + + assert_eq!( + verify_sd_jwt_vc(serialized.as_bytes(), &jwks, &policy), + Err(VerificationError::Key) + ); + } + #[tokio::test] async fn structured_value_round_trips_as_top_level_field_disclosures() { let mut evidence = fixture_evidence(); @@ -1906,10 +2700,11 @@ mod tests { let evidence = fixture_evidence(); let signer = fixture_signer().await; let holder = crate::model::HolderPublicKey { - kty: "OKP".to_owned(), - crv: "Ed25519".to_owned(), - x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo".to_owned(), - alg: Some("EdDSA".to_owned()), + kty: "EC".to_owned(), + crv: "P-256".to_owned(), + x: "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4".to_owned(), + y: "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU".to_owned(), + alg: Some("ES256".to_owned()), kid: Some("holder-1".to_owned()), }; let input = crate::sdjwt_vc::issuance_input(&evidence, Some(&holder), &BTreeMap::new()) @@ -2037,9 +2832,9 @@ mod tests { let (jwt, disclosures) = split_sd_jwt(&serialized); for header in [ - json!({"alg": "none", "kid": "evidence-key-1", "typ": EVIDENCE_SD_JWT_VC_TYP}), - json!({"alg": "EdDSA", "kid": "evidence-key-1", "typ": "JWT"}), - json!({"alg": "EdDSA", "kid": "evidence-key-1", "typ": EVIDENCE_SD_JWT_VC_TYP, "jwk": {"kty": "OKP"}}), + json!({"alg": "none", "kid": KEY_ID, "typ": EVIDENCE_SD_JWT_VC_TYP}), + json!({"alg": "ES256", "kid": KEY_ID, "typ": "JWT"}), + json!({"alg": "ES256", "kid": KEY_ID, "typ": EVIDENCE_SD_JWT_VC_TYP, "jwk": {"kty": "EC"}}), ] { let replacement = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header serializes")); @@ -2060,8 +2855,8 @@ mod tests { let (serialized, jwks, policy) = issued_sd_jwt_vc().await; let (jwt, disclosures) = split_sd_jwt(&serialized); let header = json!({ - "alg": "EdDSA", - "kid": "some-other-key", + "alg": "ES256", + "kid": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "typ": EVIDENCE_SD_JWT_VC_TYP }); let replacement = @@ -2078,6 +2873,23 @@ mod tests { ); } + fn generated_public_jwk() -> PublicJwk { + let signing_key = p256::ecdsa::SigningKey::random(&mut OsRng); + let point = signing_key.verifying_key().to_encoded_point(false); + let mut key = PublicJwk { + kty: "EC".to_owned(), + kid: None, + alg: Some("ES256".to_owned()), + crv: Some("P-256".to_owned()), + x: point.x().map(|x| URL_SAFE_NO_PAD.encode(x)), + y: point.y().map(|y| URL_SAFE_NO_PAD.encode(y)), + n: None, + e: None, + }; + key.kid = Some(key.jkt().expect("thumbprint computes")); + key + } + #[tokio::test] async fn sd_jwt_prohibited_claim_rejected() { let signer = fixture_signer().await; diff --git a/crates/registry-evidence/Cargo.toml b/crates/registry-evidence/Cargo.toml index cea54a01c..8d7797ebc 100644 --- a/crates/registry-evidence/Cargo.toml +++ b/crates/registry-evidence/Cargo.toml @@ -29,8 +29,10 @@ http.workspace = true jsonschema.workspace = true jsonwebtoken.workspace = true registry-evidence-verifier.workspace = true +p256.workspace = true registry-platform-audit.workspace = true -registry-platform-crypto.workspace = true +registry-platform-crypto = { workspace = true, features = ["transit"] } +registry-platform-config.workspace = true registry-platform-httpsec.workspace = true registry-platform-httputil.workspace = true registry-platform-oidc.workspace = true diff --git a/crates/registry-evidence/src/audit.rs b/crates/registry-evidence/src/audit.rs index 4fdcae9c8..7e25f75bd 100644 --- a/crates/registry-evidence/src/audit.rs +++ b/crates/registry-evidence/src/audit.rs @@ -16,11 +16,13 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; pub use registry_platform_audit::segmented_audit_paths as audit_segment_paths; use registry_platform_audit::{ verify_segmented_audit_chain, visit_stopped_segmented_audit_chain, AuditChainHasher, - AuditEnvelope, AuditError, AuditHashSecret, AuditKeyHasher, DurableSegmentedAuditLog, + AuditEnvelope, AuditError, AuditHashSecret, AuditKeyHasher, AuditProfile, + DurableSegmentedAuditLog, }; use registry_platform_crypto::canonicalize_json; use serde::{Deserialize, Serialize}; use thiserror::Error; +use zeroize::Zeroizing; use crate::config::AssuranceProfile; @@ -376,9 +378,9 @@ impl EvidenceAuditLog { )) .into()); } - let secret = AuditHashSecret::new(master_secret)?; - let chain_hasher = AuditChainHasher::keyed(secret.clone()); - let key_hasher = AuditKeyHasher::Keyed(secret); + let profile = AuditProfile::production_from_secret_bytes(Zeroizing::new(master_secret))?; + let chain_hasher = profile.chain_hasher(); + let key_hasher = profile.key_hasher(); let sink = Arc::new( DurableSegmentedAuditLog::initialize(path, maximum_file_bytes, chain_hasher).await?, ); @@ -676,18 +678,18 @@ fn coherent_operation_pair(access: &EvidenceAuditEvent, terminal: &EvidenceAudit /// exact verified envelopes in that one replay. pub fn verified_last_local_audit_operation( path: &Path, - master_secret: &AuditHashSecret, + chain_secret: &AuditHashSecret, ) -> Result { verified_last_local_audit_operation_with_bounds( path, - master_secret, + chain_secret, LocalAuditInspectionBounds::DEFAULT, ) } fn verified_last_local_audit_operation_with_bounds( path: &Path, - master_secret: &AuditHashSecret, + chain_secret: &AuditHashSecret, bounds: LocalAuditInspectionBounds, ) -> Result { if bounds.maximum_segments == 0 @@ -697,7 +699,7 @@ fn verified_last_local_audit_operation_with_bounds( return Err(EvidenceAuditError::Configuration); } - let hasher = AuditChainHasher::keyed(master_secret.clone()); + let hasher = AuditChainHasher::keyed(chain_secret.clone()); let mut collector = LocalAuditCollector::new(bounds); visit_stopped_segmented_audit_chain( path, @@ -713,10 +715,10 @@ fn verified_last_local_audit_operation_with_bounds( /// Verify every retained segment, including the active segment when no writer is running. pub fn verify_audit_chain( path: &Path, - master_secret: &AuditHashSecret, + chain_secret: &AuditHashSecret, ) -> Result { let summary = - verify_segmented_audit_chain(path, &AuditChainHasher::keyed(master_secret.clone())) + verify_segmented_audit_chain(path, &AuditChainHasher::keyed(chain_secret.clone())) .map_err(map_platform_audit_error)?; Ok(AuditChainSummary { segments: summary.segments, @@ -843,7 +845,7 @@ mod tests { release.decision = AuditDecision::Released; release.disclosed_concepts = Some(vec!["urn:example:fixture:concept:boolean-a".to_owned()]); release.evidence_id = Some("urn:example:fixture:evidence:001".to_owned()); - release.signing_key_id = Some("fixture-key-2026-01".to_owned()); + release.signing_key_id = Some("_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo".to_owned()); release.duration_milliseconds = 12; release .validate_phase_fields() @@ -865,7 +867,8 @@ mod tests { serde_json::to_value(&unsigned_release).expect("unsigned release event serializes"), fixture["unsigned_disclosure_release"] ); - unsigned_release.signing_key_id = Some("fixture-key-2026-01".to_owned()); + unsigned_release.signing_key_id = + Some("_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo".to_owned()); assert!(matches!( unsigned_release.validate_phase_fields(), Err(EvidenceAuditError::InvalidEvent) @@ -1178,6 +1181,68 @@ mod tests { ); } + #[tokio::test] + async fn replacement_master_cannot_append_to_an_existing_evidence_epoch() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let original = b"original-evidence-audit-master-32-bytes"; + let replacement = b"replacement-audit-master-is-also-32-bytes"; + { + let log = EvidenceAuditLog::initialize(&path, 64 * 1024, original.to_vec(), 1) + .await + .expect("original audit epoch initializes"); + log.append(event(&log)).await.expect("event appends"); + } + + assert!(verify_audit_chain(&path, &chain_secret(replacement)).is_err()); + assert!( + EvidenceAuditLog::initialize(&path, 64 * 1024, replacement.to_vec(), 1) + .await + .is_err(), + "replacement master bytes cannot append under the existing epoch configuration" + ); + assert!(verify_audit_chain(&path, &chain_secret(original)).is_ok()); + } + + #[tokio::test] + async fn archived_and_fresh_evidence_audit_epochs_verify_independently() { + let directory = tempfile::tempdir().expect("temporary directory"); + let archived_path = directory.path().join("audit-epoch-1.jsonl"); + let fresh_path = directory.path().join("audit-epoch-2.jsonl"); + let archived_master = b"archived-evidence-audit-master-32-bytes"; + let fresh_master = b"fresh-evidence-audit-master-value-32-bytes"; + + { + let archived = EvidenceAuditLog::initialize( + &archived_path, + 64 * 1024, + archived_master.to_vec(), + 1, + ) + .await + .expect("archived epoch initializes"); + archived + .append(event(&archived)) + .await + .expect("archived event appends"); + } + { + let fresh = + EvidenceAuditLog::initialize(&fresh_path, 64 * 1024, fresh_master.to_vec(), 2) + .await + .expect("fresh epoch initializes"); + fresh + .append(event(&fresh)) + .await + .expect("fresh event appends"); + } + + assert!(verify_audit_chain(&archived_path, &chain_secret(archived_master)).is_ok()); + assert!(verify_audit_chain(&fresh_path, &chain_secret(fresh_master)).is_ok()); + assert!(verify_audit_chain(&archived_path, &chain_secret(fresh_master)).is_err()); + assert!(verify_audit_chain(&fresh_path, &chain_secret(archived_master)).is_err()); + } + #[tokio::test] async fn same_length_external_mutation_fails_readiness_and_future_appends() { let directory = tempfile::tempdir().expect("temporary directory"); @@ -1282,9 +1347,17 @@ mod tests { ); } + fn chain_secret(master: &[u8]) -> AuditHashSecret { + let profile = AuditProfile::production_from_secret_bytes(Zeroizing::new(master.to_vec())) + .expect("audit profile builds"); + match profile.chain_hasher() { + AuditChainHasher::Keyed(secret) => secret, + AuditChainHasher::UnkeyedDevOnly => panic!("production profile must be keyed"), + } + } + fn audit_secret() -> AuditHashSecret { - AuditHashSecret::new(b"0123456789abcdef0123456789abcdef".to_vec()) - .expect("audit secret builds") + chain_secret(b"0123456789abcdef0123456789abcdef") } fn local_access(log: &EvidenceAuditLog, operation: &str) -> EvidenceAuditEvent { diff --git a/crates/registry-evidence/src/auth.rs b/crates/registry-evidence/src/auth.rs index bb697db11..b2c9f0e90 100644 --- a/crates/registry-evidence/src/auth.rs +++ b/crates/registry-evidence/src/auth.rs @@ -239,7 +239,11 @@ impl Authenticator { config.audiences.clone(), algorithms, token_types, - ); + ) + .with_denied_kids(config.revoked_key_ids.iter().cloned().collect()) + .with_max_token_lifetime(Some(Duration::from_secs( + config.maximum_token_lifetime_seconds, + ))); let fetcher = Arc::new(JwksFetcher::new_with_fetch_url_policy( config.jwks_uri.clone(), JwksFetcherConfig::defaults(), diff --git a/crates/registry-evidence/src/bundle.rs b/crates/registry-evidence/src/bundle.rs index d5c185421..87af63226 100644 --- a/crates/registry-evidence/src/bundle.rs +++ b/crates/registry-evidence/src/bundle.rs @@ -6,8 +6,9 @@ use std::fs::{self, File, Metadata}; use std::io::Read; use std::path::{Path, PathBuf}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use base64::Engine as _; use jsonschema::{Draft, JSONSchema}; +use registry_platform_crypto::{PublicJwk, SigningAlgorithm as ProviderSigningAlgorithm}; use rhai::{Engine, AST}; use serde::de::{self, MapAccess, Visitor}; use serde::{Deserialize, Deserializer}; @@ -246,7 +247,8 @@ pub struct Bundle { pub fact_schemas: BTreeMap, pub codelists: BTreeMap, pub fixtures: BTreeMap, - pub retired_public_jwks: BTreeMap, + pub active_public_jwk: PublicJwk, + pub published_public_jwks: BTreeMap, } /// One captured operator runtime configuration and its bound trust anchors. @@ -365,7 +367,7 @@ impl Bundle { let codelists = load_codelists(&config, &files)?; validate_codelist_references(&config, &codelists)?; let fixtures = load_fixtures(&config, &files)?; - let retired_public_jwks = load_retired_public_jwks(&config, &files)?; + let (active_public_jwk, published_public_jwks) = load_public_jwks(&config, &files)?; let revision = compute_revision(&files)?; Ok(Self { @@ -377,7 +379,8 @@ impl Bundle { fact_schemas, codelists, fixtures, - retired_public_jwks, + active_public_jwk, + published_public_jwks, }) } @@ -719,7 +722,8 @@ fn validate_file_closure( } } } - for path in &config.signing.retired_public_jwk_files { + expected.insert(config.signing.active_public_jwk_file.as_str().to_owned()); + for path in &config.signing.published_public_jwk_files { expected.insert(path.as_str().to_owned()); } expected.extend(reviewed_schema_paths(config, files)?); @@ -1689,27 +1693,78 @@ impl FixtureCategories { } } -fn load_retired_public_jwks( +fn load_public_jwks( config: &EvidenceConfig, files: &BTreeMap>, -) -> Result, BundleError> { +) -> Result<(PublicJwk, BTreeMap), BundleError> { + let active_path = config.signing.active_public_jwk_file.as_str(); + let active = files + .get(active_path) + .ok_or(invalid_artifact("active public JWK is missing")) + .and_then(|bytes| parse_service_public_jwk(bytes)) + .map_err(|error| error.in_artifact(active_path))?; + let active_kid = active + .kid + .as_deref() + .ok_or(invalid_artifact("active public JWK kid is missing"))?; + validate_public_jwk_path(active_path, active_kid) + .map_err(|error| error.in_artifact(active_path))?; + if config + .signing + .revoked_key_ids + .iter() + .any(|kid| kid == active_kid) + { + return Err(invalid_artifact("active public JWK is revoked").in_artifact(active_path)); + } + let mut keys = BTreeMap::new(); - for path in &config.signing.retired_public_jwk_files { + for path in &config.signing.published_public_jwk_files { let path = path.as_str(); - let load = || -> Result<(String, JsonMap), BundleError> { + let load = || -> Result<(String, PublicJwk), BundleError> { let bytes = files .get(path) - .ok_or(invalid_artifact("retired public JWK is missing"))?; - let object = parse_strict_json_object(bytes)?; - let kid = validate_public_jwk(&object, &config.signing.active_key_id)?; - Ok((kid, object)) + .ok_or(invalid_artifact("published public JWK is missing"))?; + let jwk = parse_service_public_jwk(bytes)?; + let kid = jwk + .kid + .as_deref() + .ok_or(invalid_artifact("published public JWK kid is missing"))? + .to_owned(); + validate_public_jwk_path(path, &kid)?; + if kid == active_kid { + return Err(invalid_artifact( + "published public JWK duplicates the active key", + )); + } + if config + .signing + .revoked_key_ids + .iter() + .any(|revoked| revoked == &kid) + { + return Err(invalid_artifact("published public JWK is revoked")); + } + Ok((kid, jwk)) }; - let (kid, object) = load().map_err(|error| error.in_artifact(path))?; - if keys.insert(kid, JsonValue::Object(object)).is_some() { - return Err(invalid_artifact("retired public JWK kid is duplicated").in_artifact(path)); + let (kid, jwk) = load().map_err(|error| error.in_artifact(path))?; + if keys.insert(kid, jwk).is_some() { + return Err( + invalid_artifact("published public JWK kid is duplicated").in_artifact(path) + ); } } - Ok(keys) + Ok((active, keys)) +} + +fn validate_public_jwk_path(path: &str, kid: &str) -> Result<(), BundleError> { + let expected = format!("public-keys/{kid}.jwk.json"); + if path != expected { + return Err(invalid_artifact( + "public JWK filename does not match its RFC 7638 thumbprint", + )); + } + Ok(()) } fn parse_strict_json_object(bytes: &[u8]) -> Result, BundleError> { @@ -1753,54 +1808,36 @@ fn parse_strict_json_object(bytes: &[u8]) -> Result, Ok(object.0) } -fn validate_public_jwk( - object: &JsonMap, - active_key_id: &str, -) -> Result { - const ALLOWED: [&str; 7] = ["kty", "crv", "x", "kid", "alg", "use", "key_ops"]; - if object.keys().any(|key| !ALLOWED.contains(&key.as_str())) - || object.get("kty").and_then(JsonValue::as_str) != Some("OKP") - || object.get("crv").and_then(JsonValue::as_str) != Some("Ed25519") - || object.get("alg").and_then(JsonValue::as_str) != Some("EdDSA") +fn parse_service_public_jwk(bytes: &[u8]) -> Result { + const EXACT_MEMBERS: [&str; 6] = ["kty", "crv", "x", "y", "alg", "kid"]; + let object = parse_strict_json_object(bytes)?; + if object.len() != EXACT_MEMBERS.len() || object - .get("use") - .is_some_and(|value| value.as_str() != Some("sig")) + .keys() + .any(|member| !EXACT_MEMBERS.contains(&member.as_str())) { - return Err(invalid_artifact( - "retired JWK is not an allowed public EdDSA key", - )); + return Err(invalid_artifact("service public JWK members are not exact")); + } + let json = serde_json::to_string(&object) + .map_err(|_| invalid_artifact("service public JWK JSON is invalid"))?; + let jwk = + PublicJwk::parse(&json).map_err(|_| invalid_artifact("service public JWK is invalid"))?; + if jwk.algorithm().ok() != Some(ProviderSigningAlgorithm::Es256) + || jwk.kty != "EC" + || jwk.crv.as_deref() != Some("P-256") + || jwk.alg.as_deref() != Some("ES256") + { + return Err(invalid_artifact("service public JWK must be ES256 P-256")); } - let kid = object - .get("kid") - .and_then(JsonValue::as_str) - .filter(|kid| { - !kid.is_empty() - && kid.len() <= 256 - && !kid.chars().any(char::is_control) - && *kid != active_key_id - }) - .ok_or(invalid_artifact("retired JWK kid is invalid"))?; - let x = object - .get("x") - .and_then(JsonValue::as_str) - .ok_or(invalid_artifact("retired JWK public coordinate is missing"))?; - let decoded = URL_SAFE_NO_PAD - .decode(x) - .map_err(|_| invalid_artifact("retired JWK public coordinate is invalid"))?; - if decoded.len() != 32 { + let thumbprint = jwk + .jkt() + .map_err(|_| invalid_artifact("service public JWK thumbprint is invalid"))?; + if thumbprint.len() != 43 || jwk.kid.as_deref() != Some(thumbprint.as_str()) { return Err(invalid_artifact( - "retired JWK public coordinate has the wrong size", + "service public JWK kid must equal its RFC 7638 thumbprint", )); } - if let Some(operations) = object.get("key_ops") { - let operations = operations - .as_array() - .ok_or(invalid_artifact("retired JWK key_ops is invalid"))?; - if operations.len() != 1 || operations[0].as_str() != Some("verify") { - return Err(invalid_artifact("retired JWK key_ops is not verify-only")); - } - } - Ok(kid.to_owned()) + Ok(jwk) } fn concept_codelist_path(constraints: &OrderedMap) -> Result<&str, BundleError> { @@ -1821,6 +1858,44 @@ fn validate_runtime_bindings( bundle: &EvidenceConfig, runtime: &RuntimeConfig, ) -> Result<(), BundleError> { + let signer_matches_assurance = match bundle.assurance_profile { + crate::config::AssuranceProfile::Local => runtime.signer.is_local_jwk(), + crate::config::AssuranceProfile::Production + | crate::config::AssuranceProfile::EvidenceGrade => runtime.signer.is_transit(), + }; + if !signer_matches_assurance { + return Err(invalid_artifact( + "runtime signer kind does not match the bundle assurance profile", + )); + } + let audit_ref = &bundle.audit.hash_secret_ref; + let subject_ref = &bundle.subject_binding.secret_ref; + if let Some(signing_ref) = runtime.signer.private_key_ref() { + if signing_ref == audit_ref || signing_ref == subject_ref { + return Err(invalid_artifact( + "the local signing key reference must be distinct from audit and subject-binding references", + )); + } + } + let secret_root = Path::new(&runtime.secret_providers.file.root); + let audit_path = Path::new(&runtime.audit_storage.path); + let configured_secret_paths = [ + Some(audit_ref), + Some(subject_ref), + runtime.signer.private_key_ref(), + ] + .into_iter() + .flatten() + .filter_map(|reference| reference.as_str().strip_prefix("secret:file/")) + .map(|name| secret_root.join(name)); + if configured_secret_paths + .into_iter() + .any(|path| path == audit_path) + { + return Err(invalid_artifact( + "the audit storage path must not resolve to configured secret material", + )); + } let required = bundle .sources .iter() @@ -2134,15 +2209,13 @@ mod tests { #[test] fn strict_public_jwk_rejects_private_material_and_duplicate_members() { let private = br#"{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"old","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","d":"secret"}"#; - let object = parse_strict_json_object(private).expect("JSON parses"); - assert!(validate_public_jwk(&object, "active").is_err()); + assert!(parse_service_public_jwk(private).is_err()); let duplicate = br#"{"kty":"OKP","kty":"OKP"}"#; assert!(parse_strict_json_object(duplicate).is_err()); let control_kid = br#"{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"old\u000aidentifier","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#; - let object = parse_strict_json_object(control_kid).expect("JSON parses"); - assert!(validate_public_jwk(&object, "active").is_err()); + assert!(parse_service_public_jwk(control_kid).is_err()); } #[cfg(unix)] @@ -2411,7 +2484,7 @@ mod tests { fs::write( &runtime_path, format!( - "version: 1\nbundleDirectory: /etc/registry-evidence/bundle\nlistener:\n bindHost: 127.0.0.1\n port: 8080\n tlsTermination: operator-controlled-upstream\n trustProxyIdentityHeaders: false\n maximumRequestBytes: 65536\n maximumConcurrentRequests: 64\n requestTimeoutMilliseconds: 10000\n shutdownGraceMilliseconds: 30000\nsecretProviders:\n file: {{root: {}}}\nauditStorage:\n path: /var/lib/registry-evidence/audit/evidence.jsonl\n maximumFileBytes: 1073741824\noutboundTls:\n systemRoots: true\n trustProfiles:\n internal-pki: {{caBundleFile: {}}}\n", + "version: 1\nbundleDirectory: /etc/registry-evidence/bundle\nlistener:\n bindHost: 127.0.0.1\n port: 8080\n tlsTermination: operator-controlled-upstream\n trustProxyIdentityHeaders: false\n maximumRequestBytes: 65536\n maximumConcurrentRequests: 64\n requestTimeoutMilliseconds: 10000\n shutdownGraceMilliseconds: 30000\nsecretProviders:\n file: {{root: {}}}\nsigner:\n kind: transit\n unixSocketPath: /run/registry-evidence/transit-proxy.sock\n mount: transit\n keyName: evidence-signing\n keyVersion: 7\n timeoutMilliseconds: 2000\nauditStorage:\n path: /var/lib/registry-evidence/audit/evidence.jsonl\n maximumFileBytes: 1073741824\noutboundTls:\n systemRoots: true\n trustProfiles:\n internal-pki: {{caBundleFile: {}}}\n", secret_root.display(), ca_path.display() ), diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs index a82591a97..7616b7c59 100644 --- a/crates/registry-evidence/src/config.rs +++ b/crates/registry-evidence/src/config.rs @@ -10,6 +10,7 @@ use std::net::{IpAddr, Ipv6Addr}; use std::path::{Component, Path}; use std::str::FromStr; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use serde::de::{self, MapAccess, Visitor}; use serde::ser::SerializeMap; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -386,9 +387,17 @@ impl EvidenceConfig { self.authentication.validate(self.assurance_profile)?; self.audit.validate()?; self.subject_binding.validate()?; + if self.audit.hash_secret_ref == self.subject_binding.secret_ref { + return invalid("audit and subject-binding secret references must be distinct"); + } self.rate_limits.validate()?; self.signing.validate()?; validate_response_formats(&self.response_formats, "bundle response formats")?; + if self.assurance_profile != AssuranceProfile::Local + && self.response_formats.contains(&ResponseFormat::SdJwtVc) + { + validate_https_origin(&self.service.provider_id)?; + } validate_named_map(&self.selector_profiles, 1, 128, |profile| { profile.validate() })?; @@ -701,6 +710,23 @@ impl EvidenceConfig { } } +fn validate_https_origin(value: &str) -> Result<(), ConfigError> { + let url = Url::parse(value) + .map_err(|_| ConfigError::Invalid("service providerId is not a stable HTTPS origin"))?; + if url.scheme() != "https" + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + || value.ends_with('/') + { + return invalid("SD-JWT VC requires service.providerId to be a stable HTTPS origin"); + } + Ok(()) +} + #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ServiceConfig { @@ -725,6 +751,9 @@ pub struct RuntimeConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub metrics_listener: Option, pub secret_providers: RuntimeSecretProviders, + /// Process-local binding to the signer that controls the governed active + /// public key. This cannot change the governed key set or algorithm. + pub signer: RuntimeSignerConfig, pub audit_storage: AuditStorageConfig, pub outbound_tls: OutboundTlsConfig, } @@ -751,11 +780,78 @@ impl RuntimeConfig { metrics.validate(&self.listener)?; } self.secret_providers.validate()?; + self.signer.validate()?; self.audit_storage.validate()?; self.outbound_tls.validate() } } +/// Closed process-local signer binding. Production deployments reach Transit +/// only over a workload-local Unix socket and never receive a provider token. +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +pub enum RuntimeSignerConfig { + LocalJwk { + #[serde(rename = "privateKeyRef")] + private_key_ref: SecretRef, + }, + Transit { + #[serde(rename = "unixSocketPath")] + unix_socket_path: String, + mount: String, + #[serde(rename = "keyName")] + key_name: String, + #[serde(rename = "keyVersion")] + key_version: u32, + #[serde(rename = "timeoutMilliseconds")] + timeout_milliseconds: u64, + }, +} + +impl RuntimeSignerConfig { + fn validate(&self) -> Result<(), ConfigError> { + match self { + Self::LocalJwk { .. } => Ok(()), + Self::Transit { + unix_socket_path, + mount, + key_name, + key_version, + timeout_milliseconds, + } => { + validate_absolute_path(unix_socket_path)?; + if !valid_local_id(mount) || !valid_local_id(key_name) { + return invalid("Transit signer mount and keyName must be local identifiers"); + } + if *key_version == 0 { + return invalid("Transit signer keyVersion must be positive"); + } + validate_range( + *timeout_milliseconds, + 1, + 30_000, + "Transit signer timeoutMilliseconds", + ) + } + } + } + + pub fn is_local_jwk(&self) -> bool { + matches!(self, Self::LocalJwk { .. }) + } + + pub fn is_transit(&self) -> bool { + matches!(self, Self::Transit { .. }) + } + + pub fn private_key_ref(&self) -> Option<&SecretRef> { + match self { + Self::LocalJwk { private_key_ref } => Some(private_key_ref), + Self::Transit { .. } => None, + } + } +} + #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct RuntimeSecretProviders { @@ -957,6 +1053,11 @@ pub struct AuthenticationConfig { pub evidence_audience_claim: String, pub grant_id_claim: String, pub grant_authority_claim: String, + /// Maximum lifetime accepted for inbound access tokens. The verifier + /// requires `iat`, requires `exp > iat`, and applies this bound. + pub maximum_token_lifetime_seconds: u64, + /// Emergency denylist applied before JWKS cache selection. + pub revoked_key_ids: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub actor_claim: Option, } @@ -984,6 +1085,27 @@ impl AuthenticationConfig { validate_unique_strings(&self.audiences, 1, 16, 1, 512, "authentication audiences")?; validate_unique(&self.token_types, 1, 4, "authentication tokenTypes")?; validate_unique(&self.algorithms, 1, 3, "authentication algorithms")?; + validate_range( + self.maximum_token_lifetime_seconds, + 1, + 86_400, + "authentication maximumTokenLifetimeSeconds", + )?; + validate_unique_strings( + &self.revoked_key_ids, + 0, + 32, + 1, + 256, + "authentication revokedKeyIds", + )?; + if self + .revoked_key_ids + .iter() + .any(|kid| kid.chars().any(char::is_control)) + { + return invalid("authentication revokedKeyIds contain a control character"); + } // Ordered principal first, because `sub` is legitimate for that claim // alone and the shadowing check below reads the rest of the list. let claims = [ @@ -1220,9 +1342,9 @@ impl RateLimitConfig { pub struct SigningConfig { pub format: SigningFormat, pub algorithm: SigningAlgorithm, - pub active_key_id: String, - pub active_key_ref: SecretRef, - pub retired_public_jwk_files: Vec, + pub active_public_jwk_file: PublicJwkPath, + pub published_public_jwk_files: Vec, + pub revoked_key_ids: Vec, pub jwks_path: String, pub maximum_assertion_validity_seconds: u64, pub verifier_clock_skew_seconds: u64, @@ -1230,16 +1352,20 @@ pub struct SigningConfig { impl SigningConfig { fn validate(&self) -> Result<(), ConfigError> { - validate_string(&self.active_key_id, 1, 256, "active signing key id")?; - if self.active_key_id.chars().any(char::is_control) { - return invalid("active signing key id contains a control character"); - } validate_unique( - &self.retired_public_jwk_files, + &self.published_public_jwk_files, 0, 32, - "retired public JWK paths", + "published public JWK paths", )?; + if self + .published_public_jwk_files + .iter() + .any(|path| path == &self.active_public_jwk_file) + { + return invalid("the active public JWK file must not also be published"); + } + validate_key_identifiers(&self.revoked_key_ids, 33, "signing revokedKeyIds")?; if self.jwks_path != "/.well-known/evidence/jwks.json" { return invalid("JWKS path is not the Version 1 discovery path"); } @@ -1269,7 +1395,27 @@ pub enum SigningFormat { #[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] pub enum SigningAlgorithm { - EdDSA, + ES256, +} + +fn validate_key_identifiers( + identifiers: &[String], + maximum: usize, + label: &'static str, +) -> Result<(), ConfigError> { + validate_unique_strings(identifiers, 0, maximum, 43, 43, label)?; + if identifiers.iter().any(|identifier| { + let alphabet_is_valid = identifier + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')); + let encoding_is_canonical = URL_SAFE_NO_PAD.decode(identifier).is_ok_and(|decoded| { + decoded.len() == 32 && URL_SAFE_NO_PAD.encode(&decoded) == *identifier + }); + !alphabet_is_valid || !encoding_is_canonical + }) { + return invalid("key identifiers must be RFC 7638 SHA-256 thumbprints"); + } + Ok(()) } #[derive(Clone, Eq, Ord, PartialEq, PartialOrd, Hash)] @@ -3901,8 +4047,12 @@ mod tests { ); assert!(EvidenceConfig::parse_yaml(yaml.as_bytes()).is_ok()); let shrunk_maximum = yaml.replace( - "maximumAssertionValiditySeconds: 86400", - "maximumAssertionValiditySeconds: 3600", + "maximumAssertionValiditySeconds: 300", + "maximumAssertionValiditySeconds: 299", + ); + assert_ne!( + shrunk_maximum, yaml, + "fixture mutation must remain effective" ); assert!(matches!( EvidenceConfig::parse_yaml(shrunk_maximum.as_bytes()), @@ -4211,8 +4361,8 @@ mod tests { assert_ne!(unexpected, valid, "fixture mutation must remain effective"); assert!(EvidenceConfig::parse_yaml(unexpected.as_bytes()).is_err()); let literal_secret = valid.replacen( - "activeKeyRef: secret:file/signing-key", - "activeKeyRef: literal-private-key", + "hashSecretRef: secret:file/audit-hash-key", + "hashSecretRef: literal-audit-key", 1, ); assert_ne!( @@ -4220,15 +4370,27 @@ mod tests { "fixture mutation must remain effective" ); assert!(EvidenceConfig::parse_yaml(literal_secret.as_bytes(),).is_err()); - assert!(EvidenceConfig::parse_yaml( - valid - .replace( - "activeKeyId: fixture-key-2026-01", - "activeKeyId: \"fixture-key\\u000A2026-01\"", - ) - .as_bytes(), - ) - .is_err()); + let invalid_revocation = valid.replacen( + "revokedKeyIds: []", + "revokedKeyIds: [\"invalid\\u000Akey\"]", + 1, + ); + assert_ne!( + invalid_revocation, valid, + "fixture mutation must remain effective" + ); + assert!(EvidenceConfig::parse_yaml(invalid_revocation.as_bytes()).is_err()); + + let external_revocation = valid.replacen( + "revokedKeyIds: []", + "revokedKeyIds: [external-issuer-key-v7]", + 1, + ); + assert_ne!( + external_revocation, valid, + "fixture mutation must remain effective" + ); + assert!(EvidenceConfig::parse_yaml(external_revocation.as_bytes()).is_ok()); } #[test] @@ -4570,6 +4732,13 @@ listener: shutdownGraceMilliseconds: 30000 secretProviders: file: {root: /run/secrets/registry-evidence} +signer: + kind: transit + unixSocketPath: /run/registry-evidence/transit-proxy.sock + mount: transit + keyName: evidence-signing + keyVersion: 7 + timeoutMilliseconds: 2000 auditStorage: path: /var/lib/registry-evidence/audit/evidence.jsonl maximumFileBytes: 1073741824 @@ -4653,6 +4822,13 @@ listener: shutdownGraceMilliseconds: 30000 secretProviders: file: {root: /run/secrets/registry-evidence} +signer: + kind: transit + unixSocketPath: /run/registry-evidence/transit-proxy.sock + mount: transit + keyName: evidence-signing + keyVersion: 7 + timeoutMilliseconds: 2000 auditStorage: path: /var/lib/registry-evidence/audit/evidence.jsonl maximumFileBytes: 1073741824 @@ -4728,6 +4904,13 @@ listener: shutdownGraceMilliseconds: 30000 secretProviders: file: {root: /run/secrets/registry-evidence} +signer: + kind: transit + unixSocketPath: /run/registry-evidence/transit-proxy.sock + mount: transit + keyName: evidence-signing + keyVersion: 7 + timeoutMilliseconds: 2000 auditStorage: path: /var/lib/registry-evidence/audit/evidence.jsonl maximumFileBytes: 1073741824 @@ -4826,4 +5009,14 @@ outboundTls: assert!(SecretRef::parse("secret:env/SOURCE_TOKEN").is_err()); assert!(SecretRef::parse("literal-token").is_err()); } + + #[test] + fn service_revoked_key_ids_require_canonical_sha256_thumbprints() { + let canonical = "A".repeat(43); + assert!(validate_key_identifiers(&[canonical], 33, "revoked keys").is_ok()); + + let noncanonical = format!("{}B", "A".repeat(42)); + assert_eq!(noncanonical.len(), 43); + assert!(validate_key_identifiers(&[noncanonical], 33, "revoked keys").is_err()); + } } diff --git a/crates/registry-evidence/src/contracts.rs b/crates/registry-evidence/src/contracts.rs index dfbf3efab..68e727b15 100644 --- a/crates/registry-evidence/src/contracts.rs +++ b/crates/registry-evidence/src/contracts.rs @@ -51,7 +51,7 @@ const JWKS_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/jwks-v1 /// Shape of the server-minted operation identifier, shared by the response /// header and the problem member so the two cannot describe different values. const OPERATION_PATTERN: &str = "^[0-9A-HJKMNP-TV-Z]{26}$"; -/// Unpadded base64url encoding of exactly one 32-byte Ed25519 public key. +/// Unpadded base64url encoding of exactly one 32-byte P-256 affine coordinate. const HOLDER_KEY_COORDINATE_PATTERN: &str = "^[A-Za-z0-9_-]{43}$"; const PROBLEM_VARIANTS: [(&str, u16, &str); 9] = [ ("malformed_request", 400, "Request is not valid"), @@ -312,12 +312,13 @@ fn request_schema() -> Value { }, "holder-key": { "type": "object", "additionalProperties": false, - "required": ["kty", "crv", "x"], + "required": ["kty", "crv", "x", "y"], "properties": { - "kty": {"type": "string", "enum": ["OKP"]}, - "crv": {"type": "string", "enum": ["Ed25519"]}, + "kty": {"type": "string", "enum": ["EC"]}, + "crv": {"type": "string", "enum": ["P-256"]}, "x": {"type": "string", "pattern": HOLDER_KEY_COORDINATE_PATTERN}, - "alg": {"type": "string", "enum": ["EdDSA"]}, + "y": {"type": "string", "pattern": HOLDER_KEY_COORDINATE_PATTERN}, + "alg": {"type": "string", "enum": ["ES256"]}, "kid": {"type": "string", "minLength": 1, "maxLength": 256} } } @@ -476,7 +477,7 @@ fn jws_schema() -> Value { "payload": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9_-]+$"}, "signature": {"type": "string", "pattern": "^[A-Za-z0-9_-]{86}$"} }, - "$comment": "Flattened JWS JSON Serialization. The protected header has exactly alg=EdDSA, kid, typ=evidence+jws, and cty=application/evidence+json. The payload is the base64url encoding without padding of exact UTF-8 Evidence JSON bytes." + "$comment": "Flattened JWS JSON Serialization. The protected header has exactly alg=ES256, an RFC 7638 thumbprint kid, typ=evidence+jws, and cty=application/evidence+json. The payload is the base64url encoding without padding of exact UTF-8 Evidence JSON bytes." }) } @@ -564,23 +565,24 @@ fn jwks_schema() -> Value { "properties": { "keys": { "type": "array", "minItems": 1, "maxItems": 33, "uniqueItems": true, - "items": {"$ref": "#/$defs/ed25519-public-jwk"} + "items": {"$ref": "#/$defs/p256-public-jwk"} } }, "$defs": { - "ed25519-public-jwk": { + "p256-public-jwk": { "type": "object", "additionalProperties": false, - "required": ["kty", "kid", "alg", "crv", "x"], + "required": ["kty", "kid", "alg", "crv", "x", "y"], "properties": { - "kty": {"const": "OKP"}, - "kid": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$"}, - "alg": {"const": "EdDSA"}, - "crv": {"const": "Ed25519"}, - "x": {"type": "string", "pattern": "^[A-Za-z0-9_-]{43}$"} + "kty": {"const": "EC"}, + "kid": {"type": "string", "pattern": "^[A-Za-z0-9_-]{43}$"}, + "alg": {"const": "ES256"}, + "crv": {"const": "P-256"}, + "x": {"type": "string", "pattern": "^[A-Za-z0-9_-]{43}$"}, + "y": {"type": "string", "pattern": "^[A-Za-z0-9_-]{43}$"} } } }, - "$comment": "Only the active and configured retired public keys are published. Key ids are unique and limited to 256 UTF-8 bytes by the runtime; JSON Schema maxLength is an additional code-point bound. Discovery is not a trust anchor; verifiers pin the governed provider and JWKS location." + "$comment": "Only the governed active and published non-revoked P-256 keys are returned. Each kid is the key's RFC 7638 SHA-256 thumbprint. Discovery is not a trust anchor; verifiers pin the governed provider and JWKS location." }) } @@ -836,8 +838,8 @@ fn openapi_document( "additionalProperties": false, "required": ["alg", "kid", "typ", "cty"], "properties": { - "alg": {"type": "string", "enum": ["EdDSA"]}, - "kid": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^\\u0000-\\u001F\\u007F]+$"}, + "alg": {"type": "string", "enum": ["ES256"]}, + "kid": {"type": "string", "pattern": "^[A-Za-z0-9_-]{43}$"}, "typ": {"type": "string", "enum": ["evidence+jws"]}, "cty": {"type": "string", "enum": ["application/evidence+json"]} } @@ -848,7 +850,7 @@ fn openapi_document( &mut schemas, "JwksDocument", jwks, - &[("ed25519-public-jwk", "Ed25519PublicJwk")], + &[("p256-public-jwk", "P256PublicJwk")], ); schemas.insert( "SdJwtVcCredential".to_string(), @@ -863,10 +865,10 @@ fn openapi_document( json!({ "type": "object", "additionalProperties": false, - "required": ["issuer", "jwks"], + "required": ["issuer", "jwks_uri"], "properties": { "issuer": {"type": "string", "maxLength": 512}, - "jwks": {"$ref": "#/components/schemas/JwksDocument"} + "jwks_uri": {"type": "string", "format": "uri", "maxLength": 1024} } }), ); @@ -1224,7 +1226,7 @@ mod tests { assert_eq!( document["components"]["schemas"]["EvidenceProtectedHeader"]["properties"]["alg"] ["enum"], - json!(["EdDSA"]) + json!(["ES256"]) ); for path in paths.values() { @@ -1375,8 +1377,9 @@ mod tests { ( jwks_schema(), json!({"keys": [{ - "kty": "OKP", "kid": "evidence-key-1", "alg": "EdDSA", - "crv": "Ed25519", "x": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "kty": "EC", "kid": "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo", "alg": "ES256", + "crv": "P-256", "x": "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4", + "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU" }]}), ), ]; diff --git a/crates/registry-evidence/src/kernel.rs b/crates/registry-evidence/src/kernel.rs index bf15a3951..544526d63 100644 --- a/crates/registry-evidence/src/kernel.rs +++ b/crates/registry-evidence/src/kernel.rs @@ -1181,7 +1181,6 @@ mod tests { use super::*; use std::fs; use std::path::{Path, PathBuf}; - use std::time::Duration as StdDuration; use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; use serde_json::json; @@ -1193,7 +1192,8 @@ mod tests { const KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; const AUDIENCE: &str = "urn:example:fixture:audience"; - const SUPPORTED_VALUE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"supported-values-fixture-key"}"#; + const SUPPORTED_VALUE_KEY_ID: &str = "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"; + const SUPPORTED_VALUE_PRIVATE_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"}"#; fn projection() -> ValueProjection<'static> { ValueProjection { @@ -1769,7 +1769,7 @@ mod tests { assert_eq!(first.supported_values[0].value, PublicValue::Boolean(false)); assert_eq!(first.subjects[0].role, "child"); assert_eq!(first.subjects[1].role, "candidate-parent"); - assert_eq!(first.valid_until, "2026-08-03T00:00:01Z"); + assert_eq!(first.valid_until, "2026-08-02T00:05:01Z"); } #[tokio::test] @@ -2249,7 +2249,7 @@ mod tests { let private = PrivateJwk::parse(SUPPORTED_VALUE_PRIVATE_JWK).expect("fixture key parses"); let provider: Arc = Arc::new(LocalJwkSigner::new(private).expect("fixture signer builds")); - EvidenceSigner::initialize(provider, "supported-values-fixture-key") + EvidenceSigner::initialize(provider, SUPPORTED_VALUE_KEY_ID) .await .expect("fixture signer initializes") } @@ -2292,10 +2292,11 @@ mod tests { &EvidenceVerificationPolicy::from_accepted_transaction( &evidence, &evidence.request_nonce, - StdDuration::from_secs(48 * 60 * 60), - "2026-08-02T12:00:00Z".parse().expect("time"), - StdDuration::from_secs(30), - ), + 48 * 60 * 60, + "2026-08-02T00:03:00Z".parse().expect("time"), + 30, + ) + .expect("the fixture policy states bounds the contract allows"), ) .expect("signed Evidence verifies"); assert_eq!( diff --git a/crates/registry-evidence/src/local_verification.rs b/crates/registry-evidence/src/local_verification.rs index b0783ab78..3d9dec52c 100644 --- a/crates/registry-evidence/src/local_verification.rs +++ b/crates/registry-evidence/src/local_verification.rs @@ -134,7 +134,7 @@ pub async fn prepare_local_verification_context_for_format( subject_binding_secret, signer: _, jwks, - } = validate_verification_material(bundle, &secrets) + } = validate_verification_material(bundle, &deployment.runtime.config.signer, &secrets) .await .map_err(|_| LocalVerificationError)?; let expected_subjects = resolved @@ -183,6 +183,7 @@ pub async fn prepare_local_verification_context_for_format( ), }) .collect(), + revoked_key_ids: bundle.config.signing.revoked_key_ids.clone(), maximum_assertion_lifetime_seconds: requirement.validity_seconds, clock_skew_seconds: bundle.config.signing.verifier_clock_skew_seconds, }, @@ -218,7 +219,14 @@ pub(crate) fn verify_local_response_at( if context.schema != LOCAL_VERIFICATION_CONTEXT_SCHEMA_V1 { return Err(LocalVerificationError); } - let policy = context.verification_policy.into_policy(now); + // The requirement validity and the verifier skew this policy carries are + // both bounded by the bundle schema, so a loaded deployment cannot reach a + // policy the verification policy contract forbids. A context assembled some + // other way is refused rather than verified against. + let policy = context + .verification_policy + .try_into_policy(now) + .map_err(|_| LocalVerificationError)?; match context.response_format { LocalResponseFormat::SignedJws => { verify_flattened_jws(response, &context.trusted_jwks, &policy) diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs index c53a98c4b..5a2d0a5e2 100644 --- a/crates/registry-evidence/src/main.rs +++ b/crates/registry-evidence/src/main.rs @@ -14,7 +14,7 @@ use std::{ use chrono::{DateTime, NaiveDate, SecondsFormat, TimeZone, Utc}; use chrono_tz::Tz; use clap::{ArgGroup, Parser, Subcommand}; -use ed25519_dalek::SigningKey; +use p256::ecdsa::SigningKey; use rand_core::OsRng; use registry_evidence::{ audit::{ @@ -56,7 +56,9 @@ use registry_evidence::{ EvidenceVerificationPolicy, EvidenceVerificationPolicyDocument, VerificationError, }, }; -use registry_platform_audit::{AuditHashSecret, OptionalHashHex}; +use registry_platform_audit::{ + AuditChainHasher, AuditChainProfile, AuditHashSecret, OptionalHashHex, +}; use registry_platform_crypto::{canonicalize_json, parse_json_strict, LocalJwkSigner, PrivateJwk}; use serde_json::{Map as JsonMap, Value}; use zeroize::Zeroizing; @@ -94,6 +96,20 @@ enum Command { #[arg(long)] fixture: PathBuf, }, + /// Internal Evidencectl seam for bundle-only semantic validation. + #[command(hide = true)] + BundleCheck { + #[arg(long)] + bundle: PathBuf, + }, + /// Internal Evidencectl seam for bundle-only fixture evaluation. + #[command(hide = true)] + BundleEvaluate { + #[arg(long)] + bundle: PathBuf, + #[arg(long)] + fixture: PathBuf, + }, /// Start the native Evidence HTTP service. Serve, /// Re-verify one stored signed response offline against a pinned key set. @@ -242,7 +258,7 @@ async fn run(cli: Cli) -> Result { &runtime.config.secret_providers.file.root, ) .map_err(|_| runtime_initialization_error(RuntimeInitializationError::Secrets))?; - validate_secret_material(&bundle, &secrets) + validate_secret_material(&bundle, &runtime.config, &secrets) .await .map_err(runtime_initialization_error)?; println!( @@ -260,7 +276,32 @@ async fn run(cli: Cli) -> Result { let kernel = OfflineKernel::compile(Arc::clone(&bundle)) .map_err(|_| CliError("fixture bundle compilation failed"))?; let source_plans = compile_source_plans(&bundle.config, &runtime)?; - let summary = evaluate_fixture(&bundle, &kernel, &source_plans, &fixture).await?; + let summary = evaluate_fixture(&bundle, &kernel, &source_plans, &fixture, true).await?; + println!( + "Evidence fixture passed ({} evaluated cases)", + summary.evaluated_cases + ); + Ok(ExitCode::SUCCESS) + } + Command::BundleCheck { bundle } => { + let bundle = Arc::new(Bundle::load(&bundle).map_err(deployment_load_error)?); + OfflineKernel::compile(Arc::clone(&bundle)) + .map_err(|_| CliError("bundle compilation failed"))?; + let _source_plans = compile_bundle_source_plans(&bundle.config)?; + println!( + "Evidence bundle {} passed check ({} requirements)", + bundle.revision(), + bundle.config.requirements.len() + ); + Ok(ExitCode::SUCCESS) + } + Command::BundleEvaluate { bundle, fixture } => { + let bundle = Arc::new(Bundle::load(&bundle).map_err(deployment_load_error)?); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)) + .map_err(|_| CliError("fixture bundle compilation failed"))?; + let source_plans = compile_bundle_source_plans(&bundle.config)?; + let summary = + evaluate_fixture(&bundle, &kernel, &source_plans, &fixture, false).await?; println!( "Evidence fixture passed ({} evaluated cases)", summary.evaluated_cases @@ -400,6 +441,27 @@ fn compile_source_plans_with_runtime( Ok(plans) } +fn compile_bundle_source_plans( + config: &EvidenceConfig, +) -> Result, CliError> { + let secrets = Arc::new( + SecretResolver::new([SecretProvider::File], "/") + .map_err(|_| CliError("source plan compilation failed"))?, + ); + let mut plans = BTreeMap::new(); + for (source_id, source) in config.sources.iter() { + let allowed_selector_sets = config.source_selector_sets(source_id); + let plan = SourceExecutor::new_for_offline_fixture( + source, + &allowed_selector_sets, + Arc::clone(&secrets), + ) + .map_err(|_| CliError("source plan compilation failed"))?; + plans.insert(source_id.to_owned(), plan); + } + Ok(plans) +} + /// Install the operational log subscriber for the serving process. /// /// Records are line-delimited JSON on stdout so a collector can read them @@ -636,8 +698,8 @@ fn local_audit_last_operation_command(runtime_path: &Path) -> Result Result { let audit_secret = secrets .resolve(deployment.bundle.config.audit.hash_secret_ref.as_str()) .map_err(|_| CliError("audit verification secret resolution failed"))?; - let master_secret = AuditHashSecret::new(audit_secret.expose_secret().to_vec()) + let master_secret = derived_audit_chain_secret(audit_secret.expose_secret()) .map_err(|_| CliError("audit verification secret is invalid"))?; verify_audit_with_secret( Path::new(&deployment.runtime.config.audit_storage.path), @@ -817,6 +885,16 @@ fn verify_audit_with_secret( } } +fn derived_audit_chain_secret(master_secret: &[u8]) -> Result { + let profile = + AuditChainProfile::production_from_secret_bytes(Zeroizing::new(master_secret.to_vec())) + .map_err(|_| ())?; + match profile.hasher() { + AuditChainHasher::Keyed(secret) => Ok(secret), + AuditChainHasher::UnkeyedDevOnly => Err(()), + } +} + /// Render an out-of-band audit verification result for an operator. /// /// The head hash and the segment and record counts carry no request content, @@ -867,8 +945,13 @@ async fn evaluate_fixture( kernel: &OfflineKernel, source_plans: &BTreeMap, fixture_path: &Path, + exercise_signing: bool, ) -> Result { - let signer = offline_fixture_signer().await?; + let signer = if exercise_signing { + Some(offline_fixture_signer().await?) + } else { + None + }; let fixture_name = safe_fixture_name(fixture_path)?; let referenced = bundle .config @@ -909,7 +992,7 @@ async fn evaluate_fixture( bundle, kernel, source_plans, - &signer, + signer.as_ref(), requirement, object, ) @@ -1029,7 +1112,7 @@ async fn evaluate_fixture( sign_and_verify_fixture_evidence( bundle, kernel, - &signer, + signer.as_ref(), requirement, &resolved, values, @@ -1073,7 +1156,7 @@ async fn evaluate_reference_fixture( bundle: &Arc, kernel: &OfflineKernel, source_plans: &BTreeMap, - signer: &EvidenceSigner, + signer: Option<&EvidenceSigner>, requirement: ®istry_evidence::config::RequirementConfig, fixture: &JsonMap, ) -> Result { @@ -1366,7 +1449,7 @@ async fn evaluate_reference_fixture( struct ReferenceResponseContext<'a> { bundle: &'a Bundle, kernel: &'a OfflineKernel, - signer: &'a EvidenceSigner, + signer: Option<&'a EvidenceSigner>, requirement: &'a registry_evidence::config::RequirementConfig, resolved: &'a ResolvedAuthorization, } @@ -1505,7 +1588,7 @@ async fn validate_reference_response( async fn sign_and_verify_fixture_evidence( bundle: &Bundle, kernel: &OfflineKernel, - signer: &EvidenceSigner, + signer: Option<&EvidenceSigner>, requirement: ®istry_evidence::config::RequirementConfig, resolved: &ResolvedAuthorization, values: ValidatedValues, @@ -1546,6 +1629,10 @@ async fn sign_and_verify_fixture_evidence( }, ) .map_err(|_| CliError("fixture evidence construction failed"))?; + let Some(signer) = signer else { + return serde_json::to_value(evidence) + .map_err(|_| CliError("fixture evidence is not representable")); + }; let signed = signer .sign_json(&evidence) .await @@ -1555,10 +1642,11 @@ async fn sign_and_verify_fixture_evidence( let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( &evidence, registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, - std::time::Duration::from_secs(31_536_000), + registry_evidence::verifier::MAXIMUM_ASSERTION_LIFETIME_SECONDS, issued_at, - std::time::Duration::ZERO, - ); + 0, + ) + .map_err(|_| CliError("fixture verification policy is outside its contract bounds"))?; policy.issued_by = bundle.config.issuer.id.clone(); policy.provided_by = bundle.config.service.provider_id.clone(); policy.requirement = requirement.id.clone(); @@ -1580,18 +1668,29 @@ async fn sign_and_verify_fixture_evidence( async fn offline_fixture_signer() -> Result { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; - const KEY_ID: &str = "offline-fixture-signing-key"; - let signing_key = SigningKey::generate(&mut OsRng); + let signing_key = SigningKey::random(&mut OsRng); let private_bytes = Zeroizing::new(signing_key.to_bytes()); - let public_bytes = signing_key.verifying_key().to_bytes(); - let private_jwk = PrivateJwk { - kty: "OKP".to_owned(), - kid: Some(KEY_ID.to_owned()), - alg: Some("EdDSA".to_owned()), - crv: Some("Ed25519".to_owned()), - d: Some(URL_SAFE_NO_PAD.encode(private_bytes.as_slice())), - x: Some(URL_SAFE_NO_PAD.encode(public_bytes)), - y: None, + let public = signing_key.verifying_key().to_encoded_point(false); + let mut private_jwk = PrivateJwk { + kty: "EC".to_owned(), + kid: None, + alg: Some("ES256".to_owned()), + crv: Some("P-256".to_owned()), + d: Some(URL_SAFE_NO_PAD.encode(&private_bytes[..])), + x: Some( + URL_SAFE_NO_PAD.encode( + public + .x() + .ok_or(CliError("offline fixture public key is invalid"))?, + ), + ), + y: Some( + URL_SAFE_NO_PAD.encode( + public + .y() + .ok_or(CliError("offline fixture public key is invalid"))?, + ), + ), n: None, e: None, p: None, @@ -1600,11 +1699,16 @@ async fn offline_fixture_signer() -> Result { dq: None, qi: None, }; + let key_id = private_jwk + .public() + .jkt() + .map_err(|_| CliError("offline fixture key identifier derivation failed"))?; + private_jwk.kid = Some(key_id.clone()); let provider = Arc::new( LocalJwkSigner::new(private_jwk) .map_err(|_| CliError("offline fixture signer initialization failed"))?, ); - EvidenceSigner::initialize(provider, KEY_ID) + EvidenceSigner::initialize(provider, &key_id) .await .map_err(|_| CliError("offline fixture signer self-test failed")) } @@ -2664,6 +2768,8 @@ mod tests { fn local_shell_seams_are_hidden_from_adopter_help() { let command = Cli::command(); for name in [ + "bundle-check", + "bundle-evaluate", "prepare-local-verification-context", "verify-local-response", "local-audit-last-operation", @@ -2779,7 +2885,11 @@ mod tests { let policy: EvidenceVerificationPolicyDocument = serde_norway::from_str(&document) .unwrap_or_else(|error| panic!("`{written}` is a policy form: {error}")); assert_eq!( - policy.into_policy(Utc::now()).expected_outputs[0].form, + policy + .try_into_policy(Utc::now()) + .expect("the fixture policy states bounds the contract allows") + .expected_outputs[0] + .form, expected, "`{written}` parsed as a different form" ); @@ -2820,6 +2930,7 @@ mod tests { expectedOutputs:\n\ \x20 - concept: urn:example:concept\n\ \x20 {form}\n\ + revokedKeyIds: []\n\ maximumAssertionLifetimeSeconds: 86400\n\ clockSkewSeconds: 30\n", binding = "A".repeat(43), @@ -3085,8 +3196,8 @@ mod tests { } fn test_audit_secret() -> AuditHashSecret { - AuditHashSecret::new(b"0123456789abcdef0123456789abcdef".to_vec()) - .expect("audit secret builds") + derived_audit_chain_secret(b"0123456789abcdef0123456789abcdef") + .expect("audit chain secret derives") } fn test_audit_event(log: &EvidenceAuditLog) -> EvidenceAuditEvent { @@ -3311,7 +3422,7 @@ mod tests { .expect("cases") .len(); assert_eq!( - evaluate_fixture(&bundle, &kernel, &source_plans, fixture).await, + evaluate_fixture(&bundle, &kernel, &source_plans, fixture, true).await, Ok(FixtureSummary { evaluated_cases: expected_cases, }), @@ -3352,7 +3463,7 @@ mod tests { .as_str(), ); assert!( - evaluate_fixture(&bundle, &kernel, &source_plans, fixture) + evaluate_fixture(&bundle, &kernel, &source_plans, fixture, true) .await .is_ok(), "combined acceptance fixture failed" @@ -3421,7 +3532,7 @@ mod tests { .expect("cases") .len(); assert_eq!( - evaluate_fixture(&bundle, &kernel, &source_plans, fixture).await, + evaluate_fixture(&bundle, &kernel, &source_plans, fixture, true).await, Ok(FixtureSummary { evaluated_cases: expected_cases, }), diff --git a/crates/registry-evidence/src/model.rs b/crates/registry-evidence/src/model.rs index 598c3e028..ad2d9cd06 100644 --- a/crates/registry-evidence/src/model.rs +++ b/crates/registry-evidence/src/model.rs @@ -223,6 +223,20 @@ redacted_debug!( mod tests { use super::*; + #[test] + fn holder_public_key_rejects_coordinates_that_are_not_on_p256() { + let key = HolderPublicKey { + kty: "EC".to_owned(), + crv: "P-256".to_owned(), + x: "A".repeat(43), + y: "A".repeat(43), + alg: Some("ES256".to_owned()), + kid: Some("wallet-owned-key-7".to_owned()), + }; + + assert!(!key.is_acceptable()); + } + #[test] fn schema_integer_lexical_forms_canonicalize_to_safe_i64() { for input in ["1", "1.0", "1e0"] { diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index 3fc2b2f6f..6693f11f3 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -6,12 +6,14 @@ use std::{ path::Path, str, sync::Arc, - time::Instant, + time::{Duration, Instant}, }; use chrono::Utc; -use registry_platform_audit::{AuditError, AuditHashSecret}; -use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, PublicJwk}; +use registry_platform_audit::{AuditError, AuditProfile}; +use registry_platform_crypto::{ + LocalJwkSigner, PrivateJwk, SigningProvider, TransitSigner, TransitSignerConfig, +}; use serde_json::{Map as JsonMap, Value}; use thiserror::Error; @@ -25,7 +27,8 @@ use crate::{ bundle::{Bundle, DeploymentInputs}, config::{ AssuranceProfile, AuthorityKind, ConceptForm, RequirementKind, ResponseFormat, - RuntimeConfig, SelectorField, SelectorInput, SubjectCardinality, ValueOrigin, + RuntimeConfig, RuntimeSignerConfig, SelectorField, SelectorInput, SubjectCardinality, + ValueOrigin, }, contracts::definitions_contract_accepts, kernel::{EvidenceConstruction, KernelError, KernelOutcome, OfflineKernel, ValueProjection}, @@ -45,11 +48,12 @@ use crate::{ validate_subject_binding_key, AuthorizationError, MatchedEntitlement, ResolvedAuthorization, ResolvedSelectorValue, }, - signing::{jwks_document, EvidenceSigner}, + signing::EvidenceSigner, source::{ResolvedSourceSelector, SourceError, SourceExecutor}, EVIDENCE_DEFINITIONS_SCHEMA_V1, EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_SD_JWT_VC_MEDIA_TYPE, EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1, EVIDENCE_UNSIGNED_MEDIA_TYPE, }; +use zeroize::Zeroizing; const MAX_OPERATION_BYTES: usize = 128; @@ -168,15 +172,21 @@ pub struct ValidatedVerificationMaterial { /// owns them. pub async fn validate_secret_material( bundle: &Bundle, + runtime: &RuntimeConfig, secrets: &SecretResolver, ) -> Result { let audit_secret = secrets .resolve(bundle.config.audit.hash_secret_ref.as_str()) .map_err(|_| RuntimeInitializationError::Audit(AuditInitializationFault::Secret))?; - AuditHashSecret::new(audit_secret.expose_secret().to_vec()) - .map_err(|_| RuntimeInitializationError::Audit(AuditInitializationFault::Secret))?; - - let verification = validate_verification_material(bundle, secrets).await?; + AuditProfile::production_from_secret_bytes(Zeroizing::new( + audit_secret.expose_secret().to_vec(), + )) + .map_err(|_| RuntimeInitializationError::Audit(AuditInitializationFault::Secret))?; + + let verification = validate_verification_material(bundle, &runtime.signer, secrets).await?; + if audit_secret.expose_secret() == verification.subject_binding_secret.expose_secret() { + return Err(RuntimeInitializationError::Secrets); + } Ok(ValidatedSecretMaterial { audit_secret, @@ -191,6 +201,7 @@ pub async fn validate_secret_material( /// source credentials and the audit boundary. pub async fn validate_verification_material( bundle: &Bundle, + signer_config: &RuntimeSignerConfig, secrets: &SecretResolver, ) -> Result { let subject_binding_secret = secrets @@ -203,32 +214,52 @@ pub async fn validate_verification_material( ) .map_err(|_| RuntimeInitializationError::Secrets)?; - let signing_secret = secrets - .resolve(bundle.config.signing.active_key_ref.as_str()) - .map_err(|_| RuntimeInitializationError::Signing)?; - let signing_json = str::from_utf8(signing_secret.expose_secret()) - .map_err(|_| RuntimeInitializationError::Signing)?; - let private_jwk = - PrivateJwk::parse(signing_json).map_err(|_| RuntimeInitializationError::Signing)?; - let provider = Arc::new( - LocalJwkSigner::new(private_jwk).map_err(|_| RuntimeInitializationError::Signing)?, - ); - let signer = EvidenceSigner::initialize(provider, &bundle.config.signing.active_key_id) + let provider: Arc = match signer_config { + RuntimeSignerConfig::LocalJwk { private_key_ref } => { + let signing_secret = secrets + .resolve(private_key_ref.as_str()) + .map_err(|_| RuntimeInitializationError::Signing)?; + let signing_json = str::from_utf8(signing_secret.expose_secret()) + .map_err(|_| RuntimeInitializationError::Signing)?; + let private_jwk = + PrivateJwk::parse(signing_json).map_err(|_| RuntimeInitializationError::Signing)?; + Arc::new( + LocalJwkSigner::new(private_jwk) + .map_err(|_| RuntimeInitializationError::Signing)?, + ) + } + RuntimeSignerConfig::Transit { + unix_socket_path, + mount, + key_name, + key_version, + timeout_milliseconds, + } => { + let config = TransitSignerConfig::new( + unix_socket_path, + mount, + key_name, + *key_version, + bundle.active_public_jwk.clone(), + Duration::from_millis(*timeout_milliseconds), + ) + .map_err(|_| RuntimeInitializationError::Signing)?; + Arc::new( + TransitSigner::initialize(config) + .await + .map_err(|_| RuntimeInitializationError::Signing)?, + ) + } + }; + let signer = EvidenceSigner::initialize_governed(provider, &bundle.active_public_jwk) .await .map_err(|_| RuntimeInitializationError::Signing)?; - let retired = bundle - .retired_public_jwks - .values() - .map(|value| { - serde_json::to_string(value) - .map_err(|_| RuntimeInitializationError::Signing) - .and_then(|json| { - PublicJwk::parse(&json).map_err(|_| RuntimeInitializationError::Signing) - }) - }) - .collect::, _>>()?; - let jwks = jwks_document(signer.public_jwk(), retired) - .map_err(|_| RuntimeInitializationError::Signing)?; + let jwks = crate::signing::jwks_document_with_revocations( + signer.public_jwk(), + bundle.published_public_jwks.values().cloned(), + bundle.config.signing.revoked_key_ids.clone(), + ) + .map_err(|_| RuntimeInitializationError::Signing)?; Ok(ValidatedVerificationMaterial { subject_binding_secret, @@ -370,7 +401,7 @@ impl EvidenceRuntime { .map_err(|_| RuntimeInitializationError::Secrets)?, ); - let material = validate_secret_material(&bundle, &secrets).await?; + let material = validate_secret_material(&bundle, &runtime_config, &secrets).await?; let audit = EvidenceAuditLog::initialize( &runtime_config.audit_storage.path, runtime_config.audit_storage.maximum_file_bytes, @@ -486,7 +517,7 @@ impl EvidenceRuntime { &self.bundle().config.service.trust_domain, ) .is_err() - || !self.signer.ready() + || !self.signer.ensure_ready().await || !self.audit.ready().await { return false; @@ -1150,8 +1181,9 @@ impl EvidenceRuntime { ) } ResponseFormat::UnsignedJson => { - // No signing operation runs, but the ordinary signing - // dependency must still be ready for the deployment. + // Unsigned output is never a recovery or fallback path for a + // failed signer. Signed requests still attempt the provider, + // which lets a recovered Transit dependency return to Ready. if !self.signer.ready() { self.append_failure( &material, diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs index 23ecdf478..f30f06afc 100644 --- a/crates/registry-evidence/src/runtime_tests.rs +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -17,7 +17,9 @@ use axum_test::TestServer; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::{DateTime, Utc}; use jsonwebtoken::{jwk::JwkSet, Algorithm}; -use registry_platform_audit::{verify_jsonl_lines_with_hasher, AuditChainHasher, AuditHashSecret}; +use registry_platform_audit::{ + verify_jsonl_lines_with_hasher, AuditChainHasher, AuditChainProfile, +}; use registry_platform_crypto::{ sign, KeyReadiness, LocalJwkSigner, PrivateJwk, PublicJwk, SigningAlgorithm, SigningError, SigningProvider, @@ -57,13 +59,15 @@ use crate::{ server::{build_app, build_app_at_for_test, build_app_with_metrics, serve_listener_for_test}, signing::EvidenceSigner, verifier::{ - verify_flattened_jws, verify_sd_jwt_vc, EvidenceVerificationPolicy, ExpectedValueForm, + verify_flattened_jws, verify_sd_jwt_vc, EvidenceVerificationPolicy, + EvidenceVerificationPolicyDocument, ExpectedValueForm, }, EVIDENCE_SD_JWT_VC_MEDIA_TYPE, EVIDENCE_UNSIGNED_MEDIA_TYPE, }; -const AUTH_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"acceptance-auth-key"}"#; -const EVIDENCE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"acceptance-evidence-key"}"#; +const AUTH_PRIVATE_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE","x":"axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpY","y":"T-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU","alg":"ES256","kid":"acceptance-auth-key"}"#; +const EVIDENCE_KEY_ID: &str = "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"; +const EVIDENCE_PRIVATE_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"}"#; const TOKEN_ISSUER: &str = "https://identity.invalid"; const TOKEN_AUDIENCE: &str = "evidence-fixture"; const EVIDENCE_AUDIENCE: &str = "https://relying.invalid/procedure"; @@ -102,7 +106,7 @@ struct PreparedFixture { audit_path: PathBuf, } -struct FailAfterSelfTestSigner { +struct FailOnceAfterSelfTestSigner { delegate: LocalJwkSigner, calls: AtomicUsize, } @@ -329,7 +333,7 @@ impl SigningProvider for UnavailableReadinessSigner { } #[async_trait] -impl SigningProvider for FailAfterSelfTestSigner { +impl SigningProvider for FailOnceAfterSelfTestSigner { fn algorithm(&self) -> SigningAlgorithm { self.delegate.algorithm() } @@ -343,14 +347,18 @@ impl SigningProvider for FailAfterSelfTestSigner { } fn readiness(&self) -> KeyReadiness { - KeyReadiness::Ready + if self.calls.load(Ordering::Acquire) == 2 { + KeyReadiness::NotReady + } else { + KeyReadiness::Ready + } } async fn sign(&self, payload: &[u8]) -> Result, SigningError> { - if self.calls.fetch_add(1, Ordering::AcqRel) == 0 { - self.delegate.sign(payload).await - } else { - Err(SigningError::external("synthetic unavailable signer")) + match self.calls.fetch_add(1, Ordering::AcqRel) { + 0 => self.delegate.sign(payload).await, + 1 => Err(SigningError::external("synthetic unavailable signer")), + _ => self.delegate.sign(payload).await, } } } @@ -1298,10 +1306,11 @@ async fn local_runtime_without_fixture_references_keeps_the_real_security_path() let mut holder_request = request.clone(); holder_request.holder_key = Some(HolderPublicKey { - kty: "OKP".to_owned(), - crv: "Ed25519".to_owned(), - x: "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc".to_owned(), - alg: Some("EdDSA".to_owned()), + kty: "EC".to_owned(), + crv: "P-256".to_owned(), + x: "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4".to_owned(), + y: "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU".to_owned(), + alg: Some("ES256".to_owned()), kid: Some("acceptable-holder-key".to_owned()), }); assert!( @@ -1638,7 +1647,7 @@ async fn readiness_fails_for_missing_credentials_tampered_audit_and_unready_sign let provider: Arc = Arc::new(UnavailableReadinessSigner { delegate: LocalJwkSigner::new(private).expect("test signer builds"), }); - let signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + let signer = EvidenceSigner::initialize(provider, EVIDENCE_KEY_ID) .await .expect("provider self-test succeeds independently of readiness posture"); runtime.replace_signer_for_test(signer); @@ -1837,15 +1846,16 @@ async fn signing_failure_is_transient_audited_and_never_releases_unsigned_eviden .expect("runtime initializes"); let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); let delegate = LocalJwkSigner::new(private).expect("local signer builds"); - let provider: Arc = Arc::new(FailAfterSelfTestSigner { + let provider = Arc::new(FailOnceAfterSelfTestSigner { delegate, calls: AtomicUsize::new(0), }); - let failing_signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + let signing_provider: Arc = provider.clone(); + let failing_signer = EvidenceSigner::initialize(signing_provider, EVIDENCE_KEY_ID) .await .expect("signer passes its startup self-test"); runtime.replace_signer_for_test(failing_signer); - mount_adult_source(&prepared.server, None).await; + mount_adult_source_expecting(&prepared.server, None, 2).await; let error = runtime .evaluate( @@ -1856,10 +1866,27 @@ async fn signing_failure_is_transient_audited_and_never_releases_unsigned_eviden .await .expect_err("signing failure cannot produce any success representation"); assert_eq!(error.problem(), ProblemCode::ServiceUnavailable); + assert_eq!(provider.readiness(), KeyReadiness::NotReady); + assert!( + runtime.ready().await, + "readiness retries a failed provider so load-balanced replicas can recover" + ); + assert_eq!(provider.readiness(), KeyReadiness::Ready); + + runtime + .evaluate( + "operation-signing-recovered", + &access_token(None), + &adult_request(), + ) + .await + .expect("a later signed request retries the provider and recovers"); + assert!(runtime.ready().await); + let audit = fs::read_to_string(&prepared.audit_path).expect("audit is readable"); - assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 2); assert_eq!(audit.matches("\"decision\":\"signing-failure\"").count(), 1); - assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 1); for canary in privacy_canaries() { assert!(!audit.contains(canary)); } @@ -2302,7 +2329,7 @@ async fn unsigned_envelope_is_exact_audited_and_never_a_signing_fallback() { let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); let delegate = LocalJwkSigner::new(private).expect("local signer builds"); let provider: Arc = Arc::new(UnavailableReadinessSigner { delegate }); - let unready_signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + let unready_signer = EvidenceSigner::initialize(provider, EVIDENCE_KEY_ID) .await .expect("signer passes its startup self-test"); runtime.replace_signer_for_test(unready_signer); @@ -2330,11 +2357,11 @@ async fn signing_failure_returns_a_problem_and_never_an_unsigned_body() { .expect("runtime initializes"); let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); let delegate = LocalJwkSigner::new(private).expect("local signer builds"); - let provider: Arc = Arc::new(FailAfterSelfTestSigner { + let provider: Arc = Arc::new(FailOnceAfterSelfTestSigner { delegate, calls: AtomicUsize::new(0), }); - let failing_signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + let failing_signer = EvidenceSigner::initialize(provider, EVIDENCE_KEY_ID) .await .expect("signer passes its startup self-test"); runtime.replace_signer_for_test(failing_signer); @@ -2546,10 +2573,11 @@ async fn sd_jwt_format_not_permitted_by_grant() { let policy = EvidenceVerificationPolicy::from_accepted_transaction( &expected, &request.request_nonce, - Duration::from_secs(48 * 60 * 60), + 48 * 60 * 60, Utc::now(), - Duration::from_secs(30), - ); + 30, + ) + .expect("the transaction states bounds the contract allows"); let credential = http .post("/v1/evidence") @@ -2589,7 +2617,7 @@ async fn sd_jwt_format_not_permitted_by_grant() { .expect("the credential release records the SD-JWT VC mode"); assert_eq!( credential_release["record"]["signingKeyId"], - json!("acceptance-evidence-key") + json!(EVIDENCE_KEY_ID) ); assert!(!audit.contains(&request.request_nonce)); for canary in privacy_canaries() { @@ -2607,9 +2635,10 @@ async fn sd_jwt_holder_key_with_private_member_rejected() { for holder_key in [ json!({ - "kty": "OKP", - "crv": "Ed25519", - "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "kty": "EC", + "crv": "P-256", + "x": "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4", + "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU", "d": "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A" }), json!({ @@ -2663,11 +2692,11 @@ async fn sd_jwt_holder_key_wrong_algorithm_rejected() { let mut body = serde_json::to_value(adult_request()).expect("request serializes"); for holder_key in [ - json!({"kty": "OKP", "crv": "Ed25519", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", "alg": "ES256"}), - json!({"kty": "EC", "crv": "P-256", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", "alg": "EdDSA"}), - json!({"kty": "OKP", "crv": "X25519", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}), - json!({"kty": "OKP", "crv": "Ed25519", "x": "11qYAYKxCrfVS_7TyWQHOg"}), - json!({"kty": "OKP", "crv": "Ed25519", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo="}), + json!({"kty": "OKP", "crv": "P-256", "x": "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4", "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"}), + json!({"kty": "EC", "crv": "P-256", "x": "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4", "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU", "alg": "EdDSA"}), + json!({"kty": "EC", "crv": "P-384", "x": "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4", "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"}), + json!({"kty": "EC", "crv": "P-256", "x": "11qYAYKxCrfVS_7TyWQHOg", "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"}), + json!({"kty": "EC", "crv": "P-256", "x": "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4=", "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"}), ] { body["holderKey"] = holder_key.clone(); let response = http @@ -2708,11 +2737,11 @@ async fn sd_jwt_signing_failure_no_fallback_format() { .expect("runtime initializes"); let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); let delegate = LocalJwkSigner::new(private).expect("local signer builds"); - let provider: Arc = Arc::new(FailAfterSelfTestSigner { + let provider: Arc = Arc::new(FailOnceAfterSelfTestSigner { delegate, calls: AtomicUsize::new(0), }); - let failing_signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + let failing_signer = EvidenceSigner::initialize(provider, EVIDENCE_KEY_ID) .await .expect("signer passes its startup self-test"); runtime.replace_signer_for_test(failing_signer); @@ -2956,8 +2985,9 @@ fn demo_verification_policy_document(policy: &EvidenceVerificationPolicy) -> Str .iter() .map(|output| json!({"concept": output.concept, "form": expected_form_document(&output.form)})) .collect::>(), - "maximumAssertionLifetimeSeconds": policy.maximum_assertion_lifetime.as_secs(), - "clockSkewSeconds": policy.clock_skew.as_secs(), + "revokedKeyIds": policy.revoked_key_ids, + "maximumAssertionLifetimeSeconds": policy.maximum_assertion_lifetime().as_secs(), + "clockSkewSeconds": policy.clock_skew().as_secs(), }); serde_norway::to_string(&document).expect("the policy document serializes as YAML") } @@ -3086,8 +3116,8 @@ fn verification_policy_stub( .iter() .find(|candidate| candidate.id == request.requirement) .expect("requirement is loaded"); - EvidenceVerificationPolicy { - assurance_profile: runtime.bundle().config.assurance_profile, + EvidenceVerificationPolicyDocument { + expected_assurance_profile: runtime.bundle().config.assurance_profile, issued_by: runtime.bundle().config.issuer.id.clone(), provided_by: runtime.bundle().config.service.provider_id.clone(), requirement: request.requirement.clone(), @@ -3098,10 +3128,12 @@ fn verification_policy_stub( request_nonce: request.request_nonce.clone(), expected_subjects: Vec::new(), expected_outputs: Vec::new(), - maximum_assertion_lifetime: Duration::from_secs(48 * 60 * 60), - now: Utc::now(), - clock_skew: Duration::from_secs(30), + revoked_key_ids: Vec::new(), + maximum_assertion_lifetime_seconds: 48 * 60 * 60, + clock_skew_seconds: 30, } + .try_into_policy(Utc::now()) + .expect("the stub policy states bounds the contract allows") } #[tokio::test] @@ -4195,7 +4227,7 @@ fn authenticator() -> Authenticator { TokenVerifierConfig::access_token_profile( TOKEN_ISSUER, vec![TOKEN_AUDIENCE.to_owned()], - vec![Algorithm::EdDSA], + vec![Algorithm::ES256], vec!["at+jwt".to_owned()], ), fetcher, @@ -4224,7 +4256,7 @@ fn fetching_authenticator(jwks_uri: &str) -> Authenticator { TokenVerifierConfig::access_token_profile( TOKEN_ISSUER, vec![TOKEN_AUDIENCE.to_owned()], - vec![Algorithm::EdDSA], + vec![Algorithm::ES256], vec!["at+jwt".to_owned()], ), Arc::new(JwksFetcher::new_with_fetch_url_policy( @@ -4261,7 +4293,7 @@ fn access_token_for_issuer(issuer: &str, principal: &str, extra: Option) "aud": TOKEN_AUDIENCE, "sub": principal, "iat": now - 1, - "exp": now + 3600, + "exp": now + 298, "evidence_tags": ["fixture-agency"], "evidence_audience": EVIDENCE_AUDIENCE }); @@ -4277,7 +4309,7 @@ fn access_token_for_issuer(issuer: &str, principal: &str, extra: Option) fn signed_access_token(claims: Value) -> String { let header = URL_SAFE_NO_PAD.encode( serde_json::to_vec(&json!({ - "alg": "EdDSA", + "alg": "ES256", "kid": "acceptance-auth-key", "typ": "at+jwt" })) @@ -4619,10 +4651,11 @@ fn verification_policy( let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( &evidence, &request.request_nonce, - Duration::from_secs(48 * 60 * 60), + 48 * 60 * 60, Utc::now(), - Duration::from_secs(30), - ); + 30, + ) + .expect("the transaction states bounds the contract allows"); policy.issued_by = runtime.bundle().config.issuer.id.clone(); policy.provided_by = runtime.bundle().config.service.provider_id.clone(); policy.requirement = request.requirement.clone(); @@ -4710,8 +4743,8 @@ fn rewrite_deployment_values(bundle_root: &Path, source_origin: &str) { replace_exact(&mut text, "https://source.invalid", source_origin, 4); replace_exact( &mut text, - "fixture-key-2026-01", - "acceptance-evidence-key", + "assuranceProfile: evidence-grade", + "assuranceProfile: local", 1, ); fs::write(path, text).expect("deployment-only fixture rewrite succeeds"); @@ -4799,6 +4832,9 @@ listener: secretProviders: file: root: {} +signer: + kind: local-jwk + privateKeyRef: secret:file/signing-key auditStorage: path: {} maximumFileBytes: {} @@ -5277,10 +5313,11 @@ fn audit_probe_event(index: usize) -> EvidenceAuditEvent { } fn acceptance_audit_hasher() -> AuditChainHasher { - AuditChainHasher::keyed( - AuditHashSecret::new(b"audit-hash-secret-canary-32-bytes-minimum".to_vec()) - .expect("the acceptance audit secret is accepted"), - ) + AuditChainProfile::production_from_secret_bytes(zeroize::Zeroizing::new( + b"audit-hash-secret-canary-32-bytes-minimum".to_vec(), + )) + .expect("the acceptance audit chain key derives") + .hasher() } /// Distinct evidence identities across every disclosure-release record. @@ -5690,7 +5727,7 @@ impl SustainedFixture { /// /// Every measured request runs token verification, rate limiting, Rhai request /// preparation, one outbound source call, Rhai extraction, evidence -/// construction, Ed25519 signing, and two durable audit appends, over real +/// construction, ES256 signing, and two durable audit appends, over real /// sockets against the real router. At 1000 requests per second that is 2000 /// audit appends per second. /// diff --git a/crates/registry-evidence/src/secrets.rs b/crates/registry-evidence/src/secrets.rs index 14a567748..796391ebe 100644 --- a/crates/registry-evidence/src/secrets.rs +++ b/crates/registry-evidence/src/secrets.rs @@ -1,384 +1,8 @@ -//! Bounded resolution of runtime secret references. +//! Shared bounded secret resolution used by Evidence. +//! +//! The implementation lives in `registry-platform-config` so Evidence and +//! Mint apply the same anchored, no-follow, owner-only file policy. -use std::{ - collections::BTreeSet, - env, fmt, - fs::File, - io::Read, - path::{Path, PathBuf}, +pub use registry_platform_config::{ + ProtectedSecret, SecretError, SecretProvider, SecretResolver, MAX_SECRET_BYTES, }; - -use thiserror::Error; -use zeroize::Zeroizing; - -/// The maximum size of a resolved secret value. -pub const MAX_SECRET_BYTES: usize = 64 * 1024; - -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub enum SecretProvider { - Environment, - File, -} - -#[derive(Debug, Error, Eq, PartialEq)] -pub enum SecretError { - #[error("the secret reference is invalid")] - InvalidReference, - #[error("the secret reference uses a disabled provider")] - ProviderDisabled, - #[error("the secret provider configuration is invalid")] - InvalidProviderConfiguration, - #[error("the referenced secret is unavailable")] - Unavailable, - #[error("the referenced secret file is unsafe")] - UnsafeFile, - #[error("the referenced secret could not be read")] - Read, - #[error("the referenced secret value is invalid")] - InvalidValue, -} - -/// Secret bytes that are erased when dropped and never exposed by `Debug`. -pub struct ProtectedSecret(Zeroizing>); - -impl ProtectedSecret { - /// Explicitly borrow the secret for the smallest possible consumer scope. - pub fn expose_secret(&self) -> &[u8] { - self.0.as_slice() - } - - pub fn len(&self) -> usize { - self.0.len() - } - - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl fmt::Debug for ProtectedSecret { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("ProtectedSecret([REDACTED])") - } -} - -/// Resolves only the providers explicitly enabled by runtime configuration. -#[derive(Debug)] -pub struct SecretResolver { - providers: BTreeSet, - file_root: PathBuf, -} - -impl SecretResolver { - pub fn new( - providers: impl IntoIterator, - file_root: impl Into, - ) -> Result { - let providers = providers.into_iter().collect::>(); - let file_root = file_root.into(); - if providers.is_empty() - || (providers.contains(&SecretProvider::File) && !file_root.is_absolute()) - { - return Err(SecretError::InvalidProviderConfiguration); - } - Ok(Self { - providers, - file_root, - }) - } - - pub fn resolve(&self, reference: &str) -> Result { - let (provider, name) = parse_reference(reference)?; - if !self.providers.contains(&provider) { - return Err(SecretError::ProviderDisabled); - } - - let bytes = match provider { - SecretProvider::Environment => read_environment(name)?, - SecretProvider::File => read_secret_file(&self.file_root, name)?, - }; - validate_secret(bytes) - } -} - -fn parse_reference(reference: &str) -> Result<(SecretProvider, &str), SecretError> { - if let Some(name) = reference.strip_prefix("secret:env/") { - if valid_environment_name(name) { - return Ok((SecretProvider::Environment, name)); - } - } else if let Some(name) = reference.strip_prefix("secret:file/") { - if valid_file_name(name) { - return Ok((SecretProvider::File, name)); - } - } - Err(SecretError::InvalidReference) -} - -fn valid_environment_name(name: &str) -> bool { - let bytes = name.as_bytes(); - matches!(bytes.first(), Some(b'A'..=b'Z')) - && bytes.len() <= 128 - && bytes[1..] - .iter() - .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || *byte == b'_') -} - -fn valid_file_name(name: &str) -> bool { - let bytes = name.as_bytes(); - matches!(bytes.first(), Some(b'a'..=b'z')) - && bytes.len() <= 128 - && bytes[1..].iter().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') - }) -} - -fn read_environment(name: &str) -> Result>, SecretError> { - let value = env::var_os(name).ok_or(SecretError::Unavailable)?; - #[cfg(unix)] - let bytes = { - use std::os::unix::ffi::OsStringExt as _; - value.into_vec() - }; - #[cfg(not(unix))] - let bytes = value - .into_string() - .map_err(|_| SecretError::InvalidValue)? - .into_bytes(); - Ok(Zeroizing::new(bytes)) -} - -#[cfg(unix)] -fn read_secret_file(root: &Path, name: &str) -> Result>, SecretError> { - use rustix::fs::{Mode, OFlags}; - - let root = rustix::fs::open( - root, - OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::DIRECTORY, - Mode::empty(), - ) - .map_err(|_| SecretError::Unavailable)?; - let secret = rustix::fs::openat( - &root, - name, - OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, - Mode::empty(), - ) - .map_err(|_| SecretError::Unavailable)?; - let file = File::from(secret); - validate_file_metadata(&file)?; - read_bounded(file) -} - -#[cfg(unix)] -fn validate_file_metadata(file: &File) -> Result<(), SecretError> { - use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; - - let metadata = file.metadata().map_err(|_| SecretError::Read)?; - if !metadata.is_file() - || metadata.uid() != rustix::process::geteuid().as_raw() - || metadata.permissions().mode() & 0o7777 != 0o600 - || metadata.nlink() != 1 - { - return Err(SecretError::UnsafeFile); - } - Ok(()) -} - -#[cfg(not(unix))] -fn read_secret_file(root: &Path, name: &str) -> Result>, SecretError> { - let path = root.join(name); - let metadata = std::fs::symlink_metadata(&path).map_err(|_| SecretError::Unavailable)?; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(SecretError::UnsafeFile); - } - let file = File::open(path).map_err(|_| SecretError::Unavailable)?; - read_bounded(file) -} - -fn read_bounded(file: File) -> Result>, SecretError> { - let mut bytes = Zeroizing::new(Vec::new()); - file.take((MAX_SECRET_BYTES + 1) as u64) - .read_to_end(&mut bytes) - .map_err(|_| SecretError::Read)?; - Ok(bytes) -} - -fn validate_secret(bytes: Zeroizing>) -> Result { - if bytes.is_empty() || bytes.len() > MAX_SECRET_BYTES || bytes.contains(&0) { - return Err(SecretError::InvalidValue); - } - Ok(ProtectedSecret(bytes)) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::{Mutex, OnceLock}; - - fn environment_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())).lock().expect("lock") - } - - #[test] - fn references_use_only_the_two_exact_contract_grammars() { - for valid in [ - "secret:env/A", - "secret:env/SOURCE_2_PASSWORD", - "secret:file/a", - "secret:file/source-token_v2.json", - ] { - assert!(parse_reference(valid).is_ok(), "{valid}"); - } - for invalid in [ - "secret:env/", - "secret:env/lower", - "secret:env/A-B", - "secret:environment/A", - "secret:file/Upper", - "secret:file/../token", - "secret:file/nested/token", - "secret:file/.token", - "secret:file/token\0suffix", - "plain-value", - ] { - assert_eq!(parse_reference(invalid), Err(SecretError::InvalidReference)); - } - assert!(parse_reference(&format!("secret:env/A{}", "B".repeat(127))).is_ok()); - assert_eq!( - parse_reference(&format!("secret:env/A{}", "B".repeat(128))), - Err(SecretError::InvalidReference) - ); - } - - #[test] - fn provider_allowlist_is_enforced_before_lookup() { - let resolver = - SecretResolver::new([SecretProvider::File], "/safe-root").expect("resolver builds"); - assert!(matches!( - resolver.resolve("secret:env/DEFINITELY_NOT_PRESENT"), - Err(SecretError::ProviderDisabled) - )); - } - - #[test] - fn environment_secret_is_bounded_and_debug_is_redacted() { - let _guard = environment_lock(); - const NAME: &str = "REGISTRY_EVIDENCE_SECRET_RESOLVER_TEST"; - env::set_var(NAME, "environment-canary"); - let resolver = - SecretResolver::new([SecretProvider::Environment], "").expect("resolver builds"); - let secret = resolver - .resolve("secret:env/REGISTRY_EVIDENCE_SECRET_RESOLVER_TEST") - .expect("secret resolves"); - env::remove_var(NAME); - - assert!( - secret.expose_secret() == b"environment-canary", - "resolved environment secret bytes differ" - ); - assert_eq!(format!("{secret:?}"), "ProtectedSecret([REDACTED])"); - assert!(!format!("{secret:?}").contains("environment-canary")); - } - - #[test] - fn empty_nul_and_oversized_values_are_rejected_without_echo() { - for value in [ - Vec::new(), - b"canary\0value".to_vec(), - vec![b'x'; MAX_SECRET_BYTES + 1], - ] { - let error = validate_secret(Zeroizing::new(value)).expect_err("invalid secret"); - assert_eq!(error, SecretError::InvalidValue); - assert_eq!(error.to_string(), "the referenced secret value is invalid"); - } - } - - #[cfg(unix)] - mod unix { - use super::*; - use std::{fs, os::unix::fs::PermissionsExt as _}; - - fn write_secret(root: &Path, name: &str, value: &[u8], mode: u32) { - let path = root.join(name); - fs::write(&path, value).expect("write secret"); - fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("set mode"); - } - - #[test] - fn file_secret_uses_open_file_owner_and_exact_mode_checks() { - let root = tempfile::tempdir().expect("temporary root"); - write_secret(root.path(), "source-token", b"file-canary", 0o600); - let resolver = - SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); - let secret = resolver - .resolve("secret:file/source-token") - .expect("safe file resolves"); - assert!( - secret.expose_secret() == b"file-canary", - "resolved file secret bytes differ" - ); - - write_secret(root.path(), "unsafe-token", b"unsafe-canary", 0o640); - assert!(matches!( - resolver.resolve("secret:file/unsafe-token"), - Err(SecretError::UnsafeFile) - )); - } - - #[test] - fn file_secret_rejects_symlinks_and_non_regular_files() { - use std::os::unix::fs::symlink; - - let root = tempfile::tempdir().expect("temporary root"); - write_secret(root.path(), "target", b"symlink-canary", 0o600); - symlink(root.path().join("target"), root.path().join("link")).expect("create symlink"); - fs::create_dir(root.path().join("directory")).expect("create directory"); - let resolver = - SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); - - assert!(matches!( - resolver.resolve("secret:file/link"), - Err(SecretError::Unavailable) - )); - assert!(matches!( - resolver.resolve("secret:file/directory"), - Err(SecretError::Unavailable | SecretError::UnsafeFile) - )); - } - - #[test] - fn file_secret_rejects_every_name_for_a_hard_link() { - let root = tempfile::tempdir().expect("temporary root"); - write_secret(root.path(), "first", b"hard-link-canary", 0o600); - fs::hard_link(root.path().join("first"), root.path().join("second")) - .expect("create hard link"); - let resolver = - SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); - - for name in ["first", "second"] { - assert!(matches!( - resolver.resolve(&format!("secret:file/{name}")), - Err(SecretError::UnsafeFile) - )); - } - } - - #[test] - fn file_secret_read_is_bounded() { - let root = tempfile::tempdir().expect("temporary root"); - write_secret( - root.path(), - "oversized", - &vec![b'x'; MAX_SECRET_BYTES + 1], - 0o600, - ); - let resolver = - SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); - assert!(matches!( - resolver.resolve("secret:file/oversized"), - Err(SecretError::InvalidValue) - )); - } - } -} diff --git a/crates/registry-evidence/src/server.rs b/crates/registry-evidence/src/server.rs index 3ab93429a..00faed16d 100644 --- a/crates/registry-evidence/src/server.rs +++ b/crates/registry-evidence/src/server.rs @@ -39,7 +39,7 @@ use tokio::{net::TcpListener, sync::Semaphore}; use crate::{ config::{ListenerConfig, ResponseFormat}, contracts::{request_contract_accepts, served_openapi_document}, - model::{request_nonce_is_canonical, EvidenceRequest, JwksDocument}, + model::{request_nonce_is_canonical, EvidenceRequest}, observability::{self, operation_id, Metrics}, problem::ProblemCode, runtime::{EvidenceRuntime, RuntimeFailure}, @@ -602,7 +602,7 @@ async fn jwks(State(state): State>, request: Request) -> } /// JWT VC Issuer Metadata. Discovery is not a trust anchor: it republishes the -/// same public keys under the provider identity the assertion already names. +/// provider identity and the exact governed JWKS URI. /// Resolution is meaningful only when that identity is the HTTPS origin of the /// deployment; a URN provider identity simply has no resolution path. async fn jwt_vc_issuer_metadata( @@ -612,7 +612,11 @@ async fn jwt_vc_issuer_metadata( let operation = operation_id(request.extensions()); let metadata = JwtVcIssuerMetadata { issuer: &state.runtime.bundle().config.service.provider_id, - jwks: state.runtime.jwks(), + jwks_uri: format!( + "{}{}", + state.runtime.bundle().config.service.provider_id, + state.runtime.bundle().config.signing.jwks_path + ), }; match serialize_response(StatusCode::OK, JSON_MEDIA_TYPE, &metadata) { Some(response) => response, @@ -623,7 +627,7 @@ async fn jwt_vc_issuer_metadata( #[derive(Serialize)] struct JwtVcIssuerMetadata<'a> { issuer: &'a str, - jwks: &'a JwksDocument, + jwks_uri: String, } async fn unknown_route(request: Request) -> Response { diff --git a/crates/registry-evidence/src/signing.rs b/crates/registry-evidence/src/signing.rs index e6a52f6d4..f0588ecad 100644 --- a/crates/registry-evidence/src/signing.rs +++ b/crates/registry-evidence/src/signing.rs @@ -10,6 +10,7 @@ use registry_platform_crypto::{ use registry_platform_sdjwt::{SdJwtError, SdJwtIssuanceInput, SdJwtIssuer}; use serde::Serialize; use thiserror::Error; +use tokio::sync::Mutex; use crate::{ model::{FlattenedJws, JwksDocument}, @@ -18,6 +19,7 @@ use crate::{ const MAX_KEY_ID_BYTES: usize = 256; const MAX_PUBLISHED_KEYS: usize = 33; +const SIGNING_SELF_TEST_MESSAGE: &[u8] = b"registry-evidence-signing-readiness-v1"; #[derive(Debug, Error)] pub enum EvidenceSigningError { @@ -51,9 +53,10 @@ struct ProtectedHeader<'a> { cty: &'static str, } -/// Evidence's single active Ed25519 signer. +/// Evidence's single active ES256/P-256 signer. pub struct EvidenceSigner { provider: Arc, + recovery_probe: Mutex<()>, } impl std::fmt::Debug for EvidenceSigner { @@ -73,12 +76,35 @@ impl EvidenceSigner { ) -> Result { validate_provider(provider.as_ref(), configured_active_key_id)?; - let self_test_message = b"registry-evidence-signing-readiness-v1"; - let signature = provider.sign(self_test_message).await?; - verify(self_test_message, &signature, &provider.public_jwk()) - .map_err(|_| EvidenceSigningError::SelfTest)?; + let signature = provider.sign(SIGNING_SELF_TEST_MESSAGE).await?; + verify( + SIGNING_SELF_TEST_MESSAGE, + &signature, + &provider.public_jwk(), + ) + .map_err(|_| EvidenceSigningError::SelfTest)?; + + Ok(Self { + provider, + recovery_probe: Mutex::new(()), + }) + } - Ok(Self { provider }) + /// Initialize against the governed active public JWK. Runtime callers use + /// this boundary so matching a `kid` alone can never substitute different + /// public key material. + pub async fn initialize_governed( + provider: Arc, + expected_public_jwk: &PublicJwk, + ) -> Result { + let expected_key_id = expected_public_jwk + .kid + .as_deref() + .ok_or(EvidenceSigningError::ActiveKeyId)?; + if provider.public_jwk() != *expected_public_jwk { + return Err(EvidenceSigningError::ActiveKeyId); + } + Self::initialize(provider, expected_key_id).await } pub fn key_id(&self) -> &str { @@ -94,6 +120,30 @@ impl EvidenceSigner { self.provider.readiness() == KeyReadiness::Ready } + /// Recover an unavailable provider through the same bounded sign-and-verify + /// probe used at startup. Healthy providers avoid an extra signing call. + pub async fn ensure_ready(&self) -> bool { + if self.ready() { + return true; + } + let Ok(_probe) = self.recovery_probe.try_lock() else { + return false; + }; + if self.ready() { + return true; + } + let Ok(signature) = self.provider.sign(SIGNING_SELF_TEST_MESSAGE).await else { + return false; + }; + verify( + SIGNING_SELF_TEST_MESSAGE, + &signature, + &self.provider.public_jwk(), + ) + .is_ok() + && self.ready() + } + /// Serialize and sign the exact JSON representation of a validated Evidence value. pub async fn sign_json( &self, @@ -110,7 +160,7 @@ impl EvidenceSigner { evidence_json: &[u8], ) -> Result { let protected = serde_json::to_vec(&ProtectedHeader { - alg: "EdDSA", + alg: "ES256", kid: self.provider.key_id(), typ: EVIDENCE_JWS_TYP, cty: EVIDENCE_JWS_CTY, @@ -146,15 +196,24 @@ impl EvidenceSigner { pub fn jwks_document( active: PublicJwk, - retired: impl IntoIterator, + published: impl IntoIterator, ) -> Result { + jwks_document_with_revocations(active, published, std::iter::empty::()) +} + +pub fn jwks_document_with_revocations( + active: PublicJwk, + published: impl IntoIterator, + revoked: impl IntoIterator, +) -> Result { + let revoked = revoked.into_iter().collect::>(); let mut seen = BTreeSet::new(); let mut keys = Vec::new(); - for key in std::iter::once(active).chain(retired) { + for key in std::iter::once(active).chain(published) { if keys.len() == MAX_PUBLISHED_KEYS { return Err(EvidenceSigningError::PublishedKey); } - if key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) { + if key.algorithm().ok() != Some(SigningAlgorithm::Es256) { return Err(EvidenceSigningError::Algorithm); } let key_id = key @@ -162,7 +221,10 @@ pub fn jwks_document( .as_deref() .ok_or(EvidenceSigningError::PublishedKey)?; validate_key_id(key_id)?; - if !seen.insert(key_id.to_owned()) { + if key.jkt().ok().as_deref() != Some(key_id) + || revoked.contains(key_id) + || !seen.insert(key_id.to_owned()) + { return Err(EvidenceSigningError::PublishedKey); } keys.push(serde_json::to_value(key).map_err(EvidenceSigningError::KeySerialization)?); @@ -174,7 +236,7 @@ fn validate_provider( provider: &dyn SigningProvider, configured_active_key_id: &str, ) -> Result<(), EvidenceSigningError> { - if provider.algorithm() != SigningAlgorithm::EdDsa { + if provider.algorithm() != SigningAlgorithm::Es256 { return Err(EvidenceSigningError::Algorithm); } validate_key_id(provider.key_id())?; @@ -182,8 +244,9 @@ fn validate_provider( return Err(EvidenceSigningError::ActiveKeyId); } let public = provider.public_jwk(); - if public.algorithm().ok() != Some(SigningAlgorithm::EdDsa) + if public.algorithm().ok() != Some(SigningAlgorithm::Es256) || public.kid.as_deref() != Some(provider.key_id()) + || public.jkt().ok().as_deref() != Some(provider.key_id()) { return Err(EvidenceSigningError::Algorithm); } @@ -191,7 +254,11 @@ fn validate_provider( } fn validate_key_id(key_id: &str) -> Result<(), EvidenceSigningError> { - if key_id.is_empty() || key_id.len() > MAX_KEY_ID_BYTES || key_id.chars().any(char::is_control) + if key_id.len() != 43 + || key_id.len() > MAX_KEY_ID_BYTES + || !key_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) { return Err(EvidenceSigningError::KeyId); } @@ -201,28 +268,101 @@ fn validate_key_id(key_id: &str) -> Result<(), EvidenceSigningError> { #[cfg(test)] mod tests { use super::*; + use async_trait::async_trait; + use rand_core::OsRng; use registry_platform_crypto::{LocalJwkSigner, PrivateJwk}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::time::Duration; + + const KEY_ID: &str = "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"; + const PRIVATE_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"}"#; + const FIXTURE_PRIVATE_JWK: &str = PRIVATE_JWK; + const SAME_KID_DIFFERENT_PRIVATE_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE","x":"axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpY","y":"T-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"}"#; + + struct RecoveringSigner { + delegate: LocalJwkSigner, + ready: AtomicBool, + recovery_calls: AtomicUsize, + } - const PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"evidence-key-1"}"#; - const FIXTURE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"fixture-key-2026-01"}"#; - const SAME_KID_DIFFERENT_PRIVATE_JWK: &str = r#"{"crv":"Ed25519","d":"f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys","x":"pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec","kty":"OKP","alg":"EdDSA","kid":"evidence-key-1"}"#; + #[async_trait] + impl SigningProvider for RecoveringSigner { + fn algorithm(&self) -> SigningAlgorithm { + self.delegate.algorithm() + } + + fn key_id(&self) -> &str { + self.delegate.key_id() + } + + fn public_jwk(&self) -> PublicJwk { + self.delegate.public_jwk() + } + + fn readiness(&self) -> KeyReadiness { + if self.ready.load(Ordering::Acquire) { + KeyReadiness::Ready + } else { + KeyReadiness::NotReady + } + } + + async fn sign(&self, payload: &[u8]) -> Result, ProviderSigningError> { + if !self.ready.load(Ordering::Acquire) { + self.recovery_calls.fetch_add(1, Ordering::AcqRel); + tokio::time::sleep(Duration::from_millis(25)).await; + let signature = self.delegate.sign(payload).await?; + self.ready.store(true, Ordering::Release); + Ok(signature) + } else { + self.delegate.sign(payload).await + } + } + } async fn signer() -> EvidenceSigner { let private = PrivateJwk::parse(PRIVATE_JWK).expect("test key parses"); let provider: Arc = Arc::new(LocalJwkSigner::new(private).expect("test signer builds")); - EvidenceSigner::initialize(provider, "evidence-key-1") + EvidenceSigner::initialize(provider, KEY_ID) .await .expect("signer initializes") } + #[tokio::test] + async fn concurrent_recovery_readiness_probes_do_not_stampede_the_provider() { + let private = PrivateJwk::parse(PRIVATE_JWK).expect("test key parses"); + let provider = Arc::new(RecoveringSigner { + delegate: LocalJwkSigner::new(private).expect("test signer builds"), + ready: AtomicBool::new(true), + recovery_calls: AtomicUsize::new(0), + }); + let signing_provider: Arc = provider.clone(); + let signer = EvidenceSigner::initialize(signing_provider, KEY_ID) + .await + .expect("signer initializes"); + provider.ready.store(false, Ordering::Release); + + let (first, concurrent) = tokio::join!(signer.ensure_ready(), signer.ensure_ready()); + + assert_eq!( + [first, concurrent] + .into_iter() + .filter(|ready| *ready) + .count(), + 1 + ); + assert_eq!(provider.recovery_calls.load(Ordering::Acquire), 1); + assert!(signer.ready()); + } + async fn fixture_signer() -> EvidenceSigner { let private = PrivateJwk::parse(FIXTURE_PRIVATE_JWK).expect("test key parses"); let provider: Arc = Arc::new(LocalJwkSigner::new(private).expect("test signer builds")); - EvidenceSigner::initialize(provider, "fixture-key-2026-01") + EvidenceSigner::initialize(provider, KEY_ID) .await .expect("signer initializes") } @@ -241,8 +381,8 @@ mod tests { assert_eq!( protected, serde_json::json!({ - "alg": "EdDSA", - "kid": "evidence-key-1", + "alg": "ES256", + "kid": KEY_ID, "typ": "evidence+jws", "cty": "application/evidence+json" }) @@ -285,25 +425,34 @@ mod tests { Err(EvidenceSigningError::PublishedKey) )); - let retired = (0..32).map(|index| { - let mut key = signer.public_jwk(); - key.kid = Some(format!("retired-evidence-key-{index:02}")); - key - }); + let retired = (0..32).map(|_| generated_public_jwk()); let boundary = jwks_document(signer.public_jwk(), retired).expect("33 keys are allowed"); assert_eq!(boundary.keys.len(), 33); - let too_many = (0..33).map(|index| { - let mut key = signer.public_jwk(); - key.kid = Some(format!("excess-evidence-key-{index:02}")); - key - }); + let too_many = (0..33).map(|_| generated_public_jwk()); assert!(matches!( jwks_document(signer.public_jwk(), too_many), Err(EvidenceSigningError::PublishedKey) )); } + fn generated_public_jwk() -> PublicJwk { + let signing_key = p256::ecdsa::SigningKey::random(&mut OsRng); + let point = signing_key.verifying_key().to_encoded_point(false); + let mut key = PublicJwk { + kty: "EC".to_owned(), + kid: None, + alg: Some("ES256".to_owned()), + crv: Some("P-256".to_owned()), + x: point.x().map(|x| URL_SAFE_NO_PAD.encode(x)), + y: point.y().map(|y| URL_SAFE_NO_PAD.encode(y)), + n: None, + e: None, + }; + key.kid = Some(key.jkt().expect("thumbprint computes")); + key + } + /// The SD-JWT VC fixture is the adopter-facing wire contract, so it must be /// reproduced by the production issuance path over every golden payload: /// the exact protected header, one root disclosure per unprojected golden diff --git a/crates/registry-evidence/src/source.rs b/crates/registry-evidence/src/source.rs index 3a176d591..3db349e8d 100644 --- a/crates/registry-evidence/src/source.rs +++ b/crates/registry-evidence/src/source.rs @@ -239,7 +239,7 @@ impl SourceExecutor { if source.tls_trust_profile.is_some() { return Err(SourceError::InvalidPlan); } - Self::compile(source, allowed_selector_sets, None, secrets) + Self::compile(source, allowed_selector_sets, None, false, secrets) } /// Compile a source against runtime-owned TLS trust bindings. System roots @@ -255,14 +255,27 @@ impl SourceExecutor { source, allowed_selector_sets, Some((outbound_tls, captured_ca_bundles)), + false, secrets, ) } + /// Compile the non-credential request material used only by the hidden + /// bundle fixture evaluator. Runtime-owned private CA bytes are not needed + /// because this executor can materialize requests but is never executed. + pub fn new_for_offline_fixture( + source: &SourceConfig, + allowed_selector_sets: &[SourceSelectorSet], + secrets: Arc, + ) -> Result { + Self::compile(source, allowed_selector_sets, None, true, secrets) + } + fn compile( source: &SourceConfig, allowed_selector_sets: &[SourceSelectorSet], outbound_tls: Option<(&OutboundTlsConfig, &BTreeMap>)>, + offline_fixture: bool, secrets: Arc, ) -> Result { if matches!(source.authentication, SourceAuthentication::None {}) @@ -290,7 +303,7 @@ impl SourceExecutor { base_url, &authentication, )?; - let client = build_client(timeout, source, outbound_tls)?; + let client = build_client(timeout, source, outbound_tls, offline_fixture)?; Ok(Self { client, request, @@ -437,6 +450,7 @@ fn build_client( timeout: Duration, source: &SourceConfig, outbound_tls: Option<(&OutboundTlsConfig, &BTreeMap>)>, + offline_fixture: bool, ) -> Result { let mut builder = reqwest::Client::builder() .timeout(timeout) @@ -454,6 +468,9 @@ fn build_client( // contract. .retry(reqwest::retry::never()); if let Some(profile_name) = source.tls_trust_profile.as_deref() { + if offline_fixture && outbound_tls.is_none() { + return builder.build().map_err(|_| SourceError::InvalidPlan); + } let (tls, captured_ca_bundles) = outbound_tls.ok_or(SourceError::InvalidPlan)?; if !tls.system_roots { return Err(SourceError::InvalidPlan); @@ -1702,7 +1719,8 @@ mod tests { "factSchema": "schemas/facts.schema.yaml" })) .expect("source config deserializes"); - let client = build_client(Duration::from_secs(5), &source, None).expect("client builds"); + let client = + build_client(Duration::from_secs(5), &source, None, false).expect("client builds"); let error = client .get(format!("https://{address}/")) .send() diff --git a/crates/registry-evidence/tests/cli.rs b/crates/registry-evidence/tests/cli.rs index c26fba1af..2403e706b 100644 --- a/crates/registry-evidence/tests/cli.rs +++ b/crates/registry-evidence/tests/cli.rs @@ -21,6 +21,17 @@ fn actual_binary_checks_and_evaluates_an_immutable_project() { "../../products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence", ); copy_tree(&project.join("bundle"), &staged.path().join("bundle")); + let bundle_configuration = staged.path().join("bundle/evidence.yaml"); + let bundle_document = fs::read_to_string(&bundle_configuration).expect("read bundle document"); + fs::write( + &bundle_configuration, + bundle_document.replacen( + "assuranceProfile: evidence-grade", + "assuranceProfile: local", + 1, + ), + ) + .expect("select local assurance for the isolated CLI test"); let secret_root = staged.path().join("secrets"); fs::create_dir(&secret_root).expect("create private secret root"); fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) @@ -43,6 +54,11 @@ fn actual_binary_checks_and_evaluates_an_immutable_project() { "/var/lib/registry-evidence/audit/evidence.jsonl", audit_path.to_str().expect("temporary path is UTF-8"), 1, + ) + .replacen( + "signer:\n kind: transit\n unixSocketPath: /run/registry-evidence/transit-proxy.sock\n mount: transit\n keyName: evidence-signing\n keyVersion: 7\n timeoutMilliseconds: 2000", + "signer:\n kind: local-jwk\n privateKeyRef: secret:file/evidence-signing", + 1, ); let runtime_path = staged.path().join("runtime.yaml"); fs::write(&runtime_path, runtime).expect("stage runtime"); @@ -72,29 +88,21 @@ fn actual_binary_checks_and_evaluates_an_immutable_project() { } /// Stage the platform secrets the reference project's bundle names, with a -/// signing key generated for this run under the bundle's `activeKeyId`. +/// signing key matching the bundle's governed active public JWK. /// Source credentials stay absent: `check` must not resolve them. fn stage_reference_secrets(secret_root: &Path) { - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; - let write = |name: &str, value: &str| { let path = secret_root.join(name); fs::write(&path, value).expect("write reference secret"); fs::set_permissions(path, fs::Permissions::from_mode(0o600)) .expect("set owner-only secret mode"); }; - let signing_key = ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng); - let private_jwk = format!( - r#"{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"evidence-signing-2026-01","d":"{}","x":"{}"}}"#, - URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), - URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()) - ); write("audit-hmac-key", "audit-hash-secret-32-bytes-minimum-value"); write( "subject-binding-hmac-key", "subject-binding-secret-32-bytes-minimum-value", ); - write("signing-ed25519-private-jwk", &private_jwk); + write("evidence-signing", VERIFY_PRIVATE_JWK); } /// One deployment failure class, with the exact operator text it must produce. @@ -312,7 +320,7 @@ fn check_rejects_secret_material_the_server_would_refuse_at_startup() { } let cases = [ SecretFailureCase { - label: "signing key kid differs from the bundle's activeKeyId", + label: "signing key differs from the governed active public JWK", break_secrets: |deployment| deployment.write_mismatched_signing_key(), expected: "evidence: runtime signing initialization failed\n", }, @@ -480,11 +488,14 @@ fn serve_stops_on_sigterm_and_restarts_on_an_archived_audit_chain() { } /// The staged verification key identifier, echoed by the protected header. -const VERIFY_KEY_ID: &str = "verify-fixture-key"; +const VERIFY_KEY_ID: &str = "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"; -/// A staged Ed25519 test key. It signs fixture assertions in this test binary +/// A staged P-256 test key. It signs fixture assertions in this test binary /// only and is not a deployment key. -const VERIFY_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"verify-fixture-key"}"#; +const VERIFY_PRIVATE_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"}"#; + +/// A different valid P-256 key used to prove exact public-key matching. +const MISMATCHED_PRIVATE_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE","x":"axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpY","y":"T-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU","alg":"ES256","kid":"xx0BcA-wMohw8atYDJOe6peGModklG2wRHBlXHMvl0M"}"#; /// A staged request nonce, of the exact 43-character request-nonce shape. const FIXTURE_NONCE: &str = "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I"; @@ -578,6 +589,73 @@ fn verify_rejects_a_policy_document_with_an_unknown_field() { ); } +/// A policy stating a time bound the verification policy contract forbids is an +/// unusable input document, not a verification outcome: honouring it would make +/// the verifier accept assertions a conformant relying party must refuse, and +/// the failure-class vocabulary is frozen, so there is no class to report it +/// under. The command therefore refuses it before verifying anything, exactly as +/// it refuses a policy with an unknown field. +#[test] +fn verify_rejects_a_policy_document_outside_the_contract_time_bounds() { + for (label, replaced, with) in [ + ( + "a lifetime past the contract ceiling", + "maximumAssertionLifetimeSeconds: 172800", + "maximumAssertionLifetimeSeconds: 31536001", + ), + ( + "a zero lifetime", + "maximumAssertionLifetimeSeconds: 172800", + "maximumAssertionLifetimeSeconds: 0", + ), + ( + "a skew past the contract ceiling", + "clockSkewSeconds: 30", + "clockSkewSeconds: 301", + ), + ] { + let policy = fixture_policy().replacen(replaced, with, 1); + assert!(policy.contains(with), "{label} did not reach the policy"); + let stored = StoredResponse::stage(&fixture_evidence(), &fixture_evidence(), &policy); + let output = stored.verify(Some("2026-08-02T12:00:00Z")); + + assert_verification_failure( + &output, + "2026-08-02T12:00:00Z", + "", + "evidence: stored response verification failed (malformed)\n", + ); + } +} + +#[test] +fn verify_rejects_a_policy_document_outside_the_contract_list_bounds() { + for (label, minimum_items, maximum_items) in [ + ("a zero minimum", 0, 1), + ("a minimum past the ceiling", 65, 64), + ("a zero maximum", 1, 0), + ("a maximum past the ceiling", 1, 65), + ] { + let policy = fixture_policy().replacen( + "form: boolean", + &format!( + "form:\n list:\n minimumItems: {minimum_items}\n maximumItems: {maximum_items}" + ), + 1, + ); + let stored = StoredResponse::stage(&fixture_evidence(), &fixture_evidence(), &policy); + let output = stored.verify(Some("2026-08-02T12:00:00Z")); + + assert_verification_failure( + &output, + "2026-08-02T12:00:00Z", + "", + "evidence: stored response verification failed (malformed)\n", + ); + assert_eq!(output.status.code(), Some(1), "{label} was accepted"); + } +} + #[test] fn verify_rejects_a_verification_instant_that_is_not_strict_utc() { let stored = StoredResponse::stage(&fixture_evidence(), &fixture_evidence(), &fixture_policy()); @@ -757,6 +835,7 @@ expectedOutputs: form: boolean maximumAssertionLifetimeSeconds: 172800 clockSkewSeconds: 30 +revokedKeyIds: [] ", revision = "0".repeat(64), binding = "A".repeat(43), @@ -775,18 +854,18 @@ impl StoredResponse { /// signature no longer covers the stored bytes. fn stage(signed: &serde_json::Value, stored: &serde_json::Value, policy: &str) -> Self { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; - use ed25519_dalek::Signer as _; + use registry_platform_crypto::{sign, PrivateJwk}; let root = tempfile::tempdir().expect("temporary verification inputs"); - let key = ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng); + let key = PrivateJwk::parse(VERIFY_PRIVATE_JWK).expect("fixture key parses"); let protected = URL_SAFE_NO_PAD.encode(format!( - r#"{{"alg":"EdDSA","kid":"{VERIFY_KEY_ID}","typ":"evidence+jws","cty":"application/evidence+json"}}"# + r#"{{"alg":"ES256","kid":"{VERIFY_KEY_ID}","typ":"evidence+jws","cty":"application/evidence+json"}}"# )); let signed_payload = URL_SAFE_NO_PAD .encode(serde_json::to_vec(signed).expect("Evidence payload serializes")); let signature = URL_SAFE_NO_PAD.encode( - key.sign(format!("{protected}.{signed_payload}").as_bytes()) - .to_bytes(), + sign(format!("{protected}.{signed_payload}").as_bytes(), &key) + .expect("fixture payload signs"), ); let stored_payload = URL_SAFE_NO_PAD .encode(serde_json::to_vec(stored).expect("Evidence payload serializes")); @@ -800,10 +879,8 @@ impl StoredResponse { .expect("stage the stored response"); fs::write( root.path().join("trusted.jwks.json"), - format!( - r#"{{"keys":[{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"{VERIFY_KEY_ID}","x":"{}"}}]}}"#, - URL_SAFE_NO_PAD.encode(key.verifying_key().to_bytes()) - ), + serde_json::to_vec(&serde_json::json!({"keys": [key.public()]})) + .expect("trusted JWKS serializes"), ) .expect("stage the pinned key set"); fs::write(root.path().join("policy.yaml"), policy).expect("stage the policy"); @@ -983,6 +1060,11 @@ impl Deployment { .join("../../products/evidence/fixtures/acceptance") .join(case); copy_tree(&source, &deployment.path("bundle")); + deployment.replace( + "bundle/evidence.yaml", + "assuranceProfile: evidence-grade", + "assuranceProfile: local", + ); let secrets = deployment.path("secrets"); fs::create_dir(&secrets).expect("create private secret root"); fs::set_permissions(&secrets, fs::Permissions::from_mode(0o700)) @@ -1015,6 +1097,9 @@ listener: secretProviders: file: root: {secrets} +signer: + kind: local-jwk + privateKeyRef: secret:file/signing-key auditStorage: path: {audit} maximumFileBytes: 1073741824 @@ -1077,24 +1162,16 @@ outboundTls: /// Stage every logical secret the acceptance bundle references. /// - /// The signing key is generated for this run so no private key material is - /// tracked, and the source credentials are synthetic constants that never - /// reach a network because the test performs no evidence request. + /// The signing key matches the governed public fixture key, and the source + /// credentials are synthetic constants that never reach a network because + /// the test performs no evidence request. fn stage_acceptance_secrets(&self) { - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; - - let signing_key = ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng); - let private_jwk = format!( - r#"{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fixture-key-2026-01","d":"{}","x":"{}"}}"#, - URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), - URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()) - ); self.write_secret("audit-hash-key", "audit-hash-secret-32-bytes-minimum-value"); self.write_secret( "subject-binding-key", "subject-binding-secret-32-bytes-minimum-value", ); - self.write_secret("signing-key", &private_jwk); + self.write_secret("signing-key", VERIFY_PRIVATE_JWK); self.write_secret("source-a-token", "synthetic-source-token"); self.write_secret("source-b-token", "synthetic-source-token"); self.write_secret("source-c-username", "synthetic-source-user"); @@ -1111,18 +1188,9 @@ outboundTls: .expect("set owner-only audit chain mode"); } - /// Overwrite the staged signing key with a fresh key whose kid is not - /// the bundle's `signing.activeKeyId`. + /// Overwrite the staged signing key with a different valid P-256 key. fn write_mismatched_signing_key(&self) { - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; - - let signing_key = ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng); - let private_jwk = format!( - r#"{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"not-the-active-key","d":"{}","x":"{}"}}"#, - URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), - URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()) - ); - self.write_secret("signing-key", &private_jwk); + self.write_secret("signing-key", MISMATCHED_PRIVATE_JWK); } /// Start `serve` against the sealed deployment. diff --git a/crates/registry-evidence/tests/deployment_projects.rs b/crates/registry-evidence/tests/deployment_projects.rs index 03e765ea3..ff7397313 100644 --- a/crates/registry-evidence/tests/deployment_projects.rs +++ b/crates/registry-evidence/tests/deployment_projects.rs @@ -7,12 +7,8 @@ use std::fs; use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Duration; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::{DateTime, Utc}; -use ed25519_dalek::SigningKey; -use rand_core::OsRng; use registry_evidence::bundle::{Bundle, DeploymentInputs}; use registry_evidence::config::{ConfigError, SelectorInput}; use registry_evidence::kernel::{ @@ -32,7 +28,6 @@ use registry_platform_crypto::{LocalJwkSigner, PrivateJwk}; use serde::Deserialize; use serde_json::{Map as JsonMap, Value}; use tempfile::TempDir; -use zeroize::Zeroizing; const AUDIENCE: &str = "urn:registry-evidence:reference-project-fixtures"; const BINDING_KEY: &[u8] = b"reference-project-binding-key-v1"; @@ -590,10 +585,11 @@ async fn execute_response( let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( &evidence, registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, - Duration::from_secs(31_536_000), + 31_536_000, issued_at, - Duration::from_secs(0), - ); + 0, + ) + .expect("the fixture policy states bounds the contract allows"); policy.issued_by = bundle.config.issuer.id.clone(); policy.provided_by = bundle.config.service.provider_id.clone(); policy.requirement = requirement.id.clone(); @@ -1219,26 +1215,9 @@ fn require_name(label: &str, actual: &str, expected: &str) { } async fn fixture_signer() -> EvidenceSigner { - const KEY_ID: &str = "evidence-signing-2026-01"; - let signing_key = SigningKey::generate(&mut OsRng); - let private_bytes = Zeroizing::new(signing_key.to_bytes()); - let public_bytes = signing_key.verifying_key().to_bytes(); - let private = PrivateJwk { - kty: "OKP".to_owned(), - kid: Some(KEY_ID.to_owned()), - alg: Some("EdDSA".to_owned()), - crv: Some("Ed25519".to_owned()), - d: Some(URL_SAFE_NO_PAD.encode(private_bytes.as_slice())), - x: Some(URL_SAFE_NO_PAD.encode(public_bytes)), - y: None, - n: None, - e: None, - p: None, - q: None, - dp: None, - dq: None, - qi: None, - }; + const KEY_ID: &str = "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"; + const PRIVATE_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"}"#; + let private = PrivateJwk::parse(PRIVATE_JWK).expect("fixture key parses"); let provider = Arc::new(LocalJwkSigner::new(private).expect("fixture signer builds")); EvidenceSigner::initialize(provider, KEY_ID) .await diff --git a/crates/registry-evidence/tests/relay_shaped_source.rs b/crates/registry-evidence/tests/relay_shaped_source.rs index 2df7e8ae5..119a1c471 100644 --- a/crates/registry-evidence/tests/relay_shaped_source.rs +++ b/crates/registry-evidence/tests/relay_shaped_source.rs @@ -18,12 +18,9 @@ use std::fs; use std::os::unix::fs::PermissionsExt as _; use std::path::Path; use std::sync::Arc; -use std::time::Duration; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; -use ed25519_dalek::SigningKey; -use rand_core::OsRng; use registry_evidence::bundle::Bundle; use registry_evidence::config::{PreparationChannelPolicy, PreparationLimits, SourceConfig}; use registry_evidence::kernel::{EvidenceConstruction, OfflineKernel, ValueProjection}; @@ -42,7 +39,6 @@ use serde_json::json; use tempfile::TempDir; use wiremock::matchers::{body_string_contains, header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use zeroize::Zeroizing; /// The Relay-shaped protected read for one synthetic record: the templated /// `/v1/datasets/{dataset_id}/entities/{entity}/records/{id}` path with @@ -236,26 +232,9 @@ fn make_fixture_bundle_read_only(path: &Path) { } async fn fixture_signer() -> EvidenceSigner { - const KEY_ID: &str = "relay-composition-evidence-key"; - let signing_key = SigningKey::generate(&mut OsRng); - let private_bytes = Zeroizing::new(signing_key.to_bytes()); - let public_bytes = signing_key.verifying_key().to_bytes(); - let private = PrivateJwk { - kty: "OKP".to_owned(), - kid: Some(KEY_ID.to_owned()), - alg: Some("EdDSA".to_owned()), - crv: Some("Ed25519".to_owned()), - d: Some(URL_SAFE_NO_PAD.encode(private_bytes.as_slice())), - x: Some(URL_SAFE_NO_PAD.encode(public_bytes)), - y: None, - n: None, - e: None, - p: None, - q: None, - dp: None, - dq: None, - qi: None, - }; + const KEY_ID: &str = "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"; + const PRIVATE_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"}"#; + let private = PrivateJwk::parse(PRIVATE_JWK).expect("fixture key parses"); let provider = Arc::new(LocalJwkSigner::new(private).expect("fixture signer builds")); EvidenceSigner::initialize(provider, KEY_ID) .await @@ -511,10 +490,11 @@ async fn a_relay_shaped_protected_read_backs_a_full_signed_minimum_disclosure_as let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( &evidence, registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, - Duration::from_secs(31_536_000), + 31_536_000, observed_at, - Duration::from_secs(0), - ); + 0, + ) + .expect("the fixture policy states bounds the contract allows"); policy.issued_by = "urn:example:fixture:issuer:authority".to_owned(); policy.provided_by = "urn:example:fixture:provider:evidence".to_owned(); policy.requirement = REQUIREMENT.to_owned(); diff --git a/crates/registry-evidence/tests/security_contract_traceability.rs b/crates/registry-evidence/tests/security_contract_traceability.rs index c59520b32..441a9e942 100644 --- a/crates/registry-evidence/tests/security_contract_traceability.rs +++ b/crates/registry-evidence/tests/security_contract_traceability.rs @@ -245,16 +245,19 @@ fn every_sd_jwt_vc_profile_negative_is_bound_to_a_mapped_security_negative() { /// Prove that one mapped reference still names a real Rust test item, so a /// renamed, moved, or deleted test fails the traceability checker. fn assert_reference_is_an_executable_test(root: &Path, entry_id: &str, test: &TestReference) { - // Evidence source is the runtime crate and the portable verifier crate - // beside it. A reference may name either of those trees and nothing else. - let inside_evidence_source = [ + // Evidence security invariants may be implemented by the runtime, the + // portable verifier, or the narrowly shared platform primitives it uses. + let permitted_crate = [ "crates/registry-evidence/", "crates/registry-evidence-verifier/", + "crates/registry-platform-audit/", + "crates/registry-platform-config/", + "crates/registry-platform-crypto/", ] .iter() - .any(|tree| test.file.starts_with(tree)); + .any(|prefix| test.file.starts_with(prefix)); assert!( - inside_evidence_source && test.file.ends_with(".rs") && !test.file.contains(".."), + permitted_crate && test.file.ends_with(".rs") && !test.file.contains(".."), "{entry_id} has an unsafe source reference" ); let source = fs::read_to_string(root.join(&test.file)) diff --git a/crates/registry-evidence/tests/selector_conformance.rs b/crates/registry-evidence/tests/selector_conformance.rs index 9df5c22d2..bc7ce7f99 100644 --- a/crates/registry-evidence/tests/selector_conformance.rs +++ b/crates/registry-evidence/tests/selector_conformance.rs @@ -6,7 +6,6 @@ use std::fs; use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Duration; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::Utc; @@ -41,7 +40,8 @@ use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; const AUTH_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"selector-auth-key"}"#; -const EVIDENCE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"selector-evidence-key"}"#; +const EVIDENCE_KEY_ID: &str = "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"; +const EVIDENCE_PRIVATE_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"}"#; const TOKEN_ISSUER: &str = "https://identity.invalid"; const TOKEN_AUDIENCE: &str = "selector-conformance"; const EVIDENCE_AUDIENCE: &str = "urn:example:fixture:audience:requester-a"; @@ -382,10 +382,11 @@ async fn every_selector_profile_runs_the_complete_signed_service_path() { let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( &unverified, &request.request_nonce, - Duration::from_secs(48 * 60 * 60), + 48 * 60 * 60, Utc::now(), - Duration::from_secs(30), - ); + 30, + ) + .expect("the transaction states bounds the contract allows"); policy.issued_by = service.bundle.config.issuer.id.clone(); policy.provided_by = service.bundle.config.service.provider_id.clone(); policy.requirement = request.requirement.clone(); @@ -1042,7 +1043,7 @@ async fn prepare_service(write_source_secret: bool) -> PreparedService { let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("Evidence test key parses"); let provider: Arc = Arc::new(LocalJwkSigner::new(private).expect("Evidence signer builds")); - let signer = EvidenceSigner::initialize(provider, "selector-evidence-key") + let signer = EvidenceSigner::initialize(provider, EVIDENCE_KEY_ID) .await .expect("Evidence signer self-test succeeds"); PreparedService { @@ -1542,6 +1543,12 @@ fn rewrite_source_origin(bundle_root: &Path, source_origin: &str) { let path = bundle_root.join("evidence.yaml"); let mut text = fs::read_to_string(&path).expect("copied selector config is readable"); replace_exact(&mut text, "https://source.invalid", source_origin, 5); + replace_exact( + &mut text, + "assuranceProfile: evidence-grade", + "assuranceProfile: local", + 1, + ); fs::write(path, text).expect("deployment-only selector rewrite succeeds"); } @@ -1562,6 +1569,9 @@ fn write_runtime(runtime_path: &Path, bundle_root: &Path, secret_root: &Path, au "secretProviders:\n", " file:\n", " root: {}\n", + "signer:\n", + " kind: local-jwk\n", + " privateKeyRef: secret:file/signing-key\n", "auditStorage:\n", " path: {}\n", " maximumFileBytes: 10485760\n", diff --git a/crates/registry-evidence/tests/source_contracts.rs b/crates/registry-evidence/tests/source_contracts.rs index 8e8cd2ef3..327647935 100644 --- a/crates/registry-evidence/tests/source_contracts.rs +++ b/crates/registry-evidence/tests/source_contracts.rs @@ -44,7 +44,8 @@ use tokio_rustls::TlsAcceptor; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -const SHAPE_EVIDENCE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"source-shape-evidence-key"}"#; +const SHAPE_EVIDENCE_KEY_ID: &str = "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"; +const SHAPE_EVIDENCE_PRIVATE_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"}"#; fn source_config( base_url: &str, @@ -1226,7 +1227,7 @@ async fn every_frozen_source_shape_executes_through_production_materialization_a let private = PrivateJwk::parse(SHAPE_EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); let provider: Arc = Arc::new(LocalJwkSigner::new(private).expect("test signing provider builds")); - let signer = EvidenceSigner::initialize(provider, "source-shape-evidence-key") + let signer = EvidenceSigner::initialize(provider, SHAPE_EVIDENCE_KEY_ID) .await .expect("test signer passes its self-test"); let jwks = @@ -1281,10 +1282,11 @@ async fn every_frozen_source_shape_executes_through_production_materialization_a let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( &evidence, registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, - Duration::from_secs(31_536_000), + 31_536_000, observed_at, - Duration::from_secs(0), - ); + 0, + ) + .expect("the fixture policy states bounds the contract allows"); policy.issued_by = "urn:example:fixture:issuer:authority".to_owned(); policy.provided_by = "urn:example:fixture:provider:evidence".to_owned(); policy.requirement = requirement.to_owned(); @@ -2421,7 +2423,7 @@ fn runtime_ca_capture_rejects_symlink_malformed_and_mutable_files() { fs::write( &runtime_path, format!( - "version: 1\nbundleDirectory: /etc/registry-evidence/bundle\nlistener:\n bindHost: 127.0.0.1\n port: 8080\n tlsTermination: operator-controlled-upstream\n trustProxyIdentityHeaders: false\n maximumRequestBytes: 65536\n maximumConcurrentRequests: 64\n requestTimeoutMilliseconds: 10000\n shutdownGraceMilliseconds: 30000\nsecretProviders:\n file: {{root: {}}}\nauditStorage:\n path: /var/lib/registry-evidence/audit/evidence.jsonl\n maximumFileBytes: 1073741824\noutboundTls:\n systemRoots: true\n trustProfiles:\n private-pki: {{caBundleFile: {}}}\n", + "version: 1\nbundleDirectory: /etc/registry-evidence/bundle\nlistener:\n bindHost: 127.0.0.1\n port: 8080\n tlsTermination: operator-controlled-upstream\n trustProxyIdentityHeaders: false\n maximumRequestBytes: 65536\n maximumConcurrentRequests: 64\n requestTimeoutMilliseconds: 10000\n shutdownGraceMilliseconds: 30000\nsecretProviders:\n file: {{root: {}}}\nsigner:\n kind: transit\n unixSocketPath: /run/registry-evidence/transit-proxy.sock\n mount: transit\n keyName: evidence-signing\n keyVersion: 7\n timeoutMilliseconds: 2000\nauditStorage:\n path: /var/lib/registry-evidence/audit/evidence.jsonl\n maximumFileBytes: 1073741824\noutboundTls:\n systemRoots: true\n trustProfiles:\n private-pki: {{caBundleFile: {}}}\n", secret_root.display(), ca_path.display() ), diff --git a/crates/registry-evidencectl/Cargo.toml b/crates/registry-evidencectl/Cargo.toml index 077379995..66aeb3516 100644 --- a/crates/registry-evidencectl/Cargo.toml +++ b/crates/registry-evidencectl/Cargo.toml @@ -20,10 +20,10 @@ anyhow.workspace = true base64.workspace = true chrono.workspace = true clap.workspace = true -ed25519-dalek.workspace = true getrandom.workspace = true inquire.workspace = true registry-platform-crypto.workspace = true +p256.workspace = true rhai.workspace = true rustix.workspace = true serde.workspace = true diff --git a/crates/registry-evidencectl/src/access.rs b/crates/registry-evidencectl/src/access.rs index 505b77e73..faec595be 100644 --- a/crates/registry-evidencectl/src/access.rs +++ b/crates/registry-evidencectl/src/access.rs @@ -90,7 +90,7 @@ pub struct ClientAddArgs { /// Access policy assigned to this client. Repeat for more than one. #[arg(long, required = true)] policy: Vec, - /// Generate an owner-only Ed25519 key for local client authentication. + /// Generate an owner-only P-256 key for local client authentication. #[arg(long, required = true)] generate_local_key: bool, /// Project root. Defaults to the current directory. @@ -251,12 +251,8 @@ fn add_client(args: &ClientAddArgs) -> Result { fs::Permissions::from_mode(PRIVATE_DIRECTORY_MODE), )?; let public_key_path = staging.path().join("public.jwk"); - let (_, generated_public) = keygen::generate_dev_keypair( - staging.path(), - &format!("local-client-{}-key-1", args.client), - PRIVATE_KEY_FILENAME, - "public.jwk", - )?; + let (_, generated_public) = + keygen::generate_dev_keypair(staging.path(), PRIVATE_KEY_FILENAME, "public.jwk")?; debug_assert_eq!(generated_public, public_key_path); let public_key = read_public_jwk(&public_key_path)?; fs::remove_file(&public_key_path).context("removing staged public-key copy")?; diff --git a/crates/registry-evidencectl/src/authoring.rs b/crates/registry-evidencectl/src/authoring.rs index ccfeb4b06..b9c0ab1b6 100644 --- a/crates/registry-evidencectl/src/authoring.rs +++ b/crates/registry-evidencectl/src/authoring.rs @@ -37,7 +37,8 @@ const ACCESS_DIRECTORY: &str = "access"; const ACCESS_POLICIES_DIRECTORY: &str = "policies"; const LOCAL_URI_PREFIX: &str = "urn:registrystack:evidence:local:"; const LOCAL_AUDIENCE: &str = "registry-evidence-local"; -const SIGNING_KEY_ID: &str = "local-signing-key-1"; +const LOCAL_SIGNING_PRIVATE_FILENAME: &str = "signing-p256-private-jwk"; +const LOCAL_SIGNING_PUBLIC_FILENAME: &str = "signing-p256-public.jwk.json"; const AUTHORITY_PROFILE_ID: &str = "local-caller"; const LOCAL_CALLER_EVIDENCE_AUDIENCE: &str = "urn:registrystack:evidence:local:caller"; const MAX_OPENAPI_BYTES: u64 = 16 * 1024 * 1024; @@ -108,7 +109,11 @@ pub(crate) struct CompiledProductionProject { } enum CompileProfile { - Local(LocalServicePorts), + Local { + ports: LocalServicePorts, + active_public_jwk_file: String, + active_public_jwk: Vec, + }, Production(Value), } @@ -176,7 +181,15 @@ pub(crate) fn compile_local_project_with_ports( // Resolve the complete plan before writing anything. Unsupported or // ambiguous authoring inputs therefore leave the staging root empty. let inputs = read_inputs(&project_root, true)?; - let plan = compile_plan(inputs, CompileProfile::Local(ports))?; + let (active_public_jwk_file, active_public_jwk) = local_signing_public_jwk(&project_root)?; + let plan = compile_plan( + inputs, + CompileProfile::Local { + ports, + active_public_jwk_file, + active_public_jwk, + }, + )?; let compilation = write_plan(&project_root, staging_root, &plan, ports)?; if let Err(error) = check_with_evidence(evidence_bin, &compilation.runtime_path) { @@ -190,22 +203,31 @@ pub(crate) fn compile_local_project_with_ports( Ok(compilation) } -/// Compile one complete production bundle into an unpublished private staging -/// directory. The caller owns temporary runtime validation and publication. +/// Compile one complete non-local deployment bundle into an unpublished private +/// staging directory. The caller owns temporary runtime validation and publication. pub(crate) fn compile_production_project( project_root: &Path, + deployment_target_root: &Path, staging_root: &Path, governed_bundle: Value, ) -> Result { - validate_plain_path_components(project_root, "production project")?; + validate_plain_path_components(project_root, "authoring project")?; let project_root = validate_project_root(project_root)?; + validate_plain_path_components(deployment_target_root, "deployment target")?; + let deployment_target_root = fs::canonicalize(deployment_target_root) + .context("resolving deployment target directory")?; validate_private_empty_staging(staging_root)?; let inputs = read_inputs(&project_root, false)?; validate_production_inputs(&project_root, &inputs)?; let plan = compile_plan(inputs, CompileProfile::Production(governed_bundle))?; reject_local_production_values(&plan.bundle)?; validate_production_sources(&plan.bundle)?; - let bundle_path = write_bundle(&project_root, staging_root, &plan)?; + let bundle_path = write_bundle( + &project_root, + Some(&deployment_target_root), + staging_root, + &plan, + )?; let fixture_paths = plan .questions .iter() @@ -391,6 +413,7 @@ struct CompilePlan { questions: Vec, access_policies: Vec, bundle: Value, + local_public_jwk: Option<(String, Vec)>, } struct QuestionPlan { @@ -601,6 +624,26 @@ fn read_inputs(project_root: &Path, require_local_secrets: bool) -> Result Result<(String, Vec)> { + let path = project_root + .join(SECRETS_DIRECTORY) + .join(LOCAL_SIGNING_PUBLIC_FILENAME); + let bytes = read_regular_file(&path, MAX_SOURCE_ARTIFACT_BYTES, "local signing public JWK")?; + let text = std::str::from_utf8(&bytes).context("local signing public JWK must be UTF-8")?; + let public = registry_platform_crypto::PublicJwk::parse(text) + .context("local signing public JWK must be a valid ES256 P-256 public JWK")?; + if public.algorithm().ok() != Some(registry_platform_crypto::SigningAlgorithm::Es256) { + bail!("local signing public JWK must use ES256 P-256"); + } + let kid = public + .jkt() + .context("computing the local signing JWK thumbprint")?; + if public.kid.as_deref() != Some(kid.as_str()) { + bail!("local signing public JWK kid must equal its RFC 7638 thumbprint"); + } + Ok((format!("public-keys/{kid}.jwk.json"), bytes)) +} + fn validate_production_inputs(project_root: &Path, inputs: &Inputs) -> Result<()> { for authored in &inputs.questions { let question = &authored.question; @@ -623,11 +666,11 @@ fn validate_production_inputs(project_root: &Path, inputs: &Inputs) -> Result<() ) { if uri.starts_with(LOCAL_URI_PREFIX) { - bail!("production governance must not use disposable local identifiers"); + bail!("deployment governance must not use disposable local identifiers"); } } let fixture = project_relative_fixture(project_root, &governance.fixtures)?; - let _ = read_regular_file(&fixture, MAX_SOURCE_ARTIFACT_BYTES, "production fixture")?; + let _ = read_regular_file(&fixture, MAX_SOURCE_ARTIFACT_BYTES, "deployment fixture")?; } Ok(()) } @@ -657,7 +700,7 @@ fn project_relative_fixture(project_root: &Path, value: &str) -> Result fn reject_local_production_values(bundle: &Value) -> Result<()> { match bundle { Value::String(value) if value.starts_with(LOCAL_URI_PREFIX) => { - bail!("the production bundle contains a disposable local identifier") + bail!("the deployment bundle contains a disposable local identifier") } Value::Array(values) => { for value in values { @@ -678,7 +721,7 @@ fn validate_production_sources(bundle: &Value) -> Result<()> { let sources = bundle .get("sources") .and_then(Value::as_object) - .ok_or_else(|| anyhow!("the production bundle has no sources object"))?; + .ok_or_else(|| anyhow!("the deployment bundle has no sources object"))?; for source in sources.values() { let https = source .get("baseUrl") @@ -1289,15 +1332,31 @@ fn compile_plan(inputs: Inputs, profile: CompileProfile) -> Result )?); } let access_policies = inputs.access_policies; - let bundle = match profile { - CompileProfile::Local(ports) => render_local_bundle(&questions, &access_policies, ports), - CompileProfile::Production(governance) => render_production_bundle(&questions, governance)?, - }; - Ok(CompilePlan { - questions, - access_policies, - bundle, - }) + match profile { + CompileProfile::Local { + ports, + active_public_jwk_file, + active_public_jwk, + } => { + let bundle = + render_local_bundle(&questions, &access_policies, ports, &active_public_jwk_file); + Ok(CompilePlan { + questions, + access_policies, + bundle, + local_public_jwk: Some((active_public_jwk_file, active_public_jwk)), + }) + } + CompileProfile::Production(governance) => { + let bundle = render_production_bundle(&questions, governance)?; + Ok(CompilePlan { + questions, + access_policies, + bundle, + local_public_jwk: None, + }) + } + } } fn compile_question_plan( @@ -2666,6 +2725,7 @@ fn render_local_bundle( questions: &[QuestionPlan], access_policies: &[AuthoredAccessPolicy], ports: LocalServicePorts, + active_public_jwk_file: &str, ) -> Value { let mint_origin = ports.mint_origin(); let selector_profiles = questions @@ -2748,13 +2808,15 @@ fn render_local_bundle( "issuer": mint_origin, "audiences": [LOCAL_AUDIENCE], "tokenTypes": ["at+jwt"], - "algorithms": ["EdDSA"], + "algorithms": ["ES256"], "jwksUri": format!("{mint_origin}/.well-known/jwks.json"), "principalClaim": "sub", "requesterTagsClaim": "evidence_tags", "evidenceAudienceClaim": "evidence_audience", "grantIdClaim": "evidence_grant_id", "grantAuthorityClaim": "evidence_authority", + "maximumTokenLifetimeSeconds": 300, + "revokedKeyIds": [], }, "audit": { "format": "keyed-jsonl", @@ -2773,10 +2835,10 @@ fn render_local_bundle( }, "signing": { "format": "flattened-jws-json", - "algorithm": "EdDSA", - "activeKeyId": SIGNING_KEY_ID, - "activeKeyRef": "secret:file/signing-ed25519-private-jwk", - "retiredPublicJwkFiles": [], + "algorithm": "ES256", + "activePublicJwkFile": active_public_jwk_file, + "publishedPublicJwkFiles": [], + "revokedKeyIds": [], "jwksPath": "/.well-known/evidence/jwks.json", "maximumAssertionValiditySeconds": 300, "verifierClockSkewSeconds": 30, @@ -2792,7 +2854,7 @@ fn render_local_bundle( fn render_production_bundle(questions: &[QuestionPlan], mut governance: Value) -> Result { let object = governance .as_object_mut() - .ok_or_else(|| anyhow!("production governance must be an object"))?; + .ok_or_else(|| anyhow!("deployment governance must be an object"))?; object.insert( "selectorProfiles".to_owned(), Value::Object(Map::from_iter( @@ -2831,7 +2893,7 @@ fn write_plan( plan: &CompilePlan, ports: LocalServicePorts, ) -> Result { - write_bundle(project_root, staging_root, plan)?; + write_bundle(project_root, None, staging_root, plan)?; create_private_directory(&staging_root.join("audit"))?; let canonical_staging = fs::canonicalize(staging_root) @@ -2852,6 +2914,10 @@ fn write_plan( "shutdownGraceMilliseconds": 30000, }, "secretProviders": {"file": {"root": secret_root.to_string_lossy()}}, + "signer": { + "kind": "local-jwk", + "privateKeyRef": format!("secret:file/{LOCAL_SIGNING_PRIVATE_FILENAME}"), + }, "auditStorage": { "path": canonical_staging.join("audit/evidence.jsonl").to_string_lossy(), "maximumFileBytes": 1073741824_u64, @@ -2908,7 +2974,12 @@ fn write_plan( }) } -fn write_bundle(project_root: &Path, staging_root: &Path, plan: &CompilePlan) -> Result { +fn write_bundle( + project_root: &Path, + deployment_target_root: Option<&Path>, + staging_root: &Path, + plan: &CompilePlan, +) -> Result { let bundle = staging_root.join("bundle"); create_private_directory(&bundle)?; for directory in ["adapters", "derivations", "schemas"] { @@ -2922,9 +2993,16 @@ fn write_bundle(project_root: &Path, staging_root: &Path, plan: &CompilePlan) -> { create_private_directory(&bundle.join("codelists"))?; } + if plan.local_public_jwk.is_some() { + create_private_directory(&bundle.join("public-keys"))?; + } write_private_file(&bundle.join("evidence.yaml"), &yaml_bytes(&plan.bundle)?)?; let mut written_sources = BTreeSet::new(); let mut written_paths = BTreeSet::from(["evidence.yaml".to_owned()]); + if let Some((path, bytes)) = &plan.local_public_jwk { + write_private_file(&bundle.join(path), bytes)?; + written_paths.insert(path.clone()); + } for question in &plan.questions { if written_sources.insert(question.source_artifact_id.clone()) { if let Some(artifacts) = &question.authored_source_artifacts { @@ -3008,7 +3086,7 @@ fn write_bundle(project_root: &Path, staging_root: &Path, plan: &CompilePlan) -> project_root, path, MAX_SOURCE_ARTIFACT_BYTES, - "production fixture", + "deployment fixture", )?; ensure_generated_parent(&bundle, path)?; write_private_file(&bundle.join(path), &bytes)?; @@ -3017,11 +3095,20 @@ fn write_bundle(project_root: &Path, staging_root: &Path, plan: &CompilePlan) -> } for path in auxiliary_artifacts(&plan.bundle)? { if written_paths.insert(path.clone()) { + let artifact_root = if path.starts_with("public-keys/") { + deployment_target_root.unwrap_or(project_root) + } else { + project_root + }; let bytes = read_project_artifact( - project_root, + artifact_root, &path, MAX_SOURCE_ARTIFACT_BYTES, - "referenced bundle artifact", + if path.starts_with("public-keys/") { + "governed deployment public key" + } else { + "referenced bundle artifact" + }, )?; ensure_generated_parent(&bundle, &path)?; write_private_file(&bundle.join(path), &bytes)?; @@ -3077,14 +3164,21 @@ fn auxiliary_artifacts(bundle: &Value) -> Result> { } } } + if let Some(active) = bundle + .pointer("/signing/activePublicJwkFile") + .and_then(Value::as_str) + { + validate_auxiliary_artifact(active, "public-keys", ".jwk.json")?; + paths.insert(active.to_owned()); + } if let Some(public_keys) = bundle - .pointer("/signing/retiredPublicJwkFiles") + .pointer("/signing/publishedPublicJwkFiles") .and_then(Value::as_array) { for value in public_keys { let path = value .as_str() - .ok_or_else(|| anyhow!("retired public key paths must be strings"))?; + .ok_or_else(|| anyhow!("published public key paths must be strings"))?; validate_auxiliary_artifact(path, "public-keys", ".jwk.json")?; paths.insert(path.to_owned()); } @@ -3554,8 +3648,17 @@ properties: compiled.caller_evidence_audience, LOCAL_CALLER_EVIDENCE_AUDIENCE ); + let mut generated = tree(&fixture.staging); + let public_key_index = generated + .iter() + .position(|path| path.starts_with("bundle/public-keys/") && path.ends_with(".jwk.json")) + .expect("one governed local public JWK"); + let public_key = generated.remove(public_key_index); + assert!(public_key.starts_with("bundle/public-keys/")); + assert!(public_key.ends_with(".jwk.json")); + generated.retain(|path| path != "bundle/public-keys/"); assert_eq!( - tree(&fixture.staging), + generated, vec![ "audit/", "bundle/", @@ -4025,8 +4128,8 @@ factSchema: schemas/source-facts.schema.yaml "signing": {}, "authorityProfiles": {"authority": {"kind": "explicit-request"}}, }); - let project = fs::canonicalize(&fixture.project).expect("canonical production project"); - let compiled = compile_production_project(&project, &fixture.staging, target) + let project = fs::canonicalize(&fixture.project).expect("canonical authoring project"); + let compiled = compile_production_project(&project, &project, &fixture.staging, target) .expect("all neutral shapes compile through production"); let bundle = compiled.bundle; let requirements = bundle["requirements"].as_array().expect("requirements"); @@ -4091,10 +4194,11 @@ factSchema: schemas/source-facts.schema.yaml assert_eq!(bundle["assuranceProfile"], "local"); assert_eq!(bundle["authentication"]["issuer"], "http://127.0.0.1:8081"); - assert_eq!( - bundle["signing"]["activeKeyRef"], - "secret:file/signing-ed25519-private-jwk" - ); + assert_eq!(bundle["signing"]["algorithm"], "ES256"); + let public_key = bundle["signing"]["activePublicJwkFile"] + .as_str() + .expect("active public JWK file"); + assert!(fixture.staging.join("bundle").join(public_key).is_file()); assert_eq!( requirement["id"], "urn:authority:requirement:adult-status:v1" @@ -4884,11 +4988,6 @@ factSchema: schemas/family-facts.schema.yaml .map(PathBuf::from) .expect("set EVIDENCE_BIN to the built evidence binary"); let fixture = Fixture::new(OPENAPI, QUESTION, ANSWER, true); - crate::keygen::generate_scaffold_key_material( - &fixture.project.join("secrets"), - SIGNING_KEY_ID, - ) - .expect("generate local keys"); compile_local_project(&fixture.project, &fixture.staging, &evidence) .expect("real Evidence loader accepts generated inputs"); @@ -4896,20 +4995,10 @@ factSchema: schemas/family-facts.schema.yaml fixture.add_question(AGE_BRACKET_QUESTION, AGE_BRACKET_ANSWER); fixture.add_access_policy("age-checks", &["adult-status"]); fixture.add_access_policy("service-routing", &["age-bracket"]); - crate::keygen::generate_scaffold_key_material( - &fixture.project.join("secrets"), - SIGNING_KEY_ID, - ) - .expect("generate local keys"); compile_local_project(&fixture.project, &fixture.staging, &evidence) .expect("real Evidence loader accepts explicit access profiles"); let fixture = Fixture::new(OPENAPI, AGE_BRACKET_QUESTION, AGE_BRACKET_ANSWER, true); - crate::keygen::generate_scaffold_key_material( - &fixture.project.join("secrets"), - SIGNING_KEY_ID, - ) - .expect("generate local keys"); compile_local_project(&fixture.project, &fixture.staging, &evidence) .expect("real Evidence loader accepts the controlled category"); @@ -4923,21 +5012,11 @@ factSchema: schemas/family-facts.schema.yaml " date_of_birth: {type: string, format: date}\n dose_count: {type: integer, minimum: 0, maximum: 20}", ); let fixture = Fixture::new(&openapi, IMMUNIZATION_QUESTION, IMMUNIZATION_ANSWER, true); - crate::keygen::generate_scaffold_key_material( - &fixture.project.join("secrets"), - SIGNING_KEY_ID, - ) - .expect("generate local keys"); compile_local_project(&fixture.project, &fixture.staging, &evidence) .expect("real Evidence loader accepts multiple governed answers"); let (openapi, question, answer) = punctuated_inputs(); let fixture = Fixture::new(&openapi, &question, &answer, true); - crate::keygen::generate_scaffold_key_material( - &fixture.project.join("secrets"), - SIGNING_KEY_ID, - ) - .expect("generate local keys"); compile_local_project(&fixture.project, &fixture.staging, &evidence) .expect("real Evidence loader accepts safely quoted punctuated names"); @@ -4947,11 +5026,6 @@ factSchema: schemas/family-facts.schema.yaml MULTI_EVENT_ANSWER, true, ); - crate::keygen::generate_scaffold_key_material( - &fixture.project.join("secrets"), - SIGNING_KEY_ID, - ) - .expect("generate local keys"); compile_local_project(&fixture.project, &fixture.staging, &evidence) .expect("real Evidence loader accepts nested repeated fact extraction"); @@ -4961,11 +5035,6 @@ factSchema: schemas/family-facts.schema.yaml RELATIONSHIP_ANSWER, true, ); - crate::keygen::generate_scaffold_key_material( - &fixture.project.join("secrets"), - SIGNING_KEY_ID, - ) - .expect("generate local keys"); compile_local_project(&fixture.project, &fixture.staging, &evidence) .expect("real Evidence loader accepts multiple role-bound subjects"); } @@ -4998,6 +5067,8 @@ factSchema: schemas/family-facts.schema.yaml let mut secrets = fs::DirBuilder::new(); secrets.mode(0o700); secrets.create(project.join("secrets")).expect("secrets"); + crate::keygen::generate_scaffold_key_material(&project.join("secrets")) + .expect("local key material"); fs::write(project.join(OPENAPI_FILE), openapi).expect("OpenAPI"); let staging = root.path().join("staging"); diff --git a/crates/registry-evidencectl/src/build.rs b/crates/registry-evidencectl/src/build.rs index 691981fad..2e3e38ff7 100644 --- a/crates/registry-evidencectl/src/build.rs +++ b/crates/registry-evidencectl/src/build.rs @@ -1,13 +1,11 @@ -//! Compile an editable Evidence authoring project into one closed production -//! candidate. Production secrets and target-host paths remain operator-owned. +//! Compile an editable Evidence authoring project into one closed deployment +//! candidate. Secrets and target-host paths remain operator-owned. use std::{ collections::BTreeSet, fs::{self, File, OpenOptions}, io::{Read as _, Seek as _, Write as _}, - os::unix::fs::{ - DirBuilderExt as _, MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _, - }, + os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}, path::{Component, Path, PathBuf}, process::{Command, ExitCode, ExitStatus, Stdio}, sync::{ @@ -19,37 +17,15 @@ use std::{ }; use anyhow::{anyhow, bail, Context as _, Result}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use clap::Args; use serde::Deserialize; use serde_json::{json, Map, Value}; -use crate::{authoring, fixtures, keygen}; +use crate::{authoring, fixtures}; const MAX_TARGET_BYTES: u64 = 1024 * 1024; const MAX_EVIDENCE_STDOUT_BYTES: u64 = 1024 * 1024; const SECRET_PREFIX: &str = "secret:file/"; -const VALIDATION_CA: &[u8] = br#"-----BEGIN CERTIFICATE----- -MIIDMzCCAhugAwIBAgIULquGuNJ2HotUWgpEcRBAdsEtTkUwDQYJKoZIhvcNAQEL -BQAwKTEnMCUGA1UEAwweZXZpZGVuY2VjdGwtdmFsaWRhdGlvbi5pbnZhbGlkMB4X -DTI2MDgwNDE1MjIxMloXDTM2MDgwMTE1MjIxMlowKTEnMCUGA1UEAwweZXZpZGVu -Y2VjdGwtdmFsaWRhdGlvbi5pbnZhbGlkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEAqaK57iK2Xspf35AsdY0lCOkUgGRFP7cheDnl855jeW1izSt9ZbBZ -BO9TbUo2J5WnNApOIQFi/57kxX/9HUaTHxaQXsFRgLolYCU5CSWuAI5JMDP0OH+H -xni8AJ1j/cOFovhg/eqRAatF97tBu5Wxh6ghl1eDmZOVeboM/OHns4hauxi6zkdC -oq0ZF7XAQTM7WYbmSewfXcaY5Px4YtyuDJoTVBzsVkp9X3OposyicAXT/5BqPqjC -2jCnM9/PsO9ZpzSZTzeYn06QRtED3hCruCc3isMlWr5lE/KMvMvm9Q+q7+VfariD -qL2UuK4hCRcvTzcbW3s67x3DsohcbuA/OQIDAQABo1MwUTAdBgNVHQ4EFgQUAHgZ -2TkaFqS4edYq+6zlsG6aBDwwHwYDVR0jBBgwFoAUAHgZ2TkaFqS4edYq+6zlsG6a -BDwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAeOvtXp0JMcQw -ouUNvQGlPvu2bcfjsEfvzKOyzjRKmgf4RZYXdFTbV+TkRWjUHjkKjkGE8T18bnBs -3bLuzx0/UJw0b5BxTVSevUgmjnSDqK8XBS8ZyBomcB9MQ+MwPO4ssTDPsZCqOLao -GlhP5e68cbZwmC2YYtgu/bPRSMtlYzTp6wQv2voDlSPZgCUlzfTU67yKsS0dnQaV -wObsZ58XF4WVjuNtyoxtqToUtnrdCP9HUG/I5QiD54IFlVx2dqeWhLa/oyMeAxiR -R1YU60RrYIjPIGEnL+L1WuwoOEu8x09ly2/9wuIWhQPNgVMTCzjwnt8XdVuNecD6 -MRmJRtyidQ== ------END CERTIFICATE----- -"#; #[derive(Debug, Args)] pub struct BuildArgs { @@ -57,7 +33,7 @@ pub struct BuildArgs { #[arg(long, default_value = ".")] pub project: PathBuf, - /// Explicit production target containing governance.yaml and runtime.yaml. + /// Explicit deployment target containing governance.yaml and runtime.yaml. #[arg(long)] pub target: PathBuf, @@ -86,17 +62,20 @@ struct TargetGovernance { impl TargetGovernance { fn into_bundle(self) -> Result { if self.version != 1 { - bail!("production governance version must be 1"); + bail!("deployment governance version must be 1"); } - if self.assurance_profile != "production" { - bail!("evidencectl build requires assuranceProfile: production"); + if !matches!( + self.assurance_profile.as_str(), + "production" | "evidence-grade" + ) { + bail!("deployment governance assuranceProfile must be production or evidence-grade"); } if self .authority_profiles .as_object() .is_none_or(Map::is_empty) { - bail!("production governance requires at least one authority profile"); + bail!("deployment governance requires at least one authority profile"); } let mut object = Map::from_iter([ ("version".to_owned(), json!(self.version)), @@ -128,7 +107,7 @@ pub fn run(args: BuildArgs) -> Result { fn run_inner(args: BuildArgs, interruption: &BuildInterruption) -> Result { interruption.check()?; reject_existing_output(&args.output)?; - let project = plain_directory(&args.project, "production project")?; + let project = plain_directory(&args.project, "authoring project")?; let output_parent = plain_parent(&args.output)?; let candidate = output_parent.join( args.output @@ -138,19 +117,19 @@ fn run_inner(args: BuildArgs, interruption: &BuildInterruption) -> Result Result Result Result<()> { if self.requested.load(Ordering::Relaxed) { - bail!("production build interrupted"); + bail!("deployment build interrupted"); } Ok(()) } @@ -232,167 +211,65 @@ impl Drop for BuildInterruption { fn prepare_candidate( project: &Path, + deployment_target: &Path, staging_root: &Path, target_runtime: &[u8], governed_bundle: Value, evidence_bin: &Path, - temporary_parent: &Path, interruption: &BuildInterruption, ) -> Result<(String, Vec)> { - let compiled = authoring::compile_production_project(project, staging_root, governed_bundle)?; + let compiled = authoring::compile_production_project( + project, + deployment_target, + staging_root, + governed_bundle, + )?; interruption.check()?; reject_review_markers(&compiled.bundle_path)?; - reject_review_markers_in_bytes(target_runtime, "production runtime")?; + reject_review_markers_in_bytes(target_runtime, "deployment runtime")?; let runtime_path = staging_root.join("runtime.yaml"); write_new_file(&runtime_path, target_runtime, 0o600)?; fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o400)) - .context("sealing the copied production runtime")?; + .context("sealing the copied deployment runtime")?; let secret_references = secret_references(&compiled.bundle)?; - let validation = tempfile::Builder::new() - .prefix(".evidencectl-build-validation-") - .tempdir_in(temporary_parent) - .context("creating private production validation state")?; - fs::set_permissions(validation.path(), fs::Permissions::from_mode(0o700)) - .context("setting private production validation permissions")?; - let validation_result = (|| -> Result { - let validation_runtime = prepare_validation_runtime( - validation.path(), - &compiled.bundle_path, - &compiled.bundle, - &secret_references, - )?; - interruption.check()?; - let revision = run_check(evidence_bin, &validation_runtime, interruption)?; - for fixture in &compiled.fixture_paths { - interruption.check()?; - run_fixture(evidence_bin, &validation_runtime, fixture, interruption)?; - } + let revision = run_bundle_check(evidence_bin, &compiled.bundle_path, interruption)?; + for fixture in &compiled.fixture_paths { interruption.check()?; - Ok(revision) - })(); - validation - .close() - .context("removing private production validation staging")?; - let revision = validation_result?; - Ok((revision, secret_references)) -} - -fn prepare_validation_runtime( - root: &Path, - bundle: &Path, - config: &Value, - secret_references: &[String], -) -> Result { - let secret_root = root.join("secrets"); - let active_ref = config - .pointer("/signing/activeKeyRef") - .and_then(Value::as_str) - .and_then(|value| value.strip_prefix(SECRET_PREFIX)) - .ok_or_else(|| anyhow!("production signing must use one logical file secret reference"))?; - let active_key_id = config - .pointer("/signing/activeKeyId") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("production signing must declare one active key id"))?; - keygen::generate_dev_keypair( - &secret_root, - active_key_id, - active_ref, - ".validation-public.jwk.json", - )?; - for reference in secret_references { - if reference == active_ref { - continue; - } - let mut entropy = [0_u8; 32]; - getrandom::fill(&mut entropy).context("generating temporary validation material")?; - let encoded = URL_SAFE_NO_PAD.encode(entropy); - write_new_file(&secret_root.join(reference), encoded.as_bytes(), 0o600)?; + run_bundle_fixture(evidence_bin, &compiled.bundle_path, fixture, interruption)?; } - - let ca_root = root.join("ca"); - create_private_directory(&ca_root)?; - let mut trust_profiles = Map::new(); - for profile in tls_trust_profiles(config)? { - let path = ca_root.join(format!("{profile}.pem")); - write_new_file(&path, VALIDATION_CA, 0o400)?; - trust_profiles.insert(profile, json!({"caBundleFile": path.to_string_lossy()})); - } - let audit = root.join("audit"); - create_private_directory(&audit)?; - let runtime = json!({ - "version": 1, - "bundleDirectory": fs::canonicalize(bundle)?.to_string_lossy(), - "listener": { - "bindHost": "127.0.0.1", - "port": 1, - "tlsTermination": "operator-controlled-upstream", - "trustProxyIdentityHeaders": false, - "maximumRequestBytes": 65536, - "maximumConcurrentRequests": 1, - "requestTimeoutMilliseconds": 10000, - "shutdownGraceMilliseconds": 30000, - }, - "secretProviders": {"file": {"root": fs::canonicalize(&secret_root)?.to_string_lossy()}}, - "auditStorage": { - "path": audit.join("evidence.jsonl").to_string_lossy(), - "maximumFileBytes": 1048576, - }, - "outboundTls": {"systemRoots": true, "trustProfiles": trust_profiles}, - }); - let path = root.join("runtime.yaml"); - let mut bytes = serde_norway::to_string(&runtime)?.into_bytes(); - if !bytes.ends_with(b"\n") { - bytes.push(b'\n'); - } - write_new_file(&path, &bytes, 0o400)?; - Ok(path) -} - -fn tls_trust_profiles(config: &Value) -> Result> { - let mut profiles = BTreeSet::new(); - if let Some(sources) = config.get("sources").and_then(Value::as_object) { - for source in sources.values() { - if let Some(profile) = source.get("tlsTrustProfile").and_then(Value::as_str) { - if !valid_local_identifier(profile) { - bail!("production TLS trust profile identifier is invalid"); - } - profiles.insert(profile.to_owned()); - } - } - } - Ok(profiles.into_iter().collect()) + Ok((revision, secret_references)) } -fn run_check( +fn run_bundle_check( evidence_bin: &Path, - runtime: &Path, + bundle: &Path, interruption: &BuildInterruption, ) -> Result { let mut command = Command::new(evidence_bin); command - .arg("--runtime") - .arg(runtime) - .arg("check") + .arg("bundle-check") + .arg("--bundle") + .arg(bundle) .env_remove("REGISTRY_EVIDENCE_RUNTIME"); let output = run_evidence(command, interruption, true)?; if !output.status.success() { - return runtime_failure("Evidence rejected the generated production bundle"); + return runtime_failure("Evidence rejected the generated deployment bundle"); } parse_bundle_revision(&String::from_utf8_lossy(&output.stdout)) } -fn run_fixture( +fn run_bundle_fixture( evidence_bin: &Path, - runtime: &Path, + bundle: &Path, fixture: &str, interruption: &BuildInterruption, ) -> Result<()> { let mut command = Command::new(evidence_bin); command - .arg("--runtime") - .arg(runtime) - .arg("evaluate") + .arg("bundle-evaluate") + .arg("--bundle") + .arg(bundle) .arg("--fixture") .arg(fixture) .env_remove("REGISTRY_EVIDENCE_RUNTIME"); @@ -400,7 +277,7 @@ fn run_fixture( if output.status.success() { return Ok(()); } - runtime_failure("Evidence rejected a production fixture") + runtime_failure("Evidence rejected a deployment fixture") } struct EvidenceOutput { @@ -426,25 +303,25 @@ fn run_evidence( } let mut child = command .spawn() - .context("starting the Evidence production validation")?; + .context("starting the Evidence deployment validation")?; let status = loop { if interruption.check().is_err() { terminate_validation_child(&mut child); - return Err(anyhow!("production build interrupted")); + return Err(anyhow!("deployment build interrupted")); } if stdout.as_ref().is_some_and(|file| { file.metadata() .is_ok_and(|metadata| metadata.len() > MAX_EVIDENCE_STDOUT_BYTES) }) { terminate_validation_child(&mut child); - bail!("Evidence production validation output exceeded its byte limit"); + bail!("Evidence deployment validation output exceeded its byte limit"); } match child.try_wait() { Ok(Some(status)) => break status, Ok(None) => thread::sleep(Duration::from_millis(10)), Err(error) => { terminate_validation_child(&mut child); - return Err(error).context("waiting for Evidence production validation"); + return Err(error).context("waiting for Evidence deployment validation"); } } }; @@ -456,7 +333,7 @@ fn run_evidence( file.take(MAX_EVIDENCE_STDOUT_BYTES + 1) .read_to_end(&mut captured)?; if captured.len() as u64 > MAX_EVIDENCE_STDOUT_BYTES { - bail!("Evidence production validation output exceeded its byte limit"); + bail!("Evidence deployment validation output exceeded its byte limit"); } } Ok(EvidenceOutput { @@ -502,7 +379,7 @@ fn collect_secret_references(value: &Value, references: &mut BTreeSet) - Value::String(value) => { if let Some(reference) = value.strip_prefix(SECRET_PREFIX) { if !valid_secret_name(reference) { - bail!("production logical file secret reference has invalid syntax"); + bail!("deployment logical file secret reference has invalid syntax"); } references.insert(reference.to_owned()); } @@ -531,19 +408,10 @@ fn valid_secret_name(name: &str) -> bool { }) } -fn valid_local_identifier(value: &str) -> bool { - let bytes = value.as_bytes(); - matches!(bytes.first(), Some(b'a'..=b'z')) - && bytes.len() <= 128 - && bytes[1..].iter().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') - }) -} - fn reject_review_markers(bundle: &Path) -> Result<()> { for path in bundle_files(bundle)? { let bytes = fs::read(&path).context("reading one generated bundle artifact")?; - reject_review_markers_in_bytes(&bytes, "production bundle")?; + reject_review_markers_in_bytes(&bytes, "deployment bundle")?; } Ok(()) } @@ -668,14 +536,6 @@ fn read_plain_file(path: &Path, maximum: u64, description: &str) -> Result Result<()> { - let mut builder = fs::DirBuilder::new(); - builder.mode(0o700); - builder - .create(path) - .with_context(|| format!("creating {}", path.display())) -} - fn write_new_file(path: &Path, contents: &[u8], mode: u32) -> Result<()> { let mut file = OpenOptions::new() .write(true) @@ -712,7 +572,7 @@ fn close_candidate_staging(staging: tempfile::TempDir) -> Result<()> { make_tree_removable(staging.path())?; staging .close() - .context("removing private production candidate staging") + .context("removing private deployment candidate staging") } fn publish(staging: tempfile::TempDir, output: &Path) -> Result<()> { @@ -720,7 +580,7 @@ fn publish(staging: tempfile::TempDir, output: &Path) -> Result<()> { if let Err(error) = rename_noreplace(&staged, output) { let _ = make_tree_removable(&staged); let _ = fs::remove_dir_all(&staged); - return Err(error).context("publishing the production candidate without replacement"); + return Err(error).context("publishing the deployment candidate without replacement"); } Ok(()) } @@ -786,10 +646,6 @@ requirements: [] assert_eq!(names, ["audit-key", "source-token"]); assert!(secret_references(&json!({"key": "secret:file/../escape"})).is_err()); assert!(secret_references(&json!({"key": "secret:file/nested/escape"})).is_err()); - assert!(tls_trust_profiles(&json!({ - "sources": {"source": {"tlsTrustProfile": "../../escape"}} - })) - .is_err()); } #[test] @@ -801,7 +657,7 @@ requirements: [] let link = root.join("link"); symlink(&actual, &link).expect("ancestor symlink"); - assert!(plain_directory(&link, "production target").is_err()); + assert!(plain_directory(&link, "deployment target").is_err()); assert!(plain_parent(&link.join("candidate")).is_err()); } diff --git a/crates/registry-evidencectl/src/dev.rs b/crates/registry-evidencectl/src/dev.rs index 442cffd4f..14ad15fbd 100644 --- a/crates/registry-evidencectl/src/dev.rs +++ b/crates/registry-evidencectl/src/dev.rs @@ -47,8 +47,6 @@ const CALLER_ID: &str = "local-tutorial-caller"; const LOCAL_ACCESS_TOKEN_AUDIENCE: &str = "registry-evidence-local"; const LOCAL_CALLER_EVIDENCE_AUDIENCE: &str = "urn:registrystack:evidence:local:caller"; const LOCAL_REQUESTER_TAG: &str = "local-caller"; -const MINT_KEY_ID: &str = "local-mint-signing-key-1"; -const CALLER_KEY_ID: &str = "local-tutorial-caller-key-1"; const MINT_AUDIT_KEY_FILENAME: &str = "mint-audit-hmac-key"; const PRIVATE_DIR_MODE: u32 = 0o700; const PRIVATE_FILE_MODE: u32 = 0o600; @@ -914,24 +912,15 @@ fn prepare_and_start( create_private_directory(directory)?; } - let (mint_private, _) = keygen::generate_dev_keypair( - &keys, - MINT_KEY_ID, - "mint-private.jwk", - "mint-public.jwk.json", - )?; + let mint_public = generate_service_and_holder_keys(&keys)?; let mint_audit_key = keys.join(MINT_AUDIT_KEY_FILENAME); generate_mint_audit_key(&mint_audit_key)?; - let mint_config = mint_config(&compiled, &mint_private, &mint_audit_key, ports); + let mint_config = mint_config(&compiled, &mint_public, &keys, ports); let mint_config_path = generated.join("mint.yaml"); write_private_yaml(&mint_config_path, &mint_config)?; let caller = if compiled.access_policies.is_empty() { - let (caller_private, caller_public) = keygen::generate_dev_keypair( - &keys, - CALLER_KEY_ID, - "caller-private.jwk", - "caller-public.jwk.json", - )?; + let (caller_private, caller_public) = + keygen::generate_dev_keypair(&keys, "caller-private.jwk", "caller-public.jwk.json")?; let caller_public = read_owner_json(&caller_public, 16 * 1024)?; write_private_yaml( &clients.join("caller.yaml"), @@ -1020,6 +1009,57 @@ fn prepare_and_start( Ok(ExitCode::SUCCESS) } +fn generate_service_and_holder_keys(keys: &Path) -> Result { + for name in [ + "mint-private.jwk", + "mint-public.jwk.json", + "holder-private.jwk", + "holder-public.jwk.json", + ] { + match fs::symlink_metadata(keys.join(name)) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(_) => bail!("refusing to replace existing private dev key material"), + Err(error) => return Err(error).context("inspecting private dev key material"), + } + } + let (_mint_private, staged_mint_public) = + keygen::generate_dev_keypair(keys, "mint-private.jwk", "mint-public.jwk.json")?; + let mint_public = publish_thumbprint_named_public_jwk(&staged_mint_public)?; + // Keep one disposable holder pair beside the other private local session + // keys so wallet-binding examples need no extra setup. Evidence does not + // consume the private half and neither half leaves supervised dev state. + let _holder = + keygen::generate_dev_keypair(keys, "holder-private.jwk", "holder-public.jwk.json")?; + Ok(mint_public) +} + +fn publish_thumbprint_named_public_jwk(staged: &Path) -> Result { + let bytes = read_owner_file(staged, 16 * 1024)?; + let encoded = std::str::from_utf8(&bytes).context("generated public JWK is not UTF-8")?; + let public = registry_platform_crypto::PublicJwk::parse(encoded) + .context("generated public JWK failed validation")?; + let kid = public + .kid + .as_deref() + .ok_or_else(|| anyhow!("generated public JWK has no key id"))?; + if public + .jkt() + .context("generated public JWK has no thumbprint")? + != kid + { + bail!("generated public JWK key id is not its RFC 7638 thumbprint"); + } + let published = staged + .parent() + .ok_or_else(|| anyhow!("generated public JWK has no parent directory"))? + .join(format!("{kid}.jwk.json")); + let mut file = create_private_file(&published)?; + file.write_all(&bytes)?; + file.sync_all()?; + fs::remove_file(staged).context("failed to remove the staged public JWK")?; + Ok(published) +} + fn stop_dev(project: &Path) -> Result { let project = canonical_project(project)?; let generated_root = existing_private_generated_root(&project)?; @@ -1157,7 +1197,7 @@ fn supervise(args: SupervisorArgs, terminate: &AtomicBool) -> Result<()> { if wait_for_http( &format!("{}/.well-known/jwks.json", state.mint_origin), children.mint.as_mut().expect("Mint child was assigned"), - HttpProof::MintKey(MINT_KEY_ID), + HttpProof::MintEs256Key, args.ready_timeout_seconds, terminate, ) @@ -1316,15 +1356,15 @@ fn spawn_evidence(binary: &Path, runtime: &Path, log: &Path) -> Result { .with_context(|| format!("failed to start {}", binary.display())) } -enum HttpProof<'a> { - MintKey(&'a str), +enum HttpProof { + MintEs256Key, EvidenceReady, } fn wait_for_http( url: &str, child: &mut Child, - proof: HttpProof<'_>, + proof: HttpProof, seconds: u64, terminate: &AtomicBool, ) -> Result<()> { @@ -1349,9 +1389,14 @@ fn wait_for_http( if bytes.len() as u64 <= MAX_HTTP_BODY_BYTES { let value: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); let matches = match proof { - HttpProof::MintKey(kid) => value["keys"] - .as_array() - .is_some_and(|keys| keys.iter().any(|key| key["kid"] == kid)), + HttpProof::MintEs256Key => value["keys"].as_array().is_some_and(|keys| { + keys.iter().any(|key| { + key["kty"] == "EC" + && key["crv"] == "P-256" + && key["alg"] == "ES256" + && key["kid"].as_str().is_some_and(|kid| kid.len() == 43) + }) + }), HttpProof::EvidenceReady => value == json!({"status": "ready"}), }; if matches && child.try_wait()?.is_none() { @@ -1460,8 +1505,8 @@ fn abort_start(supervisor: &mut Child) -> Result<()> { fn mint_config( compiled: &CompiledProject, - mint_private: &Path, - mint_audit_key: &Path, + mint_public: &Path, + secret_root: &Path, ports: LocalServicePorts, ) -> Value { let mint_origin = ports.mint_origin(); @@ -1477,19 +1522,24 @@ fn mint_config( "requestTimeoutMilliseconds": 5000, }, "signing": { - "algorithm": "EdDSA", - "activeKeyId": MINT_KEY_ID, - "activeKeyFile": mint_private, - "retiredPublicJwkFiles": [], + "algorithm": "ES256", + "activePublicJwkFile": mint_public, + "publishedPublicJwkFiles": [], + "revokedKeyIds": [], "jwksPath": "/.well-known/jwks.json", }, + "signer": { + "kind": "local-jwk", + "privateKeyRef": "secret:file/mint-private.jwk", + }, + "secretProviders": {"file": {"root": secret_root}}, "audit": { "path": "audit/mint.jsonl", // Mint rotates a sealed segment at this threshold. A local // tutorial session never reaches it, and the value matches the // documented deployment example. "maximumFileBytes": 1_073_741_824u64, - "hashKeyFile": mint_audit_key, + "hashKeyRef": "secret:file/mint-audit-hmac-key", "hashKeyVersion": 1, }, "accessTokens": { @@ -1506,7 +1556,7 @@ fn mint_config( "clientAssertion": { "audience": token_url, "maximumLifetimeSeconds": 120, - "algorithms": ["EdDSA"], + "algorithms": ["ES256"], "replayCacheEntries": 256, }, "clients": {"directory": "clients"}, @@ -1946,13 +1996,13 @@ mod tests { let compiled = compiled(Path::new("/private/runtime.yaml")); let config = mint_config( &compiled, - Path::new("/private/mint-private.jwk"), - Path::new("/private/mint-audit-hmac-key"), + Path::new("/private/mint-public.jwk.json"), + Path::new("/private"), LocalServicePorts::default(), ); let caller = local_caller_registration( &compiled, - json!({"kty":"OKP","crv":"Ed25519","kid":"caller","alg":"EdDSA","x":"public"}), + json!({"kty":"EC","crv":"P-256","kid":"caller","alg":"ES256","x":"public","y":"public"}), ); assert_eq!(config["validationMode"], "supervised-local-development"); assert_eq!(config["issuer"], "http://127.0.0.1:8081"); @@ -1972,7 +2022,7 @@ mod tests { json!({ "path": "audit/mint.jsonl", "maximumFileBytes": 1_073_741_824u64, - "hashKeyFile": "/private/mint-audit-hmac-key", + "hashKeyRef": "secret:file/mint-audit-hmac-key", "hashKeyVersion": 1, }) ); @@ -1984,6 +2034,67 @@ mod tests { assert!(caller.to_string().find("private").is_none()); } + #[test] + fn supervised_dev_generates_create_only_private_p256_mint_and_holder_pairs() { + let root = tempfile::tempdir().expect("tempdir"); + let keys = root.path().join("keys"); + let mint_public = generate_service_and_holder_keys(&keys).expect("generate dev keys"); + + for name in ["mint", "holder"] { + let private_path = keys.join(format!("{name}-private.jwk")); + let private = registry_platform_crypto::PrivateJwk::parse( + &fs::read_to_string(&private_path).expect("private JWK"), + ) + .expect("private JWK parses"); + let public_path = if name == "mint" { + assert_eq!( + mint_public.file_name().and_then(|value| value.to_str()), + private + .kid + .as_deref() + .map(|kid| format!("{kid}.jwk.json")) + .as_deref() + ); + mint_public.clone() + } else { + keys.join("holder-public.jwk.json") + }; + let public = registry_platform_crypto::PublicJwk::parse( + &fs::read_to_string(&public_path).expect("public JWK"), + ) + .expect("public JWK parses"); + assert_eq!(private.kty, "EC"); + assert_eq!(private.crv.as_deref(), Some("P-256")); + assert_eq!(private.alg.as_deref(), Some("ES256")); + assert_eq!(private.kid, public.kid); + assert_eq!( + fs::metadata(private_path) + .expect("private JWK metadata") + .permissions() + .mode() + & 0o777, + PRIVATE_FILE_MODE + ); + assert_eq!( + fs::metadata(public_path) + .expect("public JWK metadata") + .permissions() + .mode() + & 0o777, + PRIVATE_FILE_MODE + ); + } + assert!(!keys.join("mint-public.jwk.json").exists()); + + let before = fs::read(keys.join("holder-private.jwk")).expect("holder private JWK"); + assert!(generate_service_and_holder_keys(&keys).is_err()); + assert_eq!( + fs::read(keys.join("holder-private.jwk")).expect("holder private JWK"), + before, + "a repeated dev setup must not replace disposable keys" + ); + } + #[test] fn structured_concepts_use_the_runtime_value_form_in_lifecycle_state() { let mut compiled = compiled(Path::new("/private/runtime.yaml")); diff --git a/crates/registry-evidencectl/src/doctor.rs b/crates/registry-evidencectl/src/doctor.rs index d568d0c39..1a75d5f4f 100644 --- a/crates/registry-evidencectl/src/doctor.rs +++ b/crates/registry-evidencectl/src/doctor.rs @@ -167,7 +167,10 @@ fn check_secrets( runtime_path: &Path, bundle: &YamlValue, ) -> Vec { - let references = secret_references(bundle); + // Signing providers and other runtime-only facilities can name file + // secrets independently of the governed bundle. Inspect both inputs so + // `doctor` follows the same complete startup configuration as Evidence. + let references = secret_references([bundle, runtime]); let root = runtime .get("secretProviders") .and_then(|providers| providers.get("file")) @@ -683,9 +686,11 @@ fn resolve_bundle_directory( /// rather than by the field carrying it, so a secret a future field names is /// checked without this walk learning that field. `evidence check` is left to /// reject a bundle that is otherwise malformed. -fn secret_references(bundle: &YamlValue) -> Vec { +fn secret_references<'a>(documents: impl IntoIterator) -> Vec { let mut names = Vec::new(); - collect_secret_references(bundle, &mut names); + for document in documents { + collect_secret_references(document, &mut names); + } names } diff --git a/crates/registry-evidencectl/src/keygen.rs b/crates/registry-evidencectl/src/keygen.rs index 7d43add6f..2e64165a4 100644 --- a/crates/registry-evidencectl/src/keygen.rs +++ b/crates/registry-evidencectl/src/keygen.rs @@ -12,13 +12,13 @@ use std::{ use anyhow::{bail, Context, Result}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use clap::{Args, Subcommand}; -use ed25519_dalek::SigningKey; +use p256::ecdsa::SigningKey; use registry_platform_crypto::{PrivateJwk, PublicJwk}; use zeroize::Zeroizing; #[derive(Debug, Subcommand)] pub enum KeygenCommand { - /// Ed25519 signing keypair as private and public JWK files. + /// P-256 ES256 signing keypair as private and public JWK files. Signing(SigningArgs), /// One random raw secret file, 32 bytes (audit or subject-binding HMAC). /// @@ -28,7 +28,7 @@ pub enum KeygenCommand { Secret(SecretArgs), /// One random bearer token file, printable and header-safe. Token(TokenArgs), - /// Ed25519 holder keypair for SD-JWT VC confirmation binding. + /// P-256 ES256 holder keypair for SD-JWT VC confirmation binding. Holder(HolderArgs), } @@ -38,17 +38,9 @@ pub struct SigningArgs { #[arg(long)] pub out_dir: PathBuf, - /// Key identifier; defaults to the RFC 7638 JWK thumbprint. - #[arg(long)] - pub kid: Option, - /// Public JWK output path; defaults to a file inside the secret directory. #[arg(long)] pub public_out: Option, - - /// Overwrite existing output files. - #[arg(long)] - pub force: bool, } #[derive(Debug, Args)] @@ -56,10 +48,6 @@ pub struct SecretArgs { /// Output file for the raw secret (written 0600). #[arg(long)] pub out: PathBuf, - - /// Overwrite an existing output file. - #[arg(long)] - pub force: bool, } #[derive(Debug, Args)] @@ -67,10 +55,6 @@ pub struct TokenArgs { /// Output file for the bearer token (written 0600). #[arg(long)] pub out: PathBuf, - - /// Overwrite an existing output file. - #[arg(long)] - pub force: bool, } #[derive(Debug, Args)] @@ -79,25 +63,17 @@ pub struct HolderArgs { #[arg(long)] pub out_dir: PathBuf, - /// Key identifier; defaults to the RFC 7638 JWK thumbprint. - #[arg(long)] - pub kid: Option, - /// Public JWK output path; defaults to a file inside the secret directory. #[arg(long)] pub public_out: Option, - - /// Overwrite existing output files. - #[arg(long)] - pub force: bool, } /// Filename for the private signing JWK, fixed to match the reference /// deployment project's secret-mount layout. -const SIGNING_PRIVATE_FILENAME: &str = "signing-ed25519-private-jwk"; -const SIGNING_PUBLIC_FILENAME: &str = "signing-ed25519-public.jwk.json"; -const HOLDER_PRIVATE_FILENAME: &str = "holder-ed25519-private-jwk"; -const HOLDER_PUBLIC_FILENAME: &str = "holder-ed25519-public.jwk.json"; +const SIGNING_PRIVATE_FILENAME: &str = "signing-p256-private-jwk"; +const SIGNING_PUBLIC_FILENAME: &str = "signing-p256-public.jwk.json"; +const HOLDER_PRIVATE_FILENAME: &str = "holder-p256-private-jwk"; +const HOLDER_PUBLIC_FILENAME: &str = "holder-p256-public.jwk.json"; const AUDIT_HMAC_FILENAME: &str = "audit-hmac-key"; const SUBJECT_BINDING_HMAC_FILENAME: &str = "subject-binding-hmac-key"; @@ -115,9 +91,7 @@ pub fn run(command: KeygenCommand) -> Result { match command { KeygenCommand::Signing(args) => run_keypair( &args.out_dir, - args.kid.as_deref(), args.public_out.as_deref(), - args.force, SIGNING_PRIVATE_FILENAME, SIGNING_PUBLIC_FILENAME, ), @@ -125,9 +99,7 @@ pub fn run(command: KeygenCommand) -> Result { KeygenCommand::Token(args) => run_token(&args), KeygenCommand::Holder(args) => run_keypair( &args.out_dir, - args.kid.as_deref(), args.public_out.as_deref(), - args.force, HOLDER_PRIVATE_FILENAME, HOLDER_PUBLIC_FILENAME, ), @@ -140,13 +112,11 @@ pub fn run(command: KeygenCommand) -> Result { /// never accepts force and never reports paths, so a collision fails closed /// and no private material reaches standard output. Publication of the staged /// project makes the complete batch visible at once. -pub(crate) fn generate_scaffold_key_material(out_dir: &Path, kid: &str) -> Result<()> { +pub(crate) fn generate_scaffold_key_material(out_dir: &Path) -> Result<()> { ensure_private_dir(out_dir)?; run_keypair_impl( out_dir, - Some(kid), None, - false, SIGNING_PRIVATE_FILENAME, SIGNING_PUBLIC_FILENAME, false, @@ -156,7 +126,6 @@ pub(crate) fn generate_scaffold_key_material(out_dir: &Path, kid: &str) -> Resul run_secret_impl( &SecretArgs { out: out_dir.join(filename), - force: false, }, false, )?; @@ -169,16 +138,13 @@ pub(crate) fn generate_scaffold_key_material(out_dir: &Path, kid: &str) -> Resul /// private supervisor state rather than in a public JWKS artifact. pub(crate) fn generate_dev_keypair( out_dir: &Path, - kid: &str, private_filename: &str, public_filename: &str, ) -> Result<(PathBuf, PathBuf)> { ensure_private_dir(out_dir)?; run_keypair_impl( out_dir, - Some(kid), None, - false, private_filename, public_filename, false, @@ -192,17 +158,13 @@ pub(crate) fn generate_dev_keypair( fn run_keypair( out_dir: &Path, - kid: Option<&str>, public_out: Option<&Path>, - force: bool, private_filename: &str, public_filename: &str, ) -> Result { run_keypair_impl( out_dir, - kid, public_out, - force, private_filename, public_filename, true, @@ -210,23 +172,14 @@ fn run_keypair( ) } -#[allow(clippy::too_many_arguments)] fn run_keypair_impl( out_dir: &Path, - kid: Option<&str>, public_out: Option<&Path>, - force: bool, private_filename: &str, public_filename: &str, report: bool, public_file_mode: u32, ) -> Result { - if let Some(kid) = kid { - if kid.trim().is_empty() { - bail!("--kid must not be empty or whitespace-only"); - } - } - let private_path = out_dir.join(private_filename); let public_path = public_out .map(Path::to_path_buf) @@ -234,41 +187,38 @@ fn run_keypair_impl( // Every target path is known up front, so the whole batch can be checked // for collisions before anything is written. - reject_existing(&[&private_path, &public_path], force)?; - - let mut secret = Zeroizing::new([0_u8; 32]); - getrandom::fill(secret.as_mut_slice()).context("failed to generate random key material")?; - let signing_key = SigningKey::from_bytes(&secret); - let x = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().as_bytes()); - let d = Zeroizing::new(URL_SAFE_NO_PAD.encode(secret.as_slice())); + reject_existing(&[&private_path, &public_path])?; - let kid = match kid { - Some(kid) => kid.to_string(), - None => default_kid(&x)?, - }; + let signing_key = SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng); + let point = signing_key.verifying_key().to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(point.x().expect("uncompressed P-256 point has x")); + let y = URL_SAFE_NO_PAD.encode(point.y().expect("uncompressed P-256 point has y")); + let d = Zeroizing::new(URL_SAFE_NO_PAD.encode(signing_key.to_bytes())); + let kid = default_kid(&x, &y)?; let private_json = Zeroizing::new( serde_json::to_string_pretty(&serde_json::json!({ - "kty": "OKP", - "crv": "Ed25519", + "kty": "EC", + "crv": "P-256", // `json!` copies `d` into a `serde_json::Value::String` whose heap // buffer this crate does not zeroize, unlike every other copy of // the secret above. Accepted: serde_json owns the escaping here, // and the copy is short-lived, but it is not wiped. "d": d.as_str(), "x": x, - "alg": "EdDSA", + "y": y, + "alg": "ES256", "kid": kid, })) .context("failed to render the private JWK")?, ); let public_json = serde_json::to_string_pretty(&serde_json::json!({ - "kty": "OKP", - "crv": "Ed25519", + "kty": "EC", + "crv": "P-256", "x": x, - "alg": "EdDSA", + "y": y, + "alg": "ES256", "kid": kid, - "use": "sig", })) .context("failed to render the public JWK")?; @@ -278,18 +228,8 @@ fn run_keypair_impl( ensure_private_dir(out_dir)?; ensure_parent_dir(&public_path)?; - write_owner_file( - &private_path, - private_json.as_bytes(), - PRIVATE_FILE_MODE, - force, - )?; - write_owner_file( - &public_path, - public_json.as_bytes(), - public_file_mode, - force, - )?; + write_owner_file(&private_path, private_json.as_bytes(), PRIVATE_FILE_MODE)?; + write_owner_file(&public_path, public_json.as_bytes(), public_file_mode)?; if report { println!("wrote {}", private_path.display()); @@ -305,7 +245,7 @@ fn run_secret(args: &SecretArgs) -> Result { } fn run_secret_impl(args: &SecretArgs, report: bool) -> Result { - reject_existing(&[&args.out], args.force)?; + reject_existing(&[&args.out])?; let secret = generate_secret()?; @@ -316,7 +256,7 @@ fn run_secret_impl(args: &SecretArgs, report: bool) -> Result { { ensure_secret_parent_dir(parent)?; } - write_owner_file(&args.out, secret.as_slice(), PRIVATE_FILE_MODE, args.force)?; + write_owner_file(&args.out, secret.as_slice(), PRIVATE_FILE_MODE)?; if report { println!("wrote {}", args.out.display()); @@ -333,7 +273,7 @@ fn run_secret_impl(args: &SecretArgs, report: bool) -> Result { /// neighbour, and the wrong tool, because its raw bytes reach an HTTP header /// that rejects most of them. fn run_token(args: &TokenArgs) -> Result { - reject_existing(&[&args.out], args.force)?; + reject_existing(&[&args.out])?; let mut entropy = Zeroizing::new([0_u8; TOKEN_ENTROPY_BYTES]); getrandom::fill(entropy.as_mut_slice()).context("failed to generate random key material")?; @@ -350,7 +290,7 @@ fn run_token(args: &TokenArgs) -> Result { { ensure_secret_parent_dir(parent)?; } - write_owner_file(&args.out, token.as_bytes(), PRIVATE_FILE_MODE, args.force)?; + write_owner_file(&args.out, token.as_bytes(), PRIVATE_FILE_MODE)?; println!("wrote {}", args.out.display()); @@ -376,37 +316,39 @@ fn generate_secret() -> Result> { } /// Default kid: the RFC 7638 thumbprint of the public key. -fn default_kid(x: &str) -> Result { +fn default_kid(x: &str, y: &str) -> Result { let public = PublicJwk { - kty: "OKP".to_string(), + kty: "EC".to_string(), kid: None, alg: None, - crv: Some("Ed25519".to_string()), + crv: Some("P-256".to_string()), x: Some(x.to_string()), - y: None, + y: Some(y.to_string()), n: None, e: None, }; public.jkt().context("failed to compute the JWK thumbprint") } -/// Refuses to proceed if any target path already exists, unless `force` is -/// set. Checked for every path before any file is written so a batch either +/// Refuses to proceed if any target path already exists. Checked for every +/// path before any file is written so a batch either /// completes in full or leaves nothing behind. -fn reject_existing(paths: &[&Path], force: bool) -> Result<()> { - if force { - return Ok(()); +fn reject_existing(paths: &[&Path]) -> Result<()> { + let mut existing = Vec::new(); + for path in paths { + match fs::symlink_metadata(path) { + Ok(_) => existing.push(path.display().to_string()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("failed to inspect {}", path.display())) + } + } } - let existing: Vec = paths - .iter() - .filter(|path| path.exists()) - .map(|path| path.display().to_string()) - .collect(); if existing.is_empty() { return Ok(()); } bail!( - "refusing to overwrite existing output without --force: {}", + "refusing to overwrite existing output: {}", existing.join(", ") ); } @@ -471,21 +413,8 @@ fn ensure_private_dir_impl(dir: &Path, normalize_existing: bool) -> Result<()> { /// Writes `contents` to `path` with `mode`, set atomically at file creation /// so there is no window where the file is readable with the wrong -/// permissions. A force overwrite first removes anything already at `path`, -/// including a symlink, so the create that follows always creates a fresh -/// file and `mode` is always the one `O_CREAT` applies, never a later chmod -/// that could instead land on whatever a symlink now points at. -fn write_owner_file(path: &Path, contents: &[u8], mode: u32, force: bool) -> Result<()> { - if force { - match fs::symlink_metadata(path) { - Ok(_) => fs::remove_file(path) - .with_context(|| format!("failed to remove existing {}", path.display()))?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error).with_context(|| format!("failed to inspect {}", path.display())) - } - } - } +/// permissions. Create-new semantics prevent replacement or symlink traversal. +fn write_owner_file(path: &Path, contents: &[u8], mode: u32) -> Result<()> { let mut options = OpenOptions::new(); options.write(true).mode(mode).create_new(true); let mut file = options diff --git a/crates/registry-evidencectl/src/main.rs b/crates/registry-evidencectl/src/main.rs index ace835211..14e994082 100644 --- a/crates/registry-evidencectl/src/main.rs +++ b/crates/registry-evidencectl/src/main.rs @@ -44,7 +44,7 @@ enum Command { Jwks(jwks::JwksArgs), /// Start an editable Evidence authoring project from OpenAPI. New(scaffold::NewArgs), - /// Compile an editable project into a reviewed production candidate. + /// Compile an editable project into a reviewed deployment candidate. Build(build::BuildArgs), /// Drive the evidence binary across a project's bundle fixtures. #[command(subcommand)] diff --git a/crates/registry-evidencectl/src/scaffold.rs b/crates/registry-evidencectl/src/scaffold.rs index 2f131522d..8ab1d12cb 100644 --- a/crates/registry-evidencectl/src/scaffold.rs +++ b/crates/registry-evidencectl/src/scaffold.rs @@ -17,7 +17,6 @@ use clap::{Args, ValueEnum}; use crate::{keygen, suggest}; const RETAINED_OPENAPI_FILE: &str = "source.openapi.yaml"; -const SIGNING_KEY_ID: &str = "local-signing-key-1"; #[derive(Clone, Debug, ValueEnum)] pub enum AuthoringProfile { @@ -38,9 +37,9 @@ pub struct NewArgs { #[arg(long, value_enum, requires = "openapi")] pub profile: Option, - /// Generate disposable, unbound local signing and HMAC material. - #[arg(long, requires = "openapi")] - pub generate_keys: bool, + /// Compatibility flag; local projects now always generate disposable keys. + #[arg(long = "generate-keys", requires = "openapi", hide = true)] + pub _generate_keys: bool, } pub fn run(args: NewArgs) -> anyhow::Result { @@ -85,10 +84,8 @@ pub fn run(args: NewArgs) -> anyhow::Result { .with_context(|| format!("creating the empty {directory} directory"))?; } - if args.generate_keys { - keygen::generate_scaffold_key_material(&staged_root.join("secrets"), SIGNING_KEY_ID) - .context("generating unbound local authoring key material")?; - } + keygen::generate_scaffold_key_material(&staged_root.join("secrets")) + .context("generating unbound local authoring key material")?; fs::set_permissions(staged_root, fs::Permissions::from_mode(0o755)) .with_context(|| format!("setting permissions on {}", staged_root.display()))?; @@ -116,12 +113,10 @@ pub fn run(args: NewArgs) -> anyhow::Result { args.directory.join("derivations").display() ); println!(" fixtures: {}", args.directory.join("fixtures").display()); - if args.generate_keys { - println!( - " keys: {} (owner-only, disposable, and unbound)", - args.directory.join("secrets").display() - ); - } + println!( + " keys: {} (owner-only, disposable, and unbound)", + args.directory.join("secrets").display() + ); println!( "Next: run `evidencectl source suggest --project {}` to draft one editable source.", args.directory.display() diff --git a/crates/registry-evidencectl/tests/dev_lifecycle.rs b/crates/registry-evidencectl/tests/dev_lifecycle.rs index 85635f57d..b074af653 100644 --- a/crates/registry-evidencectl/tests/dev_lifecycle.rs +++ b/crates/registry-evidencectl/tests/dev_lifecycle.rs @@ -162,7 +162,8 @@ fn real_detached_lifecycle_is_ready_private_and_stops_only_owned_children() { "generated/audit/mint.jsonl", "generated/keys/mint-audit-hmac-key", "generated/keys/mint-private.jwk", - "generated/keys/mint-public.jwk.json", + "generated/keys/holder-private.jwk", + "generated/keys/holder-public.jwk.json", "generated/keys/caller-private.jwk", "generated/keys/caller-public.jwk.json", "logs/supervisor.log", @@ -171,6 +172,39 @@ fn real_detached_lifecycle_is_ready_private_and_stops_only_owned_children() { ] { assert_mode(&dev.join(path), 0o600); } + let mint_private_path = dev.join("generated/keys/mint-private.jwk"); + let mint_private: Value = + serde_json::from_slice(&fs::read(&mint_private_path).expect("generated Mint private JWK")) + .expect("generated Mint private JWK parses"); + let mint_kid = mint_private["kid"] + .as_str() + .expect("generated Mint private JWK has a kid"); + let mint_public_path = dev.join(format!("generated/keys/{mint_kid}.jwk.json")); + assert_mode(&mint_public_path, 0o600); + for name in ["mint", "caller", "holder"] { + let private_path = dev.join(format!("generated/keys/{name}-private.jwk")); + let private: Value = if name == "mint" { + mint_private.clone() + } else { + serde_json::from_slice(&fs::read(private_path).expect("generated private JWK")) + .expect("generated private JWK parses") + }; + let public_path = if name == "mint" { + mint_public_path.clone() + } else { + dev.join(format!("generated/keys/{name}-public.jwk.json")) + }; + let public: Value = + serde_json::from_slice(&fs::read(public_path).expect("generated public JWK")) + .expect("generated public JWK parses"); + assert_eq!(private["kty"], "EC"); + assert_eq!(private["crv"], "P-256"); + assert_eq!(private["alg"], "ES256"); + assert_eq!(public["kty"], "EC"); + assert_eq!(public["crv"], "P-256"); + assert_eq!(public["alg"], "ES256"); + assert_eq!(private["kid"], public["kid"]); + } let state: Value = serde_json::from_slice(&fs::read(dev.join("state.json")).expect("state")) .expect("state JSON"); @@ -792,7 +826,6 @@ impl Project { &evidencectl() .args(["keygen", "signing", "--out-dir"]) .arg(&secrets) - .args(["--kid", "local-signing-key-1"]) .output() .expect("signing key"), "signing key", @@ -1059,8 +1092,12 @@ fn jwks_ready_at(port: u16) -> bool { .and_then(|response| serde_json::from_reader::<_, Value>(response.into_reader()).ok()) .and_then(|value| value["keys"].as_array().cloned()) .is_some_and(|keys| { - keys.iter() - .any(|key| key["kid"] == "local-mint-signing-key-1") + keys.iter().any(|key| { + key["kty"] == "EC" + && key["crv"] == "P-256" + && key["alg"] == "ES256" + && key["kid"].as_str().is_some_and(|kid| kid.len() == 43) + }) }) } diff --git a/crates/registry-evidencectl/tests/doctor.rs b/crates/registry-evidencectl/tests/doctor.rs index 87b43ecbd..7c1753445 100644 --- a/crates/registry-evidencectl/tests/doctor.rs +++ b/crates/registry-evidencectl/tests/doctor.rs @@ -16,13 +16,12 @@ use std::{ process::{Command, Output}, }; -const SIGNING_KID: &str = "doctor-signing-key-1"; const SECRET_FILES: [&str; 2] = ["audit-hmac-key", "subject-binding-hmac-key"]; const MATCHING_MINT_CONFIG: &str = r#"version: 1 issuer: https://identity.invalid signing: - algorithm: EdDSA + algorithm: ES256 jwksPath: /.well-known/jwks.json accessTokens: audiences: [evidence-scaffold] @@ -45,7 +44,7 @@ fn doctor_passes_a_frozen_project_and_leaves_the_public_key_beside_it_alone() { // design. The runtime never resolves it as a secret, so doctor must not // report it. A walk of the secret directory would; a walk of the secret // references the bundle actually names does not. - let public_key = project.join("secrets/signing-ed25519-public.jwk.json"); + let public_key = project.join("secrets/signing-p256-public.jwk.json"); assert_eq!( mode_of(&public_key), 0o644, @@ -67,7 +66,7 @@ fn doctor_passes_a_frozen_project_and_leaves_the_public_key_beside_it_alone() { "unexpected doctor summary: {stdout}" ); assert!( - !stdout.contains("signing-ed25519-public.jwk.json"), + !stdout.contains("signing-p256-public.jwk.json"), "doctor reported the public key that sits beside the private one: {stdout}" ); } @@ -282,7 +281,7 @@ fn doctor_reports_every_mint_field_mismatch_without_printing_values() { "credential-token-selector-source-audience", ), ( - "algorithm: EdDSA", + "algorithm: ES256", "algorithm: RS256", "authentication.algorithms", "RS256", @@ -468,8 +467,8 @@ fn doctor_accepts_set_order_supersets_custom_jwks_and_matching_actor() { ); rewrite_bundle( &project, - "algorithms: [EdDSA]", - "algorithms: [RS256, EdDSA]", + "algorithms: [ES256]", + "algorithms: [RS256, ES256]", ); rewrite_bundle( &project, @@ -560,7 +559,7 @@ fn doctor_json_aggregates_mismatches_and_redacts_every_value() { "audiences: [evidence-scaffold]", &format!("audiences: [{}]", sentinels[0]), ) - .replace("algorithm: EdDSA", &format!("algorithm: {}", sentinels[1])) + .replace("algorithm: ES256", &format!("algorithm: {}", sentinels[1])) .replace("principal: sub", &format!("principal: {}", sentinels[2])); write_mint(&mint_config, &mint); @@ -684,7 +683,9 @@ fn doctor_pairing_is_read_only_and_does_not_inspect_mint_authority_material() { provision_bearer_token(&project); let mut mint = MATCHING_MINT_CONFIG.replace( " jwksPath: /.well-known/jwks.json", - &format!(" activeKeyFile: secrets/{SENTINEL}\n jwksPath: /.well-known/jwks.json"), + &format!( + " activePublicJwkFile: public-keys/{SENTINEL}.jwk.json\n jwksPath: /.well-known/jwks.json" + ), ); mint.push_str("clients:\n directory: clients\n"); write_mint(&mint_config, &mint); @@ -750,14 +751,14 @@ fn provision(project: &Path) { issuer: https://identity.invalid audiences: [evidence-scaffold] tokenTypes: [at+jwt] - algorithms: [EdDSA] + algorithms: [ES256] jwksUri: https://identity.invalid/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience grantIdClaim: evidence_grant_id grantAuthorityClaim: evidence_authority -signing: secret:file/signing-ed25519-private-jwk +signing: secret:file/signing-p256-private-jwk audit: secret:file/audit-hmac-key subjectBinding: secret:file/subject-binding-hmac-key sourceToken: secret:file/source-bearer-token @@ -771,8 +772,6 @@ sourceToken: secret:file/source-bearer-token "signing", "--out-dir", secrets.to_str().expect("secret root"), - "--kid", - SIGNING_KID, ]); for name in SECRET_FILES { let out = secrets.join(name); diff --git a/crates/registry-evidencectl/tests/jwks.rs b/crates/registry-evidencectl/tests/jwks.rs index 0e00da919..3c1bb83de 100644 --- a/crates/registry-evidencectl/tests/jwks.rs +++ b/crates/registry-evidencectl/tests/jwks.rs @@ -26,23 +26,22 @@ fn stderr_of(output: &Output) -> String { /// Generates a signing keypair via the `keygen signing` subcommand and /// returns the path to its public JWK file. Reuses the tool under test /// instead of hand-rolling key material. -fn generate_public_jwk(dir: &Path, name: &str, kid: &str) -> std::path::PathBuf { +fn generate_public_jwk(dir: &Path, name: &str) -> std::path::PathBuf { let out_dir = dir.join(name); let output = evidencectl() .args(["keygen", "signing", "--out-dir"]) .arg(&out_dir) - .args(["--kid", kid]) .output() .expect("run evidencectl keygen"); assert!(output.status.success(), "{}", stderr_of(&output)); - out_dir.join("signing-ed25519-public.jwk.json") + out_dir.join("signing-p256-public.jwk.json") } #[test] fn assembles_a_jwks_document_from_public_jwk_files() { let dir = tempfile::tempdir().expect("tempdir"); - let first = generate_public_jwk(dir.path(), "first", "kid-a"); - let second = generate_public_jwk(dir.path(), "second", "kid-b"); + let first = generate_public_jwk(dir.path(), "first"); + let second = generate_public_jwk(dir.path(), "second"); let out = dir.path().join("jwks.json"); let output = evidencectl() @@ -66,8 +65,8 @@ fn assembles_a_jwks_document_from_public_jwk_files() { .iter() .map(|key| key["kid"].as_str().unwrap()) .collect(); - assert!(kids.contains(&"kid-a")); - assert!(kids.contains(&"kid-b")); + assert_ne!(kids[0], kids[1]); + assert!(kids.iter().all(|kid| kid.len() == 43)); } #[test] @@ -106,7 +105,7 @@ fn rejects_a_private_jwk_input_without_printing_its_contents() { #[test] fn deduplicates_identical_duplicate_entries() { let dir = tempfile::tempdir().expect("tempdir"); - let public = generate_public_jwk(dir.path(), "solo", "kid-dup"); + let public = generate_public_jwk(dir.path(), "solo"); let out = dir.path().join("jwks.json"); let output = evidencectl() @@ -132,8 +131,19 @@ fn deduplicates_identical_duplicate_entries() { #[test] fn conflicting_keys_sharing_a_kid_is_an_error() { let dir = tempfile::tempdir().expect("tempdir"); - let first = generate_public_jwk(dir.path(), "first", "shared-kid"); - let second = generate_public_jwk(dir.path(), "second", "shared-kid"); + let first = generate_public_jwk(dir.path(), "first"); + let second = generate_public_jwk(dir.path(), "second"); + let first_jwk: serde_json::Value = + serde_json::from_slice(&fs::read(&first).expect("first public JWK")).expect("first JWK"); + let shared_kid = first_jwk["kid"].as_str().expect("first kid").to_owned(); + let mut second_jwk: serde_json::Value = + serde_json::from_slice(&fs::read(&second).expect("second public JWK")).expect("second JWK"); + second_jwk["kid"] = serde_json::Value::String(shared_kid.clone()); + fs::write( + &second, + serde_json::to_vec_pretty(&second_jwk).expect("second JWK serialization"), + ) + .expect("rewrite second JWK with conflicting kid"); let out = dir.path().join("jwks.json"); let output = evidencectl() @@ -151,7 +161,7 @@ fn conflicting_keys_sharing_a_kid_is_an_error() { assert!(!out.exists()); let stderr = stderr_of(&output); assert!( - stderr.contains("shared-kid"), + stderr.contains(&shared_kid), "error should name the conflicting kid: {stderr}" ); } @@ -161,7 +171,7 @@ fn force_replaces_a_symlinked_output_path_without_writing_through_it() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().expect("tempdir"); - let public = generate_public_jwk(dir.path(), "solo", "kid-sym"); + let public = generate_public_jwk(dir.path(), "solo"); let target = dir.path().join("elsewhere.json"); fs::write(&target, b"untouched").expect("seed symlink target"); let out = dir.path().join("jwks.json"); @@ -194,7 +204,7 @@ fn force_replaces_a_symlinked_output_path_without_writing_through_it() { #[test] fn refuses_overwrite_without_force_then_succeeds_with_force() { let dir = tempfile::tempdir().expect("tempdir"); - let public = generate_public_jwk(dir.path(), "solo", "kid-x"); + let public = generate_public_jwk(dir.path(), "solo"); let out = dir.path().join("jwks.json"); let first = evidencectl() @@ -220,7 +230,7 @@ fn refuses_overwrite_without_force_then_succeeds_with_force() { ); assert_eq!(fs::read(&out).expect("read jwks"), original); - let another = generate_public_jwk(dir.path(), "another", "kid-y"); + let another = generate_public_jwk(dir.path(), "another"); let third = evidencectl() .arg("jwks") .arg("--out") diff --git a/crates/registry-evidencectl/tests/keygen.rs b/crates/registry-evidencectl/tests/keygen.rs index f69eff062..cf3149107 100644 --- a/crates/registry-evidencectl/tests/keygen.rs +++ b/crates/registry-evidencectl/tests/keygen.rs @@ -62,8 +62,8 @@ fn signing_writes_private_and_public_jwk_with_expected_modes() { assert_eq!(mode_of(&out_dir), 0o700, "out-dir mode"); - let private_path = out_dir.join("signing-ed25519-private-jwk"); - let public_path = out_dir.join("signing-ed25519-public.jwk.json"); + let private_path = out_dir.join("signing-p256-private-jwk"); + let public_path = out_dir.join("signing-p256-public.jwk.json"); assert_eq!(mode_of(&private_path), 0o600, "private file mode"); assert_eq!(mode_of(&public_path), 0o644, "public file mode"); @@ -72,10 +72,34 @@ fn signing_writes_private_and_public_jwk_with_expected_modes() { let private = PrivateJwk::parse(&private_contents).expect("private JWK parses"); let public = PublicJwk::parse(&public_contents).expect("public JWK parses"); + let public_value: serde_json::Value = + serde_json::from_str(&public_contents).expect("public JWK JSON"); + let public_members = public_value + .as_object() + .expect("public JWK object") + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + public_members, + ["alg", "crv", "kid", "kty", "x", "y"].into_iter().collect(), + "generated service public JWK must have the exact governed shape" + ); let expected_kid = public.jkt().expect("thumbprint"); + assert_eq!(private.kty, "EC"); + assert_eq!(private.crv.as_deref(), Some("P-256")); + assert_eq!(private.alg.as_deref(), Some("ES256")); + assert_eq!(public.kty, "EC"); + assert_eq!(public.crv.as_deref(), Some("P-256")); + assert_eq!(public.alg.as_deref(), Some("ES256")); + assert_eq!(expected_kid.len(), 43); assert_eq!(private.kid.as_deref(), Some(expected_kid.as_str())); assert_eq!(public.kid.as_deref(), Some(expected_kid.as_str())); + let message = b"evidencectl generated ES256 key self-test"; + let signature = registry_platform_crypto::sign(message, &private).expect("generated key signs"); + registry_platform_crypto::verify(message, &signature, &public) + .expect("generated public key verifies"); // The "d" value must never appear on stdout or stderr. let d_value = private.d.clone().expect("private JWK has d"); @@ -83,7 +107,7 @@ fn signing_writes_private_and_public_jwk_with_expected_modes() { } #[test] -fn signing_kid_override_replaces_the_default_thumbprint() { +fn signing_rejects_user_supplied_kid() { let dir = tempfile::tempdir().expect("tempdir"); let out_dir = dir.path().join("keys"); @@ -93,20 +117,11 @@ fn signing_kid_override_replaces_the_default_thumbprint() { .args(["--kid", "custom-kid-1"]) .output() .expect("run evidencectl"); - assert!(output.status.success(), "{}", stderr_of(&output)); - - let private_contents = - fs::read_to_string(out_dir.join("signing-ed25519-private-jwk")).expect("read private jwk"); - let public_contents = fs::read_to_string(out_dir.join("signing-ed25519-public.jwk.json")) - .expect("read public jwk"); - - let private = PrivateJwk::parse(&private_contents).expect("private JWK parses"); - let public = PublicJwk::parse(&public_contents).expect("public JWK parses"); - assert_eq!(private.kid.as_deref(), Some("custom-kid-1")); - assert_eq!(public.kid.as_deref(), Some("custom-kid-1")); - - let d_value = private.d.clone().expect("private JWK has d"); - assert_output_excludes(&output, &d_value); + assert!(!output.status.success(), "--kid must not be accepted"); + assert!( + !out_dir.exists(), + "a rejected invocation must not create keys" + ); } #[test] @@ -125,7 +140,7 @@ fn signing_rejects_an_empty_or_whitespace_only_kid() { "a whitespace-only --kid must be refused" ); assert!( - !out_dir.join("signing-ed25519-private-jwk").exists(), + !out_dir.join("signing-p256-private-jwk").exists(), "no key material should be generated for a refused --kid" ); } @@ -146,7 +161,7 @@ fn signing_public_out_overrides_the_default_public_path() { assert!(output.status.success(), "{}", stderr_of(&output)); assert!(public_out.is_file()); - assert!(!out_dir.join("signing-ed25519-public.jwk.json").exists()); + assert!(!out_dir.join("signing-p256-public.jwk.json").exists()); assert_eq!(mode_of(&public_out), 0o644); } @@ -162,8 +177,8 @@ fn holder_writes_private_and_public_jwk_with_holder_filenames() { .expect("run evidencectl"); assert!(output.status.success(), "{}", stderr_of(&output)); - let private_path = out_dir.join("holder-ed25519-private-jwk"); - let public_path = out_dir.join("holder-ed25519-public.jwk.json"); + let private_path = out_dir.join("holder-p256-private-jwk"); + let public_path = out_dir.join("holder-p256-public.jwk.json"); assert_eq!(mode_of(&private_path), 0o600); assert_eq!(mode_of(&public_path), 0o644); @@ -171,6 +186,9 @@ fn holder_writes_private_and_public_jwk_with_holder_filenames() { let public_contents = fs::read_to_string(&public_path).expect("read public jwk"); let private = PrivateJwk::parse(&private_contents).expect("private JWK parses"); let public = PublicJwk::parse(&public_contents).expect("public JWK parses"); + assert_eq!(private.kty, "EC"); + assert_eq!(private.crv.as_deref(), Some("P-256")); + assert_eq!(private.alg.as_deref(), Some("ES256")); assert_eq!(private.kid, public.kid); let d_value = private.d.clone().expect("private JWK has d"); @@ -247,7 +265,7 @@ fn secret_never_contains_a_nul_byte() { } #[test] -fn signing_refuses_overwrite_without_force_then_succeeds_with_force() { +fn signing_refuses_overwrite_and_force_option() { let dir = tempfile::tempdir().expect("tempdir"); let out_dir = dir.path().join("keys"); @@ -258,7 +276,7 @@ fn signing_refuses_overwrite_without_force_then_succeeds_with_force() { .expect("run evidencectl"); assert!(first.status.success(), "{}", stderr_of(&first)); - let private_path = out_dir.join("signing-ed25519-private-jwk"); + let private_path = out_dir.join("signing-p256-private-jwk"); let original_private = fs::read_to_string(&private_path).expect("read private jwk"); let second = evidencectl() @@ -268,7 +286,7 @@ fn signing_refuses_overwrite_without_force_then_succeeds_with_force() { .expect("run evidencectl"); assert!( !second.status.success(), - "second run without --force unexpectedly succeeded" + "second create-only run unexpectedly succeeded" ); let unchanged = fs::read_to_string(&private_path).expect("read private jwk"); assert_eq!( @@ -276,28 +294,21 @@ fn signing_refuses_overwrite_without_force_then_succeeds_with_force() { "file must be untouched on refusal" ); - let third = evidencectl() + let force = evidencectl() .args(["keygen", "signing", "--out-dir"]) .arg(&out_dir) .arg("--force") .output() .expect("run evidencectl"); - assert!(third.status.success(), "{}", stderr_of(&third)); + assert!(!force.status.success(), "--force must not be accepted"); assert_eq!( - mode_of(&private_path), - 0o600, - "mode preserved across --force" - ); - - let regenerated = fs::read_to_string(&private_path).expect("read private jwk"); - assert_ne!( - original_private, regenerated, - "--force must regenerate key material" + fs::read_to_string(&private_path).expect("read private jwk"), + original_private ); } #[test] -fn secret_refuses_overwrite_without_force_then_succeeds_with_force() { +fn secret_refuses_overwrite_and_force_option() { let dir = tempfile::tempdir().expect("tempdir"); let out = dir.path().join("secret.key"); @@ -317,15 +328,14 @@ fn secret_refuses_overwrite_without_force_then_succeeds_with_force() { assert!(!second.status.success()); assert_eq!(fs::read(&out).expect("read secret"), original); - let third = evidencectl() + let force = evidencectl() .args(["keygen", "secret", "--out"]) .arg(&out) .arg("--force") .output() .expect("run evidencectl"); - assert!(third.status.success(), "{}", stderr_of(&third)); - assert_eq!(mode_of(&out), 0o600); - assert_ne!(fs::read(&out).expect("read secret"), original); + assert!(!force.status.success(), "--force must not be accepted"); + assert_eq!(fs::read(&out).expect("read secret"), original); } #[test] @@ -336,7 +346,7 @@ fn signing_batch_abort_leaves_the_private_file_unwritten() { // Pre-create only the public target; the private target must never be // written once the batch is refused. - fs::write(out_dir.join("signing-ed25519-public.jwk.json"), b"stale").expect("seed public file"); + fs::write(out_dir.join("signing-p256-public.jwk.json"), b"stale").expect("seed public file"); let output = evidencectl() .args(["keygen", "signing", "--out-dir"]) @@ -345,11 +355,11 @@ fn signing_batch_abort_leaves_the_private_file_unwritten() { .expect("run evidencectl"); assert!(!output.status.success(), "batch should have been refused"); assert!( - !out_dir.join("signing-ed25519-private-jwk").exists(), + !out_dir.join("signing-p256-private-jwk").exists(), "private key must not be written when the batch aborts" ); let public_contents = - fs::read(out_dir.join("signing-ed25519-public.jwk.json")).expect("read public file"); + fs::read(out_dir.join("signing-p256-public.jwk.json")).expect("read public file"); assert_eq!( public_contents, b"stale", "pre-existing public file must be untouched" @@ -400,7 +410,7 @@ fn signing_out_dir_mode_is_normalized_to_0700_when_pre_created_looser() { } #[test] -fn signing_force_replaces_a_symlinked_private_path_without_writing_through_it() { +fn signing_refuses_a_symlinked_private_path_without_writing_through_it() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().expect("tempdir"); @@ -410,23 +420,24 @@ fn signing_force_replaces_a_symlinked_private_path_without_writing_through_it() let target = dir.path().join("attacker-target"); fs::write(&target, b"untouched").expect("seed symlink target"); - let private_path = out_dir.join("signing-ed25519-private-jwk"); + let private_path = out_dir.join("signing-p256-private-jwk"); symlink(&target, &private_path).expect("create symlink at the private path"); let output = evidencectl() .args(["keygen", "signing", "--out-dir"]) .arg(&out_dir) - .arg("--force") .output() .expect("run evidencectl"); - assert!(output.status.success(), "{}", stderr_of(&output)); + assert!( + !output.status.success(), + "create-only generation must reject a symlink" + ); let metadata = fs::symlink_metadata(&private_path).expect("stat private path"); assert!( - metadata.file_type().is_file(), - "the symlink must be replaced by a regular file" + metadata.file_type().is_symlink(), + "the symlink must remain untouched" ); - assert!(!metadata.file_type().is_symlink()); let target_contents = fs::read(&target).expect("read symlink target"); assert_eq!( @@ -440,7 +451,7 @@ fn signing_error_names_the_offending_paths() { let dir = tempfile::tempdir().expect("tempdir"); let out_dir = dir.path().join("keys"); fs::create_dir(&out_dir).expect("create out-dir"); - fs::write(out_dir.join("signing-ed25519-private-jwk"), b"stale").expect("seed private file"); + fs::write(out_dir.join("signing-p256-private-jwk"), b"stale").expect("seed private file"); let output = evidencectl() .args(["keygen", "signing", "--out-dir"]) @@ -450,7 +461,7 @@ fn signing_error_names_the_offending_paths() { assert!(!output.status.success()); let stderr = stderr_of(&output); assert!( - stderr.contains("signing-ed25519-private-jwk"), + stderr.contains("signing-p256-private-jwk"), "error should name the offending path: {stderr}" ); } @@ -512,7 +523,7 @@ fn token_invocations_generate_independent_values() { } #[test] -fn token_refuses_overwrite_without_force_then_succeeds_with_force() { +fn token_refuses_overwrite_and_force_option() { let dir = tempfile::tempdir().expect("tempdir"); let out = dir.path().join("source-bearer-token"); fs::write(&out, b"already here").expect("seed token"); @@ -529,13 +540,13 @@ fn token_refuses_overwrite_without_force_then_succeeds_with_force() { "a refused run must leave the existing token alone" ); - let forced = evidencectl() + let force = evidencectl() .args(["keygen", "token", "--force", "--out"]) .arg(&out) .output() .expect("run evidencectl"); - assert!(forced.status.success(), "{}", stderr_of(&forced)); - assert_ne!( + assert!(!force.status.success(), "--force must not be accepted"); + assert_eq!( fs::read_to_string(&out).expect("read token"), "already here" ); diff --git a/crates/registry-evidencectl/tests/production_build.rs b/crates/registry-evidencectl/tests/production_build.rs index 7976e7b98..95360825b 100644 --- a/crates/registry-evidencectl/tests/production_build.rs +++ b/crates/registry-evidencectl/tests/production_build.rs @@ -137,6 +137,9 @@ fn successful_build_copies_runtime_exactly_and_excludes_local_and_validation_sec PathBuf::from("bundle/derivations/answer.rhai"), PathBuf::from("bundle/evidence.yaml"), PathBuf::from("bundle/fixtures/answer.yaml"), + PathBuf::from( + "bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json", + ), PathBuf::from("bundle/schemas/facts.schema.yaml"), PathBuf::from("bundle/schemas/parameters.schema.yaml"), PathBuf::from("bundle/schemas/response.schema.yaml"), @@ -156,6 +159,27 @@ fn successful_build_copies_runtime_exactly_and_excludes_local_and_validation_sec fixture.assert_no_staging_residue(); } +#[test] +fn governed_public_keys_are_owned_by_the_complete_deployment_target() { + let fixture = Fixture::new(); + let relative = Path::new("public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json"); + let target_key = fixture.target.join(relative); + let bytes = fs::read(&target_key).expect("target public key"); + fs::remove_file(&target_key).expect("remove target public key"); + fs::create_dir(fixture.project.join("public-keys")).expect("project public key directory"); + fs::write(fixture.project.join(relative), bytes).expect("misplaced project public key"); + + let output = fixture.build(); + + assert_failed(&output, "a public key outside the deployment target"); + assert!(!fixture.output.exists()); + assert!( + fixture.invocations().is_empty(), + "missing target-owned key must fail before Evidence delegation" + ); + fixture.assert_no_staging_residue(); +} + #[test] fn production_metadata_and_fixture_completeness_fail_before_runtime_delegation() { for label in [ @@ -412,7 +436,7 @@ impl Fixture { ] { fs::create_dir_all(project.join(directory)).expect("project directory"); } - fs::create_dir_all(&target).expect("target directory"); + fs::create_dir_all(target.join("public-keys")).expect("target directory"); fs::write( project.join("source.openapi.yaml"), @@ -425,6 +449,13 @@ impl Fixture { ) .expect("selector profile"); fs::write(project.join("sources/registry.yaml"), SOURCE).expect("source"); + fs::write( + target.join( + "public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json", + ), + r#"{"kty":"EC","crv":"P-256","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"}"#, + ) + .expect("governed public key"); fs::write( project.join("adapters/source-prepare.rhai"), "fn prepare(selectors, parameters) { #{query: [], body: #{reference: selectors[\"subject\"][\"values\"][\"reference\"]}} }\n", @@ -684,7 +715,7 @@ fn assert_report(output: &Output, candidate: &Path) { assert_eq!( stdout, format!( - "Bundle revision: {REVISION}\nCandidate: {}\nProvision secret:file/audit-hmac-key\nProvision secret:file/signing-private-jwk\nProvision secret:file/source-token\nProvision secret:file/subject-binding-hmac-key\nTarget runtime paths and production secret material remain unverified until `evidencectl doctor --project {}` and the target-host Evidence check.\n", + "Bundle revision: {REVISION}\nCandidate: {}\nProvision secret:file/audit-hmac-key\nProvision secret:file/source-token\nProvision secret:file/subject-binding-hmac-key\nTarget runtime paths and deployment secret material remain unverified until `evidencectl doctor --project {}` and the target-host Evidence check.\n", candidate.display(), candidate.display(), ) @@ -767,22 +798,24 @@ authentication: issuer: https://issuer.invalid audiences: [evidence] tokenTypes: [at+jwt] - algorithms: [EdDSA] + algorithms: [ES256] jwksUri: https://issuer.invalid/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience grantIdClaim: evidence_grant_id grantAuthorityClaim: evidence_authority + maximumTokenLifetimeSeconds: 300 + revokedKeyIds: [] audit: {format: keyed-jsonl, hashSecretRef: 'secret:file/audit-hmac-key', hashKeyVersion: 1, failClosed: true} subjectBinding: {secretRef: 'secret:file/subject-binding-hmac-key', keyVersion: 1} rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} signing: format: flattened-jws-json - algorithm: EdDSA - activeKeyId: production-key-1 - activeKeyRef: secret:file/signing-private-jwk - retiredPublicJwkFiles: [] + algorithm: ES256 + activePublicJwkFile: public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] jwksPath: /.well-known/evidence/jwks.json maximumAssertionValiditySeconds: 300 verifierClockSkewSeconds: 30 @@ -812,6 +845,13 @@ listener: shutdownGraceMilliseconds: 10000 secretProviders: file: {root: /run/secrets/evidence} +signer: + kind: transit + unixSocketPath: /run/registry-evidence/transit-proxy.sock + mount: transit + keyName: evidence-signing + keyVersion: 7 + timeoutMilliseconds: 2000 auditStorage: {path: /var/lib/evidence/audit.jsonl, maximumFileBytes: 1048576} outboundTls: {systemRoots: true, trustProfiles: {}} "#; diff --git a/crates/registry-evidencectl/tests/production_handoff.rs b/crates/registry-evidencectl/tests/production_handoff.rs index 4853d7653..805ce1a38 100644 --- a/crates/registry-evidencectl/tests/production_handoff.rs +++ b/crates/registry-evidencectl/tests/production_handoff.rs @@ -19,11 +19,9 @@ use std::{ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::Utc; -use ed25519_dalek::{Signer as _, SigningKey}; +use p256::ecdsa::{signature::Signer as _, Signature, SigningKey}; use serde_json::{json, Value}; -const AUTH_KEY_ID: &str = "acceptance-auth-key"; -const SIGNING_KEY_ID: &str = "production-signing-key-1"; const TOKEN_AUDIENCE: &str = "registry-evidence-production-test"; const EVIDENCE_AUDIENCE: &str = "https://relying.invalid/production-acceptance"; const REQUIREMENT: &str = "urn:example:requirements:adult-status:v1"; @@ -171,6 +169,7 @@ fn production_candidate_handoff_reaches_verified_assertion_and_audit() { assert_audit_contract( &audit, &revision, + &fixture.evidence_signing_kid(), &[source_token.as_slice(), token.as_bytes()], ); stop_gracefully(&mut service, "Evidence"); @@ -289,6 +288,7 @@ fn production_candidate_accepts_a_token_from_an_independent_real_mint() { assert_audit_contract( &wait_for_audit(&fixture.audit_path), &revision, + &fixture.evidence_signing_kid(), &[source_token.as_slice(), token.as_bytes()], ); @@ -604,7 +604,7 @@ impl Fixture { ca: root.join("tls/ca.pem"), tls_cert: root.join("tls/server.pem"), tls_key: root.join("tls/server.key"), - oidc_private: root.join("oidc-private/signing-ed25519-private-jwk"), + oidc_private: root.join("oidc-private/signing-p256-private-jwk"), oidc_jwks: root.join("oidc.jwks.json"), source_token: secrets.join("source-token"), source_marker: root.join("source-requested"), @@ -1331,7 +1331,7 @@ privacy_expectation: evidencectl() .args(["keygen", "signing", "--out-dir"]) .arg(self.oidc_private.parent().expect("OIDC private directory")) - .args(["--kid", AUTH_KEY_ID, "--public-out"]) + .arg("--public-out") .arg(&public) .output() .expect("OIDC keygen starts"), @@ -1349,7 +1349,32 @@ privacy_expectation: } fn stage_target(&self) { - fs::create_dir_all(&self.target).expect("production target"); + let target_public_keys = self.target.join("public-keys"); + fs::create_dir_all(&target_public_keys).expect("deployment target public keys"); + let generated_public = self.root.join("evidence-transit-public.jwk.json"); + assert_success( + evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(self.root.join("transit-evidence-key")) + .arg("--public-out") + .arg(&generated_public) + .output() + .expect("Evidence Transit fixture keygen starts"), + "Evidence Transit fixture key generation", + ); + let governed_public: Value = serde_json::from_slice( + &fs::read(&generated_public).expect("Evidence Transit fixture public JWK"), + ) + .expect("Evidence Transit fixture public JWK parses"); + let signing_kid = governed_public["kid"] + .as_str() + .expect("Evidence Transit fixture kid"); + let governed_public_name = format!("{signing_kid}.jwk.json"); + fs::rename( + &generated_public, + target_public_keys.join(&governed_public_name), + ) + .expect("publish governed Evidence public JWK to deployment target"); let identity = format!("https://127.0.0.1:{}", self.https_port); fs::write( self.target.join("governance.yaml"), @@ -1363,22 +1388,24 @@ authentication: issuer: {identity} audiences: [{TOKEN_AUDIENCE}] tokenTypes: [at+jwt] - algorithms: [EdDSA] + algorithms: [ES256] jwksUri: {identity}/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience grantIdClaim: evidence_grant_id grantAuthorityClaim: evidence_authority + maximumTokenLifetimeSeconds: 300 + revokedKeyIds: [] audit: {{format: keyed-jsonl, hashSecretRef: 'secret:file/audit-hmac-key', hashKeyVersion: 1, failClosed: true}} subjectBinding: {{secretRef: 'secret:file/subject-binding-hmac-key', keyVersion: 1}} rateLimits: {{requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10}} signing: format: flattened-jws-json - algorithm: EdDSA - activeKeyId: {SIGNING_KEY_ID} - activeKeyRef: secret:file/signing-ed25519-private-jwk - retiredPublicJwkFiles: [] + algorithm: ES256 + activePublicJwkFile: public-keys/{governed_public_name} + publishedPublicJwkFiles: [] + revokedKeyIds: [] jwksPath: /.well-known/evidence/jwks.json maximumAssertionValiditySeconds: 86400 verifierClockSkewSeconds: 30 @@ -1400,10 +1427,11 @@ authorityProfiles: fs::write( &self.target_runtime, format!( - "version: 1\nbundleDirectory: {bundle}\nlistener:\n bindHost: 127.0.0.1\n port: {port}\n tlsTermination: operator-controlled-upstream\n trustProxyIdentityHeaders: false\n maximumRequestBytes: 65536\n maximumConcurrentRequests: 64\n requestTimeoutMilliseconds: 10000\n shutdownGraceMilliseconds: 5000\nsecretProviders:\n file:\n root: {secrets}\nauditStorage:\n path: {audit}\n maximumFileBytes: 1048576\noutboundTls:\n systemRoots: true\n trustProfiles: {{}}\n", + "version: 1\nbundleDirectory: {bundle}\nlistener:\n bindHost: 127.0.0.1\n port: {port}\n tlsTermination: operator-controlled-upstream\n trustProxyIdentityHeaders: false\n maximumRequestBytes: 65536\n maximumConcurrentRequests: 64\n requestTimeoutMilliseconds: 10000\n shutdownGraceMilliseconds: 5000\nsecretProviders:\n file:\n root: {secrets}\nsigner:\n kind: transit\n unixSocketPath: {transit_socket}\n mount: transit\n keyName: evidence-signing\n keyVersion: 1\n timeoutMilliseconds: 2000\nauditStorage:\n path: {audit}\n maximumFileBytes: 1048576\noutboundTls:\n systemRoots: true\n trustProfiles: {{}}\n", bundle = self.candidate.join("bundle").display(), port = self.evidence_port, secrets = self.secrets.display(), + transit_socket = self.root.join("transit-proxy.sock").display(), audit = self.audit_path.display(), ), ) @@ -1412,7 +1440,7 @@ authorityProfiles: fn authorize_four_shapes(&self) { let path = self.target.join("governance.yaml"); - let mut governance = fs::read_to_string(&path).expect("production governance"); + let mut governance = fs::read_to_string(&path).expect("deployment governance"); governance.push_str(&format!( r#" - requirement: {AGE_REQUIREMENT} purpose: service-path-selection @@ -1433,7 +1461,7 @@ authorityProfiles: - {{role: candidate-parent, selectorProfile: candidate-reference-v1, valueOrigin: request}} "# )); - fs::write(path, governance).expect("four-shape production governance"); + fs::write(path, governance).expect("four-shape deployment governance"); } fn build(&self, evidence: &Path) -> Output { @@ -1459,17 +1487,6 @@ authorityProfiles: fs::Permissions::from_mode(0o700), ) .expect("audit directory mode"); - let public = self.root.join("evidence-public.jwk.json"); - assert_success( - evidencectl() - .args(["keygen", "signing", "--out-dir"]) - .arg(&self.secrets) - .args(["--kid", SIGNING_KEY_ID, "--public-out"]) - .arg(&public) - .output() - .expect("Evidence signing keygen starts"), - "independent Evidence signing key generation", - ); for name in ["audit-hmac-key", "subject-binding-hmac-key"] { assert_success( evidencectl() @@ -1492,13 +1509,36 @@ authorityProfiles: evidencectl() .args(["jwks", "--out"]) .arg(&self.evidence_jwks) - .arg(public) + .arg(self.active_evidence_public_jwk()) .output() .expect("Evidence JWKS assembly starts"), "trusted Evidence JWKS assembly", ); } + fn active_evidence_public_jwk(&self) -> PathBuf { + let bundle: Value = serde_norway::from_slice( + &fs::read(self.candidate.join("bundle/evidence.yaml")).expect("candidate bundle"), + ) + .expect("candidate bundle parses"); + self.candidate.join("bundle").join( + bundle["signing"]["activePublicJwkFile"] + .as_str() + .expect("active public JWK file"), + ) + } + + fn evidence_signing_kid(&self) -> String { + let public: Value = serde_json::from_slice( + &fs::read(self.active_evidence_public_jwk()).expect("active Evidence public JWK"), + ) + .expect("active Evidence public JWK parses"); + public["kid"] + .as_str() + .expect("active Evidence signing kid") + .to_owned() + } + fn assert_compose_revision_distinction(&self, evidence: &Path, revision: &str) { let (host_bundle, host_runtime) = check_revisions( evidence, @@ -1513,12 +1553,13 @@ authorityProfiles: fs::write( &runtime, format!( - "version: 1\nbundleDirectory: {bundle}\nlistener:\n bindHost: 127.0.0.1\n port: {port}\n tlsTermination: operator-controlled-upstream\n trustProxyIdentityHeaders: false\n maximumRequestBytes: 131072\n maximumConcurrentRequests: 32\n requestTimeoutMilliseconds: 15000\n shutdownGraceMilliseconds: 10000\nsecretProviders:\n file:\n root: {secrets}\nauditStorage:\n path: {audit}\n maximumFileBytes: 2097152\noutboundTls:\n systemRoots: true\n trustProfiles: {{}}\n", + "version: 1\nbundleDirectory: {bundle}\nlistener:\n bindHost: 127.0.0.1\n port: {port}\n tlsTermination: operator-controlled-upstream\n trustProxyIdentityHeaders: false\n maximumRequestBytes: 131072\n maximumConcurrentRequests: 32\n requestTimeoutMilliseconds: 15000\n shutdownGraceMilliseconds: 10000\nsecretProviders:\n file:\n root: {secrets}\nsigner:\n kind: transit\n unixSocketPath: {transit_socket}\n mount: transit\n keyName: evidence-signing\n keyVersion: 1\n timeoutMilliseconds: 2000\nauditStorage:\n path: {audit}\n maximumFileBytes: 2097152\noutboundTls:\n systemRoots: true\n trustProfiles: {{}}\n", // This absolute host path stands for the unchanged read-only // candidate/bundle mount in the container execution context. bundle = self.candidate.join("bundle").display(), port = free_port(), secrets = self.secrets.display(), + transit_socket = self.root.join("transit-proxy.sock").display(), audit = compose.join("persistent-audit/evidence.jsonl").display(), ), ) @@ -1546,19 +1587,30 @@ authorityProfiles: let clients = mint.join("clients"); fs::create_dir_all(&clients).expect("Mint client registry"); - let mint_public = mint.join("mint-public.jwk.json"); + let mint_public_keys = mint.join("public-keys"); + fs::create_dir(&mint_public_keys).expect("Mint public key directory"); + let generated_mint_public = mint.join("mint-public.jwk.json"); assert_success( evidencectl() .args(["keygen", "signing", "--out-dir"]) - .arg(mint.join("secrets")) - .args(["--kid", "mint-signing-key-1", "--public-out"]) - .arg(&mint_public) + .arg(mint.join("transit-key")) + .arg("--public-out") + .arg(&generated_mint_public) .output() .expect("Mint signing keygen starts"), "independent Mint signing key generation", ); + let mint_public_jwk: Value = + serde_json::from_slice(&fs::read(&generated_mint_public).expect("Mint public JWK")) + .expect("Mint public JWK parses"); + let mint_kid = mint_public_jwk["kid"].as_str().expect("Mint signing kid"); + let mint_public = mint_public_keys.join(format!("{mint_kid}.jwk.json")); + fs::rename(&generated_mint_public, &mint_public).expect("publish Mint public JWK"); let audit = mint.join("audit"); fs::create_dir(&audit).expect("Mint audit directory"); + fs::create_dir(mint.join("secrets")).expect("Mint secret directory"); + fs::set_permissions(mint.join("secrets"), fs::Permissions::from_mode(0o700)) + .expect("Mint secret directory mode"); fs::set_permissions(&audit, fs::Permissions::from_mode(0o700)) .expect("Mint audit directory mode"); let audit_key = mint.join("secrets/mint-audit-hmac-key"); @@ -1594,7 +1646,7 @@ authorityProfiles: evidencectl() .args(["keygen", "signing", "--out-dir"]) .arg(&caller_directory) - .args(["--kid", "acceptance-client-key-1", "--public-out"]) + .arg("--public-out") .arg(&caller_public) .output() .expect("Mint caller keygen starts"), @@ -1631,14 +1683,16 @@ authorityProfiles: fs::write( &config, format!( - "version: 1\nissuer: {identity}\nlistener: {{address: 127.0.0.1, port: {port}}}\nsigning:\n algorithm: EdDSA\n activeKeyId: mint-signing-key-1\n activeKeyFile: secrets/signing-ed25519-private-jwk\naudit:\n path: audit/mint.jsonl\n maximumFileBytes: 1073741824\n hashKeyFile: secrets/mint-audit-hmac-key\n hashKeyVersion: 1\naccessTokens:\n audiences: [{TOKEN_AUDIENCE}]\n lifetimeSeconds: 300\n claims:\n principal: sub\n requesterTags: evidence_tags\n evidenceAudience: evidence_audience\n grantId: evidence_grant_id\n grantAuthority: evidence_authority\nclientAssertion:\n audience: {identity}/token\n algorithms: [EdDSA]\nclients:\n directory: clients\n", + "version: 1\nissuer: {identity}\nlistener: {{address: 127.0.0.1, port: {port}}}\nsigning:\n algorithm: ES256\n activePublicJwkFile: public-keys/{mint_kid}.jwk.json\n publishedPublicJwkFiles: []\n revokedKeyIds: []\nsigner:\n kind: transit\n unixSocketPath: {transit_socket}\n mount: transit\n keyName: mint-signing\n keyVersion: 1\n timeoutMilliseconds: 2000\nsecretProviders:\n file:\n root: {secrets}\naudit:\n path: audit/mint.jsonl\n maximumFileBytes: 1073741824\n hashKeyRef: secret:file/mint-audit-hmac-key\n hashKeyVersion: 1\naccessTokens:\n audiences: [{TOKEN_AUDIENCE}]\n lifetimeSeconds: 300\n claims:\n principal: sub\n requesterTags: evidence_tags\n evidenceAudience: evidence_audience\n grantId: evidence_grant_id\n grantAuthority: evidence_authority\nclientAssertion:\n audience: {identity}/token\n algorithms: [ES256]\nclients:\n directory: clients\n", port = self.mint_port, + transit_socket = self.root.join("transit-proxy.sock").display(), + secrets = mint.join("secrets").display(), ), ) .expect("Mint config"); MintDeployment { config, - caller_private: caller_directory.join("signing-ed25519-private-jwk"), + caller_private: caller_directory.join("signing-p256-private-jwk"), } } @@ -1711,12 +1765,12 @@ authorityProfiles: let secret = URL_SAFE_NO_PAD .decode(private["d"].as_str().expect("private JWK d")) .expect("private JWK d decodes"); - let secret: [u8; 32] = secret.try_into().expect("Ed25519 seed length"); - let key = SigningKey::from_bytes(&secret); + let key = SigningKey::from_slice(&secret).expect("P-256 signing scalar"); + let kid = private["kid"].as_str().expect("private JWK kid"); let identity = format!("https://127.0.0.1:{}", self.https_port); let now = Utc::now().timestamp(); let header = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&json!({"alg":"EdDSA","kid":AUTH_KEY_ID,"typ":"at+jwt"})) + serde_json::to_vec(&json!({"alg":"ES256","kid":kid,"typ":"at+jwt"})) .expect("JWT header"), ); let claims = URL_SAFE_NO_PAD.encode( @@ -1725,14 +1779,15 @@ authorityProfiles: "aud": TOKEN_AUDIENCE, "sub": "synthetic-caller", "iat": now - 1, - "exp": now + 3600, + "exp": now + 299, "evidence_tags": ["fixture-agency"], "evidence_audience": EVIDENCE_AUDIENCE, })) .expect("JWT claims"), ); let input = format!("{header}.{claims}"); - let signature = URL_SAFE_NO_PAD.encode(key.sign(input.as_bytes()).to_bytes()); + let signature: Signature = key.sign(input.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(signature.to_bytes()); format!("{input}.{signature}") } @@ -2009,7 +2064,7 @@ fn wait_for_audit(path: &Path) -> String { result.expect("the complete operation audit") } -fn assert_audit_contract(audit: &str, revision: &str, credentials: &[&[u8]]) { +fn assert_audit_contract(audit: &str, revision: &str, signing_key_id: &str, credentials: &[&[u8]]) { let records = audit .lines() .map(|line| serde_json::from_str::(line).expect("audit JSONL")) @@ -2023,7 +2078,7 @@ fn assert_audit_contract(audit: &str, revision: &str, credentials: &[&[u8]]) { assert_eq!(records[0]["record"]["decision"], "authorized"); assert_eq!(records[1]["record"]["phase"], "disclosure-release"); assert_eq!(records[1]["record"]["decision"], "released"); - assert_eq!(records[1]["record"]["signingKeyId"], SIGNING_KEY_ID); + assert_eq!(records[1]["record"]["signingKeyId"], signing_key_id); for record in &records { assert_eq!(record["record"]["bundleRevision"], revision); assert_eq!(record["record"]["assuranceProfile"], "production"); diff --git a/crates/registry-evidencectl/tests/scaffold.rs b/crates/registry-evidencectl/tests/scaffold.rs index f754b6fae..0d5ba3a4c 100644 --- a/crates/registry-evidencectl/tests/scaffold.rs +++ b/crates/registry-evidencectl/tests/scaffold.rs @@ -52,7 +52,7 @@ fn openapi_requires_the_explicit_local_profile_before_writing() { } #[test] -fn local_openapi_is_retained_byte_for_byte_without_premature_artifacts() { +fn local_openapi_is_retained_byte_for_byte_with_automatic_disposable_keys() { let workspace = TempDir::new().expect("temporary directory"); let spec = write_spec(workspace.path(), OPENAPI.as_bytes()); let project = workspace.path().join("project"); @@ -63,7 +63,7 @@ fn local_openapi_is_retained_byte_for_byte_without_premature_artifacts() { fs::read(project.join("source.openapi.yaml")).expect("retained OpenAPI"), OPENAPI.as_bytes() ); - assert_minimal_project(&project, false); + assert_minimal_project(&project, true); assert!(stdout(&output).contains("retained exactly")); assert!(stdout(&output).contains("No question, fixture case, runtime")); assert!(stdout(&output).contains("evidencectl source suggest --project")); @@ -83,7 +83,7 @@ fn remote_openapi_is_retained_byte_for_byte() { fs::read(project.join("source.openapi.yaml")).expect("retained remote OpenAPI"), remote ); - assert_minimal_project(&project, false); + assert_minimal_project(&project, true); } #[test] @@ -153,11 +153,11 @@ fn unsafe_remote_urls_are_value_free_and_fail_before_network_or_writes() { } #[test] -fn generate_keys_is_transactional_unbound_owner_only_and_prints_no_secret() { +fn automatic_keys_are_transactional_unbound_owner_only_and_print_no_secret() { let workspace = TempDir::new().expect("temporary directory"); let spec = write_spec(workspace.path(), OPENAPI.as_bytes()); let project = workspace.path().join("project"); - let output = openapi_new(&project, path(&spec), &["--generate-keys"]); + let output = openapi_new(&project, path(&spec), &[]); assert!(output.status.success(), "{}", stderr(&output)); assert_minimal_project(&project, true); @@ -170,8 +170,8 @@ fn generate_keys_is_transactional_unbound_owner_only_and_prints_no_secret() { 0o700 ); for (name, mode) in [ - ("signing-ed25519-private-jwk", 0o600), - ("signing-ed25519-public.jwk.json", 0o644), + ("signing-p256-private-jwk", 0o600), + ("signing-p256-public.jwk.json", 0o644), ("audit-hmac-key", 0o600), ("subject-binding-hmac-key", 0o600), ] { @@ -185,8 +185,8 @@ fn generate_keys_is_transactional_unbound_owner_only_and_prints_no_secret() { ); } - let private = fs::read_to_string(project.join("secrets/signing-ed25519-private-jwk")) - .expect("private JWK"); + let private = + fs::read_to_string(project.join("secrets/signing-p256-private-jwk")).expect("private JWK"); let private: serde_json::Value = serde_json::from_str(&private).expect("private JWK JSON"); let secret = private["d"].as_str().expect("private key member"); assert!(!stdout(&output).contains(secret)); diff --git a/crates/registry-mint/Cargo.toml b/crates/registry-mint/Cargo.toml index fb3ca48e0..43b864858 100644 --- a/crates/registry-mint/Cargo.toml +++ b/crates/registry-mint/Cargo.toml @@ -24,7 +24,8 @@ http.workspace = true jsonwebtoken.workspace = true registry-platform-canonical-json.workspace = true registry-platform-audit.workspace = true -registry-platform-crypto.workspace = true +registry-platform-config.workspace = true +registry-platform-crypto = { workspace = true, features = ["transit"] } registry-platform-oidc.workspace = true reqwest.workspace = true rustix.workspace = true @@ -44,5 +45,6 @@ zeroize.workspace = true [dev-dependencies] axum-test.workspace = true ed25519-dalek.workspace = true +p256.workspace = true registry-evidence.workspace = true tempfile.workspace = true diff --git a/crates/registry-mint/README.md b/crates/registry-mint/README.md index 438f7905e..d135b0913 100644 --- a/crates/registry-mint/README.md +++ b/crates/registry-mint/README.md @@ -63,13 +63,14 @@ Endpoints: | `POST /token` | Issue an access token | | `GET /.well-known/jwks.json` | Public keys for verifying minted tokens (path is configurable) | | `GET /.well-known/oauth-authorization-server` | Metadata pointing at the above | -| `GET /health`, `GET /ready` | Liveness, and readiness (503 without clients or a writable audit chain) | +| `GET /health`, `GET /ready` | Liveness, and readiness (503 without clients, a ready signer, or a writable audit chain) | ## Configuration -One YAML document. Every path in it resolves relative to the document's own -directory. Everything here is startup-only: issuer identity, signing and audit -keys, listener, and token policy are fixed for the life of the process. +One YAML document. Governed public-key, audit, and client-registry paths resolve +relative to the document's own directory. The secret root and Transit socket +are absolute. Everything here is startup-only: issuer identity, signing and +audit keys, listener, and token policy are fixed for the life of the process. ```yaml version: 1 @@ -78,15 +79,24 @@ listener: address: 127.0.0.1 port: 8081 signing: - algorithm: EdDSA - activeKeyId: mint-2026-01 - activeKeyFile: secrets/signing.jwk - # Public JWKs of keys that no longer sign but may still have live tokens. - retiredPublicJwkFiles: [] + algorithm: ES256 + activePublicJwkFile: public-keys/.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: transit + unixSocketPath: /run/registry-mint/transit-proxy.sock + mount: transit + keyName: mint-signing + keyVersion: 7 + timeoutMilliseconds: 2000 +secretProviders: + file: + root: /run/registry-mint/secrets audit: path: audit/mint.jsonl maximumFileBytes: 1073741824 - hashKeyFile: secrets/audit-hmac-key + hashKeyRef: secret:file/audit-hmac-key hashKeyVersion: 1 accessTokens: audiences: [evidence] @@ -113,15 +123,33 @@ requester tags, evidence audience, and grant pair from configurable claim names. Access token lifetime is bounded to 60..=3600 seconds; a long-lived bearer token is the thing Mint exists to avoid. -The signing key file must be a private JWK and must be readable only by its -owner. Never commit it, print it, or pass it on a command line. +Mint's service key is always P-256/ES256. Each governed public JWK carries a +`kid` equal to its 43-character RFC 7638 thumbprint and is stored as +`.jwk.json`. Strict deployments use a non-exportable Vault/OpenBao +Transit key through the configured workload-local Unix socket. Mint receives +no provider token. + +Supervised local development may replace the `signer` block with: + +```yaml +signer: + kind: local-jwk + privateKeyRef: secret:file/mint-signing +``` + +The referenced private JWK must exactly match `activePublicJwkFile`. Secret +files are resolved beneath `secretProviders.file.root` and must be regular, +single-link, owner-only files. Never commit, print, or pass them on a command +line. Client assertion keys remain independently owned and may use EdDSA, +ES256, or RS256 with their own identifiers. The audit key file is also owner-only and must contain at least 32 bytes. The audit directory, chain, and lock file must be owned by the Mint process user and unavailable to group and other users. For a new deployment, `openssl rand -hex -32 > secrets/audit-hmac-key` followed by `chmod 600 -secrets/audit-hmac-key` is sufficient. Mint verifies the keyed chain at startup -and holds a single-writer lock for the process lifetime. It writes a durable +32 > /run/registry-mint/secrets/audit-hmac-key` followed by `chmod 600 +/run/registry-mint/secrets/audit-hmac-key` is sufficient. Mint derives separate +HKDF subkeys for chain integrity and identifier pseudonyms. It verifies the +keyed chain at startup and holds a single-writer lock for the process lifetime. It writes a durable release record before returning every access token; if that write fails, the request returns `server_error` and readiness fails. Denials are recorded with value-free error categories. Raw assertions, tokens, client ids, actors, @@ -136,6 +164,30 @@ segments, so monitor total capacity and archive sealed history under the deployment's retention policy while retaining the matching audit key. Never rename or archive the active segment while Mint is running. +Audit master rotation starts a new epoch. Stop Mint, record and archive the old +chain head, runtime, key, and segments, then increment `hashKeyVersion`, select +a fresh audit path, install the new key, run `mint check`, and restart. Never +append a replacement audit master to an existing chain. + +For planned service-key rotation, create the next Transit version, publish its +public JWK first, and deploy and restart every replica with that overlap set. +Only after every replica publishes both keys, switch `activePublicJwkFile` and +the pinned `keyVersion` together while leaving the old key published, then +deploy and restart every replica again. Remove the old public key and raise the +Transit key's `min_encryption_version` only after the maximum access-token +lifetime plus consumer clock skew. For compromise, disable provider signing +authority immediately, remove its JWK, add its thumbprint to `revokedKeyIds`, +and activate a replacement or leave Mint unavailable. Add the compromised Mint +thumbprint to each Evidence consumer's `authentication.revokedKeyIds` in the +same incident rollout. Configuration is startup-only, so every rotation step +takes effect through a restart. + +Mint client-key rotation is independent of the service key. Add the new public +client key, reload, move the client, and retain the old public key for the +configured maximum client-assertion lifetime plus 30 seconds before removing +and reloading again. Remove a compromised client key immediately and reload; +do not provide an overlap window during an incident. + ## Registering a client One `*.yaml` file per client in `clients.directory`: diff --git a/crates/registry-mint/demo/evidence-bundle/evidence.yaml b/crates/registry-mint/demo/evidence-bundle/evidence.yaml index 426ac9ffe..596d4e2b6 100644 --- a/crates/registry-mint/demo/evidence-bundle/evidence.yaml +++ b/crates/registry-mint/demo/evidence-bundle/evidence.yaml @@ -8,7 +8,7 @@ # # Synthetic identifiers only. Nothing here describes a real person or source. version: 1 -assuranceProfile: production +assuranceProfile: local service: {providerId: urn:example:demo:provider:evidence, trustDomain: urn:example:demo:trust-domain:delegation} issuer: {id: urn:example:demo:issuer:authority} authentication: @@ -19,7 +19,9 @@ authentication: issuer: https://localhost:8443 audiences: [evidence.demo.invalid] tokenTypes: [at+jwt] - algorithms: [EdDSA] + algorithms: [ES256] + maximumTokenLifetimeSeconds: 300 + revokedKeyIds: [] jwksUri: https://localhost:8443/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags @@ -32,7 +34,7 @@ authentication: audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} -signing: {format: flattened-jws-json, algorithm: EdDSA, activeKeyId: demo-evidence-key, activeKeyRef: secret:file/signing-key, retiredPublicJwkFiles: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 86400, verifierClockSkewSeconds: 30} +signing: {format: flattened-jws-json, algorithm: ES256, activePublicJwkFile: public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json, publishedPublicJwkFiles: [], revokedKeyIds: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 86400, verifierClockSkewSeconds: 30} selectorProfiles: demographics-v1: maximumAggregateBytes: 420 diff --git a/crates/registry-mint/demo/evidence-bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json b/crates/registry-mint/demo/evidence-bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json new file mode 100644 index 000000000..b331659f4 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json @@ -0,0 +1 @@ +{"alg":"ES256","crv":"P-256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","kty":"EC","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"} diff --git a/crates/registry-mint/demo/support/provision.py b/crates/registry-mint/demo/support/provision.py index 88e147941..2bdbb91af 100644 --- a/crates/registry-mint/demo/support/provision.py +++ b/crates/registry-mint/demo/support/provision.py @@ -15,6 +15,7 @@ import base64 import datetime as dt +import hashlib import json import os import secrets @@ -25,7 +26,7 @@ from cryptography import x509 from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 from cryptography.x509.oid import NameOID MINT_PORT = 8090 @@ -60,6 +61,32 @@ def ed25519_jwk(kid: str) -> tuple[dict, dict]: return {**public_jwk, "d": d}, public_jwk +def p256_jwk() -> tuple[dict, dict]: + """Return a service ES256 key whose kid is its RFC 7638 thumbprint.""" + private = ec.generate_private_key(ec.SECP256R1()) + numbers = private.private_numbers() + public_numbers = numbers.public_numbers + public_jwk = { + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "x": b64(public_numbers.x.to_bytes(32, "big")), + "y": b64(public_numbers.y.to_bytes(32, "big")), + } + thumbprint_members = { + member: public_jwk[member] for member in ("crv", "kty", "x", "y") + } + thumbprint = json.dumps( + thumbprint_members, sort_keys=True, separators=(",", ":") + ).encode() + public_jwk["kid"] = b64(hashlib.sha256(thumbprint).digest()) + private_jwk = { + **public_jwk, + "d": b64(numbers.private_value.to_bytes(32, "big")), + } + return private_jwk, public_jwk + + def write(path: Path, text: str, mode: int = 0o644) -> Path: """Write a file everyone on the machine may read: certificates, configuration.""" path.parent.mkdir(parents=True, exist_ok=True) @@ -133,8 +160,10 @@ def issue_tls_certificate(root: Path) -> None: def provision_mint(root: Path) -> None: mint = root / "mint" - signing_private, _ = ed25519_jwk("mint-key-1") + signing_private, signing_public = p256_jwk() write_secret(mint / "secrets/signing.jwk", json.dumps(signing_private)) + public_file = f"{signing_public['kid']}.jwk.json" + write(mint / f"public-keys/{public_file}", json.dumps(signing_public)) write_secret(mint / "secrets/audit-hmac-key", secrets.token_hex(32)) for client_id in ("scheduler", "service-desk"): @@ -166,16 +195,23 @@ def provision_mint(root: Path) -> None: write( mint / "mint.yaml", f"""version: 1 +validationMode: supervised-local-development issuer: {MINT_ORIGIN} listener: {{address: 127.0.0.1, port: {MINT_PORT}}} signing: - algorithm: EdDSA - activeKeyId: mint-key-1 - activeKeyFile: secrets/signing.jwk + algorithm: ES256 + activePublicJwkFile: public-keys/{public_file} + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: local-jwk + privateKeyRef: secret:file/signing.jwk +secretProviders: + file: {{root: {mint / "secrets"}}} audit: path: audit/mint.jsonl maximumFileBytes: 1073741824 - hashKeyFile: secrets/audit-hmac-key + hashKeyRef: secret:file/audit-hmac-key hashKeyVersion: 1 accessTokens: audiences: [evidence.demo.invalid] @@ -214,8 +250,18 @@ def provision_evidence(root: Path, bundle_source: Path) -> None: provider_root.mkdir(parents=True, exist_ok=True) provider_root.chmod(0o700) # Evidence refuses a group- or world-readable root - signing_private, _ = ed25519_jwk("demo-evidence-key") - write_secret(provider_root / "signing-key", json.dumps(signing_private)) + evidence_signing_private = { + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "kid": "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo", + "x": "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4", + "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU", + "d": "MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4", + } + write_secret( + provider_root / "evidence-signing", json.dumps(evidence_signing_private) + ) write_secret(provider_root / "audit-hash-key", secrets.token_hex(32)) write_secret(provider_root / "subject-binding-key", secrets.token_hex(32)) write_secret(provider_root / "source-token", os.environ["DEMO_SOURCE_TOKEN"]) @@ -237,6 +283,9 @@ def provision_evidence(root: Path, bundle_source: Path) -> None: secretProviders: file: root: {provider_root} +signer: + kind: local-jwk + privateKeyRef: secret:file/evidence-signing auditStorage: path: {evidence / "audit/evidence.jsonl"} maximumFileBytes: 1073741824 diff --git a/crates/registry-mint/demo/support/test_provision.py b/crates/registry-mint/demo/support/test_provision.py index d4cbf5f2a..1eb1441d6 100644 --- a/crates/registry-mint/demo/support/test_provision.py +++ b/crates/registry-mint/demo/support/test_provision.py @@ -11,6 +11,9 @@ """ import importlib.util +import base64 +import hashlib +import json import os import stat import sys @@ -71,6 +74,22 @@ def test_ordinary_files_keep_their_readable_mode(self): self.assertEqual(0o644, stat.S_IMODE(path.stat().st_mode)) + def test_service_key_is_es256_with_an_rfc7638_identifier(self): + private, public = provision.p256_jwk() + members = {name: public[name] for name in ("crv", "kty", "x", "y")} + digest = hashlib.sha256( + json.dumps(members, sort_keys=True, separators=(",", ":")).encode() + ).digest() + expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + + self.assertEqual("ES256", public["alg"]) + self.assertEqual("EC", public["kty"]) + self.assertEqual("P-256", public["crv"]) + self.assertEqual(expected, public["kid"]) + self.assertEqual(expected, private["kid"]) + self.assertIn("d", private) + self.assertNotIn("d", public) + if __name__ == "__main__": sys.exit(0 if unittest.main(exit=False).result.wasSuccessful() else 1) diff --git a/crates/registry-mint/src/audit.rs b/crates/registry-mint/src/audit.rs index cdf1ea842..c6421bf8a 100644 --- a/crates/registry-mint/src/audit.rs +++ b/crates/registry-mint/src/audit.rs @@ -1,17 +1,18 @@ //! Fail-closed Mint audit over one durable, segmented keyed JSONL chain. use registry_platform_audit::{ - verify_segmented_audit_chain, AuditChainHasher, AuditEnvelope, AuditError, AuditHashSecret, - AuditKeyHasher, ChainState, DurableSegmentedJsonlSink, + verify_segmented_audit_chain, AuditEnvelope, AuditError, AuditKeyHasher, AuditProfile, + ChainState, DurableSegmentedJsonlSink, }; use registry_platform_canonical_json::canonicalize_json; +use registry_platform_config::{SecretError, SecretProvider, SecretResolver}; use serde::Serialize; use thiserror::Error; +use zeroize::Zeroizing; use crate::{ assertion::AuthenticatedClient, - config::AuditConfig, - secretfile::{self, SecretFileError}, + config::{AuditConfig, SecretProvidersConfig}, token::MintedToken, }; @@ -20,7 +21,7 @@ const AUDIT_SCHEMA: &str = "registry.mint.audit/v1"; #[derive(Debug, Error)] pub enum MintAuditError { #[error("the audit hash key could not be read")] - Secret(#[source] SecretFileError), + Secret(#[source] SecretError), #[error("the audit chain could not be initialized or written")] Audit(#[from] AuditError), #[error("an audit-safe reference could not be constructed")] @@ -103,18 +104,18 @@ impl std::fmt::Debug for MintAuditLog { } impl MintAuditLog { - pub async fn initialize(config: &AuditConfig, issuer: &str) -> Result { - let secret = - secretfile::read_owner_only(&config.hash_key_file).map_err(MintAuditError::Secret)?; - let secret = AuditHashSecret::new(secret.as_bytes().to_vec())?; - let chain_hasher = AuditChainHasher::keyed(secret.clone()); - let key_hasher = AuditKeyHasher::Keyed(secret); + pub async fn initialize( + config: &AuditConfig, + secrets: &SecretProvidersConfig, + issuer: &str, + ) -> Result { + let profile = audit_profile(config, secrets)?; let sink = DurableSegmentedJsonlSink::open(config.path.clone(), config.maximum_file_bytes)?; - let chain = ChainState::bootstrap_or_start_empty(&sink, chain_hasher).await?; + let chain = profile.bootstrap_or_start_empty(&sink).await?; Ok(Self { sink, chain, - key_hasher, + key_hasher: profile.key_hasher(), key_version: config.hash_key_version, scope: issuer.to_owned(), }) @@ -127,25 +128,28 @@ impl MintAuditLog { /// that process. Opening the sink here would report a healthy deployment /// back as a broken one. The hash key is what a misconfigured deployment /// actually gets wrong, and reading it takes nothing the writer holds. - pub fn check(config: &AuditConfig) -> Result<(), MintAuditError> { - let secret = - secretfile::read_owner_only(&config.hash_key_file).map_err(MintAuditError::Secret)?; - AuditHashSecret::new(secret.as_bytes().to_vec())?; + pub fn check( + config: &AuditConfig, + secrets: &SecretProvidersConfig, + ) -> Result<(), MintAuditError> { + audit_profile(config, secrets)?; Ok(()) } /// Verify the retained chain without taking the serving writer lock. - pub fn verify(config: &AuditConfig) -> Result { - let secret = - secretfile::read_owner_only(&config.hash_key_file).map_err(MintAuditError::Secret)?; - let secret = AuditHashSecret::new(secret.as_bytes().to_vec())?; - let summary = verify_segmented_audit_chain(&config.path, &AuditChainHasher::keyed(secret)) - .map_err(|error| match error { + pub fn verify( + config: &AuditConfig, + secrets: &SecretProvidersConfig, + ) -> Result { + let profile = audit_profile(config, secrets)?; + let summary = verify_segmented_audit_chain(&config.path, &profile.chain_hasher()).map_err( + |error| match error { AuditError::SegmentMissing { sequence } => { MintAuditError::SegmentMissing { sequence } } error => MintAuditError::Audit(error), - })?; + }, + )?; Ok(MintAuditSummary { segments: summary.segments, records: summary.records, @@ -252,6 +256,19 @@ impl MintAuditLog { } } +fn audit_profile( + config: &AuditConfig, + secrets: &SecretProvidersConfig, +) -> Result { + let resolver = SecretResolver::new([SecretProvider::File], secrets.file.root.clone()) + .map_err(MintAuditError::Secret)?; + let secret = resolver + .resolve(&config.hash_key_ref) + .map_err(MintAuditError::Secret)?; + AuditProfile::production_from_secret_bytes(Zeroizing::new(secret.expose_secret().to_vec())) + .map_err(MintAuditError::Audit) +} + #[cfg(test)] mod tests { use super::*; @@ -262,7 +279,7 @@ mod tests { // assignment of a live credential. const AUDIT_HASH_KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; - fn fixture() -> (tempfile::TempDir, AuditConfig) { + fn fixture() -> (tempfile::TempDir, AuditConfig, SecretProvidersConfig) { let directory = tempfile::tempdir().expect("temp dir"); let secret = directory.path().join("audit-key"); fs::write(&secret, AUDIT_HASH_KEY).expect("write audit key"); @@ -270,17 +287,22 @@ mod tests { let config = AuditConfig { path: directory.path().join("audit/mint.jsonl"), maximum_file_bytes: 1_048_576, - hash_key_file: secret, + hash_key_ref: "secret:file/audit-key".to_owned(), hash_key_version: 1, }; - (directory, config) + let secrets = SecretProvidersConfig { + file: crate::config::FileSecretProviderConfig { + root: directory.path().to_path_buf(), + }, + }; + (directory, config, secrets) } #[tokio::test] async fn a_keyed_chain_restarts_and_verifies() { - let (_directory, config) = fixture(); + let (_directory, config, secrets) = fixture(); { - let audit = MintAuditLog::initialize(&config, "https://mint.example.org") + let audit = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") .await .expect("audit initializes"); audit @@ -289,7 +311,7 @@ mod tests { .expect("first decision is durable"); } { - let audit = MintAuditLog::initialize(&config, "https://mint.example.org") + let audit = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") .await .expect("audit restarts"); audit @@ -297,7 +319,7 @@ mod tests { .await .expect("second decision is durable"); } - let summary = MintAuditLog::verify(&config).expect("chain verifies"); + let summary = MintAuditLog::verify(&config, &secrets).expect("chain verifies"); assert_eq!(summary.segments, 1); assert_eq!(summary.records, 2); assert!(summary.last_hash.is_some()); @@ -306,20 +328,20 @@ mod tests { #[tokio::test] async fn a_second_writer_is_refused() { - let (_directory, config) = fixture(); - let first = MintAuditLog::initialize(&config, "https://mint.example.org") + let (_directory, config, secrets) = fixture(); + let first = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") .await .expect("first writer initializes"); - let second = MintAuditLog::initialize(&config, "https://mint.example.org").await; + let second = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org").await; assert!(second.is_err(), "a second writer must not fork the chain"); drop(first); } #[tokio::test] async fn corruption_is_refused_at_restart_and_verification() { - let (_directory, config) = fixture(); + let (_directory, config, secrets) = fixture(); { - let audit = MintAuditLog::initialize(&config, "https://mint.example.org") + let audit = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") .await .expect("audit initializes"); audit @@ -330,20 +352,47 @@ mod tests { let mut contents = fs::read_to_string(&config.path).expect("read chain"); contents = contents.replace("invalid-client", "invalid-request"); fs::write(&config.path, contents).expect("tamper with chain"); - assert!(MintAuditLog::verify(&config).is_err()); + assert!(MintAuditLog::verify(&config, &secrets).is_err()); assert!( - MintAuditLog::initialize(&config, "https://mint.example.org") + MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") .await .is_err() ); } + #[tokio::test] + async fn a_replacement_master_cannot_append_to_an_existing_epoch() { + let (directory, config, secrets) = fixture(); + { + let audit = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") + .await + .expect("audit initializes"); + audit + .append_rejected("urn:ulid:01K00000000000000000000000", "invalid-client") + .await + .expect("decision is durable"); + } + fs::write( + directory.path().join("audit-key"), + b"abcdef0123456789abcdef0123456789", + ) + .expect("replace audit key"); + + assert!(MintAuditLog::verify(&config, &secrets).is_err()); + assert!( + MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") + .await + .is_err(), + "a new audit master must start a fresh path and epoch" + ); + } + #[tokio::test] async fn rotation_seals_history_without_breaking_restart_or_verification() { - let (_directory, mut config) = fixture(); + let (_directory, mut config, secrets) = fixture(); config.maximum_file_bytes = 550; { - let audit = MintAuditLog::initialize(&config, "https://mint.example.org") + let audit = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") .await .expect("audit initializes"); for index in 0..8 { @@ -358,12 +407,12 @@ mod tests { } let first_segment = config.path.with_extension("jsonl.00000001"); assert!(first_segment.exists(), "rotation seals the active segment"); - let summary = MintAuditLog::verify(&config).expect("segmented chain verifies"); + let summary = MintAuditLog::verify(&config, &secrets).expect("segmented chain verifies"); assert_eq!(summary.records, 8); assert!(summary.segments > 1); assert_eq!(summary.first_sequence, Some(1)); - let restarted = MintAuditLog::initialize(&config, "https://mint.example.org") + let restarted = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") .await .expect("audit restarts from the segmented tail"); restarted diff --git a/crates/registry-mint/src/config.rs b/crates/registry-mint/src/config.rs index 7a32e530a..81046c709 100644 --- a/crates/registry-mint/src/config.rs +++ b/crates/registry-mint/src/config.rs @@ -11,6 +11,7 @@ use std::{ path::{Path, PathBuf}, }; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use serde::Deserialize; use thiserror::Error; use url::Url; @@ -137,16 +138,52 @@ impl ListenerConfig { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SigningConfig { pub algorithm: Algorithm, - pub active_key_id: String, - /// Path to the private JWK, resolved relative to the configuration file. - pub active_key_file: PathBuf, - /// Public JWKs of keys that no longer sign but may still have live tokens. + /// Governed public JWK of the key that signs newly issued tokens. + pub active_public_jwk_file: PathBuf, + /// Public JWKs whose already-issued tokens may still be live. #[serde(default)] - pub retired_public_jwk_files: Vec, + pub published_public_jwk_files: Vec, + /// Compromised key identifiers that must never be published or activated. + #[serde(default)] + pub revoked_key_ids: Vec, #[serde(default = "default_jwks_path")] pub jwks_path: String, } +/// Process-local access to the active signing key. +#[derive(Debug, Deserialize)] +#[serde( + tag = "kind", + rename_all = "kebab-case", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum SignerConfig { + /// A mounted private JWK, admitted only in supervised local development. + LocalJwk { private_key_ref: String }, + /// Vault/OpenBao Transit reached only through a workload-local Unix socket. + Transit { + unix_socket_path: PathBuf, + mount: String, + key_name: String, + key_version: u32, + timeout_milliseconds: u64, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SecretProvidersConfig { + pub file: FileSecretProviderConfig, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FileSecretProviderConfig { + /// Absolute directory beneath which logical `secret:file/...` names resolve. + pub root: PathBuf, +} + /// Required, fail-closed audit storage for token decisions. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -155,8 +192,8 @@ pub struct AuditConfig { pub path: PathBuf, /// Per-segment rotation threshold. Sealed segments are never deleted. pub maximum_file_bytes: u64, - /// Owner-only master HMAC key, resolved relative to the configuration. - pub hash_key_file: PathBuf, + /// Owner-only master HMAC key resolved through the configured provider. + pub hash_key_ref: String, /// Version label written into privacy-preserving audit handles. pub hash_key_version: u32, } @@ -279,6 +316,8 @@ pub struct MintConfig { pub issuer: String, pub listener: ListenerConfig, pub signing: SigningConfig, + pub signer: SignerConfig, + pub secret_providers: SecretProvidersConfig, pub audit: AuditConfig, pub access_tokens: AccessTokenConfig, pub client_assertion: ClientAssertionConfig, @@ -286,8 +325,8 @@ pub struct MintConfig { } impl MintConfig { - /// Load and validate a configuration document, resolving every path - /// relative to the document's own directory. + /// Load and validate a configuration document, resolving governed public + /// keys, audit storage, and the client registry relative to its directory. pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path).map_err(|_| ConfigError::Unavailable)?; let mut config: Self = serde_norway::from_str(&text) @@ -308,15 +347,14 @@ impl MintConfig { root.join(path) } }; - self.signing.active_key_file = resolve(&self.signing.active_key_file); - self.signing.retired_public_jwk_files = self + self.signing.active_public_jwk_file = resolve(&self.signing.active_public_jwk_file); + self.signing.published_public_jwk_files = self .signing - .retired_public_jwk_files + .published_public_jwk_files .iter() .map(|path| resolve(path)) .collect(); self.audit.path = resolve(&self.audit.path); - self.audit.hash_key_file = resolve(&self.audit.hash_key_file); self.clients.directory = resolve(&self.clients.directory); } @@ -329,14 +367,85 @@ impl MintConfig { match self.validation_mode { ValidationMode::Strict => validate_https_issuer(&self.issuer)?, ValidationMode::SupervisedLocalDevelopment => { - self.validate_supervised_local_development_transport()?; + if self.issuer.starts_with("https://") { + validate_https_issuer(&self.issuer)?; + validate_https_endpoint(&self.client_assertion.audience)?; + } else { + self.validate_supervised_local_development_transport()?; + } } } self.listener.bind_address()?; self.listener.validate()?; - if self.signing.active_key_id.trim().is_empty() || self.signing.active_key_id.len() > 256 { - return Err(ConfigError::Invalid("active key id must be 1..=256 bytes")); + if self.signing.algorithm != Algorithm::ES256 { + return Err(ConfigError::Invalid( + "Mint service signing algorithm must be ES256", + )); + } + if self.signing.active_public_jwk_file.as_os_str().is_empty() { + return Err(ConfigError::Invalid("active public JWK file is required")); + } + if self.signing.published_public_jwk_files.len() > 32 { + return Err(ConfigError::Invalid( + "active and published public key set must contain at most 33 keys", + )); + } + if self.signing.revoked_key_ids.len() > 33 + || self + .signing + .revoked_key_ids + .iter() + .any(|kid| !is_thumbprint_key_id(kid)) + || self + .signing + .revoked_key_ids + .iter() + .collect::>() + .len() + != self.signing.revoked_key_ids.len() + { + return Err(ConfigError::Invalid( + "revoked key ids must be unique 43-character RFC 7638 thumbprints", + )); + } + match (&self.validation_mode, &self.signer) { + (ValidationMode::Strict, SignerConfig::Transit { .. }) + | (ValidationMode::SupervisedLocalDevelopment, SignerConfig::LocalJwk { .. }) + | (ValidationMode::SupervisedLocalDevelopment, SignerConfig::Transit { .. }) => {} + (ValidationMode::Strict, SignerConfig::LocalJwk { .. }) => { + return Err(ConfigError::Invalid( + "strict mode requires a Transit signer", + )); + } + } + match &self.signer { + SignerConfig::LocalJwk { private_key_ref } => { + validate_file_secret_ref(private_key_ref)?; + } + SignerConfig::Transit { + unix_socket_path, + mount, + key_name, + key_version, + timeout_milliseconds, + } => { + if !unix_socket_path.is_absolute() + || !valid_transit_name(mount) + || !valid_transit_name(key_name) + || *key_version == 0 + || !(1..=30_000).contains(timeout_milliseconds) + { + return Err(ConfigError::Invalid( + "Transit signer requires an absolute Unix socket, simple mount and key names, a non-zero key version, and a 1..=30000 millisecond timeout", + )); + } + } + } + if !self.secret_providers.file.root.is_absolute() { + return Err(ConfigError::Invalid( + "secret provider file root must be absolute", + )); } if !self.signing.jwks_path.starts_with('/') { return Err(ConfigError::Invalid("jwks path must be absolute")); @@ -351,25 +460,33 @@ impl MintConfig { "jwks path must not take a route Mint already serves", )); } - if self.audit.path.as_os_str().is_empty() - || self.audit.hash_key_file.as_os_str().is_empty() - || self.audit.hash_key_version == 0 - { + if self.audit.path.as_os_str().is_empty() || self.audit.hash_key_version == 0 { return Err(ConfigError::Invalid( - "audit path, hash key file, and non-zero hash key version are required", + "audit path, hash key reference, and non-zero hash key version are required", )); } + validate_file_secret_ref(&self.audit.hash_key_ref)?; if !(1_048_576..=1_099_511_627_776).contains(&self.audit.maximum_file_bytes) { return Err(ConfigError::Invalid( "audit maximumFileBytes must be 1048576..=1099511627776", )); } - if self.audit.path == self.audit.hash_key_file - || self.audit.path == self.signing.active_key_file - || self.audit.hash_key_file == self.signing.active_key_file + let audit_secret_path = self + .secret_providers + .file + .root + .join(file_secret_name(&self.audit.hash_key_ref)); + if self.audit.path == audit_secret_path + || matches!( + &self.signer, + SignerConfig::LocalJwk { private_key_ref } + if private_key_ref == &self.audit.hash_key_ref + || self.audit.path + == self.secret_providers.file.root.join(file_secret_name(private_key_ref)) + ) { return Err(ConfigError::Invalid( - "audit storage and secret paths must be distinct from signing material", + "audit storage, audit key, and local signing material must be distinct", )); } @@ -460,6 +577,50 @@ fn is_plain_route_path(path: &str) -> bool { }) } +fn is_thumbprint_key_id(value: &str) -> bool { + if value.len() != 43 { + return false; + } + URL_SAFE_NO_PAD + .decode(value) + .is_ok_and(|bytes| bytes.len() == 32 && URL_SAFE_NO_PAD.encode(bytes) == value) +} + +fn validate_file_secret_ref(reference: &str) -> Result<(), ConfigError> { + let Some(name) = reference.strip_prefix("secret:file/") else { + return Err(ConfigError::Invalid( + "secret references must use the exact secret:file/ grammar", + )); + }; + let bytes = name.as_bytes(); + if !matches!(bytes.first(), Some(b'a'..=b'z')) + || bytes.len() > 128 + || !bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) + { + return Err(ConfigError::Invalid( + "secret references must use the exact secret:file/ grammar", + )); + } + Ok(()) +} + +fn file_secret_name(reference: &str) -> &str { + reference + .strip_prefix("secret:file/") + .expect("validated file secret reference") +} + +fn valid_transit_name(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 128 + && bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + /// Parse the only HTTP origin admitted for supervised local development. /// /// Exact reconstruction rejects URL-parser aliases such as a trailing slash, @@ -541,13 +702,23 @@ version: 1 issuer: https://mint.example.org listener: {address: 127.0.0.1, port: 8081} signing: - algorithm: EdDSA - activeKeyId: mint-2026-01 - activeKeyFile: secrets/signing.jwk + algorithm: ES256 + activePublicJwkFile: public-keys/mint.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: transit + unixSocketPath: /run/registry-mint/transit-proxy.sock + mount: transit + keyName: mint-signing + keyVersion: 7 + timeoutMilliseconds: 2000 +secretProviders: + file: {root: /run/registry-mint/secrets} audit: path: audit/mint.jsonl maximumFileBytes: 1073741824 - hashKeyFile: secrets/audit-hmac-key + hashKeyRef: secret:file/audit-hmac-key hashKeyVersion: 1 accessTokens: audiences: [evidence] @@ -592,16 +763,13 @@ clients: assert_eq!(config.issuer, "https://mint.example.org"); assert_eq!(config.validation_mode, ValidationMode::Strict); assert_eq!( - config.signing.active_key_file, - directory.path().join("secrets/signing.jwk") + config.signing.active_public_jwk_file, + directory.path().join("public-keys/mint.jwk.json") ); assert_eq!(config.clients.directory, directory.path().join("clients")); assert_eq!(config.audit.path, directory.path().join("audit/mint.jsonl")); assert_eq!(config.audit.maximum_file_bytes, 1_073_741_824); - assert_eq!( - config.audit.hash_key_file, - directory.path().join("secrets/audit-hmac-key") - ); + assert_eq!(config.audit.hash_key_ref, "secret:file/audit-hmac-key"); assert_eq!(config.audit.hash_key_version, 1); assert_eq!(config.signing.jwks_path, "/.well-known/jwks.json"); assert_eq!(config.client_assertion.maximum_lifetime_seconds, 300); @@ -610,14 +778,14 @@ clients: #[test] fn audit_configuration_is_required_bounded_and_separate_from_secrets() { assert!(load_from(&VALID.replace( - "audit:\n path: audit/mint.jsonl\n maximumFileBytes: 1073741824\n hashKeyFile: secrets/audit-hmac-key\n hashKeyVersion: 1\n", + "audit:\n path: audit/mint.jsonl\n maximumFileBytes: 1073741824\n hashKeyRef: secret:file/audit-hmac-key\n hashKeyVersion: 1\n", "" )) .is_err()); assert_eq!( load_error(&VALID.replace("hashKeyVersion: 1", "hashKeyVersion: 0")), ConfigError::Invalid( - "audit path, hash key file, and non-zero hash key version are required" + "audit path, hash key reference, and non-zero hash key version are required" ) ); assert_eq!( @@ -626,11 +794,11 @@ clients: ); assert_eq!( load_error(&VALID.replace( - "hashKeyFile: secrets/audit-hmac-key", - "hashKeyFile: secrets/signing.jwk" + "path: audit/mint.jsonl", + "path: /run/registry-mint/secrets/audit-hmac-key" )), ConfigError::Invalid( - "audit storage and secret paths must be distinct from signing material" + "audit storage, audit key, and local signing material must be distinct" ) ); } @@ -639,8 +807,8 @@ clients: fn a_jwks_path_may_not_take_a_route_mint_already_serves() { for path in MINT_FIXED_ROUTES { let text = VALID.replace( - "activeKeyFile: secrets/signing.jwk", - &format!("activeKeyFile: secrets/signing.jwk\n jwksPath: {path}"), + "activePublicJwkFile: public-keys/mint.jwk.json", + &format!("activePublicJwkFile: public-keys/mint.jwk.json\n jwksPath: {path}"), ); assert_eq!( load_error(&text), @@ -669,8 +837,8 @@ clients: "/keys%2ftoken", ] { let text = VALID.replace( - "activeKeyFile: secrets/signing.jwk", - &format!("activeKeyFile: secrets/signing.jwk\n jwksPath: \"{path}\""), + "activePublicJwkFile: public-keys/mint.jwk.json", + &format!("activePublicJwkFile: public-keys/mint.jwk.json\n jwksPath: \"{path}\""), ); assert_eq!( load_error(&text), @@ -689,8 +857,8 @@ clients: "/a~b-c_d", ] { let text = VALID.replace( - "activeKeyFile: secrets/signing.jwk", - &format!("activeKeyFile: secrets/signing.jwk\n jwksPath: \"{path}\""), + "activePublicJwkFile: public-keys/mint.jwk.json", + &format!("activePublicJwkFile: public-keys/mint.jwk.json\n jwksPath: \"{path}\""), ); let config = load_from(&text).expect("a plain absolute path loads"); assert_eq!(config.signing.jwks_path, path); @@ -754,6 +922,10 @@ clients: .replace( "audience: https://mint.example.org/token", "audience: http://127.0.0.1:8081/token", + ) + .replace( + "signer:\n kind: transit\n unixSocketPath: /run/registry-mint/transit-proxy.sock\n mount: transit\n keyName: mint-signing\n keyVersion: 7\n timeoutMilliseconds: 2000", + "signer:\n kind: local-jwk\n privateKeyRef: secret:file/mint-signing", ); let config = load_from(&local).expect("the supervised local transport is valid"); assert_eq!( @@ -825,8 +997,8 @@ clients: } let wrong_jwks = local.replace( - "activeKeyFile: secrets/signing.jwk", - "activeKeyFile: secrets/signing.jwk\n jwksPath: /.well-known/keys.json", + "activePublicJwkFile: public-keys/mint.jwk.json", + "activePublicJwkFile: public-keys/mint.jwk.json\n jwksPath: /.well-known/keys.json", ); assert!( load_from(&wrong_jwks).is_err(), @@ -880,6 +1052,54 @@ clients: } } + #[test] + fn signer_kind_follows_the_assurance_matrix() { + let strict_local = VALID.replace( + "signer:\n kind: transit\n unixSocketPath: /run/registry-mint/transit-proxy.sock\n mount: transit\n keyName: mint-signing\n keyVersion: 7\n timeoutMilliseconds: 2000", + "signer:\n kind: local-jwk\n privateKeyRef: secret:file/mint-signing", + ); + assert_eq!( + load_error(&strict_local), + ConfigError::Invalid("strict mode requires a Transit signer") + ); + + let supervised_transit = VALID + .replace( + "version: 1", + "version: 1\nvalidationMode: supervised-local-development", + ) + .replace( + "issuer: https://mint.example.org", + "issuer: http://127.0.0.1:8081", + ) + .replace( + "audience: https://mint.example.org/token", + "audience: http://127.0.0.1:8081/token", + ); + load_from(&supervised_transit).expect("supervised local mode also permits Transit"); + } + + #[test] + fn service_signing_is_fixed_to_es256_and_thumbprint_revocations() { + assert_eq!( + load_error(&VALID.replace("algorithm: ES256", "algorithm: EdDSA")), + ConfigError::Invalid("Mint service signing algorithm must be ES256") + ); + let noncanonical = format!("{}B", "A".repeat(42)); + for revoked in [ + "short", + "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!", + &noncanonical, + ] { + let document = + VALID.replace("revokedKeyIds: []", &format!("revokedKeyIds: [{revoked}]")); + assert!( + load_from(&document).is_err(), + "accepted revoked id {revoked}" + ); + } + } + #[test] fn access_token_lifetime_is_bounded_on_both_sides() { for lifetime in ["1", "59", "3601", "86400"] { diff --git a/crates/registry-mint/src/main.rs b/crates/registry-mint/src/main.rs index 29d8e52ad..8bfe6054b 100644 --- a/crates/registry-mint/src/main.rs +++ b/crates/registry-mint/src/main.rs @@ -129,7 +129,12 @@ fn run(cli: Cli) -> Result<(), String> { Command::Check { config } => { let config = MintConfig::load(&config) .map_err(|error| format!("the configuration could not be loaded: {error}"))?; - let clients = MintService::check(&config) + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("the async runtime could not start: {error}"))?; + let clients = runtime + .block_on(MintService::check(&config)) .map_err(|error| format!("the configuration cannot be served: {error}"))?; tracing::info!( target: "registry_mint", @@ -156,7 +161,7 @@ fn run(cli: Cli) -> Result<(), String> { Command::VerifyAudit { config } => { let config = MintConfig::load(&config) .map_err(|error| format!("the configuration could not be loaded: {error}"))?; - let summary = MintAuditLog::verify(&config.audit) + let summary = MintAuditLog::verify(&config.audit, &config.secret_providers) .map_err(|error| format!("the audit chain did not verify: {error}"))?; let sealed_sequence = match (summary.first_sequence, summary.last_sequence) { (Some(first), Some(last)) => format!("{first}-{last}"), diff --git a/crates/registry-mint/src/server.rs b/crates/registry-mint/src/server.rs index 5c6cca1f4..47eaf69d4 100644 --- a/crates/registry-mint/src/server.rs +++ b/crates/registry-mint/src/server.rs @@ -51,7 +51,7 @@ const JWKS_MEDIA_TYPE: &str = "application/jwk-set+json"; #[derive(Debug, Error)] pub enum ServiceError { - #[error("the signing key could not be loaded: {0}")] + #[error("the token signing boundary could not be initialized: {0}")] Minter(#[from] MinterError), #[error("the client registry could not be loaded: {0}")] Registry(#[from] ClientRegistryError), @@ -88,7 +88,7 @@ impl std::fmt::Debug for MintService { impl MintService { /// Load the keys, audit chain, and client registry described by `config`. pub async fn load(config: MintConfig) -> Result { - let minter = TokenMinter::new(&config)?; + let minter = TokenMinter::new(&config).await?; let registry = Arc::new(ClientRegistry::load(&config.clients.directory)?); check_delegations(®istry, minter.claims())?; let replay = Arc::new(ReplayCache::new( @@ -96,7 +96,9 @@ impl MintService { )); let authenticator = ClientAuthenticator::new(registry, &config.client_assertion, Arc::clone(&replay)); - let audit = MintAuditLog::initialize(&config.audit, &config.issuer).await?; + let audit = + MintAuditLog::initialize(&config.audit, &config.secret_providers, &config.issuer) + .await?; let metadata = build_metadata(&config); Ok(Self { config, @@ -113,11 +115,11 @@ impl MintService { /// Everything [`MintService::load`] does except claiming the audit writer, /// so an operator can check an edited configuration against the deployment /// it is about to replace. Returns the number of registered clients. - pub fn check(config: &MintConfig) -> Result { - let minter = TokenMinter::new(config)?; + pub async fn check(config: &MintConfig) -> Result { + let minter = TokenMinter::new(config).await?; let registry = ClientRegistry::load(&config.clients.directory)?; check_delegations(®istry, minter.claims())?; - MintAuditLog::check(&config.audit)?; + MintAuditLog::check(&config.audit, &config.secret_providers)?; Ok(registry.len()) } @@ -221,7 +223,7 @@ impl MintService { #[must_use] async fn ready(&self) -> bool { - self.client_count() > 0 && self.audit.ready().await + self.client_count() > 0 && self.minter.ready().await && self.audit.ready().await } } diff --git a/crates/registry-mint/src/token.rs b/crates/registry-mint/src/token.rs index 669e73b48..0c192d343 100644 --- a/crates/registry-mint/src/token.rs +++ b/crates/registry-mint/src/token.rs @@ -18,31 +18,40 @@ //! refuses any request carrying its own selector values, so a token issued for //! one subject cannot be turned toward another, however the caller misbehaves. -use std::path::Path; +use std::{collections::BTreeSet, path::Path, sync::Arc, time::Duration}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; +use registry_platform_config::{SecretError, SecretProvider, SecretResolver}; +use registry_platform_crypto::{ + verify, KeyReadiness, LocalJwkSigner, PrivateJwk, PublicJwk, SigningAlgorithm, SigningError, + SigningProvider, TransitSigner, TransitSignerConfig, +}; use serde::Serialize; use serde_json::{json, Map, Value}; use thiserror::Error; use crate::{ assertion::AuthenticatedClient, - clients::{contains_private_material, Delegation, RegisteredClient}, - config::{Algorithm, ClaimNames, MintConfig}, + clients::{Delegation, RegisteredClient}, + config::{ClaimNames, MintConfig, SignerConfig}, error::TokenError, - secretfile::{self, SecretFileError}, ACCESS_TOKEN_TYP, }; #[derive(Debug, Error)] pub enum MinterError { - #[error("the signing key file could not be read: {0}")] - SigningKeyFile(#[from] SecretFileError), + #[error("the signing secret could not be resolved")] + SigningSecret(#[source] SecretError), #[error("the signing key is invalid: {0}")] SigningKey(&'static str), - #[error("a retired public key is invalid: {0}")] - RetiredKey(&'static str), + #[error("a governed public key is invalid: {0}")] + PublicKey(&'static str), + #[error("the signing provider configuration is invalid: {0}")] + SigningProviderConfiguration(#[source] SigningError), + #[error("the signing provider initialization failed: {0}")] + SigningProviderInitialization(#[source] SigningError), + #[error("the signing provider self-test failed: {0}")] + SigningProviderSelfTest(#[source] SigningError), } /// A minted access token and the lifetime the caller should assume. @@ -82,8 +91,9 @@ pub struct TokenMinter { audience: Value, lifetime_seconds: i64, claims: ClaimNames, - algorithm: Algorithm, - signer: LocalJwkSigner, + signer: Arc, + governed_active: PublicJwk, + recovery_probe: tokio::sync::Mutex<()>, jwks: Value, } @@ -92,7 +102,7 @@ impl std::fmt::Debug for TokenMinter { formatter .debug_struct("TokenMinter") .field("issuer", &self.issuer) - .field("algorithm", &self.algorithm) + .field("algorithm", &self.signer.algorithm()) .field("key_id", &self.signer.key_id()) .finish_non_exhaustive() } @@ -100,27 +110,15 @@ impl std::fmt::Debug for TokenMinter { impl TokenMinter { /// Load the active signing key and build the published JWK set. - pub fn new(config: &MintConfig) -> Result { - let key_text = secretfile::read_owner_only(&config.signing.active_key_file)?; - let private = PrivateJwk::parse(&key_text) - .map_err(|_| MinterError::SigningKey("not a private JWK"))?; - - // A mismatch here would publish one key id and sign with another, so - // verifiers would fail to find the key that actually signed. - if private.kid.as_deref() != Some(config.signing.active_key_id.as_str()) { - return Err(MinterError::SigningKey( - "key id does not match the configured active key id", - )); - } - let signer = - LocalJwkSigner::new(private).map_err(|_| MinterError::SigningKey("is not usable"))?; - if signer.public_jwk().alg.as_deref() != Some(config.signing.algorithm.as_header_value()) { - return Err(MinterError::SigningKey( - "algorithm does not match the configured signing algorithm", - )); - } - - let jwks = build_jwks(&signer, &config.signing.retired_public_jwk_files)?; + pub async fn new(config: &MintConfig) -> Result { + let public_keys = load_public_keys(config)?; + let active = public_keys + .first() + .cloned() + .expect("the active governed key is always present"); + let signer = build_signer(config, &active).await?; + self_test(signer.as_ref(), &active).await?; + let jwks = json!({ "keys": public_keys }); let audience = if config.access_tokens.audiences.len() == 1 { Value::String(config.access_tokens.audiences[0].clone()) @@ -140,8 +138,9 @@ impl TokenMinter { audience, lifetime_seconds: config.access_tokens.lifetime_seconds as i64, claims: config.access_tokens.claims.clone(), - algorithm: config.signing.algorithm, signer, + governed_active: active, + recovery_probe: tokio::sync::Mutex::new(()), jwks, }) } @@ -163,6 +162,28 @@ impl TokenMinter { &self.claims } + /// Current availability of the active signing provider. + /// + /// Transit marks itself unavailable after a failed request. When no token + /// traffic reaches an unready replica, the readiness route is the only + /// remaining path that can observe provider recovery. One caller therefore + /// repeats the bounded startup sign-and-verify proof while concurrent + /// probes fail closed rather than queueing more provider work. + pub async fn ready(&self) -> bool { + if self.signer.readiness() == KeyReadiness::Ready { + return true; + } + let Ok(_probe) = self.recovery_probe.try_lock() else { + return false; + }; + if self.signer.readiness() == KeyReadiness::Ready { + return true; + } + self_test(self.signer.as_ref(), &self.governed_active) + .await + .is_ok() + } + /// Mint an access token carrying the registry's authority for `client`. pub async fn mint( &self, @@ -241,7 +262,7 @@ impl TokenMinter { } let header = json!({ - "alg": self.algorithm.as_header_value(), + "alg": "ES256", "typ": ACCESS_TOKEN_TYP, "kid": self.signer.key_id(), }); @@ -313,71 +334,175 @@ fn encode_json(value: &Value) -> Result { Ok(URL_SAFE_NO_PAD.encode(bytes)) } -/// Publish the active public key plus any retired public keys whose tokens may -/// still be in flight. -fn build_jwks( - signer: &LocalJwkSigner, - retired: &[std::path::PathBuf], -) -> Result { - let active = serde_json::to_value(signer.public_jwk()) - .map_err(|_| MinterError::SigningKey("public key could not be serialized"))?; - let mut keys = vec![active]; - for path in retired { - keys.push(load_retired_public_key(path)?); - } - // One key id must resolve to one key. A verifier that indexes the set by - // `kid` is free to keep either entry, so a repeated id could leave the - // retired key standing in for the key every new token is signed with, - // with Mint still reporting itself ready. - let mut key_ids = std::collections::BTreeSet::new(); - for key in &keys { - let key_id = key - .get("kid") - .and_then(Value::as_str) - .ok_or(MinterError::RetiredKey("has no key id"))?; - if !key_ids.insert(key_id) { - return Err(MinterError::RetiredKey( - "repeats a key id already in the published set", +async fn build_signer( + config: &MintConfig, + active: &PublicJwk, +) -> Result, MinterError> { + match &config.signer { + SignerConfig::LocalJwk { private_key_ref } => { + let resolver = SecretResolver::new( + [SecretProvider::File], + config.secret_providers.file.root.clone(), + ) + .map_err(MinterError::SigningSecret)?; + let secret = resolver + .resolve(private_key_ref) + .map_err(MinterError::SigningSecret)?; + let text = std::str::from_utf8(secret.expose_secret()) + .map_err(|_| MinterError::SigningKey("private JWK is not UTF-8"))?; + let private = PrivateJwk::parse(text) + .map_err(|_| MinterError::SigningKey("not an exact ES256 private JWK"))?; + let signer = LocalJwkSigner::new(private) + .map_err(|_| MinterError::SigningKey("private JWK is not usable"))?; + if signer.algorithm() != SigningAlgorithm::Es256 || signer.public_jwk() != *active { + return Err(MinterError::SigningKey( + "private JWK does not match the governed active public JWK", + )); + } + Ok(Arc::new(signer)) + } + SignerConfig::Transit { + unix_socket_path, + mount, + key_name, + key_version, + timeout_milliseconds, + } => { + let transit = TransitSignerConfig::new( + unix_socket_path, + mount, + key_name, + *key_version, + active.clone(), + Duration::from_millis(*timeout_milliseconds), + ) + .map_err(MinterError::SigningProviderConfiguration)?; + let signer = TransitSigner::initialize(transit) + .await + .map_err(MinterError::SigningProviderInitialization)?; + Ok(Arc::new(signer)) + } + } +} + +async fn self_test(signer: &dyn SigningProvider, expected: &PublicJwk) -> Result<(), MinterError> { + if signer.algorithm() != SigningAlgorithm::Es256 + || signer.key_id() != expected.kid.as_deref().unwrap_or_default() + || signer.public_jwk() != *expected + { + return Err(MinterError::SigningKey( + "provider metadata does not match the governed active public JWK", + )); + } + let probe = b"registry-mint/signing-provider-self-test/v1"; + let signature = signer + .sign(probe) + .await + .map_err(MinterError::SigningProviderSelfTest)?; + if signature.len() != 64 || verify(probe, &signature, expected).is_err() { + return Err(MinterError::SigningKey( + "provider self-test signature did not verify", + )); + } + Ok(()) +} + +fn load_public_keys(config: &MintConfig) -> Result, MinterError> { + let revoked = config + .signing + .revoked_key_ids + .iter() + .map(String::as_str) + .collect::>(); + let paths = std::iter::once(&config.signing.active_public_jwk_file) + .chain(config.signing.published_public_jwk_files.iter()); + let mut keys = Vec::with_capacity(1 + config.signing.published_public_jwk_files.len()); + let mut identifiers = BTreeSet::new(); + for path in paths { + let key = load_public_key(path)?; + let kid = key + .kid + .as_deref() + .ok_or(MinterError::PublicKey("key id is missing"))?; + let expected_file_name = format!("{kid}.jwk.json"); + if path.file_name().and_then(|name| name.to_str()) != Some(expected_file_name.as_str()) { + return Err(MinterError::PublicKey( + "file name must be .jwk.json", )); } + if revoked.contains(kid) { + return Err(MinterError::PublicKey("a published key is revoked")); + } + if !identifiers.insert(kid.to_owned()) { + return Err(MinterError::PublicKey( + "key id is repeated in the published set", + )); + } + keys.push(key); } - Ok(json!({ "keys": keys })) + Ok(keys) } -fn load_retired_public_key(path: &Path) -> Result { +fn load_public_key(path: &Path) -> Result { + let metadata = std::fs::symlink_metadata(path) + .map_err(|_| MinterError::PublicKey("file is unreadable"))?; + if !metadata.is_file() { + return Err(MinterError::PublicKey("path is not a regular file")); + } + let bytes = std::fs::read(path).map_err(|_| MinterError::PublicKey("file is unreadable"))?; + if bytes.len() > registry_platform_crypto::MAX_JWK_JSON_BYTES { + return Err(MinterError::PublicKey("document is too large")); + } let text = - std::fs::read_to_string(path).map_err(|_| MinterError::RetiredKey("is unreadable"))?; - let value: Value = - serde_json::from_str(&text).map_err(|_| MinterError::RetiredKey("is not JSON"))?; + std::str::from_utf8(&bytes).map_err(|_| MinterError::PublicKey("document is not UTF-8"))?; + let value: Value = registry_platform_crypto::parse_json_strict(&bytes) + .map_err(|_| MinterError::PublicKey("document is not strict JSON"))?; let object = value .as_object() - .ok_or(MinterError::RetiredKey("is not a JSON object"))?; - // The whole point of the published set is that it is public. - if contains_private_material(object) { - return Err(MinterError::RetiredKey("contains private key material")); - } - if !object.get("kid").is_some_and(Value::is_string) { - return Err(MinterError::RetiredKey("has no key id")); - } - // Resource servers parse the whole set into a `JwkSet` before selecting a - // key, so an entry that is well-formed JSON but not a usable public key - // fails their refresh and takes the active key down with it. Parse it here, - // as the same type they will, rather than serving a set Mint has never - // proved is loadable. - serde_json::from_value::(value.clone()) - .map_err(|_| MinterError::RetiredKey("is not a usable public key"))?; - Ok(value) + .ok_or(MinterError::PublicKey("document is not a JSON object"))?; + let fields = object.keys().map(String::as_str).collect::>(); + let required = ["alg", "crv", "kid", "kty", "x", "y"] + .into_iter() + .collect::>(); + if fields != required { + return Err(MinterError::PublicKey( + "must contain exactly kty, crv, x, y, alg, and kid", + )); + } + let key = + PublicJwk::parse(text).map_err(|_| MinterError::PublicKey("is not a usable public JWK"))?; + if key.algorithm().ok() != Some(SigningAlgorithm::Es256) + || key.kty != "EC" + || key.crv.as_deref() != Some("P-256") + || key.alg.as_deref() != Some("ES256") + { + return Err(MinterError::PublicKey("must be an ES256 P-256 JWK")); + } + let thumbprint = key + .jkt() + .map_err(|_| MinterError::PublicKey("thumbprint could not be derived"))?; + if key.kid.as_deref() != Some(thumbprint.as_str()) || thumbprint.len() != 43 { + return Err(MinterError::PublicKey( + "kid must be the RFC 7638 thumbprint", + )); + } + Ok(key) } #[cfg(test)] mod tests { use super::*; use crate::clients::ClientRegistry; - use std::{fs, os::unix::fs::PermissionsExt}; + use p256::ecdsa::SigningKey as P256SigningKey; + use std::{ + fs, + os::unix::fs::PermissionsExt, + sync::atomic::{AtomicBool, AtomicUsize, Ordering}, + }; const NOW: i64 = 1_800_000_000; - fn ed25519_key(seed: u8, kid: &str) -> (String, Value) { + fn client_key(seed: u8, kid: &str) -> (String, Value) { let seed_bytes = [seed; 32]; let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); @@ -388,35 +513,69 @@ mod tests { (private.to_string(), public) } + fn p256_key(seed: u8) -> (String, Value) { + let scalar = [seed; 32]; + let signing = P256SigningKey::from_slice(&scalar).expect("valid P-256 scalar"); + let encoded = signing.verifying_key().to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(encoded.x().expect("uncompressed x")); + let y = URL_SAFE_NO_PAD.encode(encoded.y().expect("uncompressed y")); + let d = URL_SAFE_NO_PAD.encode(scalar); + let public_without_kid = PublicJwk::parse( + &json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "x":x, "y":y}).to_string(), + ) + .expect("public P-256 JWK parses"); + let kid = public_without_kid.jkt().expect("thumbprint computes"); + let private = json!({ + "kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, + "x":x, "y":y, "d":d + }); + let public = json!({ + "kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, + "x":x, "y":y + }); + (private.to_string(), public) + } + struct Fixture { _directory: tempfile::TempDir, minter: TokenMinter, registry: ClientRegistry, } - fn fixture(grant: Option<&str>) -> Fixture { - build_fixture(grant, "", "") + async fn fixture(grant: Option<&str>) -> Fixture { + build_fixture(grant, "", "").await } /// `registration` appends lines to the client registration, `claim` appends /// lines to the configured claim names. Both are how the delegation tests /// reach a shape the plain fixture does not have. - fn build_fixture(grant: Option<&str>, registration: &str, claim: &str) -> Fixture { + async fn build_fixture(grant: Option<&str>, registration: &str, claim: &str) -> Fixture { let directory = tempfile::tempdir().expect("temp dir"); let root = directory.path(); fs::create_dir_all(root.join("clients")).expect("client dir"); + fs::create_dir_all(root.join("public-keys")).expect("public key dir"); + fs::create_dir_all(root.join("secrets")).expect("secret dir"); - let (private, _public) = ed25519_key(9, "mint-2026-01"); - let key_path = root.join("signing.jwk"); + let (private, public) = p256_key(9); + let key_path = root.join("secrets/signing.jwk"); fs::write(&key_path, private).expect("write signing key"); fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)).expect("chmod"); + let public_file = format!( + "{}.jwk.json", + public["kid"].as_str().expect("service key has kid") + ); + fs::write( + root.join("public-keys").join(&public_file), + public.to_string(), + ) + .expect("write public key"); let grant_line = grant .map(|value| format!("grant: {value}\n")) .unwrap_or_default(); fs::write( root.join("clients/client-a.yaml"), - format!("clientId: client-a\nprincipal: urn:example:client-a\nevidenceAudience: https://client-a.example.org\nrequesterTags: [ministry-of-health, tier-one]\n{grant_line}keys: [{}]\n{registration}", ed25519_key(1, "client-a-1").1), + format!("clientId: client-a\nprincipal: urn:example:client-a\nevidenceAudience: https://client-a.example.org\nrequesterTags: [ministry-of-health, tier-one]\n{grant_line}keys: [{}]\n{registration}", client_key(1, "client-a-1").1), ) .expect("write client"); @@ -424,16 +583,23 @@ mod tests { let mut document = String::from( r#" version: 1 -issuer: https://mint.example.org +validationMode: supervised-local-development +issuer: http://127.0.0.1:8081 listener: {address: 127.0.0.1, port: 8081} signing: - algorithm: EdDSA - activeKeyId: mint-2026-01 - activeKeyFile: signing.jwk + algorithm: ES256 + activePublicJwkFile: public-keys/PUBLIC + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: local-jwk + privateKeyRef: secret:file/signing.jwk +secretProviders: + file: {root: ROOT} audit: path: audit/mint.jsonl maximumFileBytes: 1073741824 - hashKeyFile: audit-hmac-key + hashKeyRef: secret:file/audit-hmac-key hashKeyVersion: 1 accessTokens: audiences: [evidence] @@ -449,17 +615,20 @@ accessTokens: document.push_str(claim); document.push_str( r#"clientAssertion: - audience: https://mint.example.org/token + audience: http://127.0.0.1:8081/token algorithms: [EdDSA] clients: directory: clients "#, ); + document = document + .replace("ROOT", &root.join("secrets").display().to_string()) + .replace("PUBLIC", &public_file); fs::write(&config_path, document).expect("write config"); let config = MintConfig::load(&config_path).expect("config loads"); let registry = ClientRegistry::load(&config.clients.directory).expect("registry loads"); - let minter = TokenMinter::new(&config).expect("minter builds"); + let minter = TokenMinter::new(&config).await.expect("minter builds"); Fixture { _directory: directory, minter, @@ -467,61 +636,38 @@ clients: } } - /// The active signer the published set is built around, matching the - /// `mint-2026-01` key id the fixture configuration declares. - fn active_signer() -> LocalJwkSigner { - let (private, _public) = ed25519_key(9, "mint-2026-01"); - LocalJwkSigner::new(PrivateJwk::parse(&private).expect("private JWK parses")) - .expect("signer builds") - } - - fn write_public_key(directory: &Path, name: &str, seed: u8, kid: &str) -> std::path::PathBuf { - let path = directory.join(name); - let (_private, public) = ed25519_key(seed, kid); + fn write_public_key(directory: &Path, seed: u8) -> std::path::PathBuf { + let (_private, public) = p256_key(seed); + let kid = public["kid"].as_str().expect("key has kid"); + let path = directory.join(format!("{kid}.jwk.json")); fs::write(&path, public.to_string()).expect("write public key"); path } #[test] - fn retired_keys_publish_beside_the_active_key() { + fn published_keys_load_beside_the_active_key() { let directory = tempfile::tempdir().expect("temp dir"); - let retired = write_public_key(directory.path(), "retired.jwk", 4, "mint-2025-07"); - - let jwks = build_jwks(&active_signer(), &[retired]).expect("set builds"); - - let ids: Vec<&str> = jwks["keys"] - .as_array() - .expect("keys is an array") - .iter() - .map(|key| key["kid"].as_str().expect("key id is a string")) - .collect(); - assert_eq!(ids, ["mint-2026-01", "mint-2025-07"]); - } - - #[test] - fn a_retired_key_may_not_repeat_the_active_key_id() { - let directory = tempfile::tempdir().expect("temp dir"); - // Different key material published under the id the active key already - // uses. A verifier keyed by `kid` would be free to resolve either, so - // the retired key could displace the key every new token is signed - // with while Mint went on reporting itself ready. - let retired = write_public_key(directory.path(), "retired.jwk", 4, "mint-2026-01"); - - let error = build_jwks(&active_signer(), &[retired]).expect_err("duplicate id is rejected"); - - assert!(matches!(error, MinterError::RetiredKey(_)), "{error:?}"); + let active = write_public_key(directory.path(), 9); + let published = write_public_key(directory.path(), 4); + let mut config = crate::config::tests::sample_config(); + config.signing.active_public_jwk_file = active; + config.signing.published_public_jwk_files = vec![published]; + + let keys = load_public_keys(&config).expect("governed set loads"); + assert_eq!(keys.len(), 2); + assert_ne!(keys[0].kid, keys[1].kid); } #[test] - fn two_retired_keys_may_not_repeat_one_key_id() { + fn a_public_key_may_not_repeat_the_active_key_id() { let directory = tempfile::tempdir().expect("temp dir"); - let first = write_public_key(directory.path(), "first.jwk", 4, "mint-2025-07"); - let second = write_public_key(directory.path(), "second.jwk", 5, "mint-2025-07"); - - let error = - build_jwks(&active_signer(), &[first, second]).expect_err("duplicate id is rejected"); + let active = write_public_key(directory.path(), 9); + let mut config = crate::config::tests::sample_config(); + config.signing.active_public_jwk_file = active.clone(); + config.signing.published_public_jwk_files = vec![active]; - assert!(matches!(error, MinterError::RetiredKey(_)), "{error:?}"); + let error = load_public_keys(&config).expect_err("duplicate id is rejected"); + assert!(matches!(error, MinterError::PublicKey(_)), "{error:?}"); } /// Consumers parse the whole set into `JwkSet` before selecting a key, so a @@ -531,15 +677,14 @@ clients: /// ready. Checking for a `kid` string is not the same as checking the entry /// is a key. #[test] - fn a_retired_entry_that_is_not_a_usable_public_key_is_refused() { + fn an_entry_that_is_not_an_exact_es256_public_key_is_refused() { let directory = tempfile::tempdir().expect("temp dir"); let path = directory.path().join("retired.jwk"); fs::write(&path, r#"{"kid":"mint-2025-07"}"#).expect("write retired key"); - let error = - build_jwks(&active_signer(), &[path]).expect_err("an unusable entry is rejected"); + let error = load_public_key(&path).expect_err("an unusable entry is rejected"); - assert!(matches!(error, MinterError::RetiredKey(_)), "{error:?}"); + assert!(matches!(error, MinterError::PublicKey(_)), "{error:?}"); } /// RFC 7518 section 6.3.2.7 puts the remaining prime factors of a @@ -547,7 +692,7 @@ clients: /// and is caught by that, but the published set must not depend on which /// private member happens to be present. #[test] - fn a_retired_entry_carrying_other_prime_material_is_refused() { + fn a_public_entry_carrying_private_material_is_refused() { let directory = tempfile::tempdir().expect("temp dir"); let path = directory.path().join("retired.jwk"); fs::write( @@ -556,10 +701,63 @@ clients: ) .expect("write retired key"); - let error = - build_jwks(&active_signer(), &[path]).expect_err("private material is rejected"); + let error = load_public_key(&path).expect_err("private material is rejected"); - assert!(matches!(error, MinterError::RetiredKey(_)), "{error:?}"); + assert!(matches!(error, MinterError::PublicKey(_)), "{error:?}"); + } + + #[test] + fn revoked_keys_cannot_be_active_or_published() { + let directory = tempfile::tempdir().expect("temp dir"); + let active = write_public_key(directory.path(), 9); + let active_key = load_public_key(&active).expect("key loads"); + let mut config = crate::config::tests::sample_config(); + config.signing.active_public_jwk_file = active; + config.signing.revoked_key_ids = vec![active_key.kid.expect("kid")]; + + let error = load_public_keys(&config).expect_err("revoked active key is rejected"); + assert!(matches!(error, MinterError::PublicKey(_)), "{error:?}"); + } + + #[test] + fn governed_key_ids_and_file_names_are_derived_not_chosen() { + let directory = tempfile::tempdir().expect("temp dir"); + let (_private, mut public) = p256_key(9); + public["kid"] = json!("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + let chosen_path = directory + .path() + .join("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.jwk.json"); + fs::write(&chosen_path, public.to_string()).expect("write chosen-id key"); + assert!(load_public_key(&chosen_path).is_err()); + + let valid_path = write_public_key(directory.path(), 8); + let wrong_name = directory.path().join("active.jwk.json"); + fs::rename(&valid_path, &wrong_name).expect("rename valid key"); + let mut config = crate::config::tests::sample_config(); + config.signing.active_public_jwk_file = wrong_name; + assert!(load_public_keys(&config).is_err()); + } + + #[tokio::test] + async fn local_private_material_must_match_the_governed_active_key() { + let directory = tempfile::tempdir().expect("temp dir"); + let (private, _) = p256_key(8); + let secret_path = directory.path().join("mint-signing"); + fs::write(&secret_path, private).expect("write private key"); + fs::set_permissions(&secret_path, fs::Permissions::from_mode(0o600)).expect("chmod"); + let (_, governed_value) = p256_key(9); + let governed = PublicJwk::parse(&governed_value.to_string()).expect("public JWK parses"); + let mut config = crate::config::tests::sample_config(); + config.validation_mode = crate::config::ValidationMode::SupervisedLocalDevelopment; + config.signer = SignerConfig::LocalJwk { + private_key_ref: "secret:file/mint-signing".to_owned(), + }; + config.secret_providers.file.root = directory.path().to_path_buf(); + + assert!( + build_signer(&config, &governed).await.is_err(), + "a private key for another public JWK must be rejected" + ); } fn undelegated(client: &std::sync::Arc) -> AuthenticatedClient { @@ -583,7 +781,7 @@ clients: #[tokio::test] async fn minted_claims_come_from_the_registry() { - let fixture = fixture(None); + let fixture = fixture(None).await; let client = fixture.registry.get("client-a").expect("client registered"); let minted = fixture .minter @@ -592,7 +790,7 @@ clients: .expect("token mints"); let claims = decode_claims(&minted.access_token); - assert_eq!(claims["iss"], json!("https://mint.example.org")); + assert_eq!(claims["iss"], json!("http://127.0.0.1:8081")); assert_eq!(claims["aud"], json!("evidence")); assert_eq!(claims["sub"], json!("urn:example:client-a")); assert_eq!(claims["client_id"], json!("client-a")); @@ -613,7 +811,7 @@ clients: #[tokio::test] async fn the_header_names_the_active_key_and_access_token_type() { - let fixture = fixture(None); + let fixture = fixture(None).await; let client = fixture.registry.get("client-a").expect("client registered"); let minted = fixture .minter @@ -621,15 +819,19 @@ clients: .await .expect("token mints"); + let header = decode_header(&minted.access_token); + assert_eq!(header["alg"], json!("ES256")); + assert_eq!(header["typ"], json!("at+jwt")); assert_eq!( - decode_header(&minted.access_token), - json!({"alg": "EdDSA", "typ": "at+jwt", "kid": "mint-2026-01"}) + header["kid"].as_str().map(str::len), + Some(43), + "service kid is an RFC 7638 SHA-256 thumbprint" ); } #[tokio::test] async fn a_grant_is_minted_as_a_matched_pair_or_not_at_all() { - let without = fixture(None); + let without = fixture(None).await; let client = without.registry.get("client-a").expect("client registered"); let claims = decode_claims( &without @@ -642,7 +844,7 @@ clients: assert!(claims.get("evidence_grant_id").is_none()); assert!(claims.get("evidence_authority").is_none()); - let with = fixture(Some("{id: grant-1, authority: statute-7}")); + let with = fixture(Some("{id: grant-1, authority: statute-7}")).await; let client = with.registry.get("client-a").expect("client registered"); let claims = decode_claims( &with @@ -658,7 +860,7 @@ clients: #[tokio::test] async fn every_token_carries_a_distinct_identifier() { - let fixture = fixture(None); + let fixture = fixture(None).await; let client = fixture.registry.get("client-a").expect("client registered"); let first = fixture .minter @@ -680,8 +882,8 @@ clients: "delegation:\n actors: [urn:example:agent-one]\n subjectClaims:\n given_name: identity.given_name\n birth_date: identity.birth_date\n"; const ACTOR_CLAIM: &str = " actor: evidence_actor\n"; - fn delegated_fixture() -> Fixture { - build_fixture(None, DELEGATION, ACTOR_CLAIM) + async fn delegated_fixture() -> Fixture { + build_fixture(None, DELEGATION, ACTOR_CLAIM).await } fn delegation(subject: &[(&str, Value)]) -> crate::assertion::ResolvedDelegation { @@ -709,7 +911,7 @@ clients: /// selector it will not accept from the request body. #[tokio::test] async fn a_delegated_token_carries_the_actor_and_the_subject_at_their_declared_paths() { - let fixture = delegated_fixture(); + let fixture = delegated_fixture().await; let client = fixture.registry.get("client-a").expect("client registered"); let minted = fixture .minter @@ -741,7 +943,7 @@ clients: /// server reading the subject from the token has nothing to read. #[tokio::test] async fn an_undelegated_token_carries_no_actor_and_no_subject() { - let fixture = delegated_fixture(); + let fixture = delegated_fixture().await; let client = fixture.registry.get("client-a").expect("client registered"); let minted = fixture .minter @@ -759,7 +961,7 @@ clients: /// than mint a token whose subject no resource server can attribute. #[tokio::test] async fn minting_a_delegation_without_a_configured_actor_claim_is_a_server_error() { - let fixture = build_fixture(None, DELEGATION, ""); + let fixture = build_fixture(None, DELEGATION, "").await; let client = fixture.registry.get("client-a").expect("client registered"); let error = fixture .minter @@ -783,7 +985,7 @@ clients: #[tokio::test] async fn a_delegation_resolved_for_an_undelegated_client_is_a_server_error() { - let fixture = build_fixture(None, "", ACTOR_CLAIM); + let fixture = build_fixture(None, "", ACTOR_CLAIM).await; let client = fixture.registry.get("client-a").expect("client registered"); let error = fixture .minter @@ -801,7 +1003,7 @@ clients: #[tokio::test] async fn subject_claims_sharing_a_path_prefix_nest_into_one_object() { let deep = "delegation:\n subjectClaims:\n given_name: subject.identity.given_name\n region: subject.residence.region\n"; - let fixture = build_fixture(None, deep, ACTOR_CLAIM); + let fixture = build_fixture(None, deep, ACTOR_CLAIM).await; let client = fixture.registry.get("client-a").expect("client registered"); let minted = fixture .minter @@ -828,7 +1030,7 @@ clients: async fn a_subject_path_colliding_with_an_authority_claim_is_a_server_error() { let colliding = "delegation:\n subjectClaims:\n given_name: evidence_audience.given_name\n"; - let fixture = build_fixture(None, colliding, ACTOR_CLAIM); + let fixture = build_fixture(None, colliding, ACTOR_CLAIM).await; let client = fixture.registry.get("client-a").expect("client registered"); let error = fixture .minter @@ -841,9 +1043,9 @@ clients: ); } - #[test] - fn the_published_key_set_carries_public_material_only() { - let fixture = fixture(None); + #[tokio::test] + async fn the_published_key_set_carries_public_material_only() { + let fixture = fixture(None).await; let rendered = serde_json::to_string(fixture.minter.jwks()).expect("jwks serializes"); for member in [ "\"d\"", "\"p\"", "\"q\"", "\"dp\"", "\"dq\"", "\"qi\"", "\"k\"", @@ -853,17 +1055,18 @@ clients: "the published key set must not contain {member}" ); } - assert!(rendered.contains("mint-2026-01")); + assert!(rendered.contains("ES256")); } - #[test] - fn debug_output_never_reveals_the_signing_key() { - let fixture = fixture(None); + #[tokio::test] + async fn debug_output_never_reveals_the_signing_key() { + let fixture = fixture(None).await; let rendered = format!("{:?}", fixture.minter); // Useful for operators: which key is live, and under what identity. - assert!(rendered.contains("mint-2026-01")); - assert!(rendered.contains("https://mint.example.org")); + let kid = fixture.minter.signer.key_id(); + assert!(rendered.contains(kid)); + assert!(rendered.contains("http://127.0.0.1:8081")); // The private scalar of the fixture's signing key, verbatim. Debug is // the easiest place for key material to escape into a log line. @@ -873,4 +1076,117 @@ clients: "the debug output must never carry private key material" ); } + + struct RecoverableSigner { + inner: LocalJwkSigner, + available: AtomicBool, + ready: AtomicBool, + attempts: AtomicUsize, + } + + impl RecoverableSigner { + fn new() -> Self { + let (private, _) = p256_key(9); + let private = PrivateJwk::parse(&private).expect("private JWK parses"); + Self { + inner: LocalJwkSigner::new(private).expect("test signer builds"), + available: AtomicBool::new(true), + ready: AtomicBool::new(true), + attempts: AtomicUsize::new(0), + } + } + + fn set_available(&self, available: bool) { + self.available.store(available, Ordering::Release); + } + + fn attempts(&self) -> usize { + self.attempts.load(Ordering::Acquire) + } + } + + #[async_trait::async_trait] + impl SigningProvider for RecoverableSigner { + fn algorithm(&self) -> SigningAlgorithm { + self.inner.algorithm() + } + + fn key_id(&self) -> &str { + self.inner.key_id() + } + + fn public_jwk(&self) -> PublicJwk { + self.inner.public_jwk() + } + + fn readiness(&self) -> KeyReadiness { + if self.ready.load(Ordering::Acquire) { + KeyReadiness::Ready + } else { + KeyReadiness::NotReady + } + } + + async fn sign( + &self, + payload: &[u8], + ) -> Result, registry_platform_crypto::SigningError> { + self.attempts.fetch_add(1, Ordering::AcqRel); + if !self.available.load(Ordering::Acquire) { + self.ready.store(false, Ordering::Release); + return Err(SigningError::external("provider-token-secret")); + } + let result = self.inner.sign(payload).await; + self.ready.store(result.is_ok(), Ordering::Release); + result + } + } + + #[tokio::test] + async fn readiness_probes_recover_the_provider_without_exposing_failures() { + let mut fixture = fixture(None).await; + let signer = Arc::new(RecoverableSigner::new()); + fixture.minter.signer = signer.clone(); + assert!(fixture.minter.ready().await, "the signer starts ready"); + assert_eq!(signer.attempts(), 0, "a ready signer is not probed"); + + let client = fixture.registry.get("client-a").expect("client registered"); + let authenticated = undelegated(client); + signer.set_available(false); + let error = fixture + .minter + .mint(&authenticated, NOW) + .await + .expect_err("provider loss fails the request"); + assert_eq!(signer.readiness(), KeyReadiness::NotReady); + assert!( + !format!("{error:?}").contains("provider-token-secret"), + "provider details must not escape through the request error" + ); + assert!( + !fixture.minter.ready().await, + "a failed recovery probe remains unready" + ); + + signer.set_available(true); + assert!( + fixture.minter.ready().await, + "readiness proves provider recovery without request traffic" + ); + assert_eq!(signer.readiness(), KeyReadiness::Ready); + + signer.set_available(false); + fixture + .minter + .mint(&authenticated, NOW) + .await + .expect_err("a second provider loss fails closed"); + signer.set_available(true); + fixture + .minter + .mint(&authenticated, NOW) + .await + .expect("request-path signing also recovers readiness"); + assert_eq!(signer.readiness(), KeyReadiness::Ready); + } } diff --git a/crates/registry-mint/tests/delegated_subject_binding.rs b/crates/registry-mint/tests/delegated_subject_binding.rs index 31520528d..7d0d9296a 100644 --- a/crates/registry-mint/tests/delegated_subject_binding.rs +++ b/crates/registry-mint/tests/delegated_subject_binding.rs @@ -26,18 +26,18 @@ use registry_mint::{ server::{build_app, MintService}, CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, ON_BEHALF_OF_CLAIM, }; -use registry_platform_crypto::PrivateJwk; +use registry_platform_crypto::{PrivateJwk, PublicJwk}; use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; use serde_json::{json, Value}; /// These four must agree with `demo/evidence-bundle/evidence.yaml`. If they ever /// drift, this test is the thing that says so. -const ISSUER: &str = "https://localhost:8443"; +const ISSUER: &str = "http://127.0.0.1:8443"; const EVIDENCE_AUDIENCE: &str = "evidence.demo.invalid"; const ACTOR_CLAIM: &str = "evidence_actor"; const REQUIREMENT: &str = "urn:example:demo:requirement:residence-region:v1"; const PURPOSE: &str = "demo-routing"; -const ASSERTION_AUDIENCE: &str = "https://localhost:8443/token"; +const ASSERTION_AUDIENCE: &str = "http://127.0.0.1:8443/token"; const AGENT: &str = "urn:example:demo:agent:appointment-scheduler"; @@ -59,6 +59,23 @@ fn key_pair(seed: u8) -> (PrivateJwk, Value, Value) { (private, public, private_document) } +fn service_key_pair(seed: u8) -> (Value, Value) { + let scalar = [seed; 32]; + let signing = p256::ecdsa::SigningKey::from_slice(&scalar).expect("valid P-256 scalar"); + let encoded = signing.verifying_key().to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(encoded.x().expect("uncompressed x")); + let y = URL_SAFE_NO_PAD.encode(encoded.y().expect("uncompressed y")); + let bare = PublicJwk::parse( + &json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "x":x, "y":y}).to_string(), + ) + .expect("public JWK parses"); + let kid = bare.jkt().expect("thumbprint computes"); + ( + json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y}), + json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y, "d":URL_SAFE_NO_PAD.encode(scalar)}), + ) +} + struct Deployment { _directory: tempfile::TempDir, service: Arc, @@ -72,8 +89,18 @@ async fn deployment() -> Deployment { let root = directory.path(); fs::create_dir(root.join("secrets")).expect("create secrets directory"); fs::create_dir(root.join("clients")).expect("create clients directory"); + fs::create_dir(root.join("public-keys")).expect("create public key directory"); - let (_, _, signing_document) = key_pair(9); + let (signing_public, signing_document) = service_key_pair(9); + let public_file = format!( + "{}.jwk.json", + signing_public["kid"].as_str().expect("service key id") + ); + fs::write( + root.join("public-keys").join(&public_file), + signing_public.to_string(), + ) + .expect("write governed public key"); let signing_path = root.join("secrets/signing.jwk"); fs::write(&signing_path, signing_document.to_string()).expect("write signing key"); fs::set_permissions(&signing_path, fs::Permissions::from_mode(0o600)) @@ -111,16 +138,23 @@ async fn deployment() -> Deployment { format!( r#" version: 1 +validationMode: supervised-local-development issuer: {ISSUER} -listener: {{address: 127.0.0.1, port: 0}} +listener: {{address: 127.0.0.1, port: 8443}} signing: - algorithm: EdDSA - activeKeyId: key-9 - activeKeyFile: secrets/signing.jwk + algorithm: ES256 + activePublicJwkFile: public-keys/{public_file} + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: local-jwk + privateKeyRef: secret:file/signing.jwk +secretProviders: + file: {{root: {}}} audit: path: audit/mint.jsonl maximumFileBytes: 1073741824 - hashKeyFile: secrets/audit-hmac-key + hashKeyRef: secret:file/audit-hmac-key hashKeyVersion: 1 accessTokens: audiences: [{EVIDENCE_AUDIENCE}] @@ -137,7 +171,8 @@ clientAssertion: algorithms: [EdDSA] clients: directory: clients -"# +"#, + root.join("secrets").display() ), ) .expect("write config"); @@ -225,7 +260,7 @@ fn evidence_authenticator(jwks: &Value) -> Authenticator { let verifier_config = TokenVerifierConfig::access_token_profile( ISSUER.to_owned(), vec![EVIDENCE_AUDIENCE.to_owned()], - vec![jsonwebtoken::Algorithm::EdDSA], + vec![jsonwebtoken::Algorithm::ES256], vec!["at+jwt".to_owned()], ); Authenticator::new( diff --git a/crates/registry-mint/tests/evidence_compatibility.rs b/crates/registry-mint/tests/evidence_compatibility.rs index 1bd1ecee8..c690fc12a 100644 --- a/crates/registry-mint/tests/evidence_compatibility.rs +++ b/crates/registry-mint/tests/evidence_compatibility.rs @@ -22,7 +22,7 @@ use registry_mint::{ server::{build_app, serve, MintService}, CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, }; -use registry_platform_crypto::PrivateJwk; +use registry_platform_crypto::{PrivateJwk, PublicJwk}; use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; use serde_json::{json, Value}; @@ -30,8 +30,8 @@ use serde_json::{json, Value}; // written inline so a secret scanner does not read the write call as an // assignment of a live credential. const AUDIT_HASH_KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; -const ISSUER: &str = "https://mint.example.org"; -const ASSERTION_AUDIENCE: &str = "https://mint.example.org/token"; +const ISSUER: &str = "http://127.0.0.1:18082"; +const ASSERTION_AUDIENCE: &str = "http://127.0.0.1:18082/token"; const LOCAL_ISSUER: &str = "http://127.0.0.1:18081"; const LOCAL_ASSERTION_AUDIENCE: &str = "http://127.0.0.1:18081/token"; const EVIDENCE_AUDIENCE: &str = "evidence.example.org"; @@ -59,6 +59,23 @@ fn key_pair(seed: u8) -> (PrivateJwk, Value, Value) { (private, public, private_document) } +fn service_key_pair(seed: u8) -> (Value, Value) { + let scalar = [seed; 32]; + let signing = p256::ecdsa::SigningKey::from_slice(&scalar).expect("valid P-256 scalar"); + let encoded = signing.verifying_key().to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(encoded.x().expect("uncompressed x")); + let y = URL_SAFE_NO_PAD.encode(encoded.y().expect("uncompressed y")); + let bare = PublicJwk::parse( + &json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "x":x, "y":y}).to_string(), + ) + .expect("public JWK parses"); + let kid = bare.jkt().expect("thumbprint computes"); + ( + json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y}), + json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y, "d":URL_SAFE_NO_PAD.encode(scalar)}), + ) +} + struct Deployment { /// Held so the directory outlives the service that reads from it. _directory: tempfile::TempDir, @@ -68,7 +85,13 @@ struct Deployment { /// Write a complete Mint deployment to disk and load it exactly as the binary /// would, including the owner-only permission requirement on the signing key. async fn deployment() -> Deployment { - deployment_with_transport(None, ISSUER, 0, ASSERTION_AUDIENCE).await + deployment_with_transport( + Some("supervised-local-development"), + ISSUER, + 18082, + ASSERTION_AUDIENCE, + ) + .await } async fn supervised_local_development_deployment() -> Deployment { @@ -91,8 +114,18 @@ async fn deployment_with_transport( let root = directory.path(); fs::create_dir(root.join("secrets")).expect("create secrets directory"); fs::create_dir(root.join("clients")).expect("create clients directory"); + fs::create_dir(root.join("public-keys")).expect("create public key directory"); - let (_, _, signing_document) = key_pair(9); + let (signing_public, signing_document) = service_key_pair(9); + let public_file = format!( + "{}.jwk.json", + signing_public["kid"].as_str().expect("service key id") + ); + fs::write( + root.join("public-keys").join(&public_file), + signing_public.to_string(), + ) + .expect("write governed public key"); let signing_path = root.join("secrets/signing.jwk"); fs::write(&signing_path, signing_document.to_string()).expect("write signing key"); fs::set_permissions(&signing_path, fs::Permissions::from_mode(0o600)) @@ -124,13 +157,19 @@ version: 1 {validation_mode}issuer: {issuer} listener: {{address: 127.0.0.1, port: {listener_port}}} signing: - algorithm: EdDSA - activeKeyId: key-9 - activeKeyFile: secrets/signing.jwk + algorithm: ES256 + activePublicJwkFile: public-keys/{public_file} + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: local-jwk + privateKeyRef: secret:file/signing.jwk +secretProviders: + file: {{root: {}}} audit: path: audit/mint.jsonl maximumFileBytes: 1073741824 - hashKeyFile: secrets/audit-hmac-key + hashKeyRef: secret:file/audit-hmac-key hashKeyVersion: 1 accessTokens: audiences: [{EVIDENCE_AUDIENCE}] @@ -146,7 +185,8 @@ clientAssertion: algorithms: [EdDSA] clients: directory: clients -"# +"#, + root.join("secrets").display() ), ) .expect("write config"); @@ -228,7 +268,7 @@ fn evidence_authenticator_for_issuer(jwks: &Value, issuer: &str) -> Authenticato let verifier_config = TokenVerifierConfig::access_token_profile( issuer.to_owned(), vec![EVIDENCE_AUDIENCE.to_owned()], - vec![jsonwebtoken::Algorithm::EdDSA], + vec![jsonwebtoken::Algorithm::ES256], vec!["at+jwt".to_owned()], ); let fetcher = Arc::new(JwksFetcher::new_static( @@ -404,7 +444,9 @@ async fn evidence_fetches_keys_from_a_real_supervised_local_mint() { issuer: issuer.clone(), audiences: vec![EVIDENCE_AUDIENCE.to_owned()], token_types: vec![AccessTokenType::AtJwt], - algorithms: vec![AccessTokenAlgorithm::EdDSA], + algorithms: vec![AccessTokenAlgorithm::ES256], + maximum_token_lifetime_seconds: 300, + revoked_key_ids: Vec::new(), jwks_uri, principal_claim: PRINCIPAL_CLAIM.to_owned(), requester_tags_claim: REQUESTER_TAGS_CLAIM.to_owned(), diff --git a/crates/registry-mint/tests/token_cli.rs b/crates/registry-mint/tests/token_cli.rs index 0acaee19e..8b01552aa 100644 --- a/crates/registry-mint/tests/token_cli.rs +++ b/crates/registry-mint/tests/token_cli.rs @@ -17,10 +17,9 @@ use std::{ }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_crypto::PublicJwk; use serde_json::{json, Value}; -const ISSUER: &str = "https://mint.example.org"; -const ASSERTION_AUDIENCE: &str = "https://mint.example.org/token"; const ACTOR: &str = "urn:example:agent:scheduler"; /// Deterministic Ed25519 material, so a test knows which identity signed what. @@ -36,6 +35,23 @@ fn key_pair(seed: u8) -> (Value, Value) { ) } +fn service_key_pair(seed: u8) -> (Value, Value) { + let scalar = [seed; 32]; + let signing = p256::ecdsa::SigningKey::from_slice(&scalar).expect("valid P-256 scalar"); + let encoded = signing.verifying_key().to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(encoded.x().expect("uncompressed x")); + let y = URL_SAFE_NO_PAD.encode(encoded.y().expect("uncompressed y")); + let bare = PublicJwk::parse( + &json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "x":x, "y":y}).to_string(), + ) + .expect("public JWK parses"); + let kid = bare.jkt().expect("thumbprint computes"); + ( + json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y}), + json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y, "d":URL_SAFE_NO_PAD.encode(scalar)}), + ) +} + /// A running `mint serve`, killed when the test drops it however it ends. struct Server { _directory: tempfile::TempDir, @@ -43,6 +59,7 @@ struct Server { port: u16, root: PathBuf, config: PathBuf, + issuer: String, } impl Drop for Server { @@ -60,6 +77,10 @@ impl Server { fn caller_key(&self, client_id: &str) -> PathBuf { self.root.join(format!("{client_id}.jwk")) } + + fn assertion_audience(&self) -> String { + format!("{}/token", self.issuer) + } } fn write_owner_only(path: &Path, contents: &str) { @@ -73,8 +94,18 @@ fn server() -> Server { let root = directory.path().to_path_buf(); fs::create_dir(root.join("secrets")).expect("create secrets directory"); fs::create_dir(root.join("clients")).expect("create clients directory"); + fs::create_dir(root.join("public-keys")).expect("create public key directory"); - let (_, signing_private) = key_pair(9); + let (signing_public, signing_private) = service_key_pair(9); + let public_file = format!( + "{}.jwk.json", + signing_public["kid"].as_str().expect("service key id") + ); + fs::write( + root.join("public-keys").join(&public_file), + signing_public.to_string(), + ) + .expect("write governed public key"); write_owner_only( &root.join("secrets/signing.jwk"), &signing_private.to_string(), @@ -91,6 +122,8 @@ fn server() -> Server { .local_addr() .expect("the reserved port") .port(); + let issuer = format!("http://127.0.0.1:{port}"); + let assertion_audience = format!("{issuer}/token"); let (scheduler_public, scheduler_private) = key_pair(1); write_owner_only(&root.join("scheduler.jwk"), &scheduler_private.to_string()); @@ -132,16 +165,23 @@ keys: [{reporter_public}] &config, format!( "version: 1 -issuer: {ISSUER} +validationMode: supervised-local-development +issuer: {issuer} listener: {{address: 127.0.0.1, port: {port}}} signing: - algorithm: EdDSA - activeKeyId: key-9 - activeKeyFile: secrets/signing.jwk + algorithm: ES256 + activePublicJwkFile: public-keys/{public_file} + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: local-jwk + privateKeyRef: secret:file/signing.jwk +secretProviders: + file: {{root: {}}} audit: path: audit/mint.jsonl maximumFileBytes: 1073741824 - hashKeyFile: secrets/audit-hmac-key + hashKeyRef: secret:file/audit-hmac-key hashKeyVersion: 1 accessTokens: audiences: [evidence.example.org] @@ -154,11 +194,12 @@ accessTokens: grantAuthority: evidence_authority actor: evidence_actor clientAssertion: - audience: {ASSERTION_AUDIENCE} + audience: {assertion_audience} algorithms: [EdDSA] clients: directory: clients -" +", + root.join("secrets").display() ), ) .expect("write config"); @@ -178,6 +219,7 @@ clients: port, root, config, + issuer, }; wait_for_listener(port); server @@ -210,7 +252,7 @@ fn mint_token(server: &Server, client_id: &str, extra: &[&str]) -> Output { // audience is its public URL, which is the deployment shape behind a // TLS terminator and the reason the flag exists. .arg("--audience") - .arg(ASSERTION_AUDIENCE) + .arg(server.assertion_audience()) .arg("--client-id") .arg(client_id) .arg("--key") @@ -245,7 +287,7 @@ fn the_subcommand_obtains_a_token_the_endpoint_agreed_to_issue() { assert_eq!(stdout.lines().count(), 1, "stdout was: {stdout:?}"); let claims = claims_of(stdout.trim()); - assert_eq!(claims["iss"], json!(ISSUER)); + assert_eq!(claims["iss"], json!(server.issuer)); assert_eq!(claims["sub"], json!("urn:example:principal:reporter")); assert_eq!(claims["client_id"], json!("reporter")); assert_eq!(claims["evidence_tags"], json!(["reporter"])); @@ -402,7 +444,7 @@ fn a_refusal_does_not_echo_the_signed_assertion_back() { .arg("--url") .arg(format!("http://127.0.0.1:{port}/token")) .arg("--audience") - .arg(ASSERTION_AUDIENCE) + .arg(server.assertion_audience()) .arg("--client-id") .arg("scheduler") .arg("--key") diff --git a/crates/registry-platform-audit/src/lib.rs b/crates/registry-platform-audit/src/lib.rs index 54529fa03..631029d62 100644 --- a/crates/registry-platform-audit/src/lib.rs +++ b/crates/registry-platform-audit/src/lib.rs @@ -232,6 +232,23 @@ pub struct AuditChainProfile { } impl AuditChainProfile { + /// Production profile backed by caller-owned master secret bytes. + /// + /// The bytes are used exactly as supplied, without trimming or decoding, + /// and are zeroized after the chain sub-key is derived. The resulting key + /// is identical to the chain key derived by [`AuditProfile::production_from_secret_bytes`] + /// for the same master bytes. + pub fn production_from_secret_bytes( + master_secret: Zeroizing>, + ) -> Result { + Ok(Self { + hasher: AuditChainHasher::Keyed(derive_subkey_from_secret_bytes( + &master_secret, + CHAIN_KEY_DERIVATION_INFO, + )?), + }) + } + /// Production profile backed by the HMAC secret in `env_var_name`. /// /// The chain key is an HKDF-derived sub-key of the master env secret so it is @@ -279,6 +296,29 @@ pub struct AuditProfile { } impl AuditProfile { + /// Production profile backed by caller-owned master secret bytes. + /// + /// The bytes are used exactly as supplied, without trimming or decoding, + /// and are zeroized after independent chain-integrity and identifier-hash + /// sub-keys are derived. This is the byte-backed counterpart to + /// [`Self::production_from_env`] for file and external secret providers. + pub fn production_from_secret_bytes( + master_secret: Zeroizing>, + ) -> Result { + let chain_hasher = AuditChainHasher::Keyed(derive_subkey_from_secret_bytes( + &master_secret, + CHAIN_KEY_DERIVATION_INFO, + )?); + let key_hasher = AuditKeyHasher::Keyed(derive_subkey_from_secret_bytes( + &master_secret, + IDENTIFIER_KEY_DERIVATION_INFO, + )?); + Ok(Self { + chain_hasher, + key_hasher, + }) + } + /// Production profile backed by the HMAC secret in `env_var_name`. /// /// The chain key and identifier key are independent HKDF-derived sub-keys of @@ -2422,16 +2462,31 @@ fn derive_subkey_from_env(env_var_name: &str, info: &[u8]) -> Result`) owns and scrubs the master secret across - // every return path; borrow its bytes for the length check and HKDF rather - // than allocating a separate copy of the secret. - if value.len() < MIN_AUDIT_SECRET_BYTES { - return Err(AuditError::WeakSecret { + derive_subkey_from_secret_bytes(value.as_bytes(), info).map_err(|error| match error { + AuditError::WeakSecret { min_bytes, .. } => AuditError::WeakSecret { name: env_var_name.to_string(), + min_bytes, + }, + error => error, + }) +} + +/// Derive one domain-separated sub-key from exact caller-owned master bytes. +/// +/// The public byte-backed profile constructors retain the `Zeroizing` owner +/// while this helper borrows the bytes, so both success and failure scrub the +/// master after all requested sub-keys have been derived. +fn derive_subkey_from_secret_bytes( + master_secret: &[u8], + info: &[u8], +) -> Result { + if master_secret.len() < MIN_AUDIT_SECRET_BYTES { + return Err(AuditError::WeakSecret { + name: "explicit master secret".to_string(), min_bytes: MIN_AUDIT_SECRET_BYTES, }); } - let derived = hkdf_expand_sha256(value.as_bytes(), info); + let derived = hkdf_expand_sha256(master_secret, info); Ok(AuditHashSecret::from_bytes(derived)) } @@ -4126,6 +4181,54 @@ mod tests { assert_eq!(chain_a.as_bytes(), expected.as_slice()); } + #[test] + fn byte_backed_profiles_match_known_answers_and_each_other() { + let master = (0_u8..32).collect::>(); + let profile = AuditProfile::production_from_secret_bytes(Zeroizing::new(master.clone())) + .expect("byte-backed profile"); + let chain_profile = + AuditChainProfile::production_from_secret_bytes(Zeroizing::new(master.clone())) + .expect("byte-backed chain profile"); + + let AuditChainHasher::Keyed(profile_chain_key) = profile.chain_hasher() else { + panic!("production audit profile must use a keyed chain hasher"); + }; + let AuditChainHasher::Keyed(chain_profile_key) = chain_profile.hasher() else { + panic!("production chain profile must use a keyed chain hasher"); + }; + let AuditKeyHasher::Keyed(identifier_key) = profile.key_hasher() else { + panic!("production audit profile must use a keyed identifier hasher"); + }; + + assert_eq!( + hex_lower(profile_chain_key.as_bytes()), + "63fb2303093a2e3f8bec0c7b65feb8de3f876b92fdb89329100a185e0cece047" + ); + assert_eq!( + hex_lower(identifier_key.as_bytes()), + "5aacb786d1d4271f42d5568b3d5a2eb5814446ef0b44067b0d4210b8a60bffdc" + ); + assert_eq!(profile_chain_key.as_bytes(), chain_profile_key.as_bytes()); + assert_ne!(profile_chain_key.as_bytes(), identifier_key.as_bytes()); + assert_ne!(profile_chain_key.as_bytes(), master.as_slice()); + assert_ne!(identifier_key.as_bytes(), master.as_slice()); + } + + #[test] + fn byte_backed_profiles_reject_weak_master_secrets_without_echo() { + let marker = b"short-secret-canary".to_vec(); + for error in [ + AuditProfile::production_from_secret_bytes(Zeroizing::new(marker.clone())) + .expect_err("weak profile master must fail"), + AuditChainProfile::production_from_secret_bytes(Zeroizing::new(marker.clone())) + .expect_err("weak chain master must fail"), + ] { + assert!(matches!(error, AuditError::WeakSecret { .. })); + assert!(!format!("{error:?}").contains("short-secret-canary")); + assert!(!error.to_string().contains("short-secret-canary")); + } + } + #[tokio::test] async fn audit_profile_chain_and_identifier_keys_are_domain_separated() { // AUDIT-03: the two profiles must agree on the derived chain key while diff --git a/crates/registry-platform-config/Cargo.toml b/crates/registry-platform-config/Cargo.toml index 9ff0d8358..a2c257edb 100644 --- a/crates/registry-platform-config/Cargo.toml +++ b/crates/registry-platform-config/Cargo.toml @@ -19,6 +19,7 @@ serde_json.workspace = true sha2.workspace = true thiserror.workspace = true time.workspace = true +zeroize.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/registry-platform-config/src/lib.rs b/crates/registry-platform-config/src/lib.rs index 09a5a451c..3c478e8a7 100644 --- a/crates/registry-platform-config/src/lib.rs +++ b/crates/registry-platform-config/src/lib.rs @@ -1,6 +1,7 @@ //! Governed runtime configuration verification contracts. mod config_bundle; +mod secrets; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -20,6 +21,7 @@ pub use config_bundle::{ MAX_MANIFEST_BYTES, MAX_SIGNATURE_ENVELOPE_BYTES, MAX_TRUST_ANCHOR_BYTES, MAX_TRUST_ANCHOR_SIGNERS, }; +pub use secrets::{ProtectedSecret, SecretError, SecretProvider, SecretResolver, MAX_SECRET_BYTES}; #[derive(Debug, Clone, Eq, PartialEq)] pub struct DeprecatedConfigField { diff --git a/crates/registry-platform-config/src/secrets.rs b/crates/registry-platform-config/src/secrets.rs new file mode 100644 index 000000000..f7fd6bae6 --- /dev/null +++ b/crates/registry-platform-config/src/secrets.rs @@ -0,0 +1,410 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Bounded resolution of closed runtime secret references. + +use std::{ + collections::BTreeSet, + env, fmt, + fs::File, + io::Read, + path::{Path, PathBuf}, +}; + +use thiserror::Error; +use zeroize::Zeroizing; + +/// Maximum accepted size of one resolved secret value. +pub const MAX_SECRET_BYTES: usize = 64 * 1024; + +/// Closed set of secret providers supported by [`SecretResolver`]. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum SecretProvider { + /// An exact process environment variable. + Environment, + /// One owner-only file immediately below the configured root. + File, +} + +/// Value-free secret resolution failure. +#[derive(Debug, Error, Eq, PartialEq)] +pub enum SecretError { + #[error("the secret reference is invalid")] + InvalidReference, + #[error("the secret reference uses a disabled provider")] + ProviderDisabled, + #[error("the secret provider configuration is invalid")] + InvalidProviderConfiguration, + #[error("the referenced secret is unavailable")] + Unavailable, + #[error("the referenced secret file is unsafe")] + UnsafeFile, + #[error("the referenced secret could not be read")] + Read, + #[error("the referenced secret value is invalid")] + InvalidValue, +} + +/// Secret bytes that are erased when dropped and never exposed by `Debug`. +pub struct ProtectedSecret(Zeroizing>); + +impl ProtectedSecret { + /// Borrow the secret for the smallest possible consumer scope. + #[must_use] + pub fn expose_secret(&self) -> &[u8] { + self.0.as_slice() + } + + #[must_use] + pub fn len(&self) -> usize { + self.0.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl fmt::Debug for ProtectedSecret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ProtectedSecret([REDACTED])") + } +} + +/// Resolver for exact secret references under an explicit provider allowlist. +/// +/// File names are single bounded path components and are opened relative to +/// the configured absolute root with `openat`. The opened file, rather than a +/// later pathname lookup, is checked for type, ownership, mode, and link count +/// before at most [`MAX_SECRET_BYTES`] are retained. +#[derive(Debug)] +pub struct SecretResolver { + providers: BTreeSet, + file_root: PathBuf, +} + +impl SecretResolver { + /// Build a resolver from the only providers a runtime enables. + pub fn new( + providers: impl IntoIterator, + file_root: impl Into, + ) -> Result { + let providers = providers.into_iter().collect::>(); + let file_root = file_root.into(); + if providers.is_empty() + || (providers.contains(&SecretProvider::File) && !file_root.is_absolute()) + { + return Err(SecretError::InvalidProviderConfiguration); + } + Ok(Self { + providers, + file_root, + }) + } + + /// Resolve one exact `secret:env/NAME` or `secret:file/name` reference. + pub fn resolve(&self, reference: &str) -> Result { + let (provider, name) = parse_reference(reference)?; + if !self.providers.contains(&provider) { + return Err(SecretError::ProviderDisabled); + } + + let bytes = match provider { + SecretProvider::Environment => read_environment(name)?, + SecretProvider::File => read_secret_file(&self.file_root, name)?, + }; + validate_secret(bytes) + } +} + +fn parse_reference(reference: &str) -> Result<(SecretProvider, &str), SecretError> { + if let Some(name) = reference.strip_prefix("secret:env/") { + if valid_environment_name(name) { + return Ok((SecretProvider::Environment, name)); + } + } else if let Some(name) = reference.strip_prefix("secret:file/") { + if valid_file_name(name) { + return Ok((SecretProvider::File, name)); + } + } + Err(SecretError::InvalidReference) +} + +fn valid_environment_name(name: &str) -> bool { + let bytes = name.as_bytes(); + matches!(bytes.first(), Some(b'A'..=b'Z')) + && bytes.len() <= 128 + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || *byte == b'_') +} + +fn valid_file_name(name: &str) -> bool { + let bytes = name.as_bytes(); + matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes.len() <= 128 + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +fn read_environment(name: &str) -> Result>, SecretError> { + let value = env::var_os(name).ok_or(SecretError::Unavailable)?; + #[cfg(unix)] + let bytes = { + use std::os::unix::ffi::OsStringExt as _; + value.into_vec() + }; + #[cfg(not(unix))] + let bytes = value + .into_string() + .map_err(|_| SecretError::InvalidValue)? + .into_bytes(); + Ok(Zeroizing::new(bytes)) +} + +#[cfg(unix)] +fn read_secret_file(root: &Path, name: &str) -> Result>, SecretError> { + use rustix::fs::{Mode, OFlags}; + + let root = rustix::fs::open( + root, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::DIRECTORY, + Mode::empty(), + ) + .map_err(|_| SecretError::Unavailable)?; + let secret = rustix::fs::openat( + &root, + name, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|_| SecretError::Unavailable)?; + let file = File::from(secret); + validate_file_metadata(&file)?; + read_bounded(file) +} + +#[cfg(unix)] +fn validate_file_metadata(file: &File) -> Result<(), SecretError> { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + let metadata = file.metadata().map_err(|_| SecretError::Read)?; + if !metadata.is_file() + || metadata.uid() != rustix::process::geteuid().as_raw() + || metadata.permissions().mode() & 0o7777 != 0o600 + || metadata.nlink() != 1 + { + return Err(SecretError::UnsafeFile); + } + Ok(()) +} + +#[cfg(not(unix))] +fn read_secret_file(root: &Path, name: &str) -> Result>, SecretError> { + let path = root.join(name); + let metadata = std::fs::symlink_metadata(&path).map_err(|_| SecretError::Unavailable)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(SecretError::UnsafeFile); + } + let file = File::open(path).map_err(|_| SecretError::Unavailable)?; + read_bounded(file) +} + +fn read_bounded(file: File) -> Result>, SecretError> { + let mut bytes = Zeroizing::new(Vec::new()); + file.take((MAX_SECRET_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| SecretError::Read)?; + Ok(bytes) +} + +fn validate_secret(bytes: Zeroizing>) -> Result { + if bytes.is_empty() || bytes.len() > MAX_SECRET_BYTES || bytes.contains(&0) { + return Err(SecretError::InvalidValue); + } + Ok(ProtectedSecret(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + fn environment_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().expect("lock") + } + + #[test] + fn references_use_only_the_two_exact_contract_grammars() { + for valid in [ + "secret:env/A", + "secret:env/SOURCE_2_PASSWORD", + "secret:file/a", + "secret:file/source-token_v2.json", + ] { + assert!(parse_reference(valid).is_ok(), "{valid}"); + } + for invalid in [ + "secret:env/", + "secret:env/lower", + "secret:env/A-B", + "secret:environment/A", + "secret:file/Upper", + "secret:file/../token", + "secret:file/nested/token", + "secret:file/.token", + "secret:file/token\0suffix", + "plain-value", + ] { + assert_eq!(parse_reference(invalid), Err(SecretError::InvalidReference)); + } + assert!(parse_reference(&format!("secret:env/A{}", "B".repeat(127))).is_ok()); + assert_eq!( + parse_reference(&format!("secret:env/A{}", "B".repeat(128))), + Err(SecretError::InvalidReference) + ); + } + + #[test] + fn provider_configuration_is_closed_and_file_roots_are_absolute() { + assert_eq!( + SecretResolver::new([], "/safe-root").expect_err("a provider is required"), + SecretError::InvalidProviderConfiguration + ); + assert_eq!( + SecretResolver::new([SecretProvider::File], "relative") + .expect_err("file root must be absolute"), + SecretError::InvalidProviderConfiguration + ); + SecretResolver::new([SecretProvider::Environment], "") + .expect("environment-only resolver needs no file root"); + } + + #[test] + fn provider_allowlist_is_enforced_before_lookup() { + let resolver = + SecretResolver::new([SecretProvider::File], "/safe-root").expect("resolver builds"); + assert!(matches!( + resolver.resolve("secret:env/DEFINITELY_NOT_PRESENT"), + Err(SecretError::ProviderDisabled) + )); + } + + #[test] + fn environment_secret_is_bounded_and_debug_is_redacted() { + let _guard = environment_lock(); + const NAME: &str = "REGISTRY_PLATFORM_CONFIG_SECRET_RESOLVER_TEST"; + env::set_var(NAME, "environment-canary"); + let resolver = + SecretResolver::new([SecretProvider::Environment], "").expect("resolver builds"); + let secret = resolver + .resolve("secret:env/REGISTRY_PLATFORM_CONFIG_SECRET_RESOLVER_TEST") + .expect("secret resolves"); + env::remove_var(NAME); + + assert_eq!(secret.expose_secret(), b"environment-canary"); + assert_eq!(format!("{secret:?}"), "ProtectedSecret([REDACTED])"); + assert!(!format!("{secret:?}").contains("environment-canary")); + assert_eq!(secret.len(), b"environment-canary".len()); + assert!(!secret.is_empty()); + } + + #[test] + fn empty_nul_and_oversized_values_are_rejected_without_echo() { + for value in [ + Vec::new(), + b"canary\0value".to_vec(), + vec![b'x'; MAX_SECRET_BYTES + 1], + ] { + let error = validate_secret(Zeroizing::new(value)).expect_err("invalid secret"); + assert_eq!(error, SecretError::InvalidValue); + assert_eq!(error.to_string(), "the referenced secret value is invalid"); + } + } + + #[cfg(unix)] + mod unix { + use super::*; + use std::{fs, os::unix::fs::PermissionsExt as _}; + + fn write_secret(root: &Path, name: &str, value: &[u8], mode: u32) { + let path = root.join(name); + fs::write(&path, value).expect("write secret"); + fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("set mode"); + } + + #[test] + fn file_secret_uses_open_file_owner_and_exact_mode_checks() { + let root = tempfile::tempdir().expect("temporary root"); + write_secret(root.path(), "source-token", b"file-canary", 0o600); + let resolver = + SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); + let secret = resolver + .resolve("secret:file/source-token") + .expect("safe file resolves"); + assert_eq!(secret.expose_secret(), b"file-canary"); + + write_secret(root.path(), "unsafe-token", b"unsafe-canary", 0o640); + assert!(matches!( + resolver.resolve("secret:file/unsafe-token"), + Err(SecretError::UnsafeFile) + )); + } + + #[test] + fn file_secret_rejects_symlinks_and_non_regular_files() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().expect("temporary root"); + write_secret(root.path(), "target", b"symlink-canary", 0o600); + symlink(root.path().join("target"), root.path().join("link")).expect("create symlink"); + fs::create_dir(root.path().join("directory")).expect("create directory"); + let resolver = + SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); + + assert!(matches!( + resolver.resolve("secret:file/link"), + Err(SecretError::Unavailable) + )); + assert!(matches!( + resolver.resolve("secret:file/directory"), + Err(SecretError::Unavailable | SecretError::UnsafeFile) + )); + } + + #[test] + fn file_secret_rejects_every_name_for_a_hard_link() { + let root = tempfile::tempdir().expect("temporary root"); + write_secret(root.path(), "first", b"hard-link-canary", 0o600); + fs::hard_link(root.path().join("first"), root.path().join("second")) + .expect("create hard link"); + let resolver = + SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); + + for name in ["first", "second"] { + assert!(matches!( + resolver.resolve(&format!("secret:file/{name}")), + Err(SecretError::UnsafeFile) + )); + } + } + + #[test] + fn file_secret_read_is_bounded() { + let root = tempfile::tempdir().expect("temporary root"); + write_secret( + root.path(), + "oversized", + &vec![b'x'; MAX_SECRET_BYTES + 1], + 0o600, + ); + let resolver = + SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); + assert!(matches!( + resolver.resolve("secret:file/oversized"), + Err(SecretError::InvalidValue) + )); + } + } +} diff --git a/crates/registry-platform-crypto/Cargo.toml b/crates/registry-platform-crypto/Cargo.toml index ce052c9ea..68b4f6657 100644 --- a/crates/registry-platform-crypto/Cargo.toml +++ b/crates/registry-platform-crypto/Cargo.toml @@ -10,6 +10,10 @@ publish = false [lints] workspace = true +[features] +default = [] +transit = ["dep:reqwest"] + [dependencies] async-trait.workspace = true aws-lc-rs.workspace = true @@ -19,6 +23,7 @@ hmac.workspace = true p256.workspace = true pkcs1.workspace = true registry-platform-canonical-json.workspace = true +reqwest = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true sha2.workspace = true @@ -29,4 +34,5 @@ zeroize.workspace = true [dev-dependencies] proptest.workspace = true -tokio.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = ["io-util"] } diff --git a/crates/registry-platform-crypto/README.md b/crates/registry-platform-crypto/README.md index c2c14dc8f..057f1a561 100644 --- a/crates/registry-platform-crypto/README.md +++ b/crates/registry-platform-crypto/README.md @@ -6,8 +6,10 @@ Crypto primitives shared by registry services. - `PrivateJwk` and `PublicJwk` parsing for OKP/Ed25519, EC/P-256, and RSA JWKs. - EdDSA, ES256, and RS256 signing and verification helpers. -- `SigningProvider` and `LocalJwkSigner` for code that should sign without - depending directly on in-process private JWK ownership. +- `SigningProvider` and `LocalJwkSigner`, plus the `transit` feature's + `TransitSigner`, for code that should sign without depending directly on one + private-key storage model. The opt-in feature keeps HTTP and async-networking + dependencies out of offline verifiers. - `KeyProviderKind`, `KeyStatus`, `KeyReadiness`, and `KeyReadinessSnapshot` for provider-neutral readiness reporting and live-apply gates. - Public JWK thumbprints through `PublicJwk::jkt`. @@ -80,11 +82,14 @@ policy. - `LocalJwkSigner` requires a non-empty `kid`, stores local key material behind shared ownership, and exposes only public JWK metadata through `SigningProvider`. -- Production deployments that require key isolation should implement - `SigningProvider` over an external service such as Vault Transit or a cloud - KMS. Adapters must bound timeouts and error messages, avoid secret-bearing - logs, and provide configured public JWK metadata when the backing service - cannot export it directly. +- `TransitSigner` supports the common Vault/OpenBao ES256 Transit API through a + dedicated local proxy's Unix socket. It requires a pinned key version, + non-exportable and non-backup custody metadata, an exact configured public + JWK match, bounded requests and responses, and a successful local + sign-and-verify check before reporting ready. Signing inputs are SHA-256 + hashed locally and sent with Transit `prehashed: true`, so assertion bytes do + not cross the signing-provider boundary. The proxy owns authentication and + token renewal; the application never receives its token. - Readiness-gated live apply should use `KeyReadinessSnapshot`; only `status = active` plus `readiness = ready` is accepted. Degraded, not-ready, unknown, publish-only, and disabled keys fail closed before @@ -100,6 +105,7 @@ policy. ```sh cargo test -p registry-platform-crypto +cargo test -p registry-platform-crypto --features transit ``` ## License diff --git a/crates/registry-platform-crypto/src/lib.rs b/crates/registry-platform-crypto/src/lib.rs index a80a7de6f..9b4e0bdfc 100644 --- a/crates/registry-platform-crypto/src/lib.rs +++ b/crates/registry-platform-crypto/src/lib.rs @@ -5,6 +5,8 @@ use async_trait::async_trait; use aws_lc_rs::rand::SystemRandom; use aws_lc_rs::rsa::{KeyPair as AwsRsaKeyPair, PublicKeyComponents as AwsRsaPublicKeyComponents}; use aws_lc_rs::signature::{RSA_PKCS1_2048_8192_SHA256, RSA_PKCS1_SHA256}; +#[cfg(feature = "transit")] +use base64::engine::general_purpose::STANDARD; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; use ed25519_dalek::{ @@ -16,6 +18,11 @@ use p256::ecdsa::{ signature::Verifier as _, Signature as P256Signature, SigningKey as P256SigningKey, VerifyingKey as P256VerifyingKey, }; +#[cfg(feature = "transit")] +use p256::elliptic_curve::sec1::ToEncodedPoint as _; +#[cfg(feature = "transit")] +use p256::pkcs8::DecodePublicKey as _; +use p256::PublicKey as P256PublicKey; use pkcs1::{der::asn1::UintRef, der::SecretDocument, RsaPrivateKey as Pkcs1RsaPrivateKey}; pub use registry_platform_canonical_json::{ canonicalize_json, parse_json_strict, JcsError, StrictJsonError, @@ -26,7 +33,13 @@ use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use std::fmt; use std::net::IpAddr; +#[cfg(feature = "transit")] +use std::path::PathBuf; +#[cfg(feature = "transit")] +use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Arc; +#[cfg(feature = "transit")] +use std::time::Duration; use thiserror::Error; use url::{Host, Url}; use zeroize::{Zeroize, Zeroizing}; @@ -435,6 +448,391 @@ impl SigningProvider for LocalJwkSigner { } } +#[cfg(feature = "transit")] +const MAX_TRANSIT_RESPONSE_BYTES: usize = 64 * 1024; +#[cfg(feature = "transit")] +const MAX_TRANSIT_SIGNING_INPUT_BYTES: usize = 1024 * 1024; +#[cfg(feature = "transit")] +const MAX_TRANSIT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +#[cfg(feature = "transit")] +const TRANSIT_SELF_TEST_MESSAGE: &[u8] = b"registry-platform-transit-signing-readiness-v1"; +#[cfg(feature = "transit")] +const TRANSIT_READINESS_UNKNOWN: u8 = 0; +#[cfg(feature = "transit")] +const TRANSIT_READINESS_READY: u8 = 1; +#[cfg(feature = "transit")] +const TRANSIT_READINESS_NOT_READY: u8 = 2; + +/// Validated connection and key binding for a Vault/OpenBao Transit signer. +/// +/// The Transit API is reached only through a Unix socket. Authentication and +/// token renewal therefore remain the responsibility of a dedicated local +/// proxy rather than entering the application process. Configuration details +/// are deliberately redacted from `Debug` because socket, mount, and key names +/// reveal deployment topology. +#[derive(Clone)] +#[cfg(feature = "transit")] +pub struct TransitSignerConfig { + socket_path: PathBuf, + mount_path: String, + key_name: String, + key_version: u32, + public_jwk: PublicJwk, + request_timeout: Duration, +} + +#[cfg(feature = "transit")] +impl TransitSignerConfig { + /// Bind one immutable ES256 public identity to one explicit Transit key + /// version. `key_version = 0` (the provider's "latest" alias) is refused so + /// rotation cannot silently replace key bytes below an unchanged `kid`. + pub fn new( + socket_path: impl Into, + mount_path: impl Into, + key_name: impl Into, + key_version: u32, + public_jwk: PublicJwk, + request_timeout: Duration, + ) -> Result { + let socket_path = socket_path.into(); + let mount_path = mount_path.into(); + let key_name = key_name.into(); + if !socket_path.is_absolute() + || !valid_transit_path(&mount_path) + || !valid_transit_segment(&key_name) + || key_version == 0 + || request_timeout.is_zero() + || request_timeout > MAX_TRANSIT_REQUEST_TIMEOUT + || public_jwk.algorithm().ok() != Some(SigningAlgorithm::Es256) + || public_jwk.kid.as_deref().is_none_or(|kid| { + kid.trim().is_empty() || kid.len() > 256 || kid.chars().any(char::is_control) + }) + { + return Err(transit_error("transit signer configuration is invalid")); + } + public_jwk + .validate_public() + .map_err(|_| transit_error("transit signer configuration is invalid"))?; + Ok(Self { + socket_path, + mount_path, + key_name, + key_version, + public_jwk, + request_timeout, + }) + } +} + +#[cfg(feature = "transit")] +impl fmt::Debug for TransitSignerConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TransitSignerConfig") + .field("algorithm", &SigningAlgorithm::Es256) + .field("key_id", &self.public_jwk.kid) + .finish_non_exhaustive() + } +} + +/// Non-exportable ES256 signer backed by the common Vault/OpenBao Transit API. +/// +/// Construction validates provider custody metadata, the pinned version, and +/// the provider's PEM public key before a sign-and-verify self-test marks the +/// signer ready. Every later signature is verified locally before release. +#[cfg(feature = "transit")] +pub struct TransitSigner { + client: reqwest::Client, + metadata_url: String, + sign_url: String, + key_version: u32, + public_jwk: PublicJwk, + key_id: String, + request_timeout: Duration, + readiness: AtomicU8, +} + +#[cfg(feature = "transit")] +impl TransitSigner { + /// Connect to Transit, validate custody and public identity metadata, and + /// prove signing access without exporting private material. + pub async fn initialize(config: TransitSignerConfig) -> Result { + let client = build_transit_client(&config)?; + let signer = Self { + client, + metadata_url: format!( + "http://localhost/v1/{}/keys/{}", + config.mount_path, config.key_name + ), + sign_url: format!( + "http://localhost/v1/{}/sign/{}/sha2-256", + config.mount_path, config.key_name + ), + key_version: config.key_version, + key_id: config + .public_jwk + .kid + .as_deref() + .expect("TransitSignerConfig validates kid") + .to_owned(), + public_jwk: config.public_jwk, + request_timeout: config.request_timeout, + readiness: AtomicU8::new(TRANSIT_READINESS_UNKNOWN), + }; + let metadata = signer + .request_json(reqwest::Method::GET, &signer.metadata_url, None) + .await + .map_err(|_| transit_error("transit signer metadata is unavailable"))?; + signer + .validate_metadata(&metadata) + .map_err(|_| transit_error("transit signer metadata is invalid"))?; + signer + .sign(TRANSIT_SELF_TEST_MESSAGE) + .await + .map_err(|_| transit_error("transit signer self-test failed"))?; + Ok(signer) + } + + async fn request_json( + &self, + method: reqwest::Method, + url: &str, + body: Option<&Value>, + ) -> Result { + let mut request = self + .client + .request(method, url) + .header("X-Vault-Request", "true") + .timeout(self.request_timeout); + if let Some(body) = body { + request = request.json(body); + } + let response = request + .send() + .await + .map_err(|_| transit_error("transit provider request failed"))?; + if !response.status().is_success() { + return Err(transit_error("transit provider request failed")); + } + let bytes = read_bounded_transit_response(response).await?; + parse_json_strict(&bytes).map_err(|_| transit_error("transit provider response is invalid")) + } + + fn validate_metadata(&self, document: &Value) -> Result<(), SigningError> { + let data = document + .get("data") + .and_then(Value::as_object) + .ok_or_else(|| transit_error("transit provider metadata is invalid"))?; + let required_false = ["derived", "exportable", "allow_plaintext_backup"]; + if data.get("type").and_then(Value::as_str) != Some("ecdsa-p256") + || data.get("supports_signing").and_then(Value::as_bool) != Some(true) + || required_false + .iter() + .any(|field| data.get(*field).and_then(Value::as_bool) != Some(false)) + { + return Err(transit_error("transit provider custody is invalid")); + } + + let latest_version = data + .get("latest_version") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| transit_error("transit provider version is invalid"))?; + let minimum_signing_version = data + .get("min_encryption_version") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| transit_error("transit provider version is invalid"))?; + if self.key_version > latest_version || self.key_version < minimum_signing_version { + return Err(transit_error("transit provider version is invalid")); + } + + let version = self.key_version.to_string(); + let pem = data + .get("keys") + .and_then(Value::as_object) + .and_then(|keys| keys.get(&version)) + .and_then(Value::as_object) + .and_then(|key| key.get("public_key")) + .and_then(Value::as_str) + .ok_or_else(|| transit_error("transit provider public key is invalid"))?; + validate_transit_public_key(pem, &self.public_jwk) + } + + fn set_readiness(&self, readiness: KeyReadiness) { + let encoded = match readiness { + KeyReadiness::Ready => TRANSIT_READINESS_READY, + KeyReadiness::NotReady | KeyReadiness::Degraded => TRANSIT_READINESS_NOT_READY, + KeyReadiness::Unknown => TRANSIT_READINESS_UNKNOWN, + }; + self.readiness.store(encoded, Ordering::Release); + } +} + +#[cfg(feature = "transit")] +impl fmt::Debug for TransitSigner { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TransitSigner") + .field("algorithm", &SigningAlgorithm::Es256) + .field("key_id", &self.key_id) + .field("readiness", &self.readiness()) + .finish_non_exhaustive() + } +} + +#[async_trait] +#[cfg(feature = "transit")] +impl SigningProvider for TransitSigner { + fn algorithm(&self) -> SigningAlgorithm { + SigningAlgorithm::Es256 + } + + fn key_id(&self) -> &str { + &self.key_id + } + + fn public_jwk(&self) -> PublicJwk { + self.public_jwk.clone() + } + + fn readiness(&self) -> KeyReadiness { + match self.readiness.load(Ordering::Acquire) { + TRANSIT_READINESS_READY => KeyReadiness::Ready, + TRANSIT_READINESS_NOT_READY => KeyReadiness::NotReady, + _ => KeyReadiness::Unknown, + } + } + + async fn sign(&self, payload: &[u8]) -> Result, SigningError> { + if payload.len() > MAX_TRANSIT_SIGNING_INPUT_BYTES { + return Err(transit_error("transit signing input is too large")); + } + let digest = Sha256::digest(payload); + let body = serde_json::json!({ + "input": STANDARD.encode(digest), + "key_version": self.key_version, + "marshaling_algorithm": "jws", + "prehashed": true, + }); + let result = async { + let document = self + .request_json(reqwest::Method::POST, &self.sign_url, Some(&body)) + .await?; + let signature = document + .get("data") + .and_then(|data| data.get("signature")) + .and_then(Value::as_str) + .ok_or_else(|| transit_error("transit provider signature is invalid"))?; + let prefix = format!("vault:v{}:", self.key_version); + let encoded = signature + .strip_prefix(&prefix) + .ok_or_else(|| transit_error("transit provider signature is invalid"))?; + let signature = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| transit_error("transit provider signature is invalid"))?; + if signature.len() != 64 { + return Err(transit_error("transit provider signature is invalid")); + } + verify(payload, &signature, &self.public_jwk) + .map_err(|_| transit_error("transit provider signature is invalid"))?; + Ok(signature) + } + .await; + match result { + Ok(signature) => { + self.set_readiness(KeyReadiness::Ready); + Ok(signature) + } + Err(error) => { + self.set_readiness(KeyReadiness::NotReady); + Err(error) + } + } + } +} + +#[cfg(feature = "transit")] +fn build_transit_client(config: &TransitSignerConfig) -> Result { + #[cfg(all(unix, feature = "transit"))] + { + reqwest::Client::builder() + .no_proxy() + .unix_socket(config.socket_path.clone()) + .build() + .map_err(|_| transit_error("transit signer configuration is invalid")) + } + #[cfg(not(unix))] + { + let _ = config; + Err(transit_error( + "transit signer requires Unix-domain socket support", + )) + } +} + +#[cfg(feature = "transit")] +async fn read_bounded_transit_response( + mut response: reqwest::Response, +) -> Result, SigningError> { + if response + .content_length() + .is_some_and(|length| length > MAX_TRANSIT_RESPONSE_BYTES as u64) + { + return Err(transit_error("transit provider response is too large")); + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| transit_error("transit provider response is invalid"))? + { + if body.len().saturating_add(chunk.len()) > MAX_TRANSIT_RESPONSE_BYTES { + return Err(transit_error("transit provider response is too large")); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +#[cfg(feature = "transit")] +fn validate_transit_public_key(pem: &str, configured: &PublicJwk) -> Result<(), SigningError> { + let provider = P256PublicKey::from_public_key_pem(pem) + .map_err(|_| transit_error("transit provider public key is invalid"))?; + let x = decode_fixed(configured.x.as_deref(), 32, "x") + .map_err(|_| transit_error("transit provider public key is invalid"))?; + let y = decode_fixed(configured.y.as_deref(), 32, "y") + .map_err(|_| transit_error("transit provider public key is invalid"))?; + let mut configured_sec1 = Vec::with_capacity(65); + configured_sec1.push(0x04); + configured_sec1.extend_from_slice(&x); + configured_sec1.extend_from_slice(&y); + if provider.to_encoded_point(false).as_bytes() != configured_sec1 { + return Err(transit_error("transit provider public key does not match")); + } + Ok(()) +} + +#[cfg(feature = "transit")] +fn valid_transit_path(value: &str) -> bool { + !value.is_empty() && value.len() <= 256 && value.split('/').all(valid_transit_segment) +} + +#[cfg(feature = "transit")] +fn valid_transit_segment(value: &str) -> bool { + !value.is_empty() + && !matches!(value, "." | "..") + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +#[cfg(feature = "transit")] +fn transit_error(message: &'static str) -> SigningError { + SigningError::external(message) +} + impl PrivateJwk { pub fn parse(json: &str) -> Result { if json.len() > MAX_JWK_JSON_BYTES { @@ -565,8 +963,14 @@ impl PublicJwk { if self.kty != "EC" || self.crv.as_deref() != Some("P-256") { return Err(JwkError::Invalid("ES256 keys must be EC/P-256")); } - decode_fixed(self.x.as_deref(), 32, "x")?; - decode_fixed(self.y.as_deref(), 32, "y")?; + let x = decode_fixed(self.x.as_deref(), 32, "x")?; + let y = decode_fixed(self.y.as_deref(), 32, "y")?; + let mut encoded = Vec::with_capacity(65); + encoded.push(0x04); + encoded.extend_from_slice(&x); + encoded.extend_from_slice(&y); + P256PublicKey::from_sec1_bytes(&encoded) + .map_err(|_| JwkError::Invalid("ES256 public point"))?; } Ok(SigningAlgorithm::Rs256) => { if self.kty != "RSA" { @@ -1159,11 +1563,256 @@ fn hex_value(value: u8) -> Option { #[cfg(test)] mod tests { use super::*; + #[cfg(all(unix, feature = "transit"))] + use p256::pkcs8::{EncodePublicKey as _, LineEnding}; use serde_json::json; + #[cfg(all(unix, feature = "transit"))] + use tempfile::TempDir; + #[cfg(all(unix, feature = "transit"))] + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + #[cfg(all(unix, feature = "transit"))] + use tokio::net::UnixListener; + #[cfg(all(unix, feature = "transit"))] + use tokio::task::JoinHandle; const RAW_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"did:web:issuer.test#key-1"}"#; const P256_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"did:web:issuer.test#p256-key-1"}"#; + #[cfg(all(unix, feature = "transit"))] + struct MockTransitReply { + method: &'static str, + path: &'static str, + body: Option, + status: u16, + response: Vec, + delay: Duration, + } + + #[cfg(all(unix, feature = "transit"))] + fn spawn_transit_mock(replies: Vec) -> (TempDir, PathBuf, JoinHandle<()>) { + let directory = tempfile::tempdir().expect("temporary Transit directory"); + let socket_path = directory.path().join("transit.sock"); + let listener = UnixListener::bind(&socket_path).expect("bind mock Transit socket"); + let task = tokio::spawn(async move { + for reply in replies { + let (mut stream, _) = listener.accept().await.expect("accept Transit request"); + let mut request = Vec::new(); + let header_end = loop { + let mut chunk = [0_u8; 4096]; + let read = stream.read(&mut chunk).await.expect("read Transit request"); + assert_ne!(read, 0, "Transit request ended before its headers"); + request.extend_from_slice(&chunk[..read]); + assert!(request.len() <= 128 * 1024, "mock request stayed bounded"); + if let Some(position) = + request.windows(4).position(|bytes| bytes == b"\r\n\r\n") + { + break position + 4; + } + }; + let headers = std::str::from_utf8(&request[..header_end]) + .expect("Transit request headers are UTF-8"); + let mut lines = headers.lines(); + let request_line = lines.next().expect("request line"); + let mut request_parts = request_line.split_whitespace(); + assert_eq!(request_parts.next(), Some(reply.method)); + assert_eq!(request_parts.next(), Some(reply.path)); + let lower_headers = headers.to_ascii_lowercase(); + assert!( + lower_headers.contains("x-vault-request: true"), + "Transit request marks the trusted proxy hop" + ); + let content_length = lines + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().parse::().expect("content length")) + .unwrap_or(0); + while request.len() < header_end + content_length { + let mut chunk = [0_u8; 4096]; + let read = stream + .read(&mut chunk) + .await + .expect("read Transit request body"); + assert_ne!(read, 0, "Transit request body ended early"); + request.extend_from_slice(&chunk[..read]); + } + let actual_body = &request[header_end..header_end + content_length]; + match reply.body { + Some(expected) => { + let actual: Value = + serde_json::from_slice(actual_body).expect("Transit request JSON"); + assert_eq!(actual, expected); + } + None => assert!(actual_body.is_empty()), + } + + if !reply.delay.is_zero() { + tokio::time::sleep(reply.delay).await; + } + let reason = if reply.status == 200 { "OK" } else { "ERROR" }; + let response_headers = format!( + "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + reply.status, + reason, + reply.response.len() + ); + let _ = stream.write_all(response_headers.as_bytes()).await; + let _ = stream.write_all(&reply.response).await; + } + }); + (directory, socket_path, task) + } + + #[cfg(all(unix, feature = "transit"))] + fn p256_public_pem(private: &PrivateJwk) -> String { + let scalar = decode_fixed(private.d.as_deref(), 32, "d").expect("P-256 scalar"); + let signing = P256SigningKey::from_slice(&scalar).expect("P-256 signing key"); + let encoded = signing.verifying_key().to_encoded_point(false); + let public = P256PublicKey::from_sec1_bytes(encoded.as_bytes()).expect("P-256 public key"); + public + .to_public_key_pem(LineEnding::LF) + .expect("P-256 public PEM") + } + + #[cfg(all(unix, feature = "transit"))] + fn transit_metadata(public_key: &str) -> Value { + json!({ + "data": { + "type": "ecdsa-p256", + "derived": false, + "exportable": false, + "allow_plaintext_backup": false, + "imported": true, + "deletion_allowed": true, + "supports_signing": true, + "latest_version": 9, + "min_encryption_version": 2, + "keys": { + "7": { + "creation_time": "2026-08-06T00:00:00Z", + "public_key": public_key, + } + } + } + }) + } + + // Mirrors the documented Vault Transit read-key response, extended with + // the versioned P-256 public-key object returned for an asymmetric key. + #[cfg(all(unix, feature = "transit"))] + fn vault_transit_metadata_response_fixture(public_key: &str) -> Value { + json!({ + "data": { + "type": "ecdsa-p256", + "deletion_allowed": false, + "derived": false, + "exportable": false, + "allow_plaintext_backup": false, + "keys": { + "7": { + "creation_time": "2026-08-06T00:00:00Z", + "public_key": public_key, + } + }, + "latest_version": 9, + "min_decryption_version": 1, + "min_encryption_version": 2, + "name": "vault-evidence-key", + "supports_encryption": false, + "supports_decryption": false, + "supports_derivation": false, + "supports_signing": true, + "imported": false, + } + }) + } + + // OpenBao documents the same Transit read-key wire schema. Keep a + // separate fixture so a future provider divergence cannot be hidden by a + // generic compatibility test. + #[cfg(all(unix, feature = "transit"))] + fn openbao_transit_metadata_response_fixture(public_key: &str) -> Value { + json!({ + "data": { + "type": "ecdsa-p256", + "deletion_allowed": true, + "derived": false, + "exportable": false, + "allow_plaintext_backup": false, + "keys": { + "7": { + "creation_time": "2026-08-06T00:00:00Z", + "public_key": public_key, + } + }, + "latest_version": 9, + "min_decryption_version": 1, + "min_encryption_version": 2, + "name": "openbao-evidence-key", + "supports_encryption": false, + "supports_decryption": false, + "supports_derivation": false, + "supports_signing": true, + "imported": true, + } + }) + } + + #[cfg(all(unix, feature = "transit"))] + fn transit_signature(private: &PrivateJwk, payload: &[u8], version: u32) -> Value { + let signature = sign(payload, private).expect("mock Transit signature"); + json!({ + "data": { + "signature": format!("vault:v{version}:{}", URL_SAFE_NO_PAD.encode(signature)) + } + }) + } + + #[cfg(all(unix, feature = "transit"))] + fn vault_transit_sign_response_fixture( + private: &PrivateJwk, + payload: &[u8], + version: u32, + ) -> Value { + transit_signature(private, payload, version) + } + + #[cfg(all(unix, feature = "transit"))] + fn openbao_transit_sign_response_fixture( + private: &PrivateJwk, + payload: &[u8], + version: u32, + ) -> Value { + transit_signature(private, payload, version) + } + + #[cfg(all(unix, feature = "transit"))] + fn transit_request(payload: &[u8], version: u32) -> Value { + let digest = Sha256::digest(payload); + json!({ + "input": STANDARD.encode(digest), + "key_version": version, + "marshaling_algorithm": "jws", + "prehashed": true, + }) + } + + #[cfg(all(unix, feature = "transit"))] + fn transit_reply( + method: &'static str, + path: &'static str, + body: Option, + response: Value, + ) -> MockTransitReply { + MockTransitReply { + method, + path, + body, + status: 200, + response: serde_json::to_vec(&response).expect("mock response JSON"), + delay: Duration::ZERO, + } + } + // Test-only 2048-bit RSA private JWK (kty=RSA, alg=RS256). Generated once // with openssl and converted to JWK; used only by RS256 tests. Not a // production key. @@ -1183,7 +1832,9 @@ mod tests { assert!(!debug.contains("2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw")); assert!(debug.contains("[redacted]")); - let public = private.public(); + let projected = private.public(); + let encoded = serde_json::to_string(&projected).expect("public JWK serializes"); + let public = PublicJwk::parse(&encoded).expect("P-256 public JWK parses"); let public_json = serde_json::to_value(&public).expect("public jwk serializes"); assert_eq!( public_json.get("x").and_then(Value::as_str), @@ -1343,6 +1994,414 @@ mod tests { assert!(!debug.contains("2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw")); } + #[cfg(all(unix, feature = "transit"))] + #[tokio::test] + async fn transit_signer_uses_the_common_vault_openbao_es256_wire_contract() { + const METADATA_PATH: &str = "/v1/registry-transit/keys/custody-key"; + const SIGN_PATH: &str = "/v1/registry-transit/sign/custody-key/sha2-256"; + let private = PrivateJwk::parse(P256_JWK).expect("P-256 private JWK"); + let public = private.public(); + let payload = &[0xfb, 0xff]; + assert_eq!( + transit_request(payload, 7)["input"], + "24/tVBWa/kCs5bSdcCJZ/YjJxACTBxgYJEh7qrXGveo=" + ); + assert_ne!(transit_request(payload, 7)["input"], "+/8="); + let replies = vec![ + transit_reply( + "GET", + METADATA_PATH, + None, + transit_metadata(&p256_public_pem(&private)), + ), + transit_reply( + "POST", + SIGN_PATH, + Some(transit_request(TRANSIT_SELF_TEST_MESSAGE, 7)), + transit_signature(&private, TRANSIT_SELF_TEST_MESSAGE, 7), + ), + transit_reply( + "POST", + SIGN_PATH, + Some(transit_request(payload, 7)), + transit_signature(&private, payload, 7), + ), + ]; + let (directory, socket_path, server) = spawn_transit_mock(replies); + let socket_marker = socket_path.display().to_string(); + let config = TransitSignerConfig::new( + socket_path, + "registry-transit", + "custody-key", + 7, + public.clone(), + Duration::from_secs(2), + ) + .expect("Transit config"); + let config_debug = format!("{config:?}"); + assert!(!config_debug.contains(&socket_marker)); + assert!(!config_debug.contains("registry-transit")); + assert!(!config_debug.contains("custody-key")); + + let signer = TransitSigner::initialize(config) + .await + .expect("Transit signer initializes"); + assert_eq!(signer.algorithm(), SigningAlgorithm::Es256); + assert_eq!(signer.key_id(), "did:web:issuer.test#p256-key-1"); + assert_eq!(signer.public_jwk(), public); + assert_eq!(signer.readiness(), KeyReadiness::Ready); + let signature = signer.sign(payload).await.expect("Transit signs"); + assert_eq!(signature.len(), 64); + verify(payload, &signature, &signer.public_jwk()).expect("signature verifies"); + assert_eq!(signer.readiness(), KeyReadiness::Ready); + + server.await.expect("mock Transit server completed"); + drop(directory); + } + + #[cfg(all(unix, feature = "transit"))] + #[tokio::test] + async fn transit_signer_accepts_vault_native_metadata_and_sign_response_fixtures() { + const METADATA_PATH: &str = "/v1/vault-transit/keys/evidence-key"; + const SIGN_PATH: &str = "/v1/vault-transit/sign/evidence-key/sha2-256"; + let private = PrivateJwk::parse(P256_JWK).expect("P-256 private JWK"); + let replies = vec![ + transit_reply( + "GET", + METADATA_PATH, + None, + vault_transit_metadata_response_fixture(&p256_public_pem(&private)), + ), + transit_reply( + "POST", + SIGN_PATH, + Some(transit_request(TRANSIT_SELF_TEST_MESSAGE, 7)), + vault_transit_sign_response_fixture(&private, TRANSIT_SELF_TEST_MESSAGE, 7), + ), + ]; + let (directory, socket_path, server) = spawn_transit_mock(replies); + let signer = TransitSigner::initialize( + TransitSignerConfig::new( + socket_path, + "vault-transit", + "evidence-key", + 7, + private.public(), + Duration::from_secs(1), + ) + .expect("Vault Transit config"), + ) + .await + .expect("Vault Transit fixtures initialize the signer"); + + assert_eq!(signer.readiness(), KeyReadiness::Ready); + server.await.expect("mock Vault Transit server completed"); + drop(directory); + } + + #[cfg(all(unix, feature = "transit"))] + #[tokio::test] + async fn transit_signer_accepts_openbao_native_metadata_and_sign_response_fixtures() { + const METADATA_PATH: &str = "/v1/openbao-transit/keys/evidence-key"; + const SIGN_PATH: &str = "/v1/openbao-transit/sign/evidence-key/sha2-256"; + let private = PrivateJwk::parse(P256_JWK).expect("P-256 private JWK"); + let replies = vec![ + transit_reply( + "GET", + METADATA_PATH, + None, + openbao_transit_metadata_response_fixture(&p256_public_pem(&private)), + ), + transit_reply( + "POST", + SIGN_PATH, + Some(transit_request(TRANSIT_SELF_TEST_MESSAGE, 7)), + openbao_transit_sign_response_fixture(&private, TRANSIT_SELF_TEST_MESSAGE, 7), + ), + ]; + let (directory, socket_path, server) = spawn_transit_mock(replies); + let signer = TransitSigner::initialize( + TransitSignerConfig::new( + socket_path, + "openbao-transit", + "evidence-key", + 7, + private.public(), + Duration::from_secs(1), + ) + .expect("OpenBao Transit config"), + ) + .await + .expect("OpenBao Transit fixtures initialize the signer"); + + assert_eq!(signer.readiness(), KeyReadiness::Ready); + server.await.expect("mock OpenBao Transit server completed"); + drop(directory); + } + + #[cfg(feature = "transit")] + #[test] + fn transit_signer_config_rejects_unpinned_or_non_es256_bindings() { + let public = PrivateJwk::parse(P256_JWK) + .expect("P-256 private JWK") + .public(); + let build = |socket: &str, + mount: &str, + name: &str, + version: u32, + key: PublicJwk, + timeout: Duration| { + TransitSignerConfig::new(socket, mount, name, version, key, timeout) + }; + assert!(build( + "relative.sock", + "transit", + "key", + 7, + public.clone(), + Duration::from_secs(1) + ) + .is_err()); + assert!(build( + "/run/transit.sock", + "../transit", + "key", + 7, + public.clone(), + Duration::from_secs(1) + ) + .is_err()); + assert!(build( + "/run/transit.sock", + "transit", + "key/name", + 7, + public.clone(), + Duration::from_secs(1) + ) + .is_err()); + assert!(build( + "/run/transit.sock", + "transit", + "key", + 0, + public.clone(), + Duration::from_secs(1) + ) + .is_err()); + assert!(build( + "/run/transit.sock", + "transit", + "key", + 7, + public.clone(), + Duration::ZERO + ) + .is_err()); + assert!(build( + "/run/transit.sock", + "transit", + "key", + 7, + public, + MAX_TRANSIT_REQUEST_TIMEOUT + Duration::from_millis(1) + ) + .is_err()); + assert!(build( + "/run/transit.sock", + "transit", + "key", + 7, + PrivateJwk::parse(RAW_JWK).expect("Ed25519 JWK").public(), + Duration::from_secs(1) + ) + .is_err()); + } + + #[cfg(all(unix, feature = "transit"))] + #[tokio::test] + async fn transit_signer_rejects_unsafe_or_mismatched_metadata() { + const METADATA_PATH: &str = "/v1/transit/keys/key"; + let private = PrivateJwk::parse(P256_JWK).expect("P-256 private JWK"); + let public = private.public(); + let pem = p256_public_pem(&private); + let mut cases = Vec::new(); + for field in ["derived", "exportable", "allow_plaintext_backup"] { + let mut metadata = transit_metadata(&pem); + metadata["data"][field] = Value::Bool(true); + cases.push(metadata); + } + let mut wrong_type = transit_metadata(&pem); + wrong_type["data"]["type"] = Value::String("ed25519".to_owned()); + cases.push(wrong_type); + let mut no_signing = transit_metadata(&pem); + no_signing["data"]["supports_signing"] = Value::Bool(false); + cases.push(no_signing); + let mut version_too_old = transit_metadata(&pem); + version_too_old["data"]["min_encryption_version"] = json!(8); + cases.push(version_too_old); + let mut missing_version = transit_metadata(&pem); + missing_version["data"]["keys"] + .as_object_mut() + .expect("keys object") + .remove("7"); + cases.push(missing_version); + + let other_scalar = [42_u8; 32]; + let other_signing = P256SigningKey::from_slice(&other_scalar).expect("second P-256 key"); + let other_point = other_signing.verifying_key().to_encoded_point(false); + let other_public = + P256PublicKey::from_sec1_bytes(other_point.as_bytes()).expect("second public key"); + let mut wrong_public = transit_metadata( + &other_public + .to_public_key_pem(LineEnding::LF) + .expect("second public PEM"), + ); + wrong_public["data"]["latest_version"] = json!(7); + cases.push(wrong_public); + + for metadata in cases { + let replies = vec![transit_reply("GET", METADATA_PATH, None, metadata)]; + let (directory, socket_path, server) = spawn_transit_mock(replies); + let config = TransitSignerConfig::new( + socket_path, + "transit", + "key", + 7, + public.clone(), + Duration::from_secs(1), + ) + .expect("Transit config"); + let error = TransitSigner::initialize(config) + .await + .expect_err("unsafe Transit metadata must reject"); + assert!(error.to_string().contains("metadata is invalid")); + server.await.expect("mock Transit server completed"); + drop(directory); + } + } + + #[cfg(all(unix, feature = "transit"))] + #[tokio::test] + async fn transit_signer_fails_closed_without_leaking_provider_responses_then_recovers() { + const METADATA_PATH: &str = "/v1/transit/keys/key"; + const SIGN_PATH: &str = "/v1/transit/sign/key/sha2-256"; + const CANARY: &str = "PRIVATE_PROVIDER_DIAGNOSTIC_CANARY"; + let private = PrivateJwk::parse(P256_JWK).expect("P-256 private JWK"); + let public = private.public(); + let payload = b"protected.payload"; + let replies = vec![ + transit_reply( + "GET", + METADATA_PATH, + None, + transit_metadata(&p256_public_pem(&private)), + ), + transit_reply( + "POST", + SIGN_PATH, + Some(transit_request(TRANSIT_SELF_TEST_MESSAGE, 7)), + transit_signature(&private, TRANSIT_SELF_TEST_MESSAGE, 7), + ), + transit_reply( + "POST", + SIGN_PATH, + Some(transit_request(payload, 7)), + json!({"data": {"signature": format!("vault:v8:{CANARY}")}}), + ), + transit_reply( + "POST", + SIGN_PATH, + Some(transit_request(payload, 7)), + transit_signature(&private, payload, 7), + ), + ]; + let (directory, socket_path, server) = spawn_transit_mock(replies); + let signer = TransitSigner::initialize( + TransitSignerConfig::new( + socket_path, + "transit", + "key", + 7, + public, + Duration::from_secs(1), + ) + .expect("Transit config"), + ) + .await + .expect("Transit signer initializes"); + + let error = signer + .sign(payload) + .await + .expect_err("wrong version rejects"); + assert!(!error.to_string().contains(CANARY)); + assert_eq!(signer.readiness(), KeyReadiness::NotReady); + signer.sign(payload).await.expect("provider recovery signs"); + assert_eq!(signer.readiness(), KeyReadiness::Ready); + + server.await.expect("mock Transit server completed"); + drop(directory); + } + + #[cfg(all(unix, feature = "transit"))] + #[tokio::test] + async fn transit_signer_bounds_time_and_response_bytes() { + const METADATA_PATH: &str = "/v1/transit/keys/key"; + let private = PrivateJwk::parse(P256_JWK).expect("P-256 private JWK"); + let public = private.public(); + let delayed = MockTransitReply { + method: "GET", + path: METADATA_PATH, + body: None, + status: 200, + response: serde_json::to_vec(&transit_metadata(&p256_public_pem(&private))) + .expect("metadata JSON"), + delay: Duration::from_millis(50), + }; + let (directory, socket_path, server) = spawn_transit_mock(vec![delayed]); + let config = TransitSignerConfig::new( + socket_path, + "transit", + "key", + 7, + public.clone(), + Duration::from_millis(5), + ) + .expect("Transit config"); + let timeout = TransitSigner::initialize(config) + .await + .expect_err("slow Transit metadata times out"); + assert!(timeout.to_string().contains("metadata is unavailable")); + server.await.expect("slow mock completed"); + drop(directory); + + let oversized = MockTransitReply { + method: "GET", + path: METADATA_PATH, + body: None, + status: 200, + response: vec![b'x'; MAX_TRANSIT_RESPONSE_BYTES + 1], + delay: Duration::ZERO, + }; + let (directory, socket_path, server) = spawn_transit_mock(vec![oversized]); + let config = TransitSignerConfig::new( + socket_path, + "transit", + "key", + 7, + public, + Duration::from_secs(1), + ) + .expect("Transit config"); + let oversized = TransitSigner::initialize(config) + .await + .expect_err("oversized Transit metadata rejects"); + assert!(oversized.to_string().contains("metadata is unavailable")); + server.await.expect("oversized mock completed"); + drop(directory); + } + #[test] fn external_signing_error_messages_are_bounded_and_single_line() { let message = format!("{}{}", "provider unavailable\n", "x".repeat(512)); @@ -1417,6 +2476,24 @@ mod tests { assert!(matches!(public.algorithm(), Ok(SigningAlgorithm::Es256))); } + #[test] + fn es256_public_jwk_rejects_length_correct_off_curve_coordinates() { + let zero_coordinate = URL_SAFE_NO_PAD.encode([0_u8; 32]); + let candidate = json!({ + "kty": "EC", + "crv": "P-256", + "x": zero_coordinate.clone(), + "y": zero_coordinate, + "alg": "ES256", + "kid": "invalid-point", + }); + + assert!(matches!( + PublicJwk::parse(&candidate.to_string()), + Err(JwkError::Invalid("ES256 public point")) + )); + } + #[test] fn es256_sign_then_verify_roundtrips() { let private = PrivateJwk::parse(P256_JWK).expect("p256 private jwk parses"); diff --git a/crates/registry-platform-oidc/src/lib.rs b/crates/registry-platform-oidc/src/lib.rs index 26f5e4554..6acf260a9 100644 --- a/crates/registry-platform-oidc/src/lib.rs +++ b/crates/registry-platform-oidc/src/lib.rs @@ -778,6 +778,12 @@ pub struct TokenVerifierConfig { pub scope_separator: char, pub scope_map: Option>>, pub allowed_clients: Vec, + /// Issuer key identifiers that must be rejected even when the key remains + /// published or cached. Empty preserves the normal JWKS selection policy. + pub denied_kids: HashSet, + /// Maximum permitted interval between a token's `iat` and `exp` claims. + /// When set, both claims are required. + pub max_token_lifetime: Option, pub leeway: Duration, } @@ -807,6 +813,8 @@ impl TokenVerifierConfig { scope_separator: ' ', scope_map: None, allowed_clients: Vec::new(), + denied_kids: HashSet::new(), + max_token_lifetime: None, leeway: Duration::ZERO, } } @@ -845,6 +853,18 @@ impl TokenVerifierConfig { self } + #[must_use] + pub fn with_denied_kids(mut self, denied_kids: HashSet) -> Self { + self.denied_kids = denied_kids; + self + } + + #[must_use] + pub fn with_max_token_lifetime(mut self, max_token_lifetime: Option) -> Self { + self.max_token_lifetime = max_token_lifetime; + self + } + #[must_use] pub fn with_leeway(mut self, leeway: Duration) -> Self { self.leeway = leeway; @@ -962,6 +982,7 @@ impl TokenVerifier { } enforce_typ(header.typ.as_deref(), &self.allowed_access_typ)?; let kid = header.kid.ok_or(OidcError::MissingKid)?; + self.enforce_kid_allowed(&kid)?; let key = self .fetcher .key_for_kid_matching_alg(&kid, header.alg) @@ -978,6 +999,7 @@ impl TokenVerifier { .collect(); let data = decode::(token, &key, &validation) .map_err(|err| map_jwt_error(err, &self.config.issuer, token))?; + self.enforce_max_token_lifetime(&data.claims)?; let matched_client = if enforce_client { self.match_client(&data.claims)? } else { @@ -998,6 +1020,7 @@ impl TokenVerifier { } enforce_optional_typ(header.typ.as_deref(), &self.allowed_id_typ)?; let kid = header.kid.ok_or(OidcError::MissingKid)?; + self.enforce_kid_allowed(&kid)?; let key = self .fetcher .key_for_kid_matching_alg(&kid, header.alg) @@ -1015,6 +1038,7 @@ impl TokenVerifier { .collect(); let data = decode::(token, &key, &validation) .map_err(|err| map_jwt_error(err, &self.config.issuer, token))?; + self.enforce_max_token_lifetime(&data.claims)?; self.enforce_present_azp(&data.claims)?; self.enforce_multi_audience_azp(&data.claims, &audiences)?; let matched_client = self.match_client(&data.claims).ok().flatten(); @@ -1056,6 +1080,7 @@ impl TokenVerifier { } enforce_optional_typ(header.typ.as_deref(), &self.allowed_userinfo_typ)?; let kid = header.kid.ok_or(OidcError::MissingKid)?; + self.enforce_kid_allowed(&kid)?; let key = self .fetcher .key_for_kid_matching_alg(&kid, header.alg) @@ -1072,6 +1097,7 @@ impl TokenVerifier { } let data = decode::(userinfo_jwt, &key, &validation) .map_err(|err| map_jwt_error(err, &self.config.issuer, userinfo_jwt))?; + self.enforce_max_token_lifetime(&data.claims)?; let issuer = data .claims .iss @@ -1103,6 +1129,34 @@ impl TokenVerifier { Ok(data.claims) } + fn enforce_kid_allowed(&self, kid: &str) -> Result<(), OidcError> { + if self.config.denied_kids.contains(kid) { + // Treat an explicitly denied key like any other unavailable key so + // callers cannot distinguish incident-response policy from normal + // key rotation. This check deliberately precedes JWKS cache access. + return Err(OidcError::UnknownKid); + } + Ok(()) + } + + fn enforce_max_token_lifetime(&self, claims: &Claims) -> Result<(), OidcError> { + let Some(max_token_lifetime) = self.config.max_token_lifetime else { + return Ok(()); + }; + let iat = claims.iat.ok_or(OidcError::InvalidToken)?; + let exp = claims.exp.ok_or(OidcError::InvalidToken)?; + let lifetime_seconds = exp + .checked_sub(iat) + .filter(|seconds| *seconds > 0) + .and_then(|seconds| u64::try_from(seconds).ok()) + .ok_or(OidcError::InvalidToken)?; + if Duration::from_secs(lifetime_seconds) > max_token_lifetime { + return Err(OidcError::InvalidToken); + } + + Ok(()) + } + fn id_token_audiences(&self) -> Vec { if self.allowed_clients.is_empty() { return self.config.audiences.clone(); @@ -1523,7 +1577,15 @@ mod tests { assert_eq!(config.scope_claim, "permissions"); assert_eq!(config.scope_separator, ','); assert_eq!(config.allowed_clients, vec!["client-a"]); + assert!(config.denied_kids.is_empty()); + assert_eq!(config.max_token_lifetime, None); assert_eq!(config.leeway, Duration::from_secs(30)); + + let hardened = config + .with_denied_kids(HashSet::from(["retired-key".to_string()])) + .with_max_token_lifetime(Some(Duration::from_secs(300))); + assert!(hardened.denied_kids.contains("retired-key")); + assert_eq!(hardened.max_token_lifetime, Some(Duration::from_secs(300))); } fn rsa_jwk_with_modulus_bytes(kid: &str, modulus_bytes: usize) -> Jwk { @@ -1607,6 +1669,8 @@ mod tests { scope_separator: ' ', scope_map: None, allowed_clients, + denied_kids: HashSet::new(), + max_token_lifetime: None, leeway: Duration::from_secs(60), }, fetcher, @@ -1712,6 +1776,8 @@ mod tests { scope_separator: ' ', scope_map: None, allowed_clients: Vec::new(), + denied_kids: HashSet::new(), + max_token_lifetime: None, leeway: Duration::from_secs(60), }, fetcher, @@ -1737,6 +1803,8 @@ mod tests { scope_separator: ' ', scope_map: None, allowed_clients: vec!["client-a".to_string()], + denied_kids: HashSet::new(), + max_token_lifetime: None, leeway: Duration::from_secs(60), }, fetcher, @@ -1806,6 +1874,8 @@ mod tests { vec!["social_protection_registry:rows".to_string()], )])), allowed_clients: Vec::new(), + denied_kids: HashSet::new(), + max_token_lifetime: None, leeway: Duration::from_secs(60), }, fetcher, @@ -1849,6 +1919,8 @@ mod tests { vec!["social_protection_registry:rows".to_string()], )])), allowed_clients: Vec::new(), + denied_kids: HashSet::new(), + max_token_lifetime: None, leeway: Duration::from_secs(60), }, fetcher, @@ -1955,6 +2027,8 @@ mod tests { vec!["social_protection_registry:rows".to_string()], )])), allowed_clients: Vec::new(), + denied_kids: HashSet::new(), + max_token_lifetime: None, leeway: Duration::from_secs(60), }, Arc::clone(&fetcher), @@ -1986,6 +2060,8 @@ mod tests { scope_separator: ' ', scope_map: None, allowed_clients: Vec::new(), + denied_kids: HashSet::new(), + max_token_lifetime: None, leeway: Duration::from_secs(60), }, fetcher, @@ -2027,6 +2103,8 @@ mod tests { vec!["registry:writer".to_string()], )])), allowed_clients: Vec::new(), + denied_kids: HashSet::new(), + max_token_lifetime: None, leeway: Duration::from_secs(60), }, fetcher, @@ -2073,6 +2151,8 @@ mod tests { vec!["registry:admin".to_string()], )])), allowed_clients: Vec::new(), + denied_kids: HashSet::new(), + max_token_lifetime: None, leeway: Duration::from_secs(60), }, fetcher, @@ -2161,6 +2241,8 @@ mod tests { scope_separator: ' ', scope_map: None, allowed_clients: Vec::new(), + denied_kids: HashSet::new(), + max_token_lifetime: None, leeway: Duration::from_secs(60), }, fetcher, @@ -2198,6 +2280,121 @@ mod tests { )); } + #[tokio::test] + async fn oidc_rejects_denied_kid_before_jwks_lookup() { + let fetcher = Arc::new(JwksFetcher::new( + "http://127.0.0.1/jwks".to_string(), + JwksFetcherConfig::defaults(), + )); + let verifier = TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + "https://issuer.example", + vec!["registry-api".to_string()], + vec![Algorithm::EdDSA], + vec!["JWT".to_string()], + ) + .with_denied_kids(HashSet::from(["denied-kid".to_string()])), + fetcher, + ); + let token = unsigned_token( + json!({ "alg": "EdDSA", "typ": "JWT", "kid": "denied-kid" }), + json!({ "iss": "https://issuer.example", "aud": "registry-api", "exp": 4_102_444_800_i64 }), + ); + + assert!(matches!( + verifier.verify(&token).await, + Err(OidcError::UnknownKid) + )); + } + + #[tokio::test] + async fn oidc_max_token_lifetime_rejects_missing_or_invalid_time_bounds() { + let secret = b"registry-platform-oidc-lifetime-secret"; + let document = Arc::new(RwLock::new(jwks_with_oct_key("kid", secret))); + let jwks_uri = serve_jwks(document, Arc::new(AtomicUsize::new(0))).await; + let fetcher = Arc::new(JwksFetcher::new_with_fetch_url_policy( + jwks_uri, + jwks_test_config(), + FetchUrlPolicy::dev(), + )); + let verifier = TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + "https://issuer.example", + vec!["registry-api".to_string()], + vec![Algorithm::HS256], + vec!["at+jwt".to_string()], + ) + .with_leeway(Duration::from_secs(60)) + .with_max_token_lifetime(Some(Duration::from_secs(300))), + fetcher, + ); + let valid_exp = 4_102_444_800_i64; + + let mut valid_claims = test_claims( + Some("https://issuer.example"), + Some("registry-api"), + Some("subject-1"), + ); + valid_claims.iat = Some(valid_exp - 300); + valid_claims.exp = Some(valid_exp); + let valid = signed_hs256_token("kid", valid_claims, secret, Some("at+jwt")); + verifier + .verify(&valid) + .await + .expect("token at the configured lifetime boundary verifies"); + + let mut missing_iat_claims = test_claims( + Some("https://issuer.example"), + Some("registry-api"), + Some("subject-1"), + ); + missing_iat_claims.exp = Some(valid_exp); + let missing_iat = signed_hs256_token("kid", missing_iat_claims, secret, Some("at+jwt")); + assert!(matches!( + verifier.verify(&missing_iat).await, + Err(OidcError::InvalidToken) + )); + + let mut missing_exp_claims = test_claims( + Some("https://issuer.example"), + Some("registry-api"), + Some("subject-1"), + ); + missing_exp_claims.iat = Some(valid_exp - 300); + missing_exp_claims.exp = None; + let missing_exp = signed_hs256_token("kid", missing_exp_claims, secret, Some("at+jwt")); + assert!(matches!( + verifier.verify(&missing_exp).await, + Err(OidcError::InvalidToken) + )); + + let mut non_positive_claims = test_claims( + Some("https://issuer.example"), + Some("registry-api"), + Some("subject-1"), + ); + non_positive_claims.iat = Some(valid_exp); + non_positive_claims.exp = Some(valid_exp); + let non_positive = signed_hs256_token("kid", non_positive_claims, secret, Some("at+jwt")); + assert!(matches!( + verifier.verify(&non_positive).await, + Err(OidcError::InvalidToken) + )); + + let mut overlong_claims = test_claims( + Some("https://issuer.example"), + Some("registry-api"), + Some("subject-1"), + ); + overlong_claims.iat = Some(valid_exp - 301); + overlong_claims.exp = Some(valid_exp); + let overlong = signed_hs256_token("kid", overlong_claims, secret, Some("at+jwt")); + assert!(matches!( + verifier.verify(&overlong).await, + Err(OidcError::InvalidToken) + )); + } + fn test_claims(issuer: Option<&str>, audience: Option<&str>, subject: Option<&str>) -> Claims { Claims { sub: subject.map(ToOwned::to_owned), diff --git a/crates/registry-platform-testing/src/lib.rs b/crates/registry-platform-testing/src/lib.rs index 86457aaa8..205b9d7e2 100644 --- a/crates/registry-platform-testing/src/lib.rs +++ b/crates/registry-platform-testing/src/lib.rs @@ -476,6 +476,8 @@ pub fn oidc_verifier_config( scope_separator: ' ', scope_map: None, allowed_clients: Vec::new(), + denied_kids: std::collections::HashSet::new(), + max_token_lifetime: None, leeway: Duration::from_secs(60), } } diff --git a/docker/compose/README.md b/docker/compose/README.md index 74d30aef2..9938b5e90 100644 --- a/docker/compose/README.md +++ b/docker/compose/README.md @@ -11,6 +11,7 @@ Set the following absolute paths before starting Compose: - `EVIDENCE_CANDIDATE_DIR`, an approved candidate containing `bundle/`. - `EVIDENCE_RUNTIME_FILE`, a container-specific runtime document. - `EVIDENCE_SECRET_ROOT`, owner-only Evidence secret files. +- `EVIDENCE_TRANSIT_SOCKET_DIR`, the dedicated directory containing `transit-proxy.sock`. - `EVIDENCE_IMAGE`, a reviewed, digest-pinned Evidence image. The maintained Evidence image runs as UID and GID `65532`; the Compose service pins @@ -18,9 +19,15 @@ The maintained Evidence image runs as UID and GID `65532`; the Compose service p owner-only modes Evidence requires. If you select another reviewed image, record its service UID and update the ownership and `user` setting together. -The service mounts the approved bundle, runtime, and secrets read-only. Its named audit volume is -the only writable Evidence storage. The runtime uses container paths and binds Evidence to the -static private address assigned in `docker-compose.yaml`. +The Transit socket directory must be searchable but not writable by the Evidence service identity. +The proxy owns the directory and creates a mode `0660` socket whose group admits only that Evidence +identity. + +The service mounts the approved bundle, runtime, and secrets read-only. It also mounts the dedicated +Transit socket directory created by an operator-managed host proxy or sidecar. Evidence receives no +provider token or auto-auth credential. Its named audit volume is the only writable Evidence +storage. The runtime uses container paths and binds Evidence to the static private address assigned +in `docker-compose.yaml`. Provision the named volume so UID and GID `65532` can create and append the configured audit file before the first start. Do not widen the Evidence process to root to compensate for a root-owned volume. @@ -29,11 +36,13 @@ volume. export EVIDENCE_CANDIDATE_DIR='' export EVIDENCE_RUNTIME_FILE='' export EVIDENCE_SECRET_ROOT='' +export EVIDENCE_TRANSIT_SOCKET_DIR='' export EVIDENCE_IMAGE='' docker compose -f docker-compose.yaml config ``` -Run the target-context check before starting the service: +Start the operator-managed Transit proxy and confirm it created the configured socket. Then run the +target-context check before starting the service: ```sh docker compose -f docker-compose.yaml run --rm evidence \ @@ -48,10 +57,10 @@ owner or mode checks for the container service identity. Mint is a separate service and is intentionally absent from the base adapter, so an Evidence-only deployment has no Mint configuration dependency. When the deployment has no suitable OIDC issuer, -add an operator-owned Mint service with its configuration, signing key, client registry, reviewed -image, and private listener mounted independently. Mint's configured issuer and JWKS URI remain -public HTTPS identities. Operator routing or split DNS resolves that public identity within the -Compose network. +add an operator-owned Mint service with its configuration, public signing keys, client registry, +reviewed image, private listener, and dedicated Transit socket mounted independently. Mint receives +no provider token or private signing key. Mint's configured issuer and JWKS URI remain public HTTPS +identities. Operator routing or split DNS resolves that public identity within the Compose network. This adapter does not establish image provenance, TLS, routing, client registration, or secret ownership. Those remain operator responsibilities. diff --git a/docker/compose/docker-compose.yaml b/docker/compose/docker-compose.yaml index a850785e2..657c930f7 100644 --- a/docker/compose/docker-compose.yaml +++ b/docker/compose/docker-compose.yaml @@ -6,6 +6,7 @@ # EVIDENCE_CANDIDATE_DIR absolute path to the approved candidate # EVIDENCE_RUNTIME_FILE container-specific runtime.yaml path # EVIDENCE_SECRET_ROOT owner-only Evidence secret directory +# EVIDENCE_TRANSIT_SOCKET_DIR directory containing transit-proxy.sock # EVIDENCE_IMAGE reviewed Evidence image by digest name: registry-evidence @@ -19,6 +20,9 @@ services: - ${EVIDENCE_CANDIDATE_DIR:?set EVIDENCE_CANDIDATE_DIR to the approved candidate}/bundle:/etc/registry-evidence/bundle:ro - ${EVIDENCE_RUNTIME_FILE:?set EVIDENCE_RUNTIME_FILE to a container runtime}:/etc/registry-evidence/runtime.yaml:ro - ${EVIDENCE_SECRET_ROOT:?set EVIDENCE_SECRET_ROOT to the Evidence secret root}:/run/secrets/registry-evidence:ro + # Evidence receives only the workload-local proxy socket, never its + # provider token or auto-auth credentials. + - ${EVIDENCE_TRANSIT_SOCKET_DIR:?set EVIDENCE_TRANSIT_SOCKET_DIR to the Transit socket directory}:/run/registry-evidence:ro # The append-only audit chain must outlive the container. - evidence-audit:/var/lib/registry-evidence networks: diff --git a/docker/compose/runtime.docker.yaml b/docker/compose/runtime.docker.yaml index f42846e57..5febbcc89 100644 --- a/docker/compose/runtime.docker.yaml +++ b/docker/compose/runtime.docker.yaml @@ -27,6 +27,17 @@ secretProviders: file: root: /run/secrets/registry-evidence +# The workload-local proxy creates this socket in the separately mounted +# /run/registry-evidence directory. Replace the key name and version with the +# exact values that match the governed active public JWK. +signer: + kind: transit + unixSocketPath: /run/registry-evidence/transit-proxy.sock + mount: transit + keyName: evidence-signing + keyVersion: 1 + timeoutMilliseconds: 2000 + auditStorage: path: /var/lib/registry-evidence/audit/evidence.jsonl maximumFileBytes: 1073741824 diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index c7bc8a44a..58ad28b14 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -321,6 +321,7 @@ export default defineConfig({ { label: 'Test with fixtures', slug: 'tutorials/prove-an-evidence-project' }, { label: 'Configure Evidence Gateway', slug: 'configure/evidence' }, { label: 'Build a production candidate', slug: 'tutorials/build-and-deploy-evidence-project' }, + { label: 'Configure Transit signing', slug: 'tutorials/move-evidence-to-production-signing' }, { label: 'Deploy with Docker Compose', slug: 'tutorials/integrate-evidence-candidate-with-docker-compose' }, ], }, @@ -347,7 +348,7 @@ export default defineConfig({ label: 'Operate Evidence Gateway', collapsed: true, items: [ - { label: 'Rotate signing keys', slug: 'tutorials/move-evidence-to-production-signing' }, + { label: 'Rotate signing keys', slug: 'tutorials/rotate-evidence-signing-keys' }, { label: 'Verify the audit chain', slug: 'operate/evidence-audit' }, ], }, diff --git a/docs/site/scripts/evidence-production-build-docs.test.mjs b/docs/site/scripts/evidence-production-build-docs.test.mjs index 98ab002d0..fdacb93ac 100644 --- a/docs/site/scripts/evidence-production-build-docs.test.mjs +++ b/docs/site/scripts/evidence-production-build-docs.test.mjs @@ -11,9 +11,11 @@ async function page(path) { return readFile(resolve(siteRoot, path), 'utf8'); } -test('production Evidence tutorials keep the build, optional Mint, and Compose boundaries explicit', async () => { - const [build, mint, compose] = await Promise.all([ +test('production Evidence tutorials keep the build, Transit, optional Mint, and Compose boundaries explicit', async () => { + const [build, transit, rotation, mint, compose] = await Promise.all([ page('src/content/docs/tutorials/build-and-deploy-evidence-project.mdx'), + page('src/content/docs/tutorials/move-evidence-to-production-signing.mdx'), + page('src/content/docs/tutorials/rotate-evidence-signing-keys.mdx'), page('src/content/docs/tutorials/issue-evidence-access-tokens-with-registry-mint.mdx'), page('src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx'), ]); @@ -23,9 +25,19 @@ test('production Evidence tutorials keep the build, optional Mint, and Compose b assert.match(build, /evidence --runtime "\/runtime\.yaml" verify-audit/u); assert.match(build, /install -m 600 \/dev\/null ""/u); assert.match(build, /Authorization: Bearer /u); + assert.match(build, /environments\/production\/evidence/u); + assert.match(build, /\/[\s\S]*public-keys\//u); assert.match(build, /\/mint\.yaml"/u); + assert.match(mint, /--mint-config "\/environments\/production\/mint\/mint\.yaml"/u); + assert.match(mint, /signer\.kind: transit/u); assert.match(mint, /memory-only/u); assert.match(mint, /umask 077\nmint token/u); assert.match(mint, /rm -f ""/u); @@ -35,14 +47,17 @@ test('production Evidence tutorials keep the build, optional Mint, and Compose b assert.match(compose, /configurationRevision/u); assert.match(compose, /not output from `evidencectl build`/u); assert.match(compose, /user: "65532:65532"/u); + assert.match(compose, /:\/run\/registry-evidence/u); + assert.match(compose, /Do not share the Evidence Gateway proxy or socket with Mint/u); assert.match(compose, /docker compose down/u); assert.match(compose, / { - const [readme, compose] = await Promise.all([ + const [readme, compose, runtime] = await Promise.all([ readFile(resolve(repoRoot, 'docker/compose/README.md'), 'utf8'), readFile(resolve(repoRoot, 'docker/compose/docker-compose.yaml'), 'utf8'), + readFile(resolve(repoRoot, 'docker/compose/runtime.docker.yaml'), 'utf8'), ]); assert.doesNotMatch(readme, /--with-mint/u); @@ -53,11 +68,16 @@ test('the maintained Compose adapter keeps Evidence independent from Mint scaffo 'EVIDENCE_CANDIDATE_DIR', 'EVIDENCE_RUNTIME_FILE', 'EVIDENCE_SECRET_ROOT', + 'EVIDENCE_TRANSIT_SOCKET_DIR', 'EVIDENCE_IMAGE', ]) { assert.match(readme, new RegExp(`export ${name}=`, 'u')); } assert.match(compose, /EVIDENCE_CANDIDATE_DIR/u); assert.match(compose, /EVIDENCE_SECRET_ROOT/u); + assert.match(compose, /EVIDENCE_TRANSIT_SOCKET_DIR/u); assert.match(compose, /user: "65532:65532"/u); + assert.match(runtime, /kind: transit/u); + assert.match(runtime, /unixSocketPath: \/run\/registry-evidence\/transit-proxy\.sock/u); + assert.doesNotMatch(runtime, /privateKeyRef/u); }); diff --git a/docs/site/src/content/docs/configure/evidence.mdx b/docs/site/src/content/docs/configure/evidence.mdx index 18f730eed..992282cd6 100644 --- a/docs/site/src/content/docs/configure/evidence.mdx +++ b/docs/site/src/content/docs/configure/evidence.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-03" +last_reviewed: "2026-08-06" doc_type: how-to locale: en standards_referenced: [] @@ -40,15 +40,16 @@ evidencectl new "$project_dir" --openapi "$openapi_source" --profile local questions/ empty question-authoring directory derivations/ empty derivation-authoring directory fixtures/ empty production-fixture directory - secrets/ optional owner-only, unbound local keys + secrets/ owner-only, unbound disposable local keys ``` The command retains the OpenAPI bytes exactly and creates no fixture content, question, source policy, -Mint configuration, README, production target, or deployable bundle. Add `--generate-keys` to create disposable local +Mint configuration, README, production target, or deployable bundle. It automatically creates disposable local signing and HMAC material. `evidencectl source suggest --project ` drafts one source and its editable artifacts from the retained contract. After you add a question, `evidencectl dev` compiles the authoring objects into a private local runtime and delegates validation to the real -Evidence Gateway loader. +Evidence Gateway loader. Each development session also scaffolds disposable P-256 keys for Registry +Mint, its caller, and the optional SD-JWT VC holder. ## The runtime file @@ -86,11 +87,11 @@ atomic revision (`products/evidence/contracts/bundle.schema.yaml`). Its top-leve | Section | Declares | | --- | --- | | `service`, `issuer` | The technical provider and the legal issuing authority, both URIs. | -| `authentication` | The one trusted OIDC access-token profile: issuer, audiences, token type, algorithms, JWKS URI, and the claim names for principal, requester tags, evidence audience, grant id, and grant authority. | +| `authentication` | The one trusted OIDC access-token profile: issuer, audiences, token type, algorithms, JWKS URI, maximum token lifetime, revoked key identifiers, and the claim names for principal, requester tags, evidence audience, grant id, and grant authority. | | `audit` | Keyed JSONL audit: the hash secret reference, key version, and fixed fail-closed behavior. | | `subjectBinding` | The secret reference and key version behind the audience-scoped entity-reference HMAC. | | `rateLimits` | Per-principal request, burst, and failed-selector-attempt bounds. | -| `signing` | The active EdDSA signing key reference, retired public JWK files, the fixed JWKS path, and assertion validity and clock-skew bounds. | +| `signing` | Fixed ES256 signing, governed active and published public JWK files, revoked service key identifiers, the fixed JWKS path, and assertion validity and clock-skew bounds. | | `responseFormats` (optional) | The bundle-wide ceiling on releasable serializations; defaults to signed JWS alone and must always include it. | | `selectorProfiles` | Named, bounded scalar field sets a subject role may be looked up by. | | `sources` | Named fixed HTTP JSON sources: transport, base URL, acquisition posture, authentication, the fixed request shape, and the response, extraction, and fact schemas bound to it. | @@ -151,21 +152,36 @@ after output validation. ## Key material -Generate each secret independently and keep the private files inside the configured secret root. -`evidencectl keygen` writes private material as owner-only files and never prints its contents -(`crates/registry-evidencectl/src/keygen.rs`): +Generate the audit and subject-binding secrets independently and keep the private files inside the +configured secret root. `evidencectl keygen` writes private material as owner-only files and never +prints its contents (`crates/registry-evidencectl/src/keygen.rs`): ```sh -evidencectl keygen signing --out-dir "" --kid "" evidencectl keygen secret --out "/audit-hmac-key" evidencectl keygen secret --out "/subject-binding-hmac-key" ``` -### `signing-ed25519-private-jwk` and `signing-ed25519-public.jwk.json` +### Local P-256 signing JWKs -Evidence Gateway signs assertions with the private Ed25519 JSON Web Key (JWK). Verifiers use the public -JWK to check the signature without receiving the private key. The key identifier must match -`signing.activeKeyId` in `bundle/evidence.yaml`. +For local assurance, Evidence Gateway signs with a P-256 private JSON Web Key (JWK) referenced by +`signer.privateKeyRef`. The governed public half lives under `bundle/public-keys/`; its service +`kid` is the RFC 7638 thumbprint that the runtime derives, not an operator-chosen value. + +Generate a disposable local pair with: + +```sh +evidencectl keygen signing --out-dir "" +``` + +Do not use this command to provision a production signer. + +Production and evidence-grade deployments use a workload-local Vault or OpenBao Transit proxy. +The runtime names its Unix socket, mount, key name, pinned nonzero version, and bounded timeout. +The private key stays in Transit. Startup validates the configured public P-256 JWK, pinned version, +custody controls, and a sign-and-verify operation before the deployment is ready. +Follow +[Configure Transit signing for Evidence Gateway and Registry Mint](../../tutorials/move-evidence-to-production-signing/) +for provider key, public JWK, proxy, and policy setup. ### `audit-hmac-key` @@ -210,8 +226,8 @@ evidence --runtime runtime.yaml evaluate --fixture "bundle/fixtures/.yaml `evidence check` loads, compiles, and validates the complete bundle and runtime file together: every selector, role, profile, authority, and source binding resolves, every script compiles -against the frozen ABI, and mounted secret and signing material parses, all without opening the -audit chain or contacting a source. `evidence evaluate` replays one fixture file's synthetic +against the frozen ABI, and mounted signing material passes its provider self-test, all without +opening the audit chain or contacting a source. `evidence evaluate` replays one fixture file's synthetic cases through the reviewed adapter, derivation, and output gate, with no source and no network involved. @@ -225,14 +241,16 @@ operator supplies those deployment artifacts, both `check` and `fixtures run` mu ## Build a production candidate -An editable project is not a deployment input. Add one explicit target under -`deployment-targets/production/` when the reviewed source, questions, and fixtures are ready: +An editable project is not a deployment input. Add complete named targets when the reviewed source, +questions, and fixtures are ready. Keep them in the deployment-operator Git repository with public +keys and nonsecret Transit settings, but never private JWKs, audit masters, provider tokens, or live +identifiers. There are no overlays, environment branches, substitutions, or symlinks: ```text -deployment-targets/ - production/ - governance.yaml - runtime.yaml +environments/ + local/evidence/{governance.yaml,runtime.yaml,public-keys/} + staging/evidence/{governance.yaml,runtime.yaml,public-keys/} + production/evidence/{governance.yaml,runtime.yaml,public-keys/} ``` `governance.yaml` supplies the production-owned bundle fields, including service, issuer, @@ -250,7 +268,7 @@ Build a new candidate directory with explicit target and output paths: ```sh evidencectl build \ --project "" \ - --target "/deployment-targets/production" \ + --target "/environments/production/evidence" \ --output "" ``` diff --git a/docs/site/src/content/docs/configure/mint.mdx b/docs/site/src/content/docs/configure/mint.mdx index b8ae50f76..c3addc64b 100644 --- a/docs/site/src/content/docs/configure/mint.mdx +++ b/docs/site/src/content/docs/configure/mint.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-04" +last_reviewed: "2026-08-06" doc_type: how-to locale: en standards_referenced: [] @@ -52,9 +52,9 @@ You need: - The registered clients this deployment will serve: one client id, principal, evidence audience, and set of requester tags per client. -- A private JWK signing key for Registry Mint itself, in the algorithm the deployment will run - (`EdDSA`, `ES256`, or `RS256`), stored in a file owner-only and not reachable through a - symlink. `crates/registry-mint/src/secretfile.rs` enforces both at load time. +- A governed public P-256 JWK for Registry Mint itself. Its service `kid` is the derived RFC 7638 + thumbprint. Strict deployments use a workload-local Vault or OpenBao Transit proxy; supervised + local development may use an owner-only local P-256 private JWK. - An independently generated audit HMAC key containing at least 32 bytes, stored in another owner-only, non-symlink file. - A private JWK per client. Registry Mint only ever stores and reads each client's public half; @@ -66,9 +66,9 @@ You need: - The claim names the resource server (Evidence Gateway, for example) reads its principal, requester tags, evidence audience, and grant pair from, so Registry Mint's `accessTokens.claims` can be set to match them exactly. -- TLS in front of Registry Mint. Registry Mint serves plain HTTP and expects TLS termination it - does not manage; Evidence Gateway in turn requires the token issuer and its key set to be HTTPS, with - no exception for loopback (`crates/registry-mint/demo/README.md`). +- TLS in front of Registry Mint for strict deployments. Registry Mint serves plain HTTP and expects + TLS termination it does not manage. Supervised local development alone admits the exact + `http://127.0.0.1:` issuer and matching token and JWKS paths. ## Configure the deployment @@ -78,18 +78,30 @@ the listener, or token policy means restarting the process. ```yaml version: 1 +validationMode: strict issuer: https://mint.example.org listener: address: 127.0.0.1 port: 8081 signing: - algorithm: EdDSA - activeKeyId: mint-2026-01 - activeKeyFile: secrets/signing.jwk + algorithm: ES256 + activePublicJwkFile: public-keys/.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: transit + unixSocketPath: /run/registry-mint/transit-proxy.sock + mount: transit + keyName: mint-signing + keyVersion: 7 + timeoutMilliseconds: 2000 +secretProviders: + file: + root: /run/registry-mint/secrets audit: path: audit/mint.jsonl maximumFileBytes: 1073741824 - hashKeyFile: secrets/audit-hmac-key + hashKeyRef: secret:file/audit-hmac-key hashKeyVersion: 1 accessTokens: audiences: [evidence] @@ -102,7 +114,7 @@ accessTokens: grantAuthority: evidence_authority clientAssertion: audience: https://mint.example.org/token - algorithms: [EdDSA] + algorithms: [EdDSA, ES256, RS256] clients: directory: clients ``` @@ -115,7 +127,14 @@ that is the only place the two configurations have to agree. `clientAssertion.au value every client's signed request must carry as its own `aud`, which stops a request built for one endpoint from being replayed at another. -`audit.hashKeyFile` must contain at least 32 bytes and remain separate from the signing key. Mint +`validationMode` defaults to `strict`, which requires the Transit signer. Set it to +`supervised-local-development` only for the disposable local developer environment; that mode may +use `signer.kind: local-jwk` with `privateKeyRef: secret:file/`. +Use +[Configure Transit signing for Evidence Gateway and Registry Mint](../../tutorials/move-evidence-to-production-signing/) +to provision a strict signer and its governed public JWK. + +The secret named by `audit.hashKeyRef` must contain at least 32 bytes and remain separate from the signing key. Mint verifies the keyed JSONL chain and takes a single-writer lock at startup. Before returning an access token it synchronizes a token-release record to the chain and its parent directory. If the write fails, Mint returns `server_error`, does not release the token, and fails readiness. The @@ -159,7 +178,8 @@ Validate the deployment before opening a socket: mint check --config /etc/mint/mint.yaml ``` -`check` loads the configuration, signing key, audit key, and client registry, then exits. It +`check` loads the configuration, governed public keys, signer, audit key, and client registry, then +performs the signer self-test before it exits. It deliberately does not open the audit chain, which admits one writer at a time, so you can check an edited configuration against the deployment it is about to replace. `check`, `serve`, and `verify-audit` accept `MINT_CONFIG` in place of `--config`. @@ -227,7 +247,7 @@ curl -sS https://mint.example.org/token \ Confirm the published key set resolves at the configured `signing.jwksPath` (default `/.well-known/jwks.json`), and that `GET /ready` returns success once at least one client is -registered and the audit writer is healthy. +registered, the audit writer is healthy, and the signing provider passes its self-test. Verify the retained keyed chain with the same configuration and audit key: @@ -244,9 +264,9 @@ records were corrupted or reordered. | --- | --- | --- | | The token request fails with `401 invalid_client` | Registry Mint collapses every client authentication failure, an unknown client id, a bad signature, a replayed `jti`, an expired assertion, into this one code, so the endpoint cannot be used to probe which client ids are registered. | Check the client id, the signing key, the assertion's `iat`/`exp`, and that the `jti` has not already been used. | | The token request fails with `400 unsupported_grant_type` | `grant_type` is missing or is not exactly `client_credentials`. | Send `grant_type=client_credentials` in the form body. | -| `mint check` or `mint serve` refuses to start over the signing key | The key file is not a regular, owner-only, single-link file, or its `kid` and algorithm do not match `signing.activeKeyId` and `signing.algorithm`. | Regenerate or re-permission the private JWK so it is owned by the running user, unreadable by group and other, and not a symlink. | +| `mint check` or `mint serve` refuses to start over signing | The active public key is not an ES256 P-256 JWK with its RFC 7638 `kid`, a revoked key is published, or the signer cannot prove it matches the governed active key. | Correct the governed key set and signer configuration. In strict mode, restore the local Transit proxy and its pinned key version. | | `mint serve` refuses to start over audit | The audit key, directory, chain, or lock file is unsafe, another writer holds the chain, or retained records do not verify. | Check owner-only permissions, run one writer per `audit.path`, then run `mint verify-audit` before deciding whether recovery is needed. Do not discard the chain to make startup pass. | | `mint check` refuses the configuration over audit | The audit hash key file is missing, is not owner-only, or is too short. `check` does not open the chain, so it never reports a running writer as a fault. | Restore the key file with owner-only permissions. Use `mint verify-audit` for the chain itself. | | The token request fails with `500 server_error` and readiness changes to `503` | Mint could not durably append the audit decision and permanently poisoned the writer for this process. | Stop traffic, restore writable durable storage, preserve and verify the retained chain, then restart Mint. The failed request did not receive an access token. | | Evidence Gateway rejects a token that Registry Mint minted | `accessTokens.claims` on Registry Mint and the resource server's own claim-name configuration name different claims for the same authority field. | Align every claim name (`principal`, `requesterTags`, `evidenceAudience`, `grantId`, `grantAuthority`, and `actor` where used) between the two configurations. | -| `GET /ready` returns `503` | No client is currently registered, the client registry directory failed to load, or the audit writer is poisoned. | Check the startup or reload log and the audit storage. Add a valid client or restore and verify audit storage as indicated. | +| `GET /ready` returns `503` | No client is currently registered, the client registry failed to load, the audit writer is poisoned, or the signing provider is unavailable. | Check startup or reload diagnostics and audit storage. Add a valid client, restore and verify audit storage, or restore the Transit proxy and pinned version. Provider readiness recovers after a successful self-test. | diff --git a/docs/site/src/content/docs/explanation/known-limitations.mdx b/docs/site/src/content/docs/explanation/known-limitations.mdx index 4d20acfe9..738489b3d 100644 --- a/docs/site/src/content/docs/explanation/known-limitations.mdx +++ b/docs/site/src/content/docs/explanation/known-limitations.mdx @@ -117,9 +117,10 @@ Read the full context in the [Evidence Gateway protocol](../../spec/rs-pr-eviden - Readiness does not prove a source returns data: neither startup nor readiness sends an evidence-data request or probes a source data endpoint. A ready service is one whose credentials, trust bindings, signing provider, and audit sink resolved, not one whose sources are answering. -- One secret provider: the reference file provider is the only one. There is no hardware - security module or PKCS#11 option, so signing-key custody is a property of the host, its disk, - its backups, and its operators. +- Two deliberately narrow signing modes: local assurance uses a file-backed private JWK; + production and evidence-grade assurance use Vault/OpenBao Transit through a workload-local + Unix-socket proxy. There is no PKCS#11 adapter, cloud-KMS abstraction, provider registry, or + plugin system. ## Registry Mint limits diff --git a/docs/site/src/content/docs/explanation/threat-model.mdx b/docs/site/src/content/docs/explanation/threat-model.mdx index 848cfd382..dc69b18bb 100644 --- a/docs/site/src/content/docs/explanation/threat-model.mdx +++ b/docs/site/src/content/docs/explanation/threat-model.mdx @@ -192,12 +192,13 @@ a bad token receives, with the reason recorded only in the deployment's own log. **Operator to signing key material, and consumer to the published key set.** Private signing material is core-owned and stays out of bundle values, Rhai, logs, audit, and errors -(`V1-I23`). It reaches the process only through the secret-reference mechanism, resolved by a -file provider that accepts only a regular, owner-only, non-symlink, single-hard-link file of -the expected mode and size. Version 1 has exactly one secret provider, the file provider: there -is no hardware-module option, so custody is a property of the host rather than of the runtime. -The operator configures one active EdDSA key whose `kid` matches `signing.activeKeyId`, and -retains each retired public key in the published JWKS for at least the maximum assertion +(`V1-I23`). Local development resolves a private JWK through the file secret provider, which +accepts only a regular, owner-only, non-symlink, single-hard-link file of the expected mode and +size. Production and evidence-grade deployments instead sign through a workload-local +Vault/OpenBao Transit proxy over a Unix socket, so the process receives no provider token or +exportable private key. +The operator governs one active ES256/P-256 public JWK whose `kid` is its RFC 7638 +thumbprint, and retains each previous public key in the published JWKS for at least the maximum assertion validity plus allowed clock skew. Signing is mandatory and fail-closed: a missing or failed signer returns a safe transient failure and never falls back to unsigned output (`V1-I22`). @@ -333,11 +334,10 @@ threat model must weigh. These are the risks the design does *not* close: - Key custody is not certified by health checks: Readiness, liveness, and offline validation - confirm that key material parses and that its file ownership and mode are what the contract - requires. They do not certify production-grade custody. Evidence Gateway Version 1 has one secret - provider, the file provider, and no hardware-module option, so a deployment using - demo-generated or long-lived software keys can be reachable and internally consistent yet not - production-secure. Custody, rotation, and provider approval remain operator responsibilities. + confirm that governed key material and the selected signer agree. They do not independently + certify the Transit service, workload-local proxy, or operator policy. A local-assurance + deployment using demo-generated software keys can be reachable and internally consistent yet + not production-secure. Custody, rotation, and provider approval remain operator responsibilities. - The combined disclosure surface is a human review: The runtime validates one bundle at startup and refuses two simultaneously enabled requirements that declare the same disclosure family, but a declared family is a trusted operator attestation, not a semantic classifier. diff --git a/docs/site/src/content/docs/operate/advanced/inspect-and-diagnose.mdx b/docs/site/src/content/docs/operate/advanced/inspect-and-diagnose.mdx index a5f50d8ad..58c7f18ba 100644 --- a/docs/site/src/content/docs/operate/advanced/inspect-and-diagnose.mdx +++ b/docs/site/src/content/docs/operate/advanced/inspect-and-diagnose.mdx @@ -9,7 +9,7 @@ source_repos: - registry-evidence - registry-mint - registry-platform -last_reviewed: "2026-08-04" +last_reviewed: "2026-08-06" doc_type: how-to locale: en standards_referenced: [] @@ -59,7 +59,7 @@ Inspect each surface for its own purpose: | Surface | Use | Boundary | | --- | --- | --- | | `GET /healthz` (Relay) or `GET /health` (Evidence Gateway, Registry Mint) | Process liveness | Does not check all dependencies or data freshness; Evidence Gateway's `/health` answers 200 even when a source credential is missing | -| `GET /ready` | Current traffic-admission readiness | Does not prove backup freshness or country approval; Evidence Gateway checks its signing, audit, subject-binding, and source credentials, while Mint checks that clients exist and its audit writer is healthy | +| `GET /ready` | Current traffic-admission readiness | Does not prove backup freshness or country approval; Evidence Gateway checks its signing, audit, subject-binding, and source credentials, while Mint checks that clients exist, its audit writer is healthy, and its signing provider is available | | `GET /admin/v1/posture` (Relay only) | Redacted deployment and control observations | Default and restricted tiers expose different detail; Evidence Gateway and Registry Mint have no equivalent admin posture endpoint | | Product audit records | Security and request evidence | Retained chain integrity does not prove complete off-host receipt; use `evidence verify-audit` for Evidence Gateway and `mint verify-audit` for Mint | | `GET /openapi.json` | Concrete API shape for the running instance | Relay's document is configuration and authentication dependent; Evidence Gateway's is the released generated artifact, unauthenticated and independent of the deployed bundle | @@ -180,7 +180,7 @@ Relay's startup codes include: Evidence Gateway and Registry Mint have no equivalent stable startup-rejection codes: `evidence check` and `mint check` fail with a descriptive message instead, covered in -[Rotate Evidence Gateway signing keys](../../../tutorials/move-evidence-to-production-signing/#validate-and-coordinate-consumer-approval) +[Rotate Evidence Gateway signing keys](../../../tutorials/rotate-evidence-signing-keys/) and [Configure Registry Mint](../../../configure/mint/#troubleshooting). Correct the signer, binding, closed file set, product validation, or sequence. diff --git a/docs/site/src/content/docs/operate/advanced/rotate-credentials-and-trust.mdx b/docs/site/src/content/docs/operate/advanced/rotate-credentials-and-trust.mdx index 9fa3c33c7..d52ca99e5 100644 --- a/docs/site/src/content/docs/operate/advanced/rotate-credentials-and-trust.mdx +++ b/docs/site/src/content/docs/operate/advanced/rotate-credentials-and-trust.mdx @@ -9,7 +9,7 @@ source_repos: - registry-evidence - registry-mint - registry-platform -last_reviewed: "2026-08-03" +last_reviewed: "2026-08-06" doc_type: how-to locale: en standards_referenced: [] @@ -32,11 +32,12 @@ exposing secret material and without widening authority. Registry Relay owns source destinations, source credentials, private certification authority material, mutual TLS keys, source protocol credentials, and Relay caller keys. -Evidence Gateway owns its signing key, audit hash secret, and subject-binding secret, each resolved -through its own owner-only secret root (`products/evidence/OPERATOR-CONTRACT.md`, Secrets and -keys). -Registry Mint owns its own signing key and its client registry: one public key and one granted -authority per registered client. +Evidence Gateway owns its governed public signing keys, Transit signer binding, audit hash secret, +and subject-binding secret. The HMAC secrets resolve through its owner-only secret root, while the +production private signing key remains in Transit (`products/evidence/OPERATOR-CONTRACT.md`, Secrets +and keys). +Registry Mint owns its governed public signing keys, Transit signer binding, audit hash secret, and +client registry. Each registered client has one or more public keys and one granted authority. The deployment operator owns secret storage, certificates, product trust anchors, traffic admission, and revocation. @@ -50,7 +51,7 @@ approved-set or anti-rollback lineage. {/* Evidence: products/evidence/OPERATOR-CONTRACT.md, crates/registry-relay/docs/ops.md, - docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx, and + docs/site/src/content/docs/tutorials/rotate-evidence-signing-keys.mdx, and docs/site/src/content/docs/configure/mint.mdx. */} ## Classify the rotation @@ -126,14 +127,22 @@ window, then send the running Registry Mint process `SIGHUP` to reload the clien without a restart (`docs/site/src/content/docs/configure/mint.mdx`). Registry Mint reloads the whole client registry atomically: a malformed replacement file fails the reload and Registry Mint keeps serving the previous registry rather than a partial one. -Remove the old registration only after every client has moved to the new key. +Remove the old registration only after every client has moved to the new key and the configured +maximum client-assertion lifetime plus 30 seconds has elapsed. A compromised client key is removed +immediately and reloaded without an overlap window. Rotating Registry Mint's own signing key is a configuration change, not a client-registry reload: -update `signing.activeKeyId` and `signing.activeKeyFile` in Registry Mint's configuration and -restart the process. -Evidence Gateway does not need reconfiguring for this: it fetches Registry Mint's JWKS from the configured -`jwksUri` on its own cache lifecycle and picks up the new key once Registry Mint restarts, -provided both processes already agree on `issuer` and `accessTokens.claims` +publish the next public JWK, then deploy and restart every replica so all of them publish the +overlap set. In a second candidate, switch `signing.activePublicJwkFile` and the pinned Transit key +version together, keep the old public JWK in `signing.publishedPublicJwkFiles`, then deploy and +restart every replica again. Raise the Transit key's `min_encryption_version` and remove the old +public JWK only after the maximum token lifetime plus consumer skew has elapsed. + +Evidence Gateway does not need reconfiguration for a planned rotation. It fetches Registry Mint's +JWKS from the configured `jwksUri` on its own cache lifecycle, provided both services already agree +on `issuer` and `accessTokens.claims`. For a compromised Mint service key, add its thumbprint to +Evidence Gateway `authentication.revokedKeyIds` in the same incident rollout and restart every +affected consumer. The denylist takes precedence over a cached JWKS (`products/evidence/OPERATOR-CONTRACT.md`, Startup and readiness). ## Rotate configuration signers and trust anchors @@ -241,10 +250,10 @@ Do not interpret two successful verifications as atomic project activation. ## Rotate Evidence Gateway and Registry Mint signing material Evidence Gateway's signing key rotation is a rehearsed procedure, not new material for this page: -generate the replacement Ed25519 key, reassemble the JWKS with the new public key and every -retired public key still inside its validity window, update `signing.activeKeyId` in the -reviewed bundle, then run `evidence check` and restart. -[Rotate Evidence Gateway signing keys](../../../tutorials/move-evidence-to-production-signing/#stage-a-new-keypair) +create the replacement non-exportable P-256 Transit key version, publish its public JWK, then +switch `signing.activePublicJwkFile` and the pinned signer version in the reviewed bundle before +running `evidence check` and restarting. +[Rotate Evidence Gateway signing keys](../../../tutorials/rotate-evidence-signing-keys/) walks through each step, and `products/evidence/OPERATOR-CONTRACT.md` (Secrets and keys) is the binding contract behind it. Missing or failed signing is fail-closed: a rotation mistake surfaces as refused requests, never @@ -253,9 +262,10 @@ as an unsigned assertion. Registry Mint's signing-key rotation is covered above, under Rotate caller keys, next to the client-key rotation it is usually done alongside. -Audit hash-secret rotation is a separate event from signing-key rotation. -Retain the old secret under the audit retention policy: changing it breaks lookup correlation with -pseudonyms recorded under the old secret. +Audit hash-secret rotation is a separate event from signing-key rotation. It begins a new epoch: +drain and stop, verify and record the old head, archive the old runtime, master, segments, and head, +then use a fresh path, a fresh master, and incremented `hashKeyVersion`. Retain the old master under +the audit retention policy because it verifies the archived epoch and its pseudonyms. ## Expected evidence @@ -322,5 +332,5 @@ requires live country data. - [Compare a baseline and reapprove a source change](../compare-and-reapprove-source-change/) - [Inspect and diagnose a running deployment](../inspect-and-diagnose/) - [Back up and restore state](../../backup-and-restore/) -- [Move Evidence Gateway to production signing](../../../tutorials/move-evidence-to-production-signing/) +- [Configure Transit signing for Evidence Gateway and Registry Mint](../../../tutorials/move-evidence-to-production-signing/) - [Configure Registry Mint](../../../configure/mint/) diff --git a/docs/site/src/content/docs/operate/evidence-audit.mdx b/docs/site/src/content/docs/operate/evidence-audit.mdx index bd906de46..8eb866082 100644 --- a/docs/site/src/content/docs/operate/evidence-audit.mdx +++ b/docs/site/src/content/docs/operate/evidence-audit.mdx @@ -121,5 +121,20 @@ governed operator process. - Record retention, deletion, recovery, and requester-identity mapping responsibilities outside the Evidence Gateway bundle. +## Rotate an audit master as a new epoch + +An audit master rotation never appends a new key to an existing chain. Treat it as a new audit +epoch, with a fresh path and a new `hashKeyVersion`: + +1. Drain traffic and stop Evidence Gateway. +2. Verify the old chain and record its head and the applicable configuration revisions. +3. Archive the old runtime, master key, sealed segments, active segment, and recorded head together. +4. Generate a fresh master, increment `hashKeyVersion`, choose a new audit path, and run the full + deployment check. +5. Start the new revision and verify its new chain independently. + +The matching master remains necessary to verify an archived epoch. Do not replace a retained master +in place, merge segments from distinct epochs, or delete a chain to make a replacement key work. + Continue with [Retention and persistent state](../retention-and-persistent-state/) and [Backup and restore](../backup-and-restore/) for the surrounding operator procedures. diff --git a/docs/site/src/content/docs/operate/retention-and-persistent-state.mdx b/docs/site/src/content/docs/operate/retention-and-persistent-state.mdx index b01643695..874b78abf 100644 --- a/docs/site/src/content/docs/operate/retention-and-persistent-state.mdx +++ b/docs/site/src/content/docs/operate/retention-and-persistent-state.mdx @@ -7,7 +7,7 @@ source_repos: - registry-stack - registry-evidence - registry-mint -last_reviewed: "2026-08-03" +last_reviewed: "2026-08-06" doc_type: reference locale: en standards_referenced: [] @@ -46,7 +46,7 @@ Two consequences follow from that boundary: | Relay ingest cache | Normalized Parquet snapshots under `server.cache_dir`. These snapshots can contain full source rows from configured registries. | No time TTL. For audited SnapshotExact, the authored `retain_generations` value keeps between `1` and `16` completed cache generations, including the active generation, after successful publication. Ordinary sources keep the built-in current and previous generations. Older snapshots are removed best effort. | Place `server.cache_dir` on writable storage with the same data classification as the source rows. Treat authored retention as a bounded recovery set, not an API for selecting arbitrary rollback targets. | | Config-trust anti-rollback state | The last accepted sequence, config and bundle hashes, root version, and optional break-glass pin metadata. Operator names, approval references, and reasons can be sensitive. | The state file is rewritten atomically and has no normal TTL. Break-glass overrides require expiry, and consumed override files are renamed. | Preserve `antirollback_state_path` across upgrades and protect break-glass override files with local root controls. | | Relay consultation correctness state | Durable consultation audit, attempts and completions, dispatch permits, quota buckets, materialization publication history, batch-child replay bindings, serving-fence state, and audit-pseudonym keyring metadata. Pseudonymous handles and operational metadata can remain linkable. | Batch-child replay rows expire after `15 minutes` and are pruned on later reservations. Other Relay consultation tables have no general time-based pruning. Keyring retention controls when retired pseudonym key metadata can leave the retained set; it does not delete durable audit rows. | Back up the complete Relay database at a quiesced or coordinated recovery point. Preserve role bindings and key material, and keep any potentially stale restore offline until acknowledged writes are reconciled. | -| Operator-owned config, source, and secret paths | Runtime configuration, signed Relay bundles and trust anchors, Evidence Gateway's governed bundle and runtime document, Registry Mint's signing key and client registry, metadata manifests, source files, and secret references. Source files can contain personal data. | Registry Stack does not expire these files, except through the specific audit, cache, replay, and break-glass mechanics above. | Mount source data read-only where possible; back up config, trust anchors, anti-rollback state, and secrets through your platform controls. | +| Operator-owned config, source, and secret paths | Runtime configuration, signed Relay bundles and trust anchors, Evidence Gateway's governed bundle and runtime document, Registry Mint's governed public keys, signer configuration, and client registry, metadata manifests, source files, and secret references. Source files can contain personal data. | Registry Stack does not expire these files, except through the specific audit, cache, replay, and break-glass mechanics above. | Mount source data read-only where possible; back up config, trust anchors, anti-rollback state, and secrets through your platform controls. | ## Process-local caches and client-held state @@ -94,9 +94,10 @@ secrets only as an intentional key-lifecycle event. ## Evidence Gateway and Registry Mint retention Evidence Gateway has no application database and persists no selector, source, evidence, or response -data. Its only durable state is the operator-configured signing key material and the audit chain +data. Its durable state is its governed signer configuration and public keys plus the audit chain described in [Durable state and externally retained records](#durable-state-and-externally-retained-records). +The external Transit service retains the production private key. An external durable audit service may own its own storage, but Evidence Gateway itself does not maintain one. @@ -109,8 +110,9 @@ active segment while the service runs: the runtime recognizes a segment by its `.` name, and a rename outside that namespace produces a silent fork on the next restart rather than a rotation. -Registry Mint's durable state is the operator-owned signing key, the client registration files -under `clients.directory`, and the segmented audit chain. Onboarding, offboarding, and caller key +Registry Mint's durable state is its governed signer configuration and public keys, the client +registration files under `clients.directory`, and the segmented audit chain. The external Transit +service retains the strict-mode private key. Onboarding, offboarding, and caller key rotation reload the client directory on `SIGHUP` without a restart. The client-assertion replay cache is in-memory only and clears on restart, so a restarted Registry Mint accepts a previously used `jti` again for whatever lifetime remains on that assertion. Keep diff --git a/docs/site/src/content/docs/reference/environment-variables.mdx b/docs/site/src/content/docs/reference/environment-variables.mdx index 22ab3115c..00bb25cfd 100644 --- a/docs/site/src/content/docs/reference/environment-variables.mdx +++ b/docs/site/src/content/docs/reference/environment-variables.mdx @@ -55,7 +55,12 @@ The `mint` binary reads the variables below. | `MINT_CONFIG` | YAML config path for `mint check` and `mint serve`. Equivalent to `--config`. | Required for those two subcommands, by flag or by variable. | | `RUST_LOG` | Tracing filter for the JSON operational logs. Applies to every subcommand; `mint token` sends its logs to standard error so the access token stays alone on standard output. | Defaults to `info`. | -Registry Mint's key material and client registrations are files named in its configuration: `signing.activeKeyFile`, `signing.retiredPublicJwkFiles`, and `clients.directory`. None of them is an environment variable. The `mint token` subcommand is a caller tool rather than an operator one: it reads no server configuration at all, and takes the caller's private JWK and any delegation subject as file paths rather than as values. See the [Registry Mint reference](../mint/) for the full configuration surface. +Registry Mint's governed service public keys, Transit proxy settings, and client registrations are named +in its configuration: `signing.activePublicJwkFile`, `signing.publishedPublicJwkFiles`, +`signing.revokedKeyIds`, `signer`, and `clients.directory`. None is an environment variable. The +`mint token` subcommand is a caller tool rather than an operator one: it reads no server configuration +at all, and takes the caller's private JWK and any delegation subject as file paths rather than as +values. See the [Registry Mint reference](../mint/) for the full configuration surface. ## Registry Relay diff --git a/docs/site/src/content/docs/reference/evidencectl.mdx b/docs/site/src/content/docs/reference/evidencectl.mdx index 986a265dd..b8191fb1c 100644 --- a/docs/site/src/content/docs/reference/evidencectl.mdx +++ b/docs/site/src/content/docs/reference/evidencectl.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-04" +last_reviewed: "2026-08-06" doc_type: reference locale: en standards_referenced: [] @@ -42,8 +42,9 @@ An editable project becomes a production build input only after it has: - A `fixtures/` regular file referenced by every production question. - Stable concept identifiers and production `governance` metadata in every question. -- `deployment-targets//governance.yaml` with production bundle-owned fields. -- `deployment-targets//runtime.yaml` with one target's runtime bindings. +- `environments//evidence/governance.yaml` with bundle-owned fields for one environment. +- `environments//evidence/runtime.yaml` with one environment's runtime bindings. +- `environments//evidence/public-keys/` with every active and published service JWK. `governance.yaml` is strict. It must carry version `1`, `assuranceProfile: production`, service, issuer, authentication, audit, subject binding, rate limits, signing, optional response formats, @@ -53,12 +54,20 @@ logical `secret:file/` references, not values or absolute secret paths. The output runtime document is copied byte-for-byte. Its paths and secret posture are accepted only by the final target-host `evidence check`. +Keep the editable project under `shared/evidence-project/` and each complete deployment target under +`environments//evidence/`. Do not use overlays, environment branches, symlinks, or runtime +substitutions. Git contains public keys and nonsecret provider configuration, but no private JWKs, +HMAC keys, provider tokens, auto-auth credentials, access tokens, live responses, or real +identifiers. + ## Local-only commands `evidencectl new --profile local` and `evidencectl dev` are local-authoring commands. Nothing under `.evidence/dev` is a production build input. `new` creates no source policy, question, fixture -content, production target, Mint configuration, or deployable bundle. `--generate-keys` creates -disposable, unbound Evidence Gateway key material only. +content, production target, Mint configuration, or deployable bundle. It automatically creates +disposable, unbound Evidence Gateway key material in the ignored owner-only `secrets/` directory. +`dev` creates session-scoped P-256 pairs for Registry Mint, the local caller, and the optional +SD-JWT VC holder. None of those keys enters a production target. `evidencectl access` manages caller access for one local project: diff --git a/docs/site/src/content/docs/reference/glossary.mdx b/docs/site/src/content/docs/reference/glossary.mdx index f39750f05..f8d376053 100644 --- a/docs/site/src/content/docs/reference/glossary.mdx +++ b/docs/site/src/content/docs/reference/glossary.mdx @@ -82,7 +82,7 @@ Product names are always in English, including on future translated pages.
An operated set of Registry Relay or Evidence Gateway product instances. Each product stages and activates its own separately verified configuration; there is no cross-product activation coordinator, and this is not atomic project activation.
deployment project
-
The directory an Evidence Gateway operator mounts: a `runtime.yaml` file holding process-local bindings, and a `bundle/` directory holding the governed configuration, scripts, schemas, codelists, and fixtures. `evidencectl new` starts a local OpenAPI authoring workspace with reusable selector and source objects, questions, derivations, scripts, schemas, fixtures, and optional disposable Evidence Gateway keys. `evidencectl build` compiles that workspace and one explicit target into a new production candidate. Evidence Gateway loads a completed bundle read-only at startup; a new revision is a new deployment, not a live change.
+
The directory an Evidence Gateway operator mounts: a `runtime.yaml` file holding process-local bindings, and a `bundle/` directory holding the governed configuration, scripts, schemas, codelists, and fixtures. `evidencectl new` starts a local OpenAPI authoring workspace with reusable selector and source objects, questions, derivations, scripts, schemas, fixtures, and automatically generated disposable Evidence Gateway keys. `evidencectl build` compiles that workspace and one explicit target into a new production candidate. Evidence Gateway loads a completed bundle read-only at startup; a new revision is a new deployment, not a live change.
decision owner
The institution accountable for the requirements, rules, decisions, and actions that use evidence. The decision owner can operate the evidence consumer directly or rely on a separate caller or intermediary.
diff --git a/docs/site/src/content/docs/reference/mint.mdx b/docs/site/src/content/docs/reference/mint.mdx index 4185d4227..b1fc0253a 100644 --- a/docs/site/src/content/docs/reference/mint.mdx +++ b/docs/site/src/content/docs/reference/mint.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-04" +last_reviewed: "2026-08-06" doc_type: reference locale: en standards_referenced: [] @@ -50,9 +50,12 @@ following tables group the surface by the struct that owns each field. | Field | Type | Default | Notes | | --- | --- | --- | --- | | `version` | integer | required | Must equal `1`. | +| `validationMode` | `strict` or `supervised-local-development` | `strict` | Strict mode requires Transit. Supervised local development also permits `local-jwk`. | | `issuer` | string (URL) | required | Must be `https`, have a host, and carry no credentials, query, or fragment. | | `listener` | object | required | See [Listener](#listener). | | `signing` | object | required | See [Signing](#signing). | +| `signer` | object | required | The local-JWK or Transit signing provider. | +| `secretProviders` | object | required | File-secret root for local signing and audit masters. | | `audit` | object | required | See [Audit](#audit). | | `accessTokens` | object | required | See [Access tokens](#access-tokens). | | `clientAssertion` | object | required | See [Client assertion](#client-assertion). | @@ -71,19 +74,28 @@ following tables group the surface by the struct that owns each field. | Field | Type | Default | Notes | | --- | --- | --- | --- | -| `algorithm` | enum: `EdDSA`, `ES256`, `RS256` | required | Shared by minted tokens and accepted client assertions. | -| `activeKeyId` | string (1..=256 bytes) | required | Must match the `kid` of the private JWK at `activeKeyFile`. | -| `activeKeyFile` | path | required | Private JWK. Must be a regular, owner-only, single-link file. | -| `retiredPublicJwkFiles` | list of paths | `[]` | Public JWKs of keys that no longer sign but may still have live tokens. | +| `algorithm` | `ES256` | required | Fixed service-token signing algorithm. Client assertions have their own allowlist. | +| `activePublicJwkFile` | path | required | Exact public P-256 JWK for the signer. Its `kid` must be the derived RFC 7638 thumbprint. | +| `publishedPublicJwkFiles` | list of paths | `[]` | Other current public P-256 JWKs that remain in JWKS during a planned rotation. | +| `revokedKeyIds` | list of thumbprints | `[]` | Denylisted service keys. They cannot be active, published, or returned by JWKS. | | `jwksPath` | string | `/.well-known/jwks.json` | A plain absolute path: one or more non-empty segments of `A-Z a-z 0-9 - . _ ~`, no dot segments, and no query, fragment, or route pattern. Must not be `/token`, `/health`, `/ready`, or `/.well-known/oauth-authorization-server`. | +### Signer + +`signer.kind` is `transit` in strict mode and `local-jwk` only for supervised local development. +A Transit configuration has `unixSocketPath`, `mount`, `keyName`, `keyVersion`, and +`timeoutMilliseconds`. The Unix socket points to a workload-local proxy, not a network provider. +The key version is nonzero and pinned. Mint verifies the public key, Transit custody controls, key +version, and a signature before readiness admits traffic. A local-JWK configuration has only +`privateKeyRef`; its resolved key must match `activePublicJwkFile`. + ### Audit | Field | Type | Default | Notes | | --- | --- | --- | --- | | `path` | path | required | One keyed JSONL chain. The parent directory and existing chain must be owner-only. | | `maximumFileBytes` | integer (`u64`) | required | Per-segment rotation threshold, from `1048576` through `1099511627776` bytes. | -| `hashKeyFile` | path | required | At least 32 bytes in a regular, owner-only, single-link file. Must differ from signing and audit paths. | +| `hashKeyRef` | `secret:file/` | required | At least 32 bytes in a regular, owner-only file beneath `secretProviders.file.root`. Must differ from the local signing key reference. | | `hashKeyVersion` | integer (`u32`) | required | Must be non-zero. Labels keyed pseudonyms so an operator can identify the correlating key generation. | Mint takes a single-writer lock, verifies the active chain at startup, and synchronizes the chain @@ -191,8 +203,8 @@ configured `clientAssertion.audience`, a `jti`, and `iat`/`exp` inside } ``` -`access_token` is a compact JWS with header `{"alg": "", "typ": "at+jwt", -"kid": ""}`. Its claims carry the standard `iss`, `aud`, `iat`, `nbf`, +`access_token` is a compact JWS with header `{"alg": "ES256", "typ": "at+jwt", +"kid": ""}`. Its claims carry the standard `iss`, `aud`, `iat`, `nbf`, `exp`, `jti`, `client_id`, and `sub` (the principal), plus the authority claims named under `accessTokens.claims`. `expires_in` equals `accessTokens.lifetimeSeconds`. Mint durably appends the matching token-release audit record before sending this response. If that append fails, the @@ -217,7 +229,7 @@ Every response body is `{"error": ""}`, from | `GET ` (default `/.well-known/jwks.json`) | Public keys for verifying minted tokens, `application/jwk-set+json`. | | `GET /.well-known/oauth-authorization-server` | Metadata pointing at the token endpoint and the key set. | | `GET /health` | Liveness. | -| `GET /ready` | Readiness. Returns `503` while no client is registered or after an audit write failure poisons the writer. | +| `GET /ready` | Readiness. Returns `503` while no client is registered, after an audit write failure poisons the writer, or while the signing provider is unavailable. Provider readiness recovers after a successful self-test. | ## How a client, Registry Mint, and Evidence Gateway interact @@ -278,8 +290,9 @@ Mint writes: `principalClaim`, `requesterTagsClaim`, `evidenceAudienceClaim`, `g configuration in `crates/registry-evidence/src/auth.rs` (`Authenticator`), reading each claim by the name configured there rather than any hardcoded name. Setting `authentication.issuer` and `authentication.jwksUri` to Registry Mint's own `issuer` and published key set, and setting each -claim name to match `accessTokens.claims` on Registry Mint, is what lets one token flow between -the two. +claim name to match `accessTokens.claims` on Registry Mint, and setting its required maximum token +lifetime and `revokedKeyIds`, is what lets one token flow between the two. Evidence Gateway checks +the key denylist before it selects a cached JWKS entry. This is proven by tests, not only by matching configuration. `registry-mint`'s `tests/evidence_compatibility.rs` drives the real Registry Mint router over a real on-disk diff --git a/docs/site/src/content/docs/security/evidence.mdx b/docs/site/src/content/docs/security/evidence.mdx index 77f11e22c..64a28e963 100644 --- a/docs/site/src/content/docs/security/evidence.mdx +++ b/docs/site/src/content/docs/security/evidence.mdx @@ -204,7 +204,9 @@ Owner-only key files. Each secret file below the configured `secretProviders.file.root` must be a regular, non-symlink file owned by the service identity with mode `0600`; the file provider rejects anything else. Audit and subject-binding secret files must contain independently generated -raw key bytes and be at least 32 bytes. +raw key bytes and be at least 32 bytes. The runtime derives separated audit-chain and identifier +subkeys from each master, but Evidence Gateway also requires the two secret references and resolved +master bytes to be distinct. Immutable bundle and runtime file. The operator mounts one reviewed governed bundle and one closed `runtime.yaml` read-only at startup; the bundle @@ -214,13 +216,14 @@ write bits. There is no runtime upload, editor, approval API, hot reload, merge, mutation, governed-field override, or fallback bundle or runtime file: a new revision is a new deployment, not a live change. -Secret handling. Source credentials and private signing material reach +Secret handling. Source credentials and local-development private signing material reach Evidence Gateway only through the secret-reference mechanism and must not appear in YAML values, Rhai, command arguments, environment dumps, logs, audit, -errors, snapshots, or generated contracts. The operator configures exactly -one active signing key, whose `kid` matches `signing.activeKeyId`, and -retains each retired public key in the published JWKS for at least the -maximum assertion validity plus allowed clock skew. +errors, snapshots, or generated contracts. Production and evidence-grade deployment uses a +workload-local Vault or OpenBao Transit proxy over a Unix socket, leaving the service private key +non-exportable. The operator configures one active public ES256 P-256 JWK, whose derived RFC 7638 +thumbprint is its `kid`, and retains published public keys for at least the maximum assertion +validity plus allowed clock skew. A revoked key can be neither active nor published. ## Report a vulnerability diff --git a/docs/site/src/content/docs/security/hardening-checklist.mdx b/docs/site/src/content/docs/security/hardening-checklist.mdx index e056f976d..17ebb57b5 100644 --- a/docs/site/src/content/docs/security/hardening-checklist.mdx +++ b/docs/site/src/content/docs/security/hardening-checklist.mdx @@ -8,7 +8,7 @@ source_repos: - registry-evidence - registry-mint - registry-platform -last_reviewed: "2026-08-04" +last_reviewed: "2026-08-06" doc_type: how-to locale: en standards_referenced: [] @@ -47,24 +47,28 @@ Evidence Gateway, [Configure Evidence Gateway](../../configure/evidence/). ## Keys and custody -- Evidence Gateway: configure exactly one active signing key, an Ed25519 private JWK whose `kid` matches - `signing.activeKeyId` in the governed bundle. `evidence check` refuses a deployment whose signing - material startup would refuse, including a `kid` mismatch. -- Evidence Gateway: supply every private key and source credential through a `secret:file/` - reference resolved under `secretProviders.file.root`, never as a YAML value, a command argument, - or an environment dump. The file provider reads only regular, non-symlink files below that root, - each owned by the service identity at mode `0600`, and it checks owner, mode, no-follow, link - count, and open-file identity. Keep the secret root itself operator-only. +- Evidence Gateway: configure one active ES256 P-256 public JWK with a derived RFC 7638 thumbprint + `kid`, plus only the public overlap keys needed for current assertions. `evidence check` refuses a + revoked, malformed, or mismatched active signer. +- Evidence Gateway: supply local-assurance private JWKs, HMAC keys, and source credentials through a + `secret:file/` reference resolved under `secretProviders.file.root`, never as a YAML value, + a command argument, or an environment dump. The file provider reads only regular, non-symlink + files below that root, each owned by the service identity at mode `0600`, and checks owner, mode, + no-follow, link count, and open-file identity. Keep the secret root itself operator-only. Strict + deployments receive only a Transit Unix socket for signing. - Evidence Gateway: give the audit hash key and the subject-binding key independently generated raw key material of at least 32 bytes each. The file provider does not base64-decode them, so write raw bytes rather than an encoded string. -- Evidence Gateway: rotate a signing key by starting a new revision with the new active key and keeping - the retired public JWK published for at least the maximum assertion validity plus the allowed - clock skew. Do not remove a public key a stored assertion still needs. -- Evidence Gateway Version 1 has exactly one secret provider, the file provider. There is no - hardware-security-module or PKCS#11 option, so key custody is a property of the host, its disk, - its backups, and its operators. Treat that as a deployment control you own, not one the runtime - provides. See [Move Evidence Gateway to production signing](../../tutorials/move-evidence-to-production-signing/). +- Evidence Gateway: rotate a signing key by first publishing its next public JWK, then changing the + active public JWK and pinned Transit key version, while retaining the old public key for at least + maximum assertion validity plus allowed clock skew. Do not remove a public key a stored assertion + still needs. Follow + [Rotate Evidence Gateway signing keys](../../tutorials/rotate-evidence-signing-keys/). +- Evidence Gateway: use a workload-local Vault or OpenBao Transit proxy over a Unix socket in + production and evidence-grade deployments. Require P-256, signing enabled, non-exportable key + material, plaintext backup disabled, and an exact governed public-key match. Local JWK signing is + for local assurance only. See + [Configure Transit signing for Evidence Gateway and Registry Mint](../../tutorials/move-evidence-to-production-signing/). - Relay: confirm every env-backed `fingerprint.name` referenced in config exists in the runtime environment, and that no raw key, fingerprint, private JWK, or full environment dump reaches a log line; see the production checklist in the @@ -72,9 +76,9 @@ Evidence Gateway, [Configure Evidence Gateway](../../configure/evidence/). - Relay: keep the OIDC algorithm allowlist to what the deployment actually uses. `HS*` and `none` are absent from the configuration type entirely; the default allowlist is RS256, ES256, and EdDSA. Evidence Gateway's own access-token allowlist is configured per bundle from the same three, while - its assertion signing is EdDSA only and not configurable. -- Registry Mint, when the deployment runs it: hold Registry Mint's private JWK in an owner-only, - non-symlink file, and treat `clients.directory` as key material rather than configuration. Any + its assertion signing is fixed ES256. +- Registry Mint, when the deployment runs it: use the same active, published, revoked P-256 service + key posture and Transit boundary as Evidence Gateway. Treat `clients.directory` as key material. Any file written there registers a client and the authority Registry Mint will assert for it, and a running process re-reads the directory on `SIGHUP` without a restart. - Registry Mint: generate its audit HMAC key independently from its signing key, give it at least @@ -250,11 +254,12 @@ Evidence Gateway, [Configure Evidence Gateway](../../configure/evidence/). - Know your private disclosure channel before you need it: see [Report a vulnerability](../report-a-vulnerability/). -- Rotate a compromised Evidence Gateway signing key by deploying a new revision with a new active key, - keeping the compromised public key published only as long as stored assertions must still - verify, then removing it. Evidence Gateway defines no revocation, status list, or presentation-time - check, so a released assertion stays verifiable until its declared validity expires. Plan the - incident response around short validity windows rather than around recall. +- Rotate a compromised Evidence Gateway signing key by disabling provider signing authority + immediately, removing its public JWK, adding its thumbprint to `signing.revokedKeyIds`, and + activating a checked replacement or leaving the service unavailable. Restart every affected + issuer and consumer. + Evidence Gateway defines no credential-status or lifecycle feature, so plan incident response + around short assertion validity windows and service-key denylisting rather than recall. - Rotate a compromised source credential or Registry Mint client key at the credential's own source and restart the affected process. Neither service reloads secret material in place. - Preserve the audit trail for post-incident review. For Evidence Gateway, take the whole audit directory diff --git a/docs/site/src/content/docs/spec/rs-sec-g.mdx b/docs/site/src/content/docs/spec/rs-sec-g.mdx index fe6998e37..c9c2932e5 100644 --- a/docs/site/src/content/docs/spec/rs-sec-g.mdx +++ b/docs/site/src/content/docs/spec/rs-sec-g.mdx @@ -8,7 +8,7 @@ source_repos: - registry-relay - registry-evidence - registry-mint -last_reviewed: "2026-08-03" +last_reviewed: "2026-08-06" doc_type: specification doc_id: RS-SEC-G category: normative @@ -113,9 +113,9 @@ The OpenAPI document requires authentication unless the deployment opts out. A r A verifier needs the issuer's public key to check a signed artifact, and it needs that key without holding a credential of its own. The model therefore separates the published public half from the private signing material, which never leaves the issuer. -REQ-SEC-G-007: An issuer MUST sign with an asymmetric key and MUST publish only the public half through the issuer JWKS. Private key material MUST NOT be published and MUST NOT be required by a verifier. A key that is being rotated out MAY remain published for verification while artifacts it signed are still within their validity, so a verifier can check previously signed artifacts across a rotation. Evidence Gateway signs with exactly one active key and retains a retired public key for at least the maximum assertion validity plus the accepted clock skew. +REQ-SEC-G-007: An issuer MUST sign with an asymmetric key and MUST publish only the public half through the issuer JWKS. Private key material MUST NOT be published and MUST NOT be required by a verifier. A key that is being rotated out MAY remain published for verification while artifacts it signed are still within their validity, so a verifier can check previously signed artifacts across a rotation. Evidence Gateway signs with exactly one active ES256/P-256 key, identifies service keys by their RFC 7638 thumbprints, and retains a previous public key for at least the maximum assertion validity plus the accepted clock skew. -The custody mechanism for the private key (environment, file, or hardware module) is a deployment choice, described in [Move Evidence Gateway to production signing](../../tutorials/move-evidence-to-production-signing/) and out of scope here (Section 9). +The approved custody mechanism depends on assurance: local assurance uses a file-backed private JWK, while production and evidence-grade assurance require Vault/OpenBao Transit through a workload-local Unix-socket proxy. See [Configure Transit signing for Evidence Gateway and Registry Mint](../../tutorials/move-evidence-to-production-signing/). Readiness, liveness, and protocol conformance checks show that a service has loaded configuration and can serve the expected protocol surface. They do not certify production-grade private-key custody. A deployment that uses software keys, local JWK files, or demo-generated keys can still be reachable and internally consistent; production custody, rotation, and approval of a key provider remain operator responsibilities under Section 9. diff --git a/docs/site/src/content/docs/start/evaluate-evidence.mdx b/docs/site/src/content/docs/start/evaluate-evidence.mdx index cb0b99876..cdd51c55b 100644 --- a/docs/site/src/content/docs/start/evaluate-evidence.mdx +++ b/docs/site/src/content/docs/start/evaluate-evidence.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-03" +last_reviewed: "2026-08-06" doc_type: explanation locale: en standards_referenced: [] @@ -19,9 +19,10 @@ the next one: what does running it actually cost. ## Runtime footprint Evidence Gateway is one crate, `registry-evidence`, and one binary, `evidence` -(`crates/registry-evidence/Cargo.toml`). There is no separate control plane, -worker process, or sidecar: one process serves one operator-controlled trust -domain (`products/evidence/README.md`). +(`crates/registry-evidence/Cargo.toml`). There is no separate control plane or worker process: one +Evidence Gateway process serves one operator-controlled trust domain. Production and evidence-grade +signing also requires an operator-managed workload-local Transit proxy +(`products/evidence/README.md`). At startup the process reads two inputs, both mounted read-only: a closed operator `runtime.yaml` that binds one listener, bundle directory, secret @@ -31,11 +32,11 @@ scripts, schemas, codelists, and fixtures. Neither input may be writable to the service process; startup and readiness fail when either is incomplete, inconsistent, or mutable (`products/evidence/OPERATOR-CONTRACT.md`). -It also needs key material on local disk before it can start: an Ed25519 -signing key whose `kid` matches `signing.activeKeyId`, plus two independently -generated raw secrets of at least 32 bytes, one for the audit hash chain and -one for subject-binding pseudonyms. All three live under an owner-only (mode -`0700`) secret root, each file mode `0600` +It needs a governed ES256 P-256 public key whose `kid` is its RFC 7638 thumbprint, plus two +independently generated raw secrets of at least 32 bytes, one for the audit hash chain and one +for subject-binding pseudonyms. Production and evidence-grade mode use a workload-local Vault or +OpenBao Transit proxy over a Unix socket. Local assurance may use an owner-only P-256 private JWK +under the secret root (mode `0700`, file mode `0600`) (`products/evidence/OPERATOR-CONTRACT.md`). The runtime requires a complete deployment layout like this: @@ -48,15 +49,17 @@ project_root/ derivations/ requirement derivation (Rhai) schemas/ closed adapter-parameter, response, and fact schemas fixtures/ synthetic acceptance cases + public-keys/ governed active and published public signing keys runtime.yaml process-local paths and listener, not governed - secrets/ key material, created empty with mode 0700 + secrets/ audit, subject-binding, and source secrets, mode 0700 + transit-proxy.sock strict-mode signing boundary, outside the secret root audit/ audit records written by the service ``` `evidencectl new --openapi --profile local` starts an editable authoring workspace. It retains the OpenAPI document and creates directories for reusable selector and -source objects, source scripts and schemas, questions, and derivations. `--generate-keys` adds -disposable owner-only local keys. `evidencectl source suggest --project ` drafts one source +source objects, source scripts and schemas, questions, and derivations, plus disposable owner-only +local Evidence keys. `evidencectl source suggest --project ` drafts one source from the retained contract, and `evidencectl dev` compiles complete authoring objects into the private local runtime layout shown here (`crates/registry-evidencectl/src/scaffold.rs`, `crates/registry-evidencectl/src/authoring.rs`). @@ -131,10 +134,11 @@ suitable OIDC issuer. ### Keys -`evidencectl keygen` generates the signing key and the two HMAC secrets; -nothing about generation happens automatically on deploy. Rotation is the -operator's job too: a deployment keeps one active signing key at a time and -must retain every retired public key in the published JWKS for at least the +`evidencectl keygen signing` generates a local P-256 signing key pair, while +`evidencectl keygen secret` generates one audit or subject-binding secret at a +time. Nothing about production generation happens automatically on deploy. +Rotation is the operator's job too: a deployment keeps one active signing key +at a time and must retain every previous public key in the published JWKS for at least the maximum assertion validity plus allowed clock skew, or a verifier holding an older cached assertion will fail to check it (`products/evidence/OPERATOR-CONTRACT.md`). diff --git a/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx b/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx index f6076b7fc..3911d5de6 100644 --- a/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx +++ b/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx @@ -113,8 +113,7 @@ In another terminal, create an editable project from the registry's OpenAPI desc ```sh evidencectl new parent-relationship \ --openapi http://127.0.0.1:8002/openapi.json \ - --profile local \ - --generate-keys + --profile local cd parent-relationship ``` diff --git a/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx b/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx index e2b10d504..95f10b79b 100644 --- a/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx +++ b/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-04" +last_reviewed: "2026-08-06" doc_type: tutorial persona: - assertion provider @@ -74,18 +74,33 @@ governance: Do not invent these URIs during the build. Review them with the institution that owns the requirement and disclosure decision. -## Create the production target +## Create complete environment targets -Create exactly one explicit target for this candidate: +Keep reviewed Evidence semantics and complete environment bindings in one protected branch of the +deployment repository: ```text -institution-evidence/ - deployment-targets/ - production/ - governance.yaml - runtime.yaml +shared/ + evidence-project/ +environments/ + local/ + evidence/{governance.yaml,runtime.yaml,public-keys/} + mint/{mint.yaml,clients/,public-keys/} + staging/ + evidence/{governance.yaml,runtime.yaml,public-keys/} + mint/{mint.yaml,clients/,public-keys/} + transit/{proxy-configs/,policies/} + production/ + evidence/{governance.yaml,runtime.yaml,public-keys/} + mint/{mint.yaml,clients/,public-keys/} + transit/{proxy-configs/,policies/} ``` +Omit the Mint directories when the deployment uses another OIDC issuer. Every environment target +is complete. Do not use overlays, environment branches, symlinks, or runtime substitutions. Git +contains public keys and nonsecret provider configuration, but never private JWKs, HMAC keys, +provider tokens, auto-auth credentials, access tokens, live responses, or real identifiers. + `governance.yaml` provides bundle-owned production values. It contains version `1`, `assuranceProfile: production`, service and issuer, authentication, audit, subject binding, rate limits, signing, optional response formats, and authority profiles. Secret references use @@ -99,6 +114,12 @@ Set `bundleDirectory` to `/bundle`, and keep the listen loopback or private address. The runtime cannot override the governed service, authentication, authority, source, disclosure, or signing fields. +`public-keys/` contains the exact active and published service JWKs named by `governance.yaml`. +Production and evidence-grade targets bind the matching non-exportable provider key through the +Transit signer in `runtime.yaml`. Use +[Configure Transit signing for Evidence Gateway and Registry Mint](../move-evidence-to-production-signing/) +before building the first strict candidate. + ## Build the candidate Choose a new output path. The command refuses an existing path and does not modify the editable @@ -106,8 +127,8 @@ project: ```sh evidencectl build \ - --project "" \ - --target "/deployment-targets/production" \ + --project "/shared/evidence-project" \ + --target "/environments/production/evidence" \ --output "" ``` @@ -122,20 +143,25 @@ The candidate contains the runtime document and closed bundle: derivations/ schemas/ fixtures/ + public-keys/ ``` The build validates the generated bundle through the real `evidence` binary and every referenced fixture before it publishes the candidate. It does not contact an identity provider, Mint, or a -source endpoint. Record the printed bundle revision with the approved candidate path. +source endpoint. It validates governed public-key semantics without contacting Transit or +generating an unrelated signing key. The target-host check performs the provider self-test. Record +the printed bundle revision with the approved candidate path. ## Provision the target host -Transfer the exact candidate. The operator independently provisions the Evidence Gateway signing key, -audit HMAC key, subject-binding HMAC key, and source credentials beneath the runtime secret root. +Transfer the exact candidate. The operator provisions the audit HMAC key, subject-binding HMAC key, +and source credentials beneath the runtime secret root. The workload-local Transit proxy holds the +provider token and auto-auth state; Evidence Gateway receives only access to the configured Unix +socket. Its non-exportable signing key remains in Transit. Make the candidate runtime and bundle non-writable to the Evidence Gateway service identity. -Run the grouped offline ceremony once after the candidate, runtime bindings, trust files, and -secrets are in place: +Start the workload-local Transit proxy. Run the grouped offline ceremony once after the candidate, +runtime bindings, trust files, secrets, and proxy socket are in place: ```sh evidencectl doctor --project "" @@ -190,8 +216,8 @@ evidence verify \ evidence --runtime "/runtime.yaml" verify-audit ``` -The signing policy and secret references are governed bundle content. Rotate a key by introducing a -new key identifier through the key-rotation procedure, not by replacing a key under an unchanged +The signing policy and secret references are governed bundle content. Rotate a key by publishing a +new public JWK whose `kid` is its RFC 7638 thumbprint, not by replacing a key under an unchanged identifier. ## Expected result @@ -213,4 +239,5 @@ rm -f "" - [Issue Evidence Gateway access tokens with Registry Mint](../issue-evidence-access-tokens-with-registry-mint/) - [Integrate an Evidence Gateway candidate with Docker Compose](../integrate-evidence-candidate-with-docker-compose/) +- [Rotate Evidence Gateway signing keys](../rotate-evidence-signing-keys/) - [Manage Evidence Gateway verifier trust](../manage-evidence-verifier-trust/) diff --git a/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx b/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx index 7ebccdc08..e94c4b997 100644 --- a/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx +++ b/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx @@ -35,8 +35,7 @@ Retain the OpenAPI document and create disposable local Evidence Gateway keys: ```sh evidencectl new institution-evidence \ --openapi \ - --profile local \ - --generate-keys + --profile local cd institution-evidence ``` diff --git a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx index 8b60efec0..b81fa495e 100644 --- a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx +++ b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx @@ -185,13 +185,12 @@ Create a local project from the registry's OpenAPI description: ```sh evidencectl new adult-status \ --openapi http://127.0.0.1:8000/openapi.json \ - --profile local \ - --generate-keys + --profile local cd adult-status ``` `--profile local` creates a loopback-only development project, not a production deployment. -`--generate-keys` creates owner-only local keys for signing, subject binding, and audit integrity. +The command creates owner-only local keys for signing, subject binding, and audit integrity. OpenAPI describes the source, but you still decide the question and what the answer may disclose. The new project separates those decisions from the retained API description: diff --git a/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx b/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx index b54c7b16d..455de246f 100644 --- a/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx +++ b/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-04" +last_reviewed: "2026-08-06" doc_type: how-to persona: - operator @@ -22,30 +22,51 @@ adapter, not output from `evidencectl build`. The candidate bundle stays unchang container deployments. ## Before you start You need an approved candidate, a container image whose provenance and digest you have reviewed, -an owner-only secret mount, and persistent storage for the audit chain. Do not use repository -development images as released production artifacts. +an owner-only secret mount, a workload-local Transit proxy, and persistent storage for the audit +chain. Do not use repository development images as released production artifacts. Prepare a container-specific runtime document. It names the same bundle content but container paths, a private Compose-network listener, secret root, audit path, and any required private CA files. +## Add the Transit proxy boundary + +Production and evidence-grade candidates require a workload-local Vault or OpenBao Transit proxy. +Run one proxy for Evidence Gateway on the host or in an operator-owned sidecar. Give the proxy a +dedicated host directory in which to create `transit-proxy.sock`, then bind-mount that directory at +`/run/registry-evidence` in the Evidence Gateway container. Make the directory searchable but not +writable by the Evidence Gateway identity. The proxy owns the directory and creates a mode `0660` +socket whose group admits only that identity. + +The proxy owns its provider auto-auth credential, provider trust file, and reviewed HCL +configuration. Do not mount those inputs into Evidence Gateway. The proxy configuration must force +its auto-auth token, require the `X-Vault-Request` header, disable request retries, and set socket +ownership for the Evidence Gateway process. Use +[Configure Transit signing for Evidence Gateway and Registry Mint](../move-evidence-to-production-signing/) +and the maintained deployment-target templates for the exact boundary. + +The Git-managed proxy HCL is nonsecret. Provider credentials and generated tokens remain outside +Git and outside the Evidence Gateway container. If Compose also owns the sidecar, replace the host +bind with a dedicated named volume shared only by the proxy and Evidence Gateway. + ## Mount the deployment inputs -The Compose service mounts four independently owned paths: +The Evidence Gateway service mounts five independently owned paths: ```text candidate/bundle -> /etc/registry-evidence/bundle read-only runtime.docker.yaml -> /etc/registry-evidence/runtime.yaml read-only Evidence Gateway secret root -> /run/secrets/registry-evidence read-only Evidence Gateway audit volume -> /var/lib/registry-evidence writable +Transit socket directory -> /run/registry-evidence socket access ``` Keep the bundle and runtime read-only. A read-only mount establishes their immutability, but does @@ -67,6 +88,7 @@ services: - ./runtime.docker.yaml:/etc/registry-evidence/runtime.yaml:ro - :/run/secrets/registry-evidence:ro - evidence-audit:/var/lib/registry-evidence + - :/run/registry-evidence:ro ``` Bind Evidence Gateway to a private Compose-network address. Put TLS termination and public routing in an @@ -74,8 +96,8 @@ operator-controlled service ahead of that listener. ## Validate in the container context -Run `evidence check` in the target execution context after mounts, ownership, paths, and trust -files are in place: +Start the Transit proxy, then run `evidence check` in the target execution context after mounts, +ownership, paths, and trust files are in place: ```sh docker compose run --rm evidence \ @@ -86,8 +108,9 @@ Changing only the container runtime does not change the governed bundle, so it d fixture suite to run again. Run fixtures again when the bundle changes. The check exits successfully only when the container can read its immutable inputs, resolve the -secret root, validate the configured audit path, and validate the runtime and bundle together. It -does not append an audit event. +secret root, validate the configured audit path, reach the Transit proxy, match the pinned provider +version to the governed public JWK, and validate the runtime and bundle together. It does not append +an audit event. ## Keep revisions distinct @@ -102,6 +125,8 @@ audit contents. When the same application uses Mint, run Mint as a separate private service. Mint retains its public HTTPS issuer and JWKS URI, while internal routing or split DNS resolves that identity. Evidence Gateway must continue to use the public HTTPS issuer and JWKS URI, not an internal plain-HTTP service name. +Strict Mint uses its own proxy, Unix-socket directory, provider identity, policy, and Transit key. +Do not share the Evidence Gateway proxy or socket with Mint. ## Stop without deleting the audit history @@ -115,5 +140,6 @@ docker compose down ## Next - [Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/) +- [Configure Transit signing for Evidence Gateway and Registry Mint](../move-evidence-to-production-signing/) - [Issue Evidence Gateway access tokens with Registry Mint](../issue-evidence-access-tokens-with-registry-mint/) - [Configure Evidence Gateway](../../configure/evidence/) diff --git a/docs/site/src/content/docs/tutorials/issue-evidence-access-tokens-with-registry-mint.mdx b/docs/site/src/content/docs/tutorials/issue-evidence-access-tokens-with-registry-mint.mdx index 26ceafe22..67455a80c 100644 --- a/docs/site/src/content/docs/tutorials/issue-evidence-access-tokens-with-registry-mint.mdx +++ b/docs/site/src/content/docs/tutorials/issue-evidence-access-tokens-with-registry-mint.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-04" +last_reviewed: "2026-08-06" doc_type: tutorial persona: - operator @@ -36,22 +36,26 @@ caller, and released `mint` and `evidencectl` binaries. Mint serves one active p Its client-assertion replay cache is memory-only and clears when the process restarts. Do not make a high-availability or durable replay-protection claim for this deployment shape. -Generate Mint's signing key independently from Evidence Gateway's signing key. Keep it owner-only outside -the Evidence Gateway candidate. +Create Mint's signing key independently from Evidence Gateway's signing key. Strict deployments keep +the non-exportable private key in Vault or OpenBao Transit and expose only a workload-local Unix +socket to Mint. ## Author Mint separately -Create a Mint directory beside, not inside, the Evidence Gateway candidate: +Create a complete Mint target beside the Evidence Gateway target in the deployment repository: ```text -mint/ +environments/production/mint/ mint.yaml clients/ .yaml - secrets/ - + public-keys/ + .jwk.json ``` +Git contains the public service JWK and public client registrations. Keep the Mint audit HMAC key, +provider token, auto-auth credentials, client private keys, and issued tokens outside Git. + Set Mint's `issuer` to its public HTTPS identity. Configure its listener on a private address and let operator-controlled routing or split DNS resolve the public HTTPS issuer inside the private network. Do not replace Evidence Gateway's issuer or JWKS URI with an internal plain-HTTP service name. @@ -59,12 +63,21 @@ network. Do not replace Evidence Gateway's issuer or JWKS URI with an internal p Register each client with its public JWK, reviewed principal, requester tags, evidence audience, and optional grant. Mint writes authority from this registration, never from the client's request. +Set `validationMode: strict`. Configure `signing.activePublicJwkFile` with the exact public P-256 +JWK, and configure `signer.kind: transit` with the workload-local socket, Transit mount, key name, +pinned nonzero version, and bounded timeout. Follow +[Configure Transit signing for Evidence Gateway and Registry Mint](../move-evidence-to-production-signing/) +to create the key, proxy identity, socket, and least-privilege policy. Mint receives no provider +token or private signing key. + ## Check the two configurations -Validate Mint without opening a listener: +Start the workload-local Mint proxy, then validate Mint without opening a listener. `mint check` +performs the provider metadata and sign-and-verify self-test, so a strict configuration fails when +the proxy is unavailable: ```sh -mint check --config "/mint.yaml" +mint check --config "/environments/production/mint/mint.yaml" ``` Then compare the completed Evidence Gateway candidate with Mint. This check is read-only and does not copy @@ -73,7 +86,7 @@ or modify either project: ```sh evidencectl doctor \ --project "" \ - --mint-config "/mint.yaml" + --mint-config "/environments/production/mint/mint.yaml" ``` The paired check compares issuer, JWKS URI, audiences, admitted algorithm and token type, plus the @@ -86,9 +99,11 @@ register a client. Start Mint behind operator-controlled TLS, keeping the Mint listener private: ```sh -mint serve --config "/mint.yaml" +mint serve --config "/environments/production/mint/mint.yaml" ``` +Route token requests only after Mint's `/ready` endpoint reports ready. + Use a registered caller's own private JWK to request one access token over the public HTTPS identity. The token is written to standard output only. Store it in an owner-only local file for the next request, never in the candidate, a fixture, a log, or a command argument. @@ -122,5 +137,6 @@ rm -f "" ## Next - [Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/) +- [Configure Transit signing for Evidence Gateway and Registry Mint](../move-evidence-to-production-signing/) - [Configure Registry Mint](../../configure/mint/) - [Registry Mint reference](../../reference/mint/) diff --git a/docs/site/src/content/docs/tutorials/issue-immunization-evidence-from-dhis2.mdx b/docs/site/src/content/docs/tutorials/issue-immunization-evidence-from-dhis2.mdx index 140b1f1ae..80316d9f1 100644 --- a/docs/site/src/content/docs/tutorials/issue-immunization-evidence-from-dhis2.mdx +++ b/docs/site/src/content/docs/tutorials/issue-immunization-evidence-from-dhis2.mdx @@ -141,8 +141,7 @@ curl --silent --show-error --fail \ evidencectl new dhis2-immunization \ --openapi .local/dhis2.openapi.yaml \ - --profile local \ - --generate-keys + --profile local cd dhis2-immunization ``` diff --git a/docs/site/src/content/docs/tutorials/manage-evidence-verifier-trust.mdx b/docs/site/src/content/docs/tutorials/manage-evidence-verifier-trust.mdx index 24a710c0e..6f12702cb 100644 --- a/docs/site/src/content/docs/tutorials/manage-evidence-verifier-trust.mdx +++ b/docs/site/src/content/docs/tutorials/manage-evidence-verifier-trust.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-04" +last_reviewed: "2026-08-06" doc_type: how-to persona: - consumer or verifier @@ -25,7 +25,7 @@ Through your organization's provider-onboarding process, verify and retain: - the legal issuer and technical provider identifiers; - the expected Evidence Gateway service and JWKS endpoints; -- the Ed25519 public keys and their `kid` values; +- the ES256 P-256 public keys and their RFC 7638 thumbprint `kid` values; - the audience that identifies your relying party; - the requirements, evidence types, purposes, concepts, and configuration revisions you accept; - the maximum assertion lifetime and clock skew your decisions allow. @@ -72,6 +72,26 @@ not issuer, provider, audience, purpose, requirement, concept, or configuration Approve and atomically activate the candidate through your normal configuration process. Keep the previous trust file and approval record for rollback and audit. +## Revoke a compromised key immediately + +Treat the current revocation list as a separate governed trust input, not as metadata learned from +the issuer or from the response. When a provider reports a compromised service key: + +1. verify the affected RFC 7638 thumbprint through the emergency onboarding channel; +2. add it to the application's current `revokedKeyIds` (Node) or `revoked_key_ids` (Rust and + Python) configuration; +3. remove the key from the current pinned JWKS; +4. reconstruct or restart the client so every instance uses both changes; and +5. prove that a response signed by the revoked key is refused before resuming decisions. + +The current denylist takes precedence even when the key remains in a cached JWKS or an older +prepared request retained it in its verification policy. This lets an application stop accepting a +compromised key without waiting for caches or prepared work to expire. + +Keep current acceptance separate from historical verification. If policy permits historical replay, +retain the exact trust set, denylist, decision instant, and approval record that governed the +original decision. Never remove a key from today's denylist merely to replay an older record. + ## Retire the old key deliberately Remove a retiring key from current verification only after: @@ -85,6 +105,6 @@ Remove a retiring key from current verification only after: Never make verification fetch a missing key automatically. An unknown `kid` is a trust-change signal and must fail closed until your organization approves the change. -Provider operators use [Rotate Evidence Gateway signing keys](../move-evidence-to-production-signing/) +Provider operators use [Rotate Evidence Gateway signing keys](../rotate-evidence-signing-keys/) for the other side of this rotation. Consumers remain independent and do not inherit trust merely because the provider published a key. diff --git a/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx b/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx index 9272cb0b6..7d2d03a1d 100644 --- a/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx +++ b/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx @@ -1,11 +1,11 @@ --- -title: Rotate Evidence Gateway signing keys -description: Stage a new Evidence Gateway signing key, publish an overlap set through the runtime JWKS endpoint, and retire the old key after consumers can verify both generations. +title: Configure Transit signing for Evidence Gateway and Registry Mint +description: Configure non-exportable P-256 signing through a workload-local Vault or OpenBao Transit proxy without giving provider credentials to either service. status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-04" +last_reviewed: "2026-08-06" doc_type: how-to persona: - operator @@ -13,108 +13,241 @@ locale: en standards_referenced: [] --- -Use this procedure after [Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/). -Coordinate the rotation with consumers through -[Manage Evidence Gateway verifier trust](../manage-evidence-verifier-trust/) before activation. Evidence Gateway -signs with one active Ed25519 key. Rotation is a reviewed, startup-only configuration change. There -is no hot-rotation endpoint or standby-key field. Publishing a new public key makes it discoverable; -it does not make independently operated consumers trust it. +Use this procedure to move Evidence Gateway or Registry Mint from disposable local signing to a +strict deployment. Each service signs through its own workload-local Unix-socket proxy. The service +process receives no Vault or OpenBao token, and the provider retains the private key. -## Stage a new keypair +## Prerequisites -Generate the replacement in a new owner-only staging directory on the host responsible for key -custody: +You need: + +- A Vault or OpenBao Transit mount administered outside the application workload. +- A separate provider identity and Transit key for each service and environment. +- An auto-auth method appropriate for the deployment platform. +- A reviewed deployment target based on the ready-to-copy templates under + `products/evidence/reference/deployment-targets/`. +- An approved tool that can convert a P-256 public key from PEM to JWK and calculate its RFC 7638 + SHA-256 thumbprint. + +The commands use a Transit mount named `transit`. Change the mount consistently when your provider +uses another name. + +## Create one key per service + +Run the provider administration commands through an authenticated operator session, not from the +Evidence Gateway or Mint container. Create non-derived, non-exportable P-256 keys with plaintext +backup disabled: + +```sh +vault write transit/keys/evidence-signing \ + type=ecdsa-p256 \ + derived=false \ + exportable=false \ + allow_plaintext_backup=false + +vault write transit/keys/mint-signing \ + type=ecdsa-p256 \ + derived=false \ + exportable=false \ + allow_plaintext_backup=false +``` + +Use the equivalent `bao` commands for OpenBao. The +[Vault Transit API](https://developer.hashicorp.com/vault/api-docs/secret/transit) and +[OpenBao Transit API](https://openbao.org/api-docs/secret/transit/) define the same fields used by +the runtime checks. + +Read each key's metadata and record the nonzero version that the application will pin: ```sh -evidencectl keygen signing \ - --out-dir "" \ - --kid "" +vault read -format=json transit/keys/evidence-signing > "/evidence-key.json" +vault read -format=json transit/keys/mint-signing > "/mint-key.json" +``` + +The reviewed metadata must report `type: ecdsa-p256`, `derived: false`, `exportable: false`, +`allow_plaintext_backup: false`, and signing support. Remove the local review files after recording +the approved public projection and controls. Do not commit provider responses. + +## Publish the exact public projections + +Convert the selected version's public PEM to an exact public JSON Web Key (JWK). Compute the RFC +7638 thumbprint from `crv`, `kty`, `x`, and `y`, then use that 43-character value as both `kid` and +the filename: + +```json +{ + "kty": "EC", + "crv": "P-256", + "x": "", + "y": "", + "alg": "ES256", + "kid": "" +} ``` -The command writes a private and public JWK without printing private bytes. Provision the private -JWK into the deployment's secret root under a new logical filename. Keep the public JWK -as a separately approved distribution artifact for consumers. The active public key does not need -to be copied into the closed bundle because Evidence Gateway derives it from the active private JWK. +The object must contain exactly those six members and no private member. Put each service's public +JWK in its complete environment target: + +```text +environments// + evidence/public-keys/.jwk.json + mint/public-keys/.jwk.json +``` -Do not overwrite the active private key in place. A candidate revision must be complete and -checkable before it becomes active. +Keep Evidence Gateway signing, Mint signing, Evidence Gateway audit, Mint audit, subject binding, +and client keys distinct. Run the target's `check-public-key-separation.sh` before review when you +start from the maintained deployment-target templates. -## Build the overlap revision +## Configure the governed key and runtime signer -In the candidate `bundle/evidence.yaml`, point the active fields at the replacement. Copy the old -public JWK into the bundle, then reference it as a retired artifact: +Reference the Evidence Gateway public JWK from `governance.yaml`: ```yaml signing: format: flattened-jws-json - algorithm: EdDSA - activeKeyId: - activeKeyRef: secret:file/ - retiredPublicJwkFiles: - - keys/.public.jwk.json + algorithm: ES256 + activePublicJwkFile: public-keys/.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] jwksPath: /.well-known/evidence/jwks.json - maximumAssertionValiditySeconds: 86400 + maximumAssertionValiditySeconds: 300 verifierClockSkewSeconds: 30 ``` -`activeKeyId` must exactly match the private JWK's `kid`. Each retired file contains public material -only. Evidence Gateway assembles its JWKS endpoint from the active private key's public half and the -configured retired public files. You do not publish a separate operator-built JWKS beside it. +Bind the matching provider key and version in `runtime.yaml`: -## Validate and coordinate consumer approval +```yaml +signer: + kind: transit + unixSocketPath: /run/registry-evidence/transit-proxy.sock + mount: transit + keyName: evidence-signing + keyVersion: + timeoutMilliseconds: 2000 +``` -Run the project fixtures and `evidence check` against the candidate revision: +Registry Mint keeps both blocks in `mint.yaml`: -```sh -evidencectl fixtures run --project "" -evidence check --runtime "/runtime.yaml" +```yaml +validationMode: strict +signing: + algorithm: ES256 + activePublicJwkFile: public-keys/.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: transit + unixSocketPath: /run/registry-mint/transit-proxy.sock + mount: transit + keyName: mint-signing + keyVersion: + timeoutMilliseconds: 2000 ``` -Give consumers the new public key, `kid`, provider identity, activation window, and overlap period -through your governed onboarding channel. Consumers are expected to add the new key to a candidate -pinned trust set and prove both an old-key and a new-key synthetic response before accepting the -change. - -The [verifier trust procedure](../manage-evidence-verifier-trust/) covers that independent side of -the rotation. +Do not put a provider token, auto-auth credential, or private JWK in either configuration. -## Activate and observe +## Isolate provider access in local proxies -Deploy the exact checked revision and restart Evidence Gateway. Confirm readiness, then inspect the public +Give each proxy identity only read access to its named key metadata and update access to its sign endpoint: -```sh -curl --fail --silent --show-error \ - "https:///.well-known/evidence/jwks.json" +```hcl +path "transit/keys/evidence-signing" { + capabilities = ["read"] +} + +path "transit/sign/evidence-signing/sha2-256" { + capabilities = ["update"] + required_parameters = ["input", "key_version", "marshaling_algorithm", "prehashed"] + allowed_parameters = { + "input" = [] + "key_version" = [] + "marshaling_algorithm" = ["jws"] + "prehashed" = [true] + } +} ``` -The JWKS contains the new active public key and the old retired public key, with no private -members. Send one authorized synthetic request and verify its `kid` with the consumer's approved -overlap set. +Use a corresponding policy for `mint-signing`. The parameter constraints make the service identity +usable only for the pinned version and exact JWS signing request shape. During a planned rotation, +temporarily allow the old and new numeric versions, for example `"key_version" = [7, 8]`, then +remove the retired version after the overlap window. The provider's `min_encryption_version` +provides a second retirement control. + +The Evidence Gateway proxy's core boundary is: + +```hcl +vault { + address = "https://vault.example.com:8200" + retry { + num_retries = -1 + } +} + +api_proxy { + use_auto_auth_token = "force" +} + +listener "unix" { + address = "/run/registry-evidence/transit-proxy.sock" + tls_disable = true + socket_mode = "0660" + socket_user = "" + socket_group = "" + require_request_header = true +} +``` -Missing, unreadable, or mismatched signing material fails closed. Evidence Gateway never falls back to an -unsigned success. +Add the deployment's reviewed `auto_auth` method and provider trust settings. Use a separate socket, +identity, and configuration for Mint. -## Retire the old public key +Configure one proxy per service to: -Keep the old public key published for at least: +- Listen only on the service-specific Unix socket. +- Force its auto-auth token for proxied requests. +- Require the `X-Vault-Request` header. +- Set `retry.num_retries` to `-1` and leave `VAULT_MAX_RETRIES` unset. +- Set socket ownership so only the intended workload can connect. -```text -maximumAssertionValiditySeconds + verifierClockSkewSeconds +These settings make one application signing attempt one provider signing request. The application +timeout remains the outer bound. The +[Vault API proxy documentation](https://developer.hashicorp.com/vault/docs/agent-and-proxy/proxy/apiproxy) +describes forced auto-auth, and the +[Vault Agent configuration](https://developer.hashicorp.com/vault/docs/agent-and-proxy/agent) +defines the retry and request-header controls. OpenBao deployments use the corresponding +[OpenBao Agent configuration](https://openbao.org/docs/agent-and-proxy/agent/). + +## Verify before routing traffic + +Start each proxy before checking its service. Run the checks from the final execution context so +the commands see the same socket, public keys, paths, ownership, and secret roots as the service: + +```sh +evidence check --runtime "/runtime.yaml" +mint check --config "/mint.yaml" ``` -Count from the last instant the old private key could have signed. Retain it longer when consumers -must re-verify historical decisions. +Each check reads provider metadata, verifies the pinned version and custody controls, compares the +provider public key with the governed JWK, and performs a sign-and-verify self-test. A mismatch, +timeout, malformed response, or unavailable proxy fails the check. + +Start the services only after the checks pass. Route requests only after both `/ready` endpoints +report ready. If provider access fails later, signing fails closed and readiness reports the signer +unavailable. Readiness recovers after a successful provider self-test. -Remove the retired public file and its `retiredPublicJwkFiles` entry only in a later reviewed bundle -revision. Run fixtures and `evidence check` again, coordinate consumer removal, deploy, and confirm -the JWKS no longer carries the old key. +## Troubleshooting -Removing a verification key is not selective assertion revocation. Evidence Gateway has no credential -status or revocation lifecycle. It only changes which signatures a current trust set can verify. +- If metadata validation fails, compare the key type, custody controls, signing support, and pinned + version with the governed configuration. +- If public-key matching fails, regenerate the JWK from the pinned provider version. Do not edit + `x`, `y`, or `kid` by hand. +- If the proxy refuses requests, check the named-key policy, forced auto-auth state, socket + ownership, and required request header. +- If checks time out, inspect the workload-local proxy and provider path. Do not add application or + proxy retries. -## Related guidance +## Next - [Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/) -- [Evidence Gateway security model](../../security/evidence/) -- [Configure Evidence Gateway](../../configure/evidence/) +- [Issue Evidence Gateway access tokens with Registry Mint](../issue-evidence-access-tokens-with-registry-mint/) +- [Rotate Evidence Gateway signing keys](../rotate-evidence-signing-keys/) diff --git a/docs/site/src/content/docs/tutorials/prove-an-evidence-project.mdx b/docs/site/src/content/docs/tutorials/prove-an-evidence-project.mdx index 29cc2b6ee..ff0eff325 100644 --- a/docs/site/src/content/docs/tutorials/prove-an-evidence-project.mdx +++ b/docs/site/src/content/docs/tutorials/prove-an-evidence-project.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-04" +last_reviewed: "2026-08-06" doc_type: how-to persona: - assertion provider @@ -95,17 +95,19 @@ Build and hand off only when these facts are recorded together: 2. `evidence check` accepted the exact candidate runtime and bundle. 3. A reviewer approved the source projection, cardinality mapping, requirement, derivation, codelists, purposes, audiences, and privacy expectations. -4. The environment supplies independent owner-only signing, subject-binding, and audit secrets. +4. The environment supplies a governed public signing key and matching Transit binding, plus + independent owner-only subject-binding and audit secrets. 5. The deployed bundle revision matches the reviewed candidate. 6. Readiness passes, followed by one authorized HTTP-path check using synthetic data. -Keep the governed bundle unchanged across environments where practical. Bind environment-specific -paths, secret roots, audit storage, listener settings, and trust files through each environment's -runtime file. [Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/) covers -the target-host ceremony. +Build a complete governed bundle for each environment. Each target owns its identities, endpoints, +audiences, public keys, authority bindings, runtime paths, secret references, audit storage, +listener settings, and trust files. Promote the reviewed editable source revision, then build +staging and production candidates separately. [Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/) +covers the target-host ceremony. ## Next - [Verify an assertion as a consumer](../verify-an-assertion-as-a-consumer/) -- [Rotate Evidence Gateway signing keys](../move-evidence-to-production-signing/) +- [Rotate Evidence Gateway signing keys](../rotate-evidence-signing-keys/) - [Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/) diff --git a/docs/site/src/content/docs/tutorials/request-evidence-as-sd-jwt-vc.mdx b/docs/site/src/content/docs/tutorials/request-evidence-as-sd-jwt-vc.mdx index 4ebe18124..0f222731d 100644 --- a/docs/site/src/content/docs/tutorials/request-evidence-as-sd-jwt-vc.mdx +++ b/docs/site/src/content/docs/tutorials/request-evidence-as-sd-jwt-vc.mdx @@ -120,7 +120,9 @@ records, not from the credential. An unprojected value has one root disclosure. A configured structured value has one disclosure for each direct field. Evidence Gateway's current verifier checks the complete stored credential. It does not -verify a selectively disclosed presentation from a wallet. +verify a selectively disclosed presentation from a wallet. The profile emits `application/dc+sd-jwt`, +`typ: dc+sd-jwt`, `vct`, SHA-256 disclosures, optional `cnf.jwk`, and a trailing tilde without a +key-binding JWT. It is pinned to RFC 9901 and SD-JWT VC draft v18. ## Keep the boundary explicit @@ -131,9 +133,14 @@ This response is not a credential lifecycle: - no presentation exchange or key-binding JWT; - no general multi-verifier subject identifier. -The subject binding remains scoped to the assertion's audience and purpose. An optional -`holderKey` can place a public JWK in `cnf`, but that inclusion alone proves no possession and is -outside this minimum path. +The subject binding remains scoped to the assertion's audience and purpose. An optional `holderKey` +can place only a public P-256 JWK in `cnf`: `kty: EC`, `crv: P-256`, `x`, and `y` are required; +optional `alg` must be `ES256`; an optional wallet-owned `kid` is allowed; private and unknown members +are refused. Its inclusion alone proves no possession and is outside this minimum path. + +Outside local mode, the service provider identifier must be a stable HTTPS origin. It is the signed +`iss`, and issuer metadata publishes its exact `issuer` and `jwks_uri`. This shape prepares a future +standards-based wallet adapter; it makes no compatibility claim for Inji, walt.id, or another wallet. Provider metadata is published at `/.well-known/jwt-vc-issuer`, and signing keys remain at `/.well-known/evidence/jwks.json`. Both are discovery. Approve trust independently through diff --git a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx index a3652385e..922894e7f 100644 --- a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx +++ b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx @@ -107,7 +107,7 @@ The application must decide which signing keys it accepts before any response ex from the project's own retained public signing key: ```sh -evidencectl jwks --out trusted-issuer-keys.json secrets/signing-ed25519-public.jwk.json +evidencectl jwks --out trusted-issuer-keys.json secrets/signing-p256-public.jwk.json ``` ```text @@ -219,6 +219,9 @@ from registry_evidence_client import EvidenceClient client = EvidenceClient( base_url="http://127.0.0.1:8080", trusted_jwks=json.loads(Path("trusted-issuer-keys.json").read_text()), + # This local project has no emergency revocations. In production, load the + # current governed denylist independently from the issuer's response. + revoked_key_ids=[], token={ "private_key_jwt": { "token_endpoint": "http://127.0.0.1:8081/token", @@ -481,6 +484,9 @@ def build_client(): return EvidenceClient( base_url="http://127.0.0.1:8080", trusted_jwks=json.loads(Path("trusted-issuer-keys.json").read_text()), + # This local project has no emergency revocations. In production, load + # the current governed denylist independently from the issuer response. + revoked_key_ids=[], token={ "private_key_jwt": { "token_endpoint": "http://127.0.0.1:8081/token", diff --git a/docs/site/src/content/docs/tutorials/rotate-evidence-signing-keys.mdx b/docs/site/src/content/docs/tutorials/rotate-evidence-signing-keys.mdx new file mode 100644 index 000000000..b1429c363 --- /dev/null +++ b/docs/site/src/content/docs/tutorials/rotate-evidence-signing-keys.mdx @@ -0,0 +1,151 @@ +--- +title: Rotate Evidence Gateway signing keys +description: Stage a new Evidence Gateway signing key, publish an overlap set through the runtime JWKS endpoint, and retire the old key after consumers can verify both generations. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-06" +doc_type: how-to +persona: + - operator +locale: en +standards_referenced: [] +--- + +Use this procedure after +[configuring Transit signing](../move-evidence-to-production-signing/). Coordinate the rotation with +consumers through [Manage Evidence Gateway verifier trust](../manage-evidence-verifier-trust/) before +activation. Evidence Gateway signs with one active ES256 P-256 key. Its service `kid` is the RFC +7638 thumbprint. Rotation is a reviewed, startup-only configuration change. + +## Prerequisites + +You need a checked strict deployment, provider authority to rotate the named Transit key, and an +approved overlap period. There is no hot-rotation endpoint or standby-key field. Publishing a new +public key makes the key discoverable, but does not make independent consumers trust the key. + +## Stage a new key version + +Create the replacement as a new version of the non-exportable P-256 Transit key. Read that version's +public PEM from Transit metadata, convert the public projection to the exact governed JWK, and commit +only that JWK to the reviewed deployment repository. For local rehearsal, generate a disposable +P-256 pair: + +```sh +evidencectl keygen signing --out-dir "" +``` + +The command writes a private and public JWK without printing private bytes. It is for local mode; +do not copy its private output to a strict deployment. Commit the production public JWK beneath +`public-keys/` as `.jwk.json`. The Transit provider retains the matching private key and +has no export or plaintext-backup permission. + +Do not overwrite an active public key file. A candidate revision must be complete and checkable +before activation. + +## Build the overlap revision + +In `governance.yaml`, add the next public JWK to the published set while the current key remains +active: + +```yaml +signing: + format: flattened-jws-json + algorithm: ES256 + activePublicJwkFile: public-keys/.jwk.json + publishedPublicJwkFiles: + - public-keys/.jwk.json + revokedKeyIds: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 300 + verifierClockSkewSeconds: 30 +``` + +Every service JWK is an exact ES256 P-256 public JWK, and its `kid` is its derived 43-character +thumbprint. Evidence Gateway assembles its JSON Web Key Set (JWKS) from the active and published +files. Do not publish a separate operator-built JWKS. + +Build a new candidate, run its fixtures, and run `evidence check` in the target execution context: + +```sh +evidencectl build \ + --project "/shared/evidence-project" \ + --target "/environments//evidence" \ + --output "" +evidencectl fixtures run --project "" +evidence check --runtime "/runtime.yaml" +``` + +Give consumers the new public key, `kid`, provider identity, activation window, and overlap period +through the governed onboarding channel. Consumers add the new key to a candidate pinned trust set +and prove both old-key and new-key synthetic responses before accepting the change. + +## Publish the overlap set + +Deploy the checked overlap candidate and restart every replica. Confirm that every replica +publishes both public keys before activating the new signer version: + +```sh +curl --fail --silent --show-error \ + "https:///.well-known/evidence/jwks.json" +``` + +Before the overlap deployment, update the proxy policy's `allowed_parameters.key_version` to list +both the old and new numeric versions. Keep the provider key's `min_encryption_version` low enough +for both application versions during the rollout. No other signing versions should be permitted. + +## Activate the new version + +Build and check the next candidate with both coordinated changes: + +1. Set `activePublicJwkFile` to the next public JWK. +2. Move the old public JWK to `publishedPublicJwkFiles`. +3. Pin `signer.keyVersion` to the new nonzero Transit version. + +Deploy that exact candidate and restart every replica. Confirm readiness and the JWKS, then send one +authorized synthetic request and verify its `kid` with the consumer's approved overlap set. + +Missing, unreadable, or mismatched signing material fails closed. Evidence Gateway never falls back +to an unsigned success. + +After every replica uses the new version and the validity-plus-skew window has elapsed, remove the +old version from `allowed_parameters.key_version` as part of retiring the old public key. + +## Retire the old version + +Keep the old public key published and keep its provider version usable for at least: + +```text +maximumAssertionValiditySeconds + verifierClockSkewSeconds +``` + +Count from the last instant the old private key could have signed. Retain the public key longer when +consumers must re-verify historical decisions. + +After the overlap period, build a later candidate that removes the old public JWK and its +`publishedPublicJwkFiles` entry. Raise the named Transit key's `min_encryption_version` to the new +version through a privileged provider-administration identity, not through the application proxy. +The provider then refuses new signing operations with the old version. Run fixtures and `evidence +check`, coordinate consumer removal, deploy, and confirm the JWKS no longer carries the old key. + +## Revoke an exposed version + +For an emergency rotation: + +1. Disable provider signing authority immediately. Raise `min_encryption_version` to an existing + uncompromised later version, or remove the proxy identity's sign capability until a replacement + exists. +2. Remove its public JWK and add its thumbprint to `signing.revokedKeyIds`. +3. Activate a checked replacement or leave Evidence Gateway unavailable. +4. Deploy and restart every affected issuer and consumer. + +For an exposed Mint issuer key, add the thumbprint to Evidence Gateway +`authentication.revokedKeyIds` before restarting Evidence Gateway. This is service-key revocation, +not an Evidence credential-status or lifecycle feature. + +## Next + +- [Manage Evidence Gateway verifier trust](../manage-evidence-verifier-trust/) +- [Evidence Gateway security model](../../security/evidence/) +- [Configure Evidence Gateway](../../configure/evidence/) diff --git a/docs/site/src/content/docs/tutorials/verify-a-registered-parent-with-opencrvs.mdx b/docs/site/src/content/docs/tutorials/verify-a-registered-parent-with-opencrvs.mdx index 88c313115..4bc2e231e 100644 --- a/docs/site/src/content/docs/tutorials/verify-a-registered-parent-with-opencrvs.mdx +++ b/docs/site/src/content/docs/tutorials/verify-a-registered-parent-with-opencrvs.mdx @@ -125,8 +125,7 @@ Create an editable Evidence Gateway project and disposable local keys: ```sh evidencectl new registered-parent \ --openapi opencrvs-events.openapi.yaml \ - --profile local \ - --generate-keys + --profile local cd registered-parent ``` diff --git a/products/evidence/AGENTS.md b/products/evidence/AGENTS.md index 482c3f044..0c450387f 100644 --- a/products/evidence/AGENTS.md +++ b/products/evidence/AGENTS.md @@ -70,8 +70,9 @@ and completion means every Definition of Done row passes on one revision. Governed configuration, Rhai scripts, schemas, codelists, and fixtures are one trusted, immutable, startup-only bundle. A separate closed runtime file owns -only process-local listener, filesystem, audit-storage, secret-mount, and TLS -trust bindings and cannot override the bundle. Rust owns authentication, +only process-local listener, filesystem, audit-storage, secret-mount, signer +transport and pinned version, and TLS trust bindings and cannot override the +bundle or governed active public key. Rust owns authentication, authorization, selector validation and minimization, credentials, fixed networking, path/header authority, response projection, script capabilities and limits, output validation, evidence construction, signing, and audit. Rhai diff --git a/products/evidence/CONCEPT.md b/products/evidence/CONCEPT.md index 9f4bb7fe6..f361c0272 100644 --- a/products/evidence/CONCEPT.md +++ b/products/evidence/CONCEPT.md @@ -22,7 +22,7 @@ An assertion may describe a property, classification, eligibility decision, status, or relationship involving one or more role-bound subjects. A national identifier is one possible selector, not a prerequisite. -The service is deliberately narrower than a data governance platform, API gateway, identity-matching service, workflow engine, credential suite, or policy decision platform. One service process may host many evidence definitions when they share one operator-controlled trust domain. Governed configuration, scripts, schemas, codelists, and fixtures form one trusted, atomic evidence bundle. A separate closed runtime file binds that bundle to process-local listener, filesystem, audit-storage, secret-mount, and TLS-trust paths without overriding evidence semantics. +The service is deliberately narrower than a data governance platform, API gateway, identity-matching service, workflow engine, credential suite, or policy decision platform. One service process may host many evidence definitions when they share one operator-controlled trust domain. Governed configuration, scripts, schemas, codelists, and fixtures form one trusted, atomic evidence bundle. A separate closed runtime file binds that bundle to process-local listener, filesystem, audit-storage, secret-mount, signer transport and pinned version, and TLS-trust paths without overriding evidence semantics or the governed active public key. JSON is the native API and evidence representation. Requirements and evidence are aligned with CCCEV, using a documented Evidence JSON profile rather than RDF or XML. YAML declares fixed requirements, authorization conditions, source requests, trusted derivation parameters, concepts, and disclosure forms. Trusted Rhai scripts execute inside the process to extract typed facts and derive declared concept values from a deterministic evaluation context. Rust retains control of authentication, authorization, networking, credentials, script capabilities and limits, output validation, disclosure enforcement, evidence construction, signing, and audit. @@ -449,7 +449,8 @@ comparison diagnostic. Governed configuration, scripts, schemas, codelists, mappings, and fixtures form one atomic bundle. A separate closed runtime file owns only process-local -listener, filesystem, audit-storage, secret-mount, and TLS-trust bindings. Both +listener, filesystem, audit-storage, secret-mount, signer transport and pinned +version, and TLS-trust bindings. Both inputs are startup-only, read-only, independently digested, and immutable for the process lifetime. Runtime configuration is not an override layer and cannot change service identity, trust domain, authentication or authority policy, @@ -466,10 +467,14 @@ service: provider_id: urn:example:data-service:evidence signing: - format: jws-json - algorithm: EdDSA - key_ref: secret:evidence-signing-key - jwks_path: /.well-known/evidence/jwks.json + format: flattened-jws-json + algorithm: ES256 + activePublicJwkFile: public-keys/.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 300 + verifierClockSkewSeconds: 30 issuer: id: urn:example:authority:population-registry @@ -884,7 +889,26 @@ Runtime configuration cannot enable it. A signed request never falls back to unsigned output after a signing, key, serialization, audit, or dependency failure. -The protected JWS header contains an allowlisted `alg`, a required `kid`, a media-type identifier, and the payload content type. Version one starts with one configured active signing key and publishes its public key through `/.well-known/evidence/jwks.json`. Retired public keys remain available for at least the maximum assertion validity plus allowed clock skew. Private key material is resolved through a secret or signing-provider reference and never appears in YAML, Rhai, logs, audit, or public errors. +The protected JWS header contains an allowlisted `alg`, a required `kid`, a +media-type identifier, and the payload content type. Version one uses ES256 +over P-256. Each service `kid` is the 43-character RFC 7638 thumbprint of its +exact public JWK. The bundle governs one `activePublicJwkFile`, zero or more +`publishedPublicJwkFiles`, and an explicit `revokedKeyIds` denylist. Active and +published keys appear in `/.well-known/evidence/jwks.json`; a revoked +identifier can be neither active nor published and is never returned. A +predecessor remains published for at least the maximum assertion validity plus +allowed clock skew during planned rotation. Emergency revocation removes it +immediately, and denylisting takes precedence over cached key selection. + +Runtime signing is a separate process-local binding. Local assurance resolves +one P-256 private JWK through `signer.kind: local-jwk`. Production and +evidence-grade use `signer.kind: transit` over a workload-local Unix socket, +with a pinned nonzero Vault/OpenBao Transit key version and no provider token +in Evidence. Transit reports `ecdsa-p256`, signing enabled, `derived=false`, +`exportable=false`, and `allow_plaintext_backup=false`. The provider public key +must equal the governed active public JWK, and startup performs a sign-and- +verify test. Private key material never appears in the bundle, Rhai, logs, +audit, or public errors. Configuration and key state do not hot reload. The published JWKS is key discovery, not a trust anchor. A verifier obtains the provider identity and JWKS location through trusted deployment configuration or governed metadata, then allowlists the algorithm and resolves `kid` only within that trusted key set. It never follows a message-provided `jku`, `x5u`, or equivalent remote key URL. @@ -1082,6 +1106,14 @@ prevents unnecessary cross-purpose linking and includes a key version for controlled rotation. Plain hashes and globally stable subject pseudonyms are prohibited. +The audit chain key and identifier-pseudonym key are HKDF-separated subkeys of +the audit master. The subject-binding master is a distinct reference and must +also resolve to distinct bytes. Audit-master rotation starts a new epoch: stop +and drain, verify and record the old head and both configuration revisions, +archive the old runtime, master, segments, and head, then increment +`hashKeyVersion`, select a fresh audit path, and restart only after the complete +check. A new master is never appended to an existing chain. + Two writes are fail-closed: 1. The access-attempt event must be durably accepted before the first source read. @@ -1235,12 +1267,12 @@ authorization decision, the same fixed source execution, the same bounded derivation, the same output validation, the same audience-scoped subject binding, the same durable access and disclosure-release audit ordering. -The assertion is emitted as an IETF SD-JWT VC: an EdDSA-signed JWT carrying -`_sd` digests, followed by the salted disclosures. The signing key, key +The assertion is emitted under RFC 9901 and the pinned SD-JWT VC draft v18 as +an ES256-signed JWT carrying `_sd` digests, followed by the salted disclosures. The signing key, key identifier, JWKS publication, and rotation rules are exactly those of the signed-JWS format. No second key and no second key ceremony are introduced. -An optional caller-supplied holder public key becomes the `cnf` claim, so the +An optional caller-supplied public P-256 JWK becomes the `cnf` claim, so the assertion can be presented later with key binding. Evidence issues; it does not receive, validate, or reason about presentations. Key-binding JWT validation is the relying party's responsibility. @@ -1265,10 +1297,18 @@ verifiers, which is the property section 13 exists to prevent. A multi-verifier holder credential is a separate profile with its own privacy analysis, not an increment on this one. -The consequence must be stated plainly in adopter-facing material. This profile -produces a standards-conformant SD-JWT VC that a wallet can parse, hold, and -present. It does not produce a credential that is meaningful to an arbitrary -verifier. +The consequence must be stated plainly in adopter-facing material. This +profile targets RFC 9901 and the pinned SD-JWT VC draft v18 so a later wallet +adapter can use the standard representation. Compatibility with any wallet's +parsing, holding, or presentation behavior remains unclaimed until that +wallet's pinned verifier passes the opt-in full-signature compatibility +harness. The profile does not produce a credential that is meaningful to an +arbitrary verifier. + +Outside local assurance, enabling this format requires `service.providerId` +to be the stable HTTPS origin of the Evidence deployment. JWT VC Issuer +Metadata publishes that exact `issuer` and an exact `jwks_uri`; it does not +inline keys. #### Profile non-goals @@ -1372,8 +1412,9 @@ mandatory default and includes: - authenticated `GET /v1/evidence-definitions` requester-scoped discovery and one `POST /v1/evidence` assertion operation with a required fixed-size request nonce; -- one active EdDSA reference signing key, default flattened JWS JSON responses, - a governed explicitly selected unsigned envelope, and a public JWKS endpoint; +- one active ES256/P-256 service signing key with RFC 7638 identity, explicit + published and revoked key sets, default flattened JWS JSON responses, a + governed explicitly selected unsigned envelope, and a public JWKS endpoint; - keyed JSONL audit on explicitly durable storage, fail-closed before source access and before release; - offline bundle checking and fixture evaluation; - adopter tooling that starts an incomplete local authoring project, compiles @@ -1689,7 +1730,8 @@ semantics: 7. Which durable audit sink is the first production target? 8. What legal timezone and observation-time rules govern each time-dependent production requirement? -9. Which supported signing algorithm and key provider fit the first deployment? +9. Which Vault/OpenBao Transit deployment, local proxy, pinned key version, and + operator policy provide the required non-exportable P-256 signing key? 10. How will relying parties obtain and pin the Evidence provider's verification trust? 11. Which permitted existence-disclosure behavior applies to each enabled requirement under the closed public problem contract? diff --git a/products/evidence/IMPLEMENTATION.md b/products/evidence/IMPLEMENTATION.md index ea12db357..b1864d04c 100644 --- a/products/evidence/IMPLEMENTATION.md +++ b/products/evidence/IMPLEMENTATION.md @@ -114,7 +114,7 @@ Reuse selected shared primitives without inheriting another product model: | Crate | Version-one use | |---|---| | `registry-platform-audit` | Keyed chain integrity, scoped pseudonymization, JSONL sink, and chain verification | -| `registry-platform-crypto` | `SigningProvider`, protected JWK handling, signing, and public JWK publication | +| `registry-platform-crypto` | `SigningProvider`, protected JWK handling, ES256 signing, RFC 7638 identifiers, and workload-local Transit signing | | `registry-platform-oidc` | Strict access-token and JWKS verification for the reference authentication profile | | `registry-platform-httpsec` | Security response headers where the existing contract fits | | `registry-platform-httputil` | Bounded source-response body reads | @@ -127,8 +127,10 @@ Evidence must not depend on `registry-notary*`, `registry-platform-pdp`, The governed bundle and closed operator runtime file are trusted and startup-only. Use typed YAML and explicit secret references. Runtime configuration binds only process-local listener, filesystem, audit-storage, -secret-mount, and TLS-trust paths; it is not an override layer. Do not expand -private key material or source credentials into either parsed YAML document. +secret-mount, signer transport and pinned version, and TLS-trust paths; it is +not an override layer and cannot change the governed active public key. Do not +expand private key material or source credentials into either parsed YAML +document. ## Reference implementation defaults @@ -145,7 +147,7 @@ must use them: | Source | One fixed HTTP JSON data request using field projection and denied redirects | | Source authentication | Secret-referenced Basic, static Bearer, static API-key header, or OAuth 2.0 client credentials; explicit local authoring may use no credential only at a canonical numeric-loopback HTTP origin | | Audit | `registry-platform-audit` JSONL sink on explicitly durable storage, fail-closed | -| Signing | Flattened JWS JSON with one active EdDSA key and a public JWKS endpoint | +| Signing | Flattened JWS JSON with one active ES256/P-256 key, RFC 7638 `kid`, explicit published and revoked sets, and a public JWKS endpoint | | Response format | Signed JWS by default; exact `Accept: application/vnd.registrystack.evidence-unsigned+json` only when the bundle and complete matched grant permit it | | Evidence storage | None | | Runtime mutation | None | @@ -219,10 +221,11 @@ fixtures/ ``` The separate `runtime.yaml` binds the bundle directory, listener, secret root, -audit destination, and logical TLS trust profiles to local paths. The bundle -and runtime hashes identify the exact inputs loaded by the process. They do not -prove trust. Deployment controls establish trust by mounting both reviewed -inputs read-only and starting a new process for a new revision. +audit destination, signer transport and pinned key version, and logical TLS +trust profiles to local paths. The bundle and runtime hashes identify the exact +inputs loaded by the process. They do not prove trust. Deployment controls +establish trust by mounting both reviewed inputs read-only and starting a new +process for a new revision. Bundle checking must validate: @@ -582,9 +585,12 @@ reaches logs, audit, errors, or disk. - Write the pseudonymized access-attempt audit durably before source access and the disclosure-release audit durably after final response serialization and before release. -- Resolve production signing keys, create the exact flattened JWS JSON - response, publish public JWKS, and define key rollover and retired-key - availability. +- Bind local assurance to a local P-256 private JWK and production or + evidence-grade assurance to a pinned-version Vault/OpenBao Transit signer + through a workload-local Unix socket with no provider token in Evidence. +- Create the exact ES256 flattened JWS JSON response, derive the RFC 7638 + `kid`, publish active and planned-rotation keys, apply revoked identifiers + before key selection, and define planned and emergency rotation windows. - Bind enabled response formats into the immutable bundle and allowed formats into every authority grant; signed JWS remains mandatory and default. - Run every acceptance definition through these boundaries. @@ -693,12 +699,12 @@ follow-up issue. | Source minimization | Rust makes exactly one evidence-data request with fixed transport authority, fixed or closed selector-bound path, fixed non-secret headers, bounded reviewed query/body rendering, and an explicit client-side response projection. It declares `source-derived`, `field-projected`, or `record-transformed` honestly, enforces response, time, redirect, pagination, TLS trust, concurrency, and ambient-proxy denial, and never persists a source response. Basic, static Bearer, static API-key, and OAuth client-credentials authentication and all three postures pass generic contract tests through the same executor. Credential-free execution is a separate local-only exception pinned to an exact numeric-loopback HTTP origin. | | Authentication and authority | Strict OIDC verification and the configured principal claim fail closed. One authorization decision binds requester, optional actor, requirement revision, purpose, every role's selector profile and value origin, subject authority path, audience, and requested response format. Possessing selector values or discovery metadata, or choosing an API media type, creates no authority. Authenticated discovery lists only complete shapes matching exactly one authority path and valid token-owned selector material; unentitled, ambiguous, and invalid-context shapes are absent. Every denial occurs before credential acquisition or source access. | | Privacy and audit | Access-attempt audit is durably accepted before source access. Rust serializes the final immutable signed or unsigned response bytes, durably accepts disclosure-release audit, then releases those exact bytes. Sink failure blocks the applicable step. Audit records the closed response-protection mode and a signing key only for JWS, and uses at most one scoped keyed pseudonym over each complete canonical role and selector bundle. Neither audit, logs, errors, metrics, nor traces contain credentials, tokens, request nonces, raw selector values, per-field quasi-identifier hashes, source values, Supported Values, or raw subject identifiers. | -| Evidence and response integrity | Rust alone constructs Evidence, signed flattened JWS, and the unsigned envelope. Signed JWS is mandatory and default, uses allowlisted protected headers and trusted key resolution, has verifiable nonce, independently expected subjects and output contract, audience, policy, and validity, and publishes usable current and retired public keys. Unsigned JSON is self-identifying, requires bundle and complete matched grant permission plus exact API selection, and makes no later-verification claim. Signed failure never falls back to unsigned. | +| Evidence and response integrity | Rust alone constructs Evidence, signed flattened JWS, and the unsigned envelope. Signed JWS is mandatory and default, uses ES256/P-256, RFC 7638 service key identifiers, allowlisted protected headers and trusted key resolution, has verifiable nonce, independently expected subjects and output contract, audience, policy, and validity, and publishes usable active and planned-rotation public keys while revoked identifiers override cached selection. Deployable assurance uses a pinned non-exportable Transit signer whose public key matches the governed active JWK and passes startup sign-and-verify. Unsigned JSON is self-identifying, requires bundle and complete matched grant permission plus exact API selection, and makes no later-verification claim. Signed failure never falls back to unsigned. | | Failure and operations | Stable safe errors, reviewed existence-disclosure semantics, public collapse of `no_match` and `ambiguous` by default, request limits, per-principal and failed-selector-attempt rate controls, authenticated requester-scoped discovery, health, readiness, dependency timeouts, and graceful shutdown work without exposing protected data. Discovery performs no source access and exposes no source plan, scripts, credentials, internal authority metadata, selector values, codelist values, or unrelated definitions. Readiness fails for missing bundle, selector binding, credential, audit, or signing dependencies required by the configured deployment. | | Multiple definitions | All four definitions run concurrently in one process and one trust domain without script state, limits, identifiers, subjects, source responses, audit context, or results crossing definition boundaries. Unsafe combined disclosure and mutually distrustful issuer configurations are rejected. | | Verification evidence | Focused invariant tests, all package tests, contract drift checks, dependency policy, formatting, package and workspace check, Clippy with warnings denied, and workspace tests pass. Security-sensitive behavior has a named threat, enforcement point, and negative test. | | Local compatibility smoke | After deterministic mocks pass, the read-only DHIS2 and OpenCRVS smoke tests are attempted when local credentials and approved demo selectors are available. Unavailability may be recorded as inconclusive; authenticated schema drift or excess disclosure is investigated and cannot be ignored. No credential or live-data artifact enters the repository or test output. | -| Operability | An adopter can author, test, deploy, and maintain a source integration from the configuration, adapter API, fixture contract, and complete DHIS2/OpenCRVS-shaped projects without editing Rust. An operator can independently bind the immutable governed bundle to listener, secret, audit, and private-CA paths for each environment without overriding evidence semantics, configure authentication, authority mappings, source bindings, signing rollover, rate limits, and verifier trust using documented supported paths, and let an authenticated consumer discover the exact revision-bound request shapes it may invoke. Static onboarding still owns token acquisition, human and legal descriptions, endpoint trust, and verifier policy. | +| Operability | An adopter can author, test, deploy, and maintain a source integration from the configuration, adapter API, fixture contract, and complete DHIS2/OpenCRVS-shaped projects without editing Rust. An operator can independently bind the immutable governed bundle to listener, secret, audit, private-CA, and Transit proxy paths for each environment without overriding evidence semantics, configure authentication, authority mappings, source bindings, planned and emergency signing rotation, audit epochs, rate limits, and verifier trust using documented supported paths, and let an authenticated consumer discover the exact revision-bound request shapes it may invoke. Static onboarding still owns token acquisition, human and legal descriptions, endpoint trust, and verifier policy. | | Production build | An editable project remains local until its author supplies exact governance metadata, stable concept identifiers, and one synthetic fixture per question. `evidencectl build` consumes one explicit closed production target, follows no symlink or outside-project reference, creates no secret or runtime residue, delegates bundle validation and every fixture to the real `evidence` binary, atomically publishes only a complete candidate, and reproduces identical bundle bytes and revision from identical inputs. It creates no keys, callers, approvals, deployments, or network side effects. | | Target-host handoff | A reviewed candidate with independently provisioned owner-only production secrets passes `evidencectl doctor`, `evidencectl fixtures run`, and real startup. One authorized synthetic-subject HTTP request yields a signed assertion that `evidence verify` accepts only under independent `production` policy and trusted keys; the resulting access and disclosure audit events pass `evidence verify-audit`. | | Optional Mint pairing | External HTTPS OIDC builds without Mint. When Mint is selected, `mint check`, the paired read-only doctor check, registered-client token acquisition, and Evidence acceptance pass. Issuer, JWKS URI, audience, algorithm, token type, and all configured claim-name mismatches fail generically without keys, tokens, credentials, selectors, or source values in output. Mint remains a single process with a memory-only replay cache. | diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index 91d521432..ecf1731fe 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -25,8 +25,12 @@ The supported native deployment has: references; - optional credential-free source access only for `assuranceProfile: local` at a canonical numeric-loopback HTTP origin with an explicit non-zero port; -- one active EdDSA reference signing key, flattened JWS JSON success responses, - and public key discovery at `/.well-known/evidence/jwks.json`; +- one active ES256/P-256 service signing key whose `kid` is its RFC 7638 + thumbprint, explicit published and revoked key sets, flattened JWS JSON + success responses, and public key discovery at + `/.well-known/evidence/jwks.json`; +- a non-exportable, pinned-version Vault/OpenBao Transit key reached through a + workload-local Unix-socket proxy for production and evidence-grade serving; - keyed JSONL audit on storage whose durability the operator has explicitly established; - production HTTPS exposure, dependency timeouts, per-source concurrency @@ -60,8 +64,9 @@ clients unbound tokens. The operator supplies one atomic bundle containing the approved YAML, preparation scripts, extraction scripts, derivation scripts, schemas, codelists, mappings, and fixtures. A separate closed `runtime.yaml` binds the bundle to -one listener, bundle directory, secret root, audit destination, and local TLS -trust files. The runtime file cannot override service identity, trust domain, +one listener, bundle directory, secret root, audit destination, signer +transport and pinned version, and local TLS trust files. The runtime file +cannot override service identity, trust domain, authentication, authority, sources, request policy, scripts, disclosure, rate limits, signing policy, or audit fail-closed behavior. The two content hashes identify the exact loaded inputs but are not trust decisions. The operator @@ -95,11 +100,12 @@ unsafe bundle safe. `evidencectl build` compiles an editable project and one explicit production target into a new candidate directory. It is a create-only authoring command, not an approval, promotion, deployment, key-generation, caller-registration, -or service-start command. It runs the real `evidence` binary against private -temporary validation material before atomically publishing a candidate with a -copied `runtime.yaml` and one closed `bundle/`. The candidate contains no -production private key, credential, token, local request, audit entry, or -source response. +or service-start command. It runs the real `evidence` binary through its +bundle-only validation entry point and evaluates every referenced fixture +without generating a temporary signing key or other validation secret. It then +atomically publishes a candidate with a copied `runtime.yaml` and one closed +`bundle/`. The candidate contains no production private key, credential, token, +local request, audit entry, or source response. The operator reviews and transfers the exact candidate, records its bundle revision, and independently provisions the signing key, audit HMAC key, @@ -146,6 +152,43 @@ names do not replace either value. Container images and their provenance are operator responsibilities; Version 1 proves this journey with released bare binaries, not generated containers or orchestrator manifests. +### Git-managed environments + +Use one protected branch and complete named environment targets. The maintained +reference layout is under +[`reference/deployment-targets/`](reference/deployment-targets/): + +```text +shared/ + evidence-project/ +environments/ + local/ + evidence/{governance.yaml,runtime.yaml,public-keys/} + mint/{mint.yaml,clients/,public-keys/} + staging/ + evidence/{governance.yaml,runtime.yaml,public-keys/} + mint/{mint.yaml,clients/,public-keys/} + transit/{proxy-configs/,policies/} + production/ + evidence/{governance.yaml,runtime.yaml,public-keys/} + mint/{mint.yaml,clients/,public-keys/} + transit/{proxy-configs/,policies/} +``` + +Shared Evidence questions, scripts, schemas, and fixtures are authored once. +Each environment target is nevertheless complete. It contains its own service +identity, issuer, endpoints, audiences, public keys, runtime paths, pinned +Transit versions, and logical secret references. There are no overlays, +environment branches, symlinks, runtime substitutions, or inherited defaults. +Promote a reviewed source revision, then build separate staging and production +candidates from their complete targets. + +Git contains public JWKs and non-secret Transit proxy and policy configuration. +It never contains private JWKs, HMAC masters, provider tokens, auto-auth +credentials, access tokens, live responses, or real identifiers. A target +template uses conspicuous replacement values and is not deployable until those +values and public JWKs have been reviewed and replaced. + ## Discovery of available evidence Evidence Version 1 answers "what may this caller request?" with authenticated @@ -319,8 +362,8 @@ different audience yields a different identifier and the credential is not a general-purpose multi-verifier credential. A request may carry an optional `holderKey`, which is echoed into the `cnf` -claim and is meaningful only for the SD-JWT VC format. Only a public OKP -Ed25519 JWK is accepted; an unacceptable key is rejected as a malformed request +claim and is meaningful only for the SD-JWT VC format. Only a public EC P-256 +JWK is accepted; an unacceptable key is rejected as a malformed request alongside the nonce check, before authentication, credential acquisition, and source access. The key never reaches authorization, selectors, Rhai, sources, audit, or the signed-JWS payload. Evidence issues no key-binding JWT, requires @@ -334,16 +377,25 @@ SD-JWT VC request to unsigned output or to the signed default. ## Secrets and keys -Source credentials and private signing material are supplied only through the -supported secret-reference mechanism. They do not appear in YAML values, -Rhai, command arguments, environment dumps, logs, audit, errors, snapshots, -or generated contracts. Private key parsing uses an explicit algorithm -allowlist. Missing or failed signing is fail-closed and never releases an -unsigned success response. - -The operator configures one active signing key and retains each retired public -key in the published JWKS for at least the maximum assertion validity plus -allowed clock skew. The JWKS is discovery, not a trust anchor. Verifiers obtain +Source credentials and local-authoring private signing material are supplied +only through the supported secret-reference mechanism. Production and +evidence-grade private signing material remains inside Vault/OpenBao Transit +and is reached through a workload-local Unix-socket proxy. Provider tokens and +auto-auth credentials stay in the proxy boundary and never enter Evidence. +Secret material does not appear in bundle YAML values, Rhai, command arguments, +environment dumps, logs, audit, errors, snapshots, or generated contracts. +Private JWK parsing uses an explicit ES256/P-256 allowlist. Missing or failed +signing is fail-closed and never releases an unsigned success response. + +The operator commits one active public JWK and zero or more additionally +published public JWKs. Every key is exact ES256/P-256 public material and its +43-character `kid` is derived as its RFC 7638 thumbprint, never configured +separately. Active and published identifiers are disjoint from +`revokedKeyIds`. The JWKS contains only the active and published keys. During +planned rotation, retain the predecessor for at least the maximum assertion +validity plus allowed clock skew. Emergency revocation removes it immediately, +and denylisting takes precedence over a cached key set. The JWKS is discovery, +not a trust anchor. Verifiers obtain the provider identity and JWKS location through governed configuration, pin that trust, allowlist the expected algorithm, and resolve `kid` only within the trusted key set. They never follow a message-provided remote key URL. @@ -354,6 +406,34 @@ notarization, create a qualified electronic signature, or create a holder credential. Governance establishes the provider's authority to act for the named legal issuer. +### Service signing-key rotation + +Planned rotation is an overlap, switch, drain sequence: + +1. Create the next non-exportable Transit key version and export only its + public key. +2. Commit that exact JWK under `public-keys/.jwk.json` and add its + path to `publishedPublicJwkFiles`. +3. Deploy and restart every replica so all of them publish both keys. +4. Keep the named Transit key's minimum signing version low enough for both + pinned application versions. The ordinary Vault/OpenBao ACL grants the + named key path, not a request-body key version. +5. Move the next path to `activePublicJwkFile`, keep the predecessor in + `publishedPublicJwkFiles`, pin `signer.keyVersion` to the next version, and + deploy and restart. +6. After `maximumAssertionValiditySeconds + verifierClockSkewSeconds`, remove + the predecessor public key and raise the Transit key's minimum signing + version, or otherwise disable the predecessor provider-side. + +Emergency rotation has no overlap guarantee. First disable provider signing +authority for the compromised version. Then remove its public JWK, add its +thumbprint to `revokedKeyIds`, activate a replacement or leave the service +unavailable, and restart every issuer and verifier that consumes the key set. +If the compromised key issued Mint access tokens, add that Mint identifier to +Evidence authentication `revokedKeyIds` in the same incident rollout. This +shortens availability when necessary and is intentionally stronger than the +ordinary validity window. + ## Source and selector controls Each subject role admits only named selector profiles from the trusted bundle. @@ -462,6 +542,12 @@ rotation, and chain verification for the selected durable sink. A deployment profile may require more reviewed metadata or retention, but it cannot silently weaken the native privacy contract. +The audit master feeds two HKDF-separated subkeys: one for chain integrity and +one for identifier pseudonyms. The subject-binding master is a separate secret +reference and must resolve to different bytes. This separation prevents a +pseudonym oracle or subject-binding use from becoming a chain-MAC oracle while +keeping the operator ceremony to two independent masters. + Exactly one Evidence process may write a given audit path. The sink takes an exclusive OS advisory lock on `.lock` at startup; a second process pointed at the same path fails at startup with a sink-locked error @@ -491,6 +577,25 @@ requires; it is what proves sealed history was not tampered with. ## Audit chain rotation and rollback +Audit-segment rotation below keeps one key and one continuous epoch. Rotating +the audit master is different and always starts a new epoch: + +1. Drain traffic and stop the sole writer. +2. Run `evidence verify-audit`; record the old chain head, bundle revision, + runtime revision, path, and `hashKeyVersion` in the change record. +3. Archive the old runtime, audit-master secret under its governed secret + controls, every segment, lock-file disposition, and recorded head together. +4. Generate a fresh independent audit master, increment `hashKeyVersion`, and + select a fresh empty `auditStorage.path`. Do not rename or reuse the old + active path. +5. Run `evidence check` and the full handoff checks, start the new process, and + route traffic only after readiness succeeds. + +Never append a new audit master to an existing chain. Startup with replacement +master bytes against existing segments fails closed. Old and new epochs verify +independently with their archived runtime and master; neither is a continuation +of the other. + `auditStorage.maximumFileBytes` is a per-segment rotation threshold, not a total ceiling on the chain. When an append would push the active segment past it, the runtime seals the active segment and opens a new one at the configured @@ -768,19 +873,26 @@ The reference file-secret provider reads only regular, non-symlink files below the configured `secretProviders.file.root`. The secret root is operator-only and each secret file must be owned by the service identity with mode `0600`. Audit and subject-binding secret files contain independently generated raw key -bytes and must each be at least 32 bytes. They are not decoded as base64 by the -file provider. Source credentials retain their provider-defined lexical form. -Signing material is an Ed25519 private JWK whose `kid` exactly matches -`signing.activeKeyId`; only the public current key and configured retired public -keys appear at the JWKS endpoint. The audit JSONL path must be on storage whose +bytes, must each be at least 32 bytes, must use distinct references, and must +resolve to distinct bytes. They are not decoded as base64 by the file provider. +Source credentials retain their provider-defined lexical form. Local signing +material is an ES256 P-256 private JWK whose public projection exactly matches +`signing.activePublicJwkFile`. Production and evidence-grade runtime +configuration instead names a Transit Unix socket, mount, key name, pinned +nonzero version, and bounded timeout. Transit metadata must report +`ecdsa-p256`, signing enabled, `derived=false`, `exportable=false`, and +`allow_plaintext_backup=false`, and its public key must exactly match the +governed active public JWK. Only active and published non-revoked public keys +appear at the JWKS endpoint. The audit JSONL path must be on storage whose append durability, permissions, capacity, backup, restore, retention, and keyed chain verification the operator owns. `evidence check` validates and compiles the complete bundle, and resolves and -validates the mounted audit, subject-binding, and signing secret material -exactly as startup does, without opening the audit chain. A deployment whose -secret material startup would refuse, including a signing key whose `kid` does -not match `signing.activeKeyId`, fails check. Source credentials are not +validates the mounted audit, subject-binding, and signer exactly as startup +does, including the asynchronous provider sign-and-verify test, without +opening the audit chain. A deployment whose secret or provider material +startup would refuse, including a signer whose public key differs from +`signing.activePublicJwkFile`, fails check. Source credentials are not resolved by check; readiness owns them. Fixture evaluation covers positive, negative, boundary, missing-data, source-failure, existence-disclosure, and anti-reconstruction behavior without a running @@ -813,9 +925,10 @@ evidence serve ``` Startup confirms that the immutable bundle compiled, runtime ownership and -every local path/trust binding validated, mounted secret files and signing -material parsed, and the audit chain opened and verified. Readiness rechecks -the subject-binding key, signing provider, pinned audit sink, and every source +every local path/trust binding validated, mounted secret files and signer +metadata parsed, the active public key matched, the signer completed its +sign-and-verify test, and the audit chain opened and verified. Readiness +rechecks the subject-binding key, signing provider, pinned audit sink, and every source credential. Basic, static Bearer, and static API-key credentials are checked locally. OAuth client-credentials readiness performs its bounded token bootstrap against the configured token endpoint. @@ -869,8 +982,10 @@ format under [response formats](#response-formats). No public or cross-requester catalog is supported. `GET /.well-known/jwt-vc-issuer` is unauthenticated discovery for the SD-JWT VC -format. It publishes the configured provider identity and the same public key -set as `/.well-known/evidence/jwks.json`, and nothing else. It is served +format. It publishes the exact configured provider identity as `issuer` and +that origin plus `/.well-known/evidence/jwks.json` as `jwks_uri`, and nothing +else. It does not inline the key set. Outside local assurance, enabling the +format requires `service.providerId` to be a stable HTTPS origin. Metadata is served whether or not any grant enables the credential format, it never reveals which requesters or requirements do, and it is discovery rather than a trust anchor on exactly the terms in [secrets and keys](#secrets-and-keys). @@ -899,7 +1014,8 @@ One end-to-end measurement is kept in the repository so capacity planning starts from a number rather than an estimate. It drives the real router over real sockets, and every request in it runs token verification, rate limiting, Rhai request preparation, one outbound source call, Rhai extraction, evidence -construction, Ed25519 signing, and both durable audit appends. +construction, in-process ES256 signing, and both durable audit appends. It does +not model the latency or availability of an external Transit deployment. | Measurement | Value | |---|---| @@ -1009,9 +1125,11 @@ A relying party or operator re-verifies a stored signed response offline with `evidence verify --jws --jwks --policy [--at ]`. The pinned JWKS file is the complete trust set and the policy document carries every expectation from independent trusted state: the retained request nonce, -the expected assurance profile, role-bound subject bindings, and output contract, -under +the expected assurance profile, role-bound subject bindings, output contract, +and explicit `revokedKeyIds` denylist under [`contracts/verification-policy.schema.yaml`](contracts/verification-policy.schema.yaml). +A denied identifier fails before a key is selected even if the pinned file +still contains it. The command performs no network access, reports cryptographic authenticity separately from current validity, and exits 0 only when both hold; an authentic but expired response exits 3. Every failed policy comparison reports diff --git a/products/evidence/README.md b/products/evidence/README.md index ad843c413..60317540e 100644 --- a/products/evidence/README.md +++ b/products/evidence/README.md @@ -17,8 +17,9 @@ signed response without the runtime. A process may host multiple evidence definitions only when they share that trust domain. Governed configuration, Rhai scripts, schemas, codelists, and fixtures are one trusted, immutable, startup-only evidence bundle. A separate closed runtime file owns only -process-local listener, filesystem, audit-storage, secret-mount, and TLS-trust -bindings and cannot override governed semantics. +process-local listener, filesystem, audit-storage, secret-mount, signer +transport and pinned version, and TLS-trust bindings. It cannot override +governed semantics or the governed active public key. The following contracts define and verify the implemented Version 1 boundary: @@ -89,10 +90,12 @@ by the same source-product and domain-neutrality checks as the runtime. `evidencectl new --openapi --profile local` retains the OpenAPI document exactly as `source.openapi.yaml` and creates empty -`questions/`, `derivations/`, and `fixtures/` directories. `--generate-keys` -adds owner-only disposable local key material that is not bound to a runtime. -The command does not select an API operation, invent a question, fixture, -policy, production target, Mint configuration, or deployable bundle. +`questions/`, `derivations/`, and `fixtures/` directories. It always creates +owner-only disposable local P-256 Evidence signing material plus distinct audit +and subject-binding masters. The command does not select an API operation, +invent a question, fixture, policy, production target, Mint configuration, or +deployable bundle. `evidencectl dev` additionally creates session-scoped P-256 +Mint, caller, and holder keys so the local happy path needs no key ceremony. `evidencectl source suggest` drafts one source from an OpenAPI description: it derives a closed response schema, an extraction script, and the facts schema @@ -120,27 +123,31 @@ that metadata, stable concept identifiers, and one project-relative fixture. It never invents requirement, framework, Evidence Type, concept, or disclosure-family URIs. -The explicit production target contains only `governance.yaml` and -`runtime.yaml`. The former contributes the bundle-owned service, -authentication, audit, signing, rate-limit, response-format, and authority -values; it may not override compiler-owned selectors, sources, or requirements. -The latter is copied unchanged and binds the completed candidate to one target -host. Secret values and absolute secret paths do not belong in either authored -governance input. - -`evidencectl build --project --target +Each explicit `deployment-targets//` target contains a complete +`governance.yaml`, `runtime.yaml`, and every governed public JWK referenced by +that governance document under `public-keys/`. Governance contributes the +bundle-owned service, authentication, audit, signing, rate-limit, +response-format, and authority values; it may not override compiler-owned +selectors, sources, or requirements. Runtime is copied unchanged and binds the +completed candidate to one target host. Targets are independent complete +inputs, not overlays. Secret values and absolute secret paths do not belong in +authored governance input. + +`evidencectl build --project --target --output ` is create-only. It reads regular files -without following symlinks, compiles one closed bundle, uses private temporary -validation material, runs `evidence check` and every referenced fixture through -the real `evidence` binary, and publishes atomically only on success. It makes -no network request, opens no listener, writes no production audit event, and -never copies local `.evidence` state, credentials, tokens, responses, or -private keys into the candidate. +without following symlinks, compiles one closed bundle, and delegates its +internal bundle-only check and every referenced fixture to the real `evidence` +binary. No temporary signing key or other validation secret is generated. The +candidate is published atomically only on success. The build makes no network +request, opens no listener, writes no production audit event, and never copies +local `.evidence` state, credentials, tokens, responses, or private keys into +the candidate. The candidate contains `runtime.yaml` and `bundle/`; the bundle may contain adapters, derivations, schemas, codelists, fixtures, and public keys where -referenced. The operator independently provisions the signing, audit, -subject-binding, and source secrets, then runs one grouped handoff: +referenced. The operator independently provisions the Transit key and +workload-local proxy, plus audit, subject-binding, and source secrets, then +runs one grouped handoff: ```sh evidencectl doctor --project '' @@ -305,7 +312,7 @@ cannot process JWS. It is never later-verifiable evidence and never a fallback when signing fails. The SD-JWT VC format is a second encoding of the one stateless assertion the -signed default carries, under the frozen profile in +signed default carries, under the frozen RFC 9901 and SD-JWT VC draft 18 profile in [the SD-JWT VC profile](contracts/sd-jwt-vc-profile.yaml). It is not a credential lifecycle: no issuance session, no holder binding ceremony, no status list, no revocation, and no presentation or key-binding verification. The diff --git a/products/evidence/SD-JWT-VC-DEMO.md b/products/evidence/SD-JWT-VC-DEMO.md index 60f1c245a..baa00cff9 100644 --- a/products/evidence/SD-JWT-VC-DEMO.md +++ b/products/evidence/SD-JWT-VC-DEMO.md @@ -36,7 +36,8 @@ The script starts the demo server, performs every request with plain `curl`, waits for the server's own checks, and finishes with the shipped offline verifier. Its six steps are: -1. fetch the issuer identity and key set from `/.well-known/jwt-vc-issuer`; +1. fetch the exact issuer metadata from `/.well-known/jwt-vc-issuer`, then its + governed key set from `/.well-known/evidence/jwks.json`; 2. request the signed default with `Accept: application/jose+json`; 3. request the same assertion with `Accept: application/dc+sd-jwt`; 4. decode the credential's protected header and disclosures; @@ -51,7 +52,7 @@ the printed form shows `$EVIDENCE_ACCESS_TOKEN` rather than its value. Expected output, abbreviated: ```text -1. Fetch the issuer keys from the published metadata route (no token) +1. Fetch the exact issuer metadata and its governed key set (no token) $ curl \ --header 'Accept: application/json' \ --output 'products/evidence/.sd-jwt-vc-demo/issuer-metadata.json' \ @@ -59,6 +60,7 @@ Expected output, abbreviated: http://127.0.0.1:18081/.well-known/jwt-vc-issuer HTTP 200 application/json issuer: urn:example:fixture:provider:evidence + jwks_uri: urn:example:fixture:provider:evidence/.well-known/evidence/jwks.json 2. Request the signed default (Accept: application/jose+json) $ curl \ --request 'POST' \ @@ -80,7 +82,7 @@ PASS: the same assertion was released as a signed JWS and as an SD-JWT VC, ... 4. The credential: an issuer-signed JWT, one root disclosure for this value, and a trailing tilde where a key-binding JWT would go 1 disclosure(s), no key-binding JWT - protected header: {"alg":"EdDSA","kid":"acceptance-evidence-key","typ":"dc+sd-jwt"} + protected header: {"alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","typ":"dc+sd-jwt"} disclosures (salt, claim name, claim value): ["7MNkDxEPeSWvyGbI2ziaRw","urn:example:fixture:concept:adult-status",true] @@ -151,9 +153,9 @@ curl --fail-with-body --silent \ --output products/evidence/.sd-jwt-vc-demo/issuer-metadata.json \ http://127.0.0.1:18081/.well-known/jwt-vc-issuer -jq '{keys: .jwks.keys}' \ - products/evidence/.sd-jwt-vc-demo/issuer-metadata.json \ - >products/evidence/.sd-jwt-vc-demo/trusted.jwks.json +curl --fail-with-body --silent \ + --output products/evidence/.sd-jwt-vc-demo/trusted.jwks.json \ + http://127.0.0.1:18081/.well-known/evidence/jwks.json ``` Then load the short-lived synthetic bearer token and request the signed default diff --git a/products/evidence/contracts/README.md b/products/evidence/contracts/README.md index f52053b40..3cec797dc 100644 --- a/products/evidence/contracts/README.md +++ b/products/evidence/contracts/README.md @@ -16,10 +16,11 @@ The normative source set is: - `request.schema.yaml`, `definitions.schema.yaml`, `evidence.schema.yaml`, and `jws-profile.yaml`: public discovery, request, the required `requestNonce` and its echo in the Evidence payload, response-format negotiation, payload, - signing, rotation, and strict verifier rules; + ES256 service signing, RFC 7638 key identifiers, publication, revocation, + rotation, and strict verifier rules; - `sd-jwt-vc-profile.yaml`: the audience-scoped SD-JWT VC response format, its exact claim and disclosure mapping, the optional `cnf` holder key, the - issuer-metadata path, and its explicit profile non-goals. It adds a + issuer-metadata path, RFC 9901 and SD-JWT VC draft v18 pins, and its explicit profile non-goals. It adds a serialization of the same assertion and no credential lifecycle; - `verification-policy.schema.yaml`: the closed all-required relying-procedure policy document consumed by the offline `evidence verify` command, its frozen @@ -89,8 +90,16 @@ Evidence vocabulary. protected value. 7. The governed bundle and `runtime.yaml` are separate closed startup inputs. Runtime binds only process-local paths, listener bounds, audit storage, - file secrets, and logical private CAs and cannot override governed - semantics or source authority. + file secrets, signer transport and pinned version, and logical private CAs. + It cannot override governed semantics, source authority, or the governed + active public key. +8. Service signing keys are exact ES256 P-256 public JWKs whose `kid` is their + RFC 7638 thumbprint. Production and evidence-grade signing uses a pinned + non-exportable Transit key through a workload-local Unix socket. Local + authoring alone may resolve a private JWK file. +9. Active, published, and revoked key sets are explicit and disjoint. Denied + identifiers are checked before selecting a cached key, and configuration + changes take effect only through restart. Version 1 stops before documents, credential issuance protocols and credential lifecycle, replay or nonce state beyond the stateless request-nonce echo and diff --git a/products/evidence/contracts/bundle.schema.yaml b/products/evidence/contracts/bundle.schema.yaml index dd2ccaa23..ec7f7a7a6 100644 --- a/products/evidence/contracts/bundle.schema.yaml +++ b/products/evidence/contracts/bundle.schema.yaml @@ -26,17 +26,21 @@ properties: signing: type: object additionalProperties: false - required: [format, algorithm, activeKeyId, activeKeyRef, retiredPublicJwkFiles, jwksPath, maximumAssertionValiditySeconds, verifierClockSkewSeconds] + required: [format, algorithm, activePublicJwkFile, publishedPublicJwkFiles, revokedKeyIds, jwksPath, maximumAssertionValiditySeconds, verifierClockSkewSeconds] properties: format: {const: flattened-jws-json} - algorithm: {const: EdDSA} - activeKeyId: {type: string, minLength: 1, maxLength: 256, pattern: '^[^\u0000-\u001F\u007F-\u009F]+$'} - activeKeyRef: {$ref: '#/$defs/secret-ref'} - retiredPublicJwkFiles: + algorithm: {const: ES256} + activePublicJwkFile: {$ref: '#/$defs/public-jwk-path'} + publishedPublicJwkFiles: type: array maxItems: 32 uniqueItems: true items: {$ref: '#/$defs/public-jwk-path'} + revokedKeyIds: + type: array + maxItems: 33 + uniqueItems: true + items: {$ref: '#/$defs/service-key-id'} jwksPath: {const: /.well-known/evidence/jwks.json} maximumAssertionValiditySeconds: {type: integer, minimum: 1, maximum: 31536000} verifierClockSkewSeconds: {type: integer, minimum: 0, maximum: 300} @@ -91,6 +95,18 @@ allOf: type: object required: [kind] properties: {kind: {const: none}} + - if: + properties: + assuranceProfile: {enum: [production, evidence-grade]} + responseFormats: {contains: {const: sd-jwt-vc}} + required: [assuranceProfile, responseFormats] + then: + properties: + service: + properties: + providerId: + type: string + pattern: '^https://(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:]+\])(?::[1-9][0-9]{0,4})?$' $defs: local-id: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} uri: {type: string, format: uri, maxLength: 512} @@ -109,10 +125,11 @@ $defs: {type: string, pattern: '^secret:file/[a-z][a-z0-9._-]{0,127}$'} relative-path: {type: string, pattern: '^(adapters|derivations|schemas|codelists|fixtures)/[A-Za-z0-9._/-]+$'} public-jwk-path: {type: string, pattern: '^public-keys/[A-Za-z0-9._-]+\.jwk\.json$'} + service-key-id: {type: string, pattern: '^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$'} authentication: type: object additionalProperties: false - required: [kind, issuer, audiences, tokenTypes, algorithms, jwksUri, principalClaim, requesterTagsClaim, evidenceAudienceClaim, grantIdClaim, grantAuthorityClaim] + required: [kind, issuer, audiences, tokenTypes, algorithms, jwksUri, principalClaim, requesterTagsClaim, evidenceAudienceClaim, grantIdClaim, grantAuthorityClaim, maximumTokenLifetimeSeconds, revokedKeyIds] properties: kind: {const: oidc-access-token} issuer: {type: string, pattern: '^https?://', maxLength: 512} @@ -140,6 +157,12 @@ $defs: evidenceAudienceClaim: {$ref: '#/$defs/claim-name'} grantIdClaim: {$ref: '#/$defs/claim-name'} grantAuthorityClaim: {$ref: '#/$defs/claim-name'} + maximumTokenLifetimeSeconds: {type: integer, minimum: 1, maximum: 86400} + revokedKeyIds: + type: array + maxItems: 32 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 256, pattern: '^[^\u0000-\u001F\u007F-\u009F]+$'} actorClaim: {$ref: '#/$defs/claim-name'} audit: type: object @@ -733,7 +756,9 @@ startup_checks: - Configured authority claim names are distinct and none names a registered JWT claim; `sub` is permitted for principalClaim alone, because it already carries the principal. A repeated or shadowing name would read one verified token member as something the issuer wrote for another purpose. - For authenticated-grant origin, verified token values at authentication.grantIdClaim and grantAuthorityClaim must exist, grantAuthority must exactly equal the matched authority-profile identifier, and the same entitlement must bind requirement, purpose, audience, role, selector profile, and valueClaims result before selector resolution. - Required signing, audit, credential, and source dependencies are ready. - - Every retired public JWK file is public-only, has a unique kid, uses the configured allowlisted algorithm, and remains available for the maximum assertion validity plus clock skew. + - The active and published public JWK files are public-only exact ES256 P-256 JWKs whose 43-character kid equals their RFC 7638 thumbprint; paths, kids, and key material are unique and no revoked identifier is active, published, or served. + - The active plus published set contains at most 33 keys. A published predecessor remains available for the maximum assertion validity plus clock skew, unless emergency revocation requires immediate removal and denylisting. + - The runtime signer matches the governed active public JWK exactly and passes a startup sign-and-verify test; local assurance uses local-jwk and production or evidence-grade uses Transit. - Every secret reference uses the closed file-secret grammar and is resolved beneath runtime.yaml secretProviders.file.root. - Fixed path and selector-bound path-template forms are mutually exclusive and every template placeholder has one authorized selector binding. - Every selector set an authority grant can activate for a source carries each role and profile that source's path template binds. The sets are derived one per grant, so covering a bound role across their union is not enough: a single grant omitting one yields a set with no value to substitute, which fails startup rather than every request that grant would serve. diff --git a/products/evidence/contracts/jws-profile.yaml b/products/evidence/contracts/jws-profile.yaml index 98c386ab2..5ada8a3e5 100644 --- a/products/evidence/contracts/jws-profile.yaml +++ b/products/evidence/contracts/jws-profile.yaml @@ -19,19 +19,24 @@ unsigned_envelope: protected_header: exact_members: [alg, kid, typ, cty] alg: - allowed: [EdDSA] + allowed: [ES256] rule: Must equal the algorithm bound to the trusted key. kid: required: true - maximum_bytes: 256 - rule: Resolved only within the verifier-pinned provider JWKS. + exact_length: 43 + rule: The RFC 7638 SHA-256 thumbprint of the exact public EC P-256 JWK, resolved only within the verifier-pinned provider JWKS. typ: {const: evidence+jws} cty: {const: application/evidence+json} prohibited: [jku, x5u, jwk, x5c, crit, b64] signing: active_keys: 1 - private_key_location: signing.activeKeyRef through a supported runtime secret provider only - retired_public_keys: signing.retiredPublicJwkFiles are bundle-owned public JWKs and never contain private members + algorithm: ES256 over P-256 + governed_active_public_key: signing.activePublicJwkFile + published_public_keys: signing.publishedPublicJwkFiles are bundle-owned public JWKs and never contain private members + revoked_key_ids: signing.revokedKeyIds are excluded from the active and published sets and from JWKS + runtime_signer: local assurance uses signer.kind local-jwk; production and evidence-grade use signer.kind transit through a workload-local Unix socket with a pinned nonzero key version + provider_controls: Transit metadata must report ecdsa-p256 with signing enabled, derived false, exportable false, allow_plaintext_backup false, and an exact public-key match. Evidence receives no provider token. + startup_proof: The runtime signer public key must equal the governed active public JWK and pass one sign-and-verify test before readiness. bundle_private_key_material: prohibited order: - core validates the complete derivation result @@ -48,13 +53,15 @@ key_discovery: content: public keys only trust_rule: Discovery is not a trust anchor; provider identity and JWKS location are pinned through governed verifier configuration. rotation: - new_assertions_use: the single configured active key - retired_public_key_minimum_availability: maximum assertion validity plus allowed verifier clock skew + planned: Publish the next public key first, deploy every replica, keep the named Transit key's minimum signing version low enough for both pinned application versions, then atomically switch activePublicJwkFile and signer.keyVersion while leaving the predecessor published. Ordinary Vault/OpenBao ACLs grant the named key path rather than filtering the request-body key version. + old_public_key_minimum_availability: maximum assertion validity plus allowed verifier clock skew + emergency: Raise the Transit minimum signing version or otherwise disable the compromised version provider-side, remove its public key, add its thumbprint to revokedKeyIds, activate a replacement or remain unavailable, then restart every affected consumer. kid_reuse: prohibited for different key material verifier_rules: - Parse flattened JWS with strict duplicate-member rejection. - Reject unprotected headers and any protected member outside the exact allowlist. - Resolve kid only in the pinned provider key set and require the allowlisted algorithm. + - Reject a policy-denied kid before selecting a pinned public key, even when that key is present in a cached or retained JWKS. - Verify the signature before parsing or acting on payload claims. - Validate the strict payload against the complete committed Evidence JSON Schema before deserializing or applying relying-procedure policy. - Require the assurance profile, schema, issuer, provider, requirement, Evidence Type, purpose, audience, observation, validity, and configuration revision expected independently by the relying procedure. An authentic local assertion never satisfies a production or evidence-grade expectation. @@ -82,6 +89,7 @@ negative_tests: - jws-protected-header-modification - jws-message-key-url-rejected - jws-unknown-kid-rejected + - jws-revoked-kid-rejected - jws-schema-invalid-signed-payload-rejected - jws-retired-key-window - signing-failure-no-unsigned-success diff --git a/products/evidence/contracts/request.schema.yaml b/products/evidence/contracts/request.schema.yaml index 85ada04f2..5db3ed52d 100644 --- a/products/evidence/contracts/request.schema.yaml +++ b/products/evidence/contracts/request.schema.yaml @@ -59,14 +59,17 @@ $defs: holder-key: type: object additionalProperties: false - required: [kty, crv, x] + required: [kty, crv, x, y] properties: - kty: {type: string, enum: [OKP]} - crv: {type: string, enum: [Ed25519]} + kty: {type: string, enum: [EC]} + crv: {type: string, enum: [P-256]} x: type: string pattern: '^[A-Za-z0-9_-]{43}$' - alg: {type: string, enum: [EdDSA]} + y: + type: string + pattern: '^[A-Za-z0-9_-]{43}$' + alg: {type: string, enum: [ES256]} kid: {type: string, minLength: 1, maxLength: 256} scalar-selector-value: oneOf: @@ -91,6 +94,6 @@ $comment: >- never uniqueness-checked, and never reaches authorization, rate limits, Rhai, source requests, logs, metrics, traces, or native audit. Callers must not encode identifiers, selectors, secrets, or document digests into it. - holderKey is an Ed25519 public JWK. A key carrying any private member, a + holderKey is an EC P-256 public JWK. A key carrying any private member, a non-allowlisted algorithm, or an unparseable body fails before credential acquisition or source access. diff --git a/products/evidence/contracts/runtime.schema.yaml b/products/evidence/contracts/runtime.schema.yaml index 9746cdb16..4324d7072 100644 --- a/products/evidence/contracts/runtime.schema.yaml +++ b/products/evidence/contracts/runtime.schema.yaml @@ -3,7 +3,7 @@ $id: https://registrystack.org/schemas/evidence/runtime-v1.json title: Evidence closed operator runtime configuration Version 1 type: object additionalProperties: false -required: [version, bundleDirectory, listener, secretProviders, auditStorage, outboundTls] +required: [version, bundleDirectory, listener, secretProviders, signer, auditStorage, outboundTls] properties: version: {const: 1} bundleDirectory: {$ref: '#/$defs/absolute-path'} @@ -50,6 +50,24 @@ properties: required: [root] properties: root: {$ref: '#/$defs/absolute-path'} + signer: + oneOf: + - type: object + additionalProperties: false + required: [kind, privateKeyRef] + properties: + kind: {const: local-jwk} + privateKeyRef: {$ref: '#/$defs/secret-ref'} + - type: object + additionalProperties: false + required: [kind, unixSocketPath, mount, keyName, keyVersion, timeoutMilliseconds] + properties: + kind: {const: transit} + unixSocketPath: {$ref: '#/$defs/absolute-path'} + mount: {$ref: '#/$defs/local-id'} + keyName: {$ref: '#/$defs/local-id'} + keyVersion: {type: integer, minimum: 1, maximum: 4294967295} + timeoutMilliseconds: {type: integer, minimum: 1, maximum: 30000} auditStorage: type: object additionalProperties: false @@ -75,6 +93,7 @@ properties: caBundleFile: {$ref: '#/$defs/absolute-path'} $defs: local-id: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} + secret-ref: {type: string, pattern: '^secret:file/[a-z][a-z0-9._-]{0,127}$'} absolute-path: {type: string, minLength: 2, maxLength: 512, pattern: '^/(?!/)(?!.*(?:^|/)\.\.?(?:/|$))[^\\\u0000]+$'} ownership: governed_fields: prohibited @@ -83,6 +102,7 @@ ownership: - listener binding and process limits - optional operator metrics listener binding - file-secret root + - signer binding to a local private JWK or workload-local Transit proxy - audit path and rotation bound - logical private-CA file bindings overrides: prohibited @@ -91,7 +111,8 @@ startup: mutability: runtime.yaml and bound CA files are captured read-only once; reload, merge, fallback, and partial serving are prohibited digest: independent SHA-256 revision over exact runtime.yaml bytes plus logical trust-profile names and exact CA bytes trust_profiles: names must exactly equal the logical tlsTrustProfile names used by the governed bundle - secrets: values are never parsed into or included in the runtime document or digest + secrets: values and provider tokens are never parsed into or included in the runtime document or digest + signer: local assurance requires local-jwk; production and evidence-grade require Transit through an absolute Unix socket. Transit key version is pinned and no provider token is supplied to Evidence. proxy: HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and NO_PROXY are ignored; no application-level proxy field exists platform: supported: Unix diff --git a/products/evidence/contracts/sd-jwt-vc-profile.yaml b/products/evidence/contracts/sd-jwt-vc-profile.yaml index 3876434fc..0da4846ff 100644 --- a/products/evidence/contracts/sd-jwt-vc-profile.yaml +++ b/products/evidence/contracts/sd-jwt-vc-profile.yaml @@ -7,6 +7,10 @@ summary: >- decision, fixed source execution, bounded derivation, output validation, audience-scoped subject binding, and the same durable access and disclosure-release audit ordering. +standards_profile: + selective_disclosure_jwt: RFC 9901 + sd_jwt_vc: draft-ietf-oauth-sd-jwt-vc-18 + evolution_rule: A later draft or RFC is a reviewed profile revision, never an implicit library upgrade. response: media_type: application/dc+sd-jwt serialization: sd-jwt-vc-compact @@ -17,7 +21,7 @@ response: protected_header: exact_members: [alg, typ, kid] alg: - allowed: [EdDSA] + allowed: [ES256] rule: Must equal the algorithm bound to the trusted key. Identical to the signed-JWS profile. typ: {const: dc+sd-jwt} kid: @@ -91,7 +95,11 @@ claims: embeds it and never validates a presentation. Key-binding JWT verification is the relying party's responsibility. constraints: - key_type: OKP Ed25519 public JWK + key_type: EC P-256 public JWK + required_members: [kty, crv, x, y] + optional_members: [alg, kid] + optional_algorithm: ES256 only + wallet_kid: Wallet-owned and not required to be an RFC 7638 thumbprint. private_members_prohibited: [d, p, q, dp, dq, qi, k] rule: A key carrying any private member, a non-allowlisted algorithm, or an unparseable body fails before credential acquisition or source access. prohibited: @@ -126,14 +134,14 @@ key_discovery: jwks_path: /.well-known/evidence/jwks.json issuer_metadata_path: /.well-known/jwt-vc-issuer issuer_metadata_media_type: application/json - issuer_metadata_members: [issuer, jwks] - content: public keys only + issuer_metadata_members: [issuer, jwks_uri] + issuer_rule: Exact service.providerId, which is a stable HTTPS origin whenever this format is enabled outside local assurance. + jwks_uri_rule: Exact service.providerId plus signing.jwksPath; the metadata contains no inline key set. trust_rule: >- Discovery is not a trust anchor. Provider identity and key set remain - pinned through governed verifier configuration. JWT VC Issuer Metadata - resolution applies only when service.providerId is the HTTPS origin of the - deployment; a URN provider identity is valid for the assertion and simply - has no metadata resolution path. + pinned through governed verifier configuration. Outside local assurance, + enabling this format requires service.providerId to be the stable HTTPS + origin of the deployment. profile_non_goals: rule: None of the following is implemented, stubbed, feature-flagged, or left as an extension seam. items: @@ -148,6 +156,7 @@ verifier_rules: - Split the compact serialization on tilde with strict rejection of an empty issuer-signed segment. - Reject any protected member outside the exact allowlist and require the allowlisted algorithm. - Resolve kid only in the pinned provider key set. + - Reject a policy-denied kid before selecting a pinned public key. - Verify the issuer-signed JWT signature before parsing or acting on any claim or disclosure. - Recompute every root-value and structured-field disclosure digest and require an exact match against its owning _sd; reject unmatched disclosures and unmatched digests alike. - Require the exact expected concept identifiers, value forms, and cardinalities after digest verification; missing, extra, duplicated, or wrongly formed output fails even under a valid signature. diff --git a/products/evidence/contracts/security-invariant-matrix.yaml b/products/evidence/contracts/security-invariant-matrix.yaml index 5a7fc0cb5..3ae9cc1ec 100644 --- a/products/evidence/contracts/security-invariant-matrix.yaml +++ b/products/evidence/contracts/security-invariant-matrix.yaml @@ -223,7 +223,7 @@ cross_cutting: negative_test: sec-request-preparation-closed runtime_ownership_split: threat: An environment-specific runtime file silently changes governed authorization, disclosure, source authority, signing, or audit policy. - enforcement: Closed runtime.yaml accepts only process-local listener, optional metrics-listener, path, secret-root, audit-storage, and logical private-CA bindings and has an independent immutable digest. + enforcement: Closed runtime.yaml accepts only process-local listener, optional metrics-listener, path, secret-root, audit-storage, signer transport and pinned version, and logical private-CA bindings; the signer must exactly match the governed active public JWK and the runtime has an independent immutable digest. negative_test: sec-runtime-cannot-override-governed-bundle outbound_tls_and_proxy: threat: A mutable or untrusted CA or ambient proxy redirects credentials and protected source queries to another authority. @@ -271,8 +271,16 @@ cross_cutting: negative_test: sec-sd-jwt-format-requires-bundle-and-grant sd_jwt_vc_holder_key_closed: threat: A caller-supplied holder key smuggles private key material, a non-allowlisted algorithm, or an unparseable body into a signed credential. - enforcement: The optional holder key must be a public OKP Ed25519 JWK; any private member, other key type or curve, or malformed body fails before credential acquisition and source access, and the rejection never echoes the submitted key material. + enforcement: The optional holder key must be a closed public EC P-256 JWK with exact x and y coordinates and optional ES256 alg and wallet-owned kid; any private or unknown member, other key type or curve, or malformed body fails before credential acquisition and source access, and the rejection never echoes the submitted key material. negative_test: sec-sd-jwt-holder-key-closed + signing_key_governance: + threat: A configured kid is detached from its public key, a compromised key remains usable through a cached key set, or exported local private material is used for deployable assurance. + enforcement: Service keys are exact ES256 P-256 public JWKs whose kid is their RFC 7638 thumbprint; active, published, and revoked sets are disjoint; production and evidence-grade require pinned-version Transit over a workload-local Unix socket and reject denied identifiers before key selection. + negative_test: sec-service-key-governance + audit_key_epoch: + threat: A replacement audit master is appended to an existing keyed chain, making one apparent chain depend on two unrecorded key histories. + enforcement: Audit chain and identifier keys are HKDF-separated from one master. Changing the master or hashKeyVersion requires a stopped, archived old epoch and a fresh audit path; the runtime rejects the replacement master against an existing chain. + negative_test: sec-audit-key-epoch sd_jwt_vc_claims_closed: threat: A claim outside the profile, such as a status reference, an audience restriction, or a smuggled selector value, reaches a relying party under a valid signature. enforcement: The published claim set is closed to the issuer-owned and always-disclosed names plus the declared disclosures; the verifier rejects any other member instead of ignoring it. diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml index d85df10d3..7e49f87f0 100644 --- a/products/evidence/contracts/security-test-traceability.yaml +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -206,10 +206,10 @@ entries: - {file: crates/registry-evidence/src/runtime_tests.rs, name: unsigned_envelope_is_exact_audited_and_never_a_signing_fallback} - id: sec-secret-file-identity tests: - - {file: crates/registry-evidence/src/secrets.rs, name: file_secret_uses_open_file_owner_and_exact_mode_checks} - - {file: crates/registry-evidence/src/secrets.rs, name: file_secret_rejects_symlinks_and_non_regular_files} - - {file: crates/registry-evidence/src/secrets.rs, name: file_secret_rejects_every_name_for_a_hard_link} - - {file: crates/registry-evidence/src/secrets.rs, name: file_secret_read_is_bounded} + - {file: crates/registry-platform-config/src/secrets.rs, name: file_secret_uses_open_file_owner_and_exact_mode_checks} + - {file: crates/registry-platform-config/src/secrets.rs, name: file_secret_rejects_symlinks_and_non_regular_files} + - {file: crates/registry-platform-config/src/secrets.rs, name: file_secret_rejects_every_name_for_a_hard_link} + - {file: crates/registry-platform-config/src/secrets.rs, name: file_secret_read_is_bounded} - id: sec-transport-backend-and-single-attempt tests: - {file: crates/registry-evidence/src/source.rs, name: evidence_client_uses_rustls_and_fails_closed_on_an_unrecognized_certificate_authority} @@ -228,6 +228,7 @@ entries: - {file: crates/registry-evidence/tests/source_contracts.rs, name: forbidden_header_collisions_and_invalid_projection_contracts_fail_at_compilation} - id: sec-sd-jwt-projection-integrity tests: + - {file: crates/registry-evidence-verifier/src/verifier.rs, name: external_rfc9901_and_draft18_vectors_verify_shared_cryptography_and_preserve_profile_boundary} - {file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_vc_round_trips_and_verifies_under_the_same_policy} - {file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_disclosure_modification_rejected} - {file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_added_disclosure_rejected} @@ -249,6 +250,19 @@ entries: - {file: crates/registry-evidence/src/runtime_tests.rs, name: sd_jwt_holder_key_with_private_member_rejected} - {file: crates/registry-evidence/src/runtime_tests.rs, name: sd_jwt_holder_key_wrong_algorithm_rejected} - {file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_vc_confirmation_is_accepted_and_carries_no_private_material} + - id: sec-service-key-governance + tests: + - {file: crates/registry-evidence/src/bundle.rs, name: strict_public_jwk_rejects_private_material_and_duplicate_members} + - {file: crates/registry-evidence/src/signing.rs, name: configured_key_id_must_match_provider} + - {file: crates/registry-evidence/src/signing.rs, name: flattened_jws_has_exact_protected_header_and_valid_signature} + - {file: crates/registry-platform-crypto/src/lib.rs, name: transit_signer_uses_the_common_vault_openbao_es256_wire_contract} + - {file: crates/registry-platform-crypto/src/lib.rs, name: transit_signer_rejects_unsafe_or_mismatched_metadata} + - {file: crates/registry-platform-crypto/src/lib.rs, name: public_jwk_thumbprint_uses_required_members_only} + - id: sec-audit-key-epoch + tests: + - {file: crates/registry-platform-audit/src/lib.rs, name: audit_profile_chain_and_identifier_keys_are_domain_separated} + - {file: crates/registry-platform-audit/src/lib.rs, name: byte_backed_profiles_reject_weak_master_secrets_without_echo} + - {file: crates/registry-evidence/src/audit.rs, name: restart_rejects_the_wrong_audit_key} - id: sec-sd-jwt-claims-closed tests: [{file: crates/registry-evidence-verifier/src/verifier.rs, name: sd_jwt_prohibited_claim_rejected}] - id: sec-local-source-none-boundary diff --git a/products/evidence/contracts/verification-policy.schema.yaml b/products/evidence/contracts/verification-policy.schema.yaml index 9ae4df18d..1dfd42bca 100644 --- a/products/evidence/contracts/verification-policy.schema.yaml +++ b/products/evidence/contracts/verification-policy.schema.yaml @@ -16,6 +16,7 @@ required: - expectedSubjects - expectedOutputs - maximumAssertionLifetimeSeconds + - revokedKeyIds properties: expectedAssuranceProfile: {enum: [local, production, evidence-grade]} issuedBy: {type: string, format: uri, maxLength: 512} @@ -48,6 +49,12 @@ properties: minimum: 1 maximum: 31536000 description: Longest acceptable validUntil minus issuedAt interval. + revokedKeyIds: + type: array + maxItems: 33 + uniqueItems: true + items: {type: string, pattern: '^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$'} + description: Emergency denylist of Evidence service-key RFC 7638 thumbprints, checked before a pinned JWKS key is selected. An empty list is explicit. clockSkewSeconds: type: integer minimum: 0 @@ -112,7 +119,7 @@ output: 3: authentic but not currently valid 1: every other outcome, including a policy mismatch, an unusable input document, and an unreadable file $comment: >- - Every expectation in this document must come from independent trusted state, + Every expectation and revoked-key decision in this document must come from independent trusted state, such as the independently retained original request and an accepted original transaction. Copying values out of the JWS under verification proves nothing, and a policy that omitted an expectation would silently skip a comparison, so diff --git a/products/evidence/fixtures/acceptance/adult-status/evidence.yaml b/products/evidence/fixtures/acceptance/adult-status/evidence.yaml index 24bd675fc..dc11be4ec 100644 --- a/products/evidence/fixtures/acceptance/adult-status/evidence.yaml +++ b/products/evidence/fixtures/acceptance/adult-status/evidence.yaml @@ -7,24 +7,26 @@ authentication: issuer: https://identity.invalid audiences: [evidence-fixture] tokenTypes: [at+jwt] - algorithms: [EdDSA] + algorithms: [ES256] jwksUri: https://identity.invalid/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience grantIdClaim: evidence_grant_id grantAuthorityClaim: evidence_authority + maximumTokenLifetimeSeconds: 300 + revokedKeyIds: [] audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} signing: format: flattened-jws-json - algorithm: EdDSA - activeKeyId: fixture-key-2026-01 - activeKeyRef: secret:file/signing-key - retiredPublicJwkFiles: [] + algorithm: ES256 + activePublicJwkFile: public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] jwksPath: /.well-known/evidence/jwks.json - maximumAssertionValiditySeconds: 86400 + maximumAssertionValiditySeconds: 300 verifierClockSkewSeconds: 30 responseFormats: [signed-jws] @@ -80,7 +82,7 @@ requirements: referenceFrameworks: [urn:example:fixture:framework:adult-status:v1] evidenceType: urn:example:fixture:evidence-type:adult-status:v1 observationTimezone: Asia/Bangkok - validitySeconds: 86400 + validitySeconds: 300 derivation: {script: derivations/adult-status.rhai, parameters: {minimum_age_years: 18}} concepts: [{id: urn:example:fixture:concept:adult-status, form: boolean, required: true, constraints: {}}] fixtures: fixtures/cases.yaml diff --git a/products/evidence/fixtures/acceptance/adult-status/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json b/products/evidence/fixtures/acceptance/adult-status/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json new file mode 100644 index 000000000..423462926 --- /dev/null +++ b/products/evidence/fixtures/acceptance/adult-status/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"} diff --git a/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml b/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml index ae6b21244..764230e93 100644 --- a/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml +++ b/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml @@ -9,25 +9,27 @@ authentication: issuer: https://identity.invalid audiences: [evidence-fixture] tokenTypes: [at+jwt] - algorithms: [EdDSA] + algorithms: [ES256] jwksUri: https://identity.invalid/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience grantIdClaim: evidence_grant_id grantAuthorityClaim: evidence_authority + maximumTokenLifetimeSeconds: 300 + revokedKeyIds: [] audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} signing: format: flattened-jws-json - algorithm: EdDSA - activeKeyId: fixture-key-2026-01 - activeKeyRef: secret:file/signing-key - retiredPublicJwkFiles: [] + algorithm: ES256 + activePublicJwkFile: public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] jwksPath: /.well-known/evidence/jwks.json - maximumAssertionValiditySeconds: 86400 + maximumAssertionValiditySeconds: 300 verifierClockSkewSeconds: 30 # The acceptance deployment deliberately enables the governed unsigned format @@ -211,7 +213,7 @@ requirements: referenceFrameworks: [urn:example:fixture:framework:adult-status:v1] evidenceType: urn:example:fixture:evidence-type:adult-status:v1 observationTimezone: Asia/Bangkok - validitySeconds: 86400 + validitySeconds: 300 derivation: script: derivations/adult-status.rhai parameters: {minimum_age_years: 18} @@ -229,7 +231,7 @@ requirements: - {role: subject, cardinality: one, selectorProfiles: [residence-record-v1]} referenceFrameworks: [urn:example:fixture:framework:residence-region:v1] evidenceType: urn:example:fixture:evidence-type:residence-region:v1 - validitySeconds: 86400 + validitySeconds: 300 derivation: script: derivations/residence-region.rhai parameters: {} @@ -251,7 +253,7 @@ requirements: referenceFrameworks: [urn:example:fixture:framework:professional-licence:v1] evidenceType: urn:example:fixture:evidence-type:professional-licence-status:v1 observationTimezone: Africa/Nairobi - validitySeconds: 43200 + validitySeconds: 300 derivation: script: derivations/professional-licence.rhai parameters: @@ -280,7 +282,7 @@ requirements: - {role: candidate-parent, cardinality: one, selectorProfiles: [person-reference-v1]} referenceFrameworks: [urn:example:fixture:framework:legal-parent-relationship:v1] evidenceType: urn:example:fixture:evidence-type:legal-parent-relationship:v1 - validitySeconds: 86400 + validitySeconds: 300 derivation: script: derivations/legal-parent-relationship.rhai selectorInputs: diff --git a/products/evidence/fixtures/acceptance/all-definitions/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json b/products/evidence/fixtures/acceptance/all-definitions/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json new file mode 100644 index 000000000..423462926 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"} diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml b/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml index 8e768196f..8e19918cd 100644 --- a/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml @@ -2,11 +2,11 @@ version: 1 assuranceProfile: evidence-grade service: {providerId: urn:example:fixture:provider:evidence, trustDomain: urn:example:fixture:trust-domain:acceptance} issuer: {id: urn:example:fixture:issuer:authority} -authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [EdDSA], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, grantIdClaim: evidence_grant_id, grantAuthorityClaim: evidence_authority} +authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, grantIdClaim: evidence_grant_id, grantAuthorityClaim: evidence_authority, maximumTokenLifetimeSeconds: 300, revokedKeyIds: []} audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} -signing: {format: flattened-jws-json, algorithm: EdDSA, activeKeyId: fixture-key-2026-01, activeKeyRef: secret:file/signing-key, retiredPublicJwkFiles: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 86400, verifierClockSkewSeconds: 30} +signing: {format: flattened-jws-json, algorithm: ES256, activePublicJwkFile: public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json, publishedPublicJwkFiles: [], revokedKeyIds: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 300, verifierClockSkewSeconds: 30} responseFormats: [signed-jws] selectorProfiles: @@ -77,7 +77,7 @@ requirements: - {role: candidate-parent, cardinality: one, selectorProfiles: [person-reference-v1]} referenceFrameworks: [urn:example:fixture:framework:legal-parent-relationship:v1] evidenceType: urn:example:fixture:evidence-type:legal-parent-relationship:v1 - validitySeconds: 86400 + validitySeconds: 300 derivation: script: derivations/legal-parent-relationship.rhai selectorInputs: diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json b/products/evidence/fixtures/acceptance/legal-parent-relationship/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json new file mode 100644 index 000000000..423462926 --- /dev/null +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"} diff --git a/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml b/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml index 94abfc1cc..c2f1dcfed 100644 --- a/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml +++ b/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml @@ -2,11 +2,11 @@ version: 1 assuranceProfile: evidence-grade service: {providerId: urn:example:fixture:provider:evidence, trustDomain: urn:example:fixture:trust-domain:acceptance} issuer: {id: urn:example:fixture:issuer:authority} -authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [EdDSA], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, grantIdClaim: evidence_grant_id, grantAuthorityClaim: evidence_authority} +authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, grantIdClaim: evidence_grant_id, grantAuthorityClaim: evidence_authority, maximumTokenLifetimeSeconds: 300, revokedKeyIds: []} audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} -signing: {format: flattened-jws-json, algorithm: EdDSA, activeKeyId: fixture-key-2026-01, activeKeyRef: secret:file/signing-key, retiredPublicJwkFiles: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 86400, verifierClockSkewSeconds: 30} +signing: {format: flattened-jws-json, algorithm: ES256, activePublicJwkFile: public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json, publishedPublicJwkFiles: [], revokedKeyIds: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 300, verifierClockSkewSeconds: 30} responseFormats: [signed-jws] selectorProfiles: @@ -59,7 +59,7 @@ requirements: referenceFrameworks: [urn:example:fixture:framework:professional-licence:v1] evidenceType: urn:example:fixture:evidence-type:professional-licence-status:v1 observationTimezone: Africa/Nairobi - validitySeconds: 43200 + validitySeconds: 300 derivation: script: derivations/professional-licence.rhai parameters: diff --git a/products/evidence/fixtures/acceptance/professional-licence/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json b/products/evidence/fixtures/acceptance/professional-licence/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json new file mode 100644 index 000000000..423462926 --- /dev/null +++ b/products/evidence/fixtures/acceptance/professional-licence/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"} diff --git a/products/evidence/fixtures/acceptance/residence-region/evidence.yaml b/products/evidence/fixtures/acceptance/residence-region/evidence.yaml index e45d2a61e..30ccd1c6b 100644 --- a/products/evidence/fixtures/acceptance/residence-region/evidence.yaml +++ b/products/evidence/fixtures/acceptance/residence-region/evidence.yaml @@ -2,11 +2,11 @@ version: 1 assuranceProfile: evidence-grade service: {providerId: urn:example:fixture:provider:evidence, trustDomain: urn:example:fixture:trust-domain:acceptance} issuer: {id: urn:example:fixture:issuer:authority} -authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [EdDSA], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, grantIdClaim: evidence_grant_id, grantAuthorityClaim: evidence_authority} +authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, grantIdClaim: evidence_grant_id, grantAuthorityClaim: evidence_authority, maximumTokenLifetimeSeconds: 300, revokedKeyIds: []} audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} -signing: {format: flattened-jws-json, algorithm: EdDSA, activeKeyId: fixture-key-2026-01, activeKeyRef: secret:file/signing-key, retiredPublicJwkFiles: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 86400, verifierClockSkewSeconds: 30} +signing: {format: flattened-jws-json, algorithm: ES256, activePublicJwkFile: public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json, publishedPublicJwkFiles: [], revokedKeyIds: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 300, verifierClockSkewSeconds: 30} responseFormats: [signed-jws] selectorProfiles: @@ -56,7 +56,7 @@ requirements: subjectRoles: [{role: subject, cardinality: one, selectorProfiles: [residence-record-v1]}] referenceFrameworks: [urn:example:fixture:framework:residence-region:v1] evidenceType: urn:example:fixture:evidence-type:residence-region:v1 - validitySeconds: 86400 + validitySeconds: 300 derivation: {script: derivations/residence-region.rhai, parameters: {}} concepts: - id: urn:example:fixture:concept:residence-region diff --git a/products/evidence/fixtures/acceptance/residence-region/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json b/products/evidence/fixtures/acceptance/residence-region/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json new file mode 100644 index 000000000..423462926 --- /dev/null +++ b/products/evidence/fixtures/acceptance/residence-region/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"} diff --git a/products/evidence/fixtures/conformance/audit-events.yaml b/products/evidence/fixtures/conformance/audit-events.yaml index e4e32ef14..23688df0f 100644 --- a/products/evidence/fixtures/conformance/audit-events.yaml +++ b/products/evidence/fixtures/conformance/audit-events.yaml @@ -43,7 +43,7 @@ disclosure_release: decision: released disclosedConcepts: [urn:example:fixture:concept:boolean-a] evidenceId: urn:example:fixture:evidence:001 - signingKeyId: fixture-key-2026-01 + signingKeyId: _QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo durationMilliseconds: 12 unsigned_disclosure_release: schema: registry.evidence.audit/v1 diff --git a/products/evidence/fixtures/conformance/external-sd-jwt-vectors.yaml b/products/evidence/fixtures/conformance/external-sd-jwt-vectors.yaml new file mode 100644 index 000000000..52919bc70 --- /dev/null +++ b/products/evidence/fixtures/conformance/external-sd-jwt-vectors.yaml @@ -0,0 +1,48 @@ +fixture: registry.evidence.external-sd-jwt-vectors/v1 +synthetic_only: true +compatibility_claim: none +purpose: >- + Ordinary-CI proof that the Evidence verifier's shared ES256 and RFC 9901 + disclosure machinery accepts authoritative standards bytes. The complete + Evidence profile remains deliberately stricter and rejects both external + examples at its protected-header or claim boundary. +issuer_public_jwk: + kty: EC + crv: P-256 + alg: ES256 + x: b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ + y: Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8 +vectors: + - id: rfc-9901-section-5-single-disclosure + standard: RFC 9901 + provenance: + source: https://www.rfc-editor.org/rfc/rfc9901.txt + revision: RFC 9901, November 2025 + location: Section 5.1 issuer-signed JWT and given_name Disclosure; public key from Appendix A.5 + derivation: >- + Exact issuer-signed JWT and exact given_name Disclosure are joined as + an RFC-permitted selective presentation and terminated with the + no-KB-JWT tilde. RFC line wrapping is removed without changing compact + serialization bytes. + serializedSha256: ded07ccce2201ac557def085e1f514f2669e1274914c33efdd7459a04bae50f2 + serialized: "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0In0.eyJfc2QiOiBbIkNyUWU3UzVrcUJBSHQtbk1ZWGdjNmJkdDJTSDVhVFkxc1VfTS1QZ2tqUEkiLCAiSnpZakg0c3ZsaUgwUjNQeUVNZmVadTZKdDY5dTVxZWhabzdGN0VQWWxTRSIsICJQb3JGYnBLdVZ1Nnh5bUphZ3ZrRnNGWEFiUm9jMkpHbEFVQTJCQTRvN2NJIiwgIlRHZjRvTGJnd2Q1SlFhSHlLVlFaVTlVZEdFMHc1cnREc3JaemZVYW9tTG8iLCAiWFFfM2tQS3QxWHlYN0tBTmtxVlI2eVoyVmE1TnJQSXZQWWJ5TXZSS0JNTSIsICJYekZyendzY002R242Q0pEYzZ2Vks4QmtNbmZHOHZPU0tmcFBJWmRBZmRFIiwgImdiT3NJNEVkcTJ4Mkt3LXc1d1BFemFrb2I5aFYxY1JEMEFUTjNvUUw5Sk0iLCAianN1OXlWdWx3UVFsaEZsTV8zSmx6TWFTRnpnbGhRRzBEcGZheVF3TFVLNCJdLCAiaXNzIjogImh0dHBzOi8vaXNzdWVyLmV4YW1wbGUuY29tIiwgImlhdCI6IDE2ODMwMDAwMDAsICJleHAiOiAxODgzMDAwMDAwLCAic3ViIjogInVzZXJfNDIiLCAibmF0aW9uYWxpdGllcyI6IFt7Ii4uLiI6ICJwRm5kamtaX1ZDem15VGE2VWpsWm8zZGgta284YUlLUWM5RGxHemhhVllvIn0sIHsiLi4uIjogIjdDZjZKa1B1ZHJ5M2xjYndIZ2VaOGtoQXYxVTFPU2xlclAwVmtCSnJXWjAifV0sICJfc2RfYWxnIjogInNoYS0yNTYiLCAiY25mIjogeyJqd2siOiB7Imt0eSI6ICJFQyIsICJjcnYiOiAiUC0yNTYiLCAieCI6ICJUQ0FFUjE5WnZ1M09IRjRqNFc0dmZTVm9ISVAxSUxpbERsczd2Q2VHZW1jIiwgInkiOiAiWnhqaVdXYlpNUUdIVldLVlE0aGJTSWlyc1ZmdWVjQ0U2dDRqVDlGMkhaUSJ9fX0.MczwjBFGtzf-6WMT-hIvYbkb11NrV1WMO-jTijpMPNbswNzZ87wY2uHz-CXo6R04b7jYrpj9mNRAvVssXou1iw~WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd~" + expected: + protectedTyp: example+sd-jwt + issuer: https://issuer.example.com + disclosureNames: [given_name] + evidenceProfileRejection: protected-header + - id: sd-jwt-vc-draft-18-figure-10 + standard: draft-ietf-oauth-sd-jwt-vc-18 + provenance: + source: https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-18.txt + revision: draft-ietf-oauth-sd-jwt-vc-18; oauth-wg tag commit 69e50ea623367c212c12c680e35e256b640b5f6b + location: Figure 10 Presented SD-JWT Example; public key from examples/settings.yml at the pinned commit + derivation: Official compact bytes with only specification line wrapping removed. + serializedSha256: d76ee28606ccc124fb90567f2511ddd5f2cddf2ee3f2ff7eeebfa51b3e759ad2 + serialized: "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImRjK3NkLWp3dCJ9.eyJfc2QiOiBbIjA5dktySk1PbHlUV00wc2pwdV9wZE9CVkJRMk0xeTNLaHBINTE1blhrcFkiLCAiMnJzakdiYUMwa3k4bVQwcEpyUGlvV1RxMF9kYXcxc1g3NnBvVWxnQ3diSSIsICJFa084ZGhXMGRIRUpidlVIbEVfVkNldUM5dVJFTE9pZUxaaGg3WGJVVHRBIiwgIklsRHpJS2VpWmREd3BxcEs2WmZieXBoRnZ6NUZnbldhLXNONndxUVhDaXciLCAiSnpZakg0c3ZsaUgwUjNQeUVNZmVadTZKdDY5dTVxZWhabzdGN0VQWWxTRSIsICJQb3JGYnBLdVZ1Nnh5bUphZ3ZrRnNGWEFiUm9jMkpHbEFVQTJCQTRvN2NJIiwgIlRHZjRvTGJnd2Q1SlFhSHlLVlFaVTlVZEdFMHc1cnREc3JaemZVYW9tTG8iLCAiamRyVEU4WWNiWTRFaWZ1Z2loaUFlX0JQZWt4SlFaSUNlaVVRd1k5UXF4SSIsICJqc3U5eVZ1bHdRUWxoRmxNXzNKbHpNYVNGemdsaFFHMERwZmF5UXdMVUs0Il0sICJpc3MiOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS9pc3N1ZXIiLCAiaWF0IjogMTY4MzAwMDAwMCwgImV4cCI6IDE4ODMwMDAwMDAsICJ2Y3QiOiAiaHR0cHM6Ly9jcmVkZW50aWFscy5leGFtcGxlLmNvbS9pZGVudGl0eV9jcmVkZW50aWFsIiwgIl9zZF9hbGciOiAic2hhLTI1NiJ9.U00sz47Qmf4V3pAveo2aZwtR6MRdq1KGTK8X2UcpbfkD0whz1Bbxuxa_c7pz3SWf1-JwVdUydDnL0zgS9bpQ-g~WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgImlzX292ZXJfNjUiLCB0cnVlXQ~WyJRZ19PNjR6cUF4ZTQxMmExMDhpcm9BIiwgImFkZHJlc3MiLCB7InN0cmVldF9hZGRyZXNzIjogIjEyMyBNYWluIFN0IiwgImxvY2FsaXR5IjogIkFueXRvd24iLCAicmVnaW9uIjogIkFueXN0YXRlIiwgImNvdW50cnkiOiAiVVMifV0~" + expected: + protectedTyp: dc+sd-jwt + issuer: https://example.com/issuer + vct: https://credentials.example.com/identity_credential + disclosureNames: [is_over_65, address] + evidenceProfileRejection: protected-header diff --git a/products/evidence/fixtures/conformance/jws-cases.yaml b/products/evidence/fixtures/conformance/jws-cases.yaml index 737ecafc8..13d2208b6 100644 --- a/products/evidence/fixtures/conformance/jws-cases.yaml +++ b/products/evidence/fixtures/conformance/jws-cases.yaml @@ -1,6 +1,6 @@ fixture: registry.evidence.jws-cases/v1 protected_header: - exact_json: '{"alg":"EdDSA","kid":"fixture-key-2026-01","typ":"evidence+jws","cty":"application/evidence+json"}' + exact_json: '{"alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","typ":"evidence+jws","cty":"application/evidence+json"}' base64url_padding: prohibited cases: - {id: adult-boolean, request: golden/adult-request.json, payload: golden/adult-evidence.json} @@ -12,7 +12,7 @@ signing_procedure: payload: For signing-profile tests, sign the exact referenced fixture file bytes including the final line feed; the runtime signs its own core-produced bytes exactly once without canonicalization or reserialization. output_members: [protected, payload, signature] output_header_member: prohibited - verification: Resolve fixture-key-2026-01 from the harness-pinned public fixture key and verify before parsing payload. + verification: Resolve the RFC 7638 thumbprint from the harness-pinned public P-256 fixture key and verify before parsing payload. negative: - mutate one protected-header byte - mutate one payload byte @@ -20,6 +20,7 @@ negative: - add an unprotected header - add jku, x5u, jwk, x5c, crit, or b64 - unknown kid + - revoked kid, even when the key remains in a cached JWKS - algorithm mismatch - signed payload violates the Evidence JSON Schema - duplicate evidence object beside payload diff --git a/products/evidence/fixtures/conformance/sd-jwt-vc-cases.yaml b/products/evidence/fixtures/conformance/sd-jwt-vc-cases.yaml index 8a1b2f415..486427a68 100644 --- a/products/evidence/fixtures/conformance/sd-jwt-vc-cases.yaml +++ b/products/evidence/fixtures/conformance/sd-jwt-vc-cases.yaml @@ -1,8 +1,9 @@ fixture: registry.evidence.sd-jwt-vc-cases/v1 profile: ../../contracts/sd-jwt-vc-profile.yaml +standards_profile: RFC 9901 and SD-JWT VC draft 18 media_type: application/dc+sd-jwt protected_header: - exact_json: '{"alg":"EdDSA","kid":"fixture-key-2026-01","typ":"dc+sd-jwt"}' + exact_json: '{"alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","typ":"dc+sd-jwt"}' base64url_padding: prohibited cty_member: prohibited serialization: @@ -33,11 +34,12 @@ negative: - mutate one payload byte - mutate one protected-header byte - unknown kid + - revoked kid, even when the key remains in a cached JWKS - a claim outside the published claim set - the format is not enabled by the immutable bundle - the format is not permitted by the complete matched grant - holder key carrying a private member - - holder key with a non-allowlisted key type, curve, or algorithm + - holder key that is not exact public EC P-256, or whose optional algorithm is not ES256 - signing-provider failure - the serialization is offered to the flattened JWS verifier expected_failure: >- diff --git a/products/evidence/fixtures/conformance/selectors/evidence.yaml b/products/evidence/fixtures/conformance/selectors/evidence.yaml index 621297d50..ce5634205 100644 --- a/products/evidence/fixtures/conformance/selectors/evidence.yaml +++ b/products/evidence/fixtures/conformance/selectors/evidence.yaml @@ -9,13 +9,15 @@ authentication: issuer: https://identity.invalid audiences: [selector-conformance] tokenTypes: [at+jwt] - algorithms: [EdDSA] + algorithms: [ES256] jwksUri: https://identity.invalid/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience grantIdClaim: evidence_grant_id grantAuthorityClaim: evidence_authority + maximumTokenLifetimeSeconds: 300 + revokedKeyIds: [] audit: format: keyed-jsonl hashSecretRef: secret:file/audit-key @@ -30,12 +32,12 @@ rateLimits: failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 100 signing: format: flattened-jws-json - algorithm: EdDSA - activeKeyId: selector-evidence-key - activeKeyRef: secret:file/signing-key - retiredPublicJwkFiles: [] + algorithm: ES256 + activePublicJwkFile: public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] jwksPath: /.well-known/evidence/jwks.json - maximumAssertionValiditySeconds: 86400 + maximumAssertionValiditySeconds: 300 verifierClockSkewSeconds: 30 responseFormats: [signed-jws] @@ -271,7 +273,7 @@ requirements: - {role: subject, cardinality: one, selectorProfiles: [opaque-record-v1]} referenceFrameworks: [urn:example:fixture:framework:classification:v1] evidenceType: urn:example:fixture:evidence-type:classification:v1 - validitySeconds: 3600 + validitySeconds: 300 derivation: {script: derivations/classification.rhai, parameters: {}} concepts: - {id: urn:example:fixture:concept:classification, form: boolean, required: true, constraints: {}} @@ -286,7 +288,7 @@ requirements: - {role: subject, cardinality: one, selectorProfiles: [demographics-v1]} referenceFrameworks: [urn:example:fixture:framework:property:v1] evidenceType: urn:example:fixture:evidence-type:property:v1 - validitySeconds: 3600 + validitySeconds: 300 derivation: {script: derivations/property.rhai, parameters: {}} concepts: - {id: urn:example:fixture:concept:property, form: boolean, required: true, constraints: {}} @@ -301,7 +303,7 @@ requirements: - {role: subject, cardinality: one, selectorProfiles: [demographics-with-event-v1]} referenceFrameworks: [urn:example:fixture:framework:property-with-event:v1] evidenceType: urn:example:fixture:evidence-type:property-with-event:v1 - validitySeconds: 3600 + validitySeconds: 300 derivation: {script: derivations/property-with-event.rhai, parameters: {}} concepts: - {id: urn:example:fixture:concept:property-with-event, form: boolean, required: true, constraints: {}} @@ -317,7 +319,7 @@ requirements: - {role: subject-b, cardinality: one, selectorProfiles: [demographics-v1, demographics-with-event-v1]} referenceFrameworks: [urn:example:fixture:framework:relationship:v1] evidenceType: urn:example:fixture:evidence-type:relationship:v1 - validitySeconds: 3600 + validitySeconds: 300 derivation: {script: derivations/relationship.rhai, parameters: {}} concepts: - {id: urn:example:fixture:concept:relationship, form: boolean, required: true, constraints: {}} @@ -332,7 +334,7 @@ requirements: - {role: subject, cardinality: one, selectorProfiles: [opaque-coordinates-v1]} referenceFrameworks: [urn:example:fixture:framework:opaque:v1] evidenceType: urn:example:fixture:evidence-type:opaque:v1 - validitySeconds: 3600 + validitySeconds: 300 derivation: {script: derivations/opaque.rhai, parameters: {}} concepts: - {id: urn:example:fixture:concept:opaque, form: boolean, required: true, constraints: {}} diff --git a/products/evidence/fixtures/conformance/selectors/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json b/products/evidence/fixtures/conformance/selectors/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json new file mode 100644 index 000000000..423462926 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"} diff --git a/products/evidence/fixtures/conformance/supported-values/evidence.yaml b/products/evidence/fixtures/conformance/supported-values/evidence.yaml index 347178884..8e7290f0d 100644 --- a/products/evidence/fixtures/conformance/supported-values/evidence.yaml +++ b/products/evidence/fixtures/conformance/supported-values/evidence.yaml @@ -9,13 +9,15 @@ authentication: issuer: https://identity.invalid audiences: [evidence-fixture] tokenTypes: [at+jwt] - algorithms: [EdDSA] + algorithms: [ES256] jwksUri: https://identity.invalid/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience grantIdClaim: evidence_grant_id grantAuthorityClaim: evidence_authority + maximumTokenLifetimeSeconds: 300 + revokedKeyIds: [] audit: format: keyed-jsonl hashSecretRef: secret:file/audit-hash-key @@ -30,12 +32,12 @@ rateLimits: failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10 signing: format: flattened-jws-json - algorithm: EdDSA - activeKeyId: supported-values-fixture-key - activeKeyRef: secret:file/signing-key - retiredPublicJwkFiles: [] + algorithm: ES256 + activePublicJwkFile: public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] jwksPath: /.well-known/evidence/jwks.json - maximumAssertionValiditySeconds: 86400 + maximumAssertionValiditySeconds: 300 verifierClockSkewSeconds: 30 responseFormats: [signed-jws] @@ -97,7 +99,7 @@ requirements: referenceFrameworks: [urn:example:fixture:framework:supported-values:v1] evidenceType: urn:example:fixture:evidence-type:supported-values:v1 observationTimezone: UTC - validitySeconds: 86400 + validitySeconds: 300 derivation: script: derivations/values.rhai parameters: {} diff --git a/products/evidence/fixtures/conformance/supported-values/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json b/products/evidence/fixtures/conformance/supported-values/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json new file mode 100644 index 000000000..423462926 --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"} diff --git a/products/evidence/generated/evidence-request-v1.schema.json b/products/evidence/generated/evidence-request-v1.schema.json index 26e4aab8e..005123cbf 100644 --- a/products/evidence/generated/evidence-request-v1.schema.json +++ b/products/evidence/generated/evidence-request-v1.schema.json @@ -6,13 +6,13 @@ "properties": { "alg": { "enum": [ - "EdDSA" + "ES256" ], "type": "string" }, "crv": { "enum": [ - "Ed25519" + "P-256" ], "type": "string" }, @@ -23,19 +23,24 @@ }, "kty": { "enum": [ - "OKP" + "EC" ], "type": "string" }, "x": { "pattern": "^[A-Za-z0-9_-]{43}$", "type": "string" + }, + "y": { + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" } }, "required": [ "kty", "crv", - "x" + "x", + "y" ], "type": "object" }, diff --git a/products/evidence/generated/flattened-jws-v1.schema.json b/products/evidence/generated/flattened-jws-v1.schema.json index ae86ca959..a5b2a7a99 100644 --- a/products/evidence/generated/flattened-jws-v1.schema.json +++ b/products/evidence/generated/flattened-jws-v1.schema.json @@ -1,5 +1,5 @@ { - "$comment": "Flattened JWS JSON Serialization. The protected header has exactly alg=EdDSA, kid, typ=evidence+jws, and cty=application/evidence+json. The payload is the base64url encoding without padding of exact UTF-8 Evidence JSON bytes.", + "$comment": "Flattened JWS JSON Serialization. The protected header has exactly alg=ES256, an RFC 7638 thumbprint kid, typ=evidence+jws, and cty=application/evidence+json. The payload is the base64url encoding without padding of exact UTF-8 Evidence JSON bytes.", "$id": "https://registrystack.org/schemas/evidence/flattened-jws-v1.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, diff --git a/products/evidence/generated/jwks-v1.schema.json b/products/evidence/generated/jwks-v1.schema.json index d47d1c18e..d635ce555 100644 --- a/products/evidence/generated/jwks-v1.schema.json +++ b/products/evidence/generated/jwks-v1.schema.json @@ -1,27 +1,29 @@ { - "$comment": "Only the active and configured retired public keys are published. Key ids are unique and limited to 256 UTF-8 bytes by the runtime; JSON Schema maxLength is an additional code-point bound. Discovery is not a trust anchor; verifiers pin the governed provider and JWKS location.", + "$comment": "Only the governed active and published non-revoked P-256 keys are returned. Each kid is the key's RFC 7638 SHA-256 thumbprint. Discovery is not a trust anchor; verifiers pin the governed provider and JWKS location.", "$defs": { - "ed25519-public-jwk": { + "p256-public-jwk": { "additionalProperties": false, "properties": { "alg": { - "const": "EdDSA" + "const": "ES256" }, "crv": { - "const": "Ed25519" + "const": "P-256" }, "kid": { - "maxLength": 256, - "minLength": 1, - "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$", + "pattern": "^[A-Za-z0-9_-]{43}$", "type": "string" }, "kty": { - "const": "OKP" + "const": "EC" }, "x": { "pattern": "^[A-Za-z0-9_-]{43}$", "type": "string" + }, + "y": { + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" } }, "required": [ @@ -29,7 +31,8 @@ "kid", "alg", "crv", - "x" + "x", + "y" ], "type": "object" } @@ -40,7 +43,7 @@ "properties": { "keys": { "items": { - "$ref": "#/$defs/ed25519-public-jwk" + "$ref": "#/$defs/p256-public-jwk" }, "maxItems": 33, "minItems": 1, diff --git a/products/evidence/generated/registry-evidence.openapi.json b/products/evidence/generated/registry-evidence.openapi.json index aac70aa74..2476a9419 100644 --- a/products/evidence/generated/registry-evidence.openapi.json +++ b/products/evidence/generated/registry-evidence.openapi.json @@ -28,47 +28,6 @@ ], "type": "object" }, - "Ed25519PublicJwk": { - "additionalProperties": false, - "properties": { - "alg": { - "enum": [ - "EdDSA" - ], - "type": "string" - }, - "crv": { - "enum": [ - "Ed25519" - ], - "type": "string" - }, - "kid": { - "maxLength": 256, - "minLength": 1, - "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$", - "type": "string" - }, - "kty": { - "enum": [ - "OKP" - ], - "type": "string" - }, - "x": { - "pattern": "^[A-Za-z0-9_-]{43}$", - "type": "string" - } - }, - "required": [ - "kty", - "kid", - "alg", - "crv", - "x" - ], - "type": "object" - }, "EntityReferenceValue": { "additionalProperties": false, "properties": { @@ -414,7 +373,7 @@ "properties": { "alg": { "enum": [ - "EdDSA" + "ES256" ], "type": "string" }, @@ -425,9 +384,7 @@ "type": "string" }, "kid": { - "maxLength": 256, - "minLength": 1, - "pattern": "^[^\\u0000-\\u001F\\u007F]+$", + "pattern": "^[A-Za-z0-9_-]{43}$", "type": "string" }, "typ": { @@ -723,13 +680,13 @@ "properties": { "alg": { "enum": [ - "EdDSA" + "ES256" ], "type": "string" }, "crv": { "enum": [ - "Ed25519" + "P-256" ], "type": "string" }, @@ -740,19 +697,24 @@ }, "kty": { "enum": [ - "OKP" + "EC" ], "type": "string" }, "x": { "pattern": "^[A-Za-z0-9_-]{43}$", "type": "string" + }, + "y": { + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" } }, "required": [ "kty", "crv", - "x" + "x", + "y" ], "type": "object" }, @@ -761,7 +723,7 @@ "properties": { "keys": { "items": { - "$ref": "#/components/schemas/Ed25519PublicJwk" + "$ref": "#/components/schemas/P256PublicJwk" }, "maxItems": 33, "minItems": 1, @@ -782,13 +744,59 @@ "maxLength": 512, "type": "string" }, - "jwks": { - "$ref": "#/components/schemas/JwksDocument" + "jwks_uri": { + "format": "uri", + "maxLength": 1024, + "type": "string" } }, "required": [ "issuer", - "jwks" + "jwks_uri" + ], + "type": "object" + }, + "P256PublicJwk": { + "additionalProperties": false, + "properties": { + "alg": { + "enum": [ + "ES256" + ], + "type": "string" + }, + "crv": { + "enum": [ + "P-256" + ], + "type": "string" + }, + "kid": { + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" + }, + "kty": { + "enum": [ + "EC" + ], + "type": "string" + }, + "x": { + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" + }, + "y": { + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" + } + }, + "required": [ + "kty", + "kid", + "alg", + "crv", + "x", + "y" ], "type": "object" }, diff --git a/products/evidence/reference/deployment-targets/README.md b/products/evidence/reference/deployment-targets/README.md new file mode 100644 index 000000000..6cd290085 --- /dev/null +++ b/products/evidence/reference/deployment-targets/README.md @@ -0,0 +1,84 @@ +# Git-managed deployment targets + +This directory is a source-neutral, ready-to-copy configuration-repository +shape. It keeps reviewed Evidence semantics under `shared/evidence-project/` +and every environment's complete deployment bindings under +`environments//`. Private keys, audit masters, provider tokens, auto-auth +credentials, access tokens, live responses, and real identifiers never belong +here. + +The examples deliberately repeat complete environment documents. They use no +overlays, environment branches, symlinks, or runtime substitutions. Replace the +reserved `example.org` identities with controlled endpoints and replace the +example public keys with the exact public projections of independently created +environment keys. Evidence signing, Mint signing, Evidence audit, Mint audit, +subject binding, and client keys must all remain distinct. + +Run `./check-public-key-separation.sh` after replacing keys. It uses Python 3 +and PyYAML to parse client registrations structurally, fingerprints the complete +public material of Ed25519, P-256, and RSA client keys, verifies every +service-key filename and `kid` against the RFC 7638 thumbprint, and rejects +private, malformed, or reused public material across roles or environments. + +```text +shared/ + evidence-project/ +environments/ + local/ + evidence/{governance.yaml,runtime.yaml,public-keys/} + mint/{mint.yaml,clients/,public-keys/} + staging/ + evidence/{governance.yaml,runtime.yaml,public-keys/} + mint/{mint.yaml,clients/,public-keys/} + transit/{proxy-configs/,policies/} + production/ + evidence/{governance.yaml,runtime.yaml,public-keys/} + mint/{mint.yaml,clients/,public-keys/} + transit/{proxy-configs/,policies/} +``` + +For staging or production, copy the reviewed shared project at one source +revision, select `environments//evidence/` as the `evidencectl build` +target, and create a new candidate. `evidencectl build` resolves governed +service public keys from that target's `public-keys/` directory. Build staging +and production separately from the same source revision. Do not promote a +staging candidate by editing its bytes. + +The proxy configurations use a dedicated Unix socket, force the proxy's +auto-auth token, and require the `X-Vault-Request` header Evidence and Mint send. +They explicitly disable provider retries so one application signing attempt is +one Transit signing request. Leave `VAULT_MAX_RETRIES` unset for these workloads +because it overrides the reviewed proxy value. The application timeout remains +the outer bound. +The included Kubernetes auto-auth block is the one deployment-specific part: +replace its server address, CA path, auth mount, role, socket user/group, and +namespace as applicable. Vault Proxy and OpenBao Agent accept the same relevant +listener, auto-auth, and API-proxy shape. Use one proxy identity and one policy +per service. + +ACLs grant read metadata and sign access to one named key. Their required and +allowed parameter constraints admit only the exact signing request shape and +the version pinned by Evidence or Mint. During planned rotation, add the next +numeric version to `allowed_parameters.key_version`, deploy the overlap, then +remove the old version after the token or assertion validity window and +consumer skew have elapsed. Raising the Transit key's +`min_encryption_version` is a second retirement control. Emergency retirement +raises it immediately, removes the public JWK, and deny-lists the thumbprint in +affected consumers. + +Local private JWKs and audit masters are disposable files created outside Git. +`evidencectl new` and `evidencectl dev` remain the normal application-developer +path; the local target documents the generated bindings and is not passed to +the strict deployment compiler. + +Before routing an environment, run `evidencectl doctor`, all Evidence fixtures, +`evidence check`, and `mint check`, then confirm both `/ready` endpoints. A +signer whose controls, pinned version, or public key differ from these governed +files must fail the handoff. + +The Transit API and proxy assumptions follow the provider documentation: + +- [Vault Transit API](https://developer.hashicorp.com/vault/api-docs/secret/transit) +- [Vault Proxy API proxy](https://developer.hashicorp.com/vault/docs/agent-and-proxy/proxy/apiproxy) +- [OpenBao Agent](https://openbao.org/docs/agent-and-proxy/agent/) +- [OpenBao Transit API](https://openbao.org/api-docs/secret/transit/) diff --git a/products/evidence/reference/deployment-targets/check-public-key-separation.sh b/products/evidence/reference/deployment-targets/check-public-key-separation.sh new file mode 100755 index 000000000..b809778bf --- /dev/null +++ b/products/evidence/reference/deployment-targets/check-public-key-separation.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +target_root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec python3 -B "$target_root/key_separation.py" "$target_root" diff --git a/products/evidence/reference/deployment-targets/environments/local/evidence/governance.yaml b/products/evidence/reference/deployment-targets/environments/local/evidence/governance.yaml new file mode 100644 index 000000000..71501196c --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/local/evidence/governance.yaml @@ -0,0 +1,41 @@ +version: 1 +assuranceProfile: local +service: {providerId: urn:example:local:evidence, trustDomain: urn:example:local:trust-domain} +issuer: {id: urn:example:issuer:authority} +authentication: + kind: oidc-access-token + issuer: http://127.0.0.1:8081 + audiences: [evidence.local] + tokenTypes: [at+jwt] + algorithms: [ES256] + jwksUri: http://127.0.0.1:8081/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority + maximumTokenLifetimeSeconds: 300 + revokedKeyIds: [] +audit: {format: keyed-jsonl, hashSecretRef: secret:file/evidence-audit-hmac, hashKeyVersion: 1, failClosed: true} +subjectBinding: {secretRef: secret:file/evidence-subject-binding, keyVersion: 1} +rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} +signing: + format: flattened-jws-json + algorithm: ES256 + activePublicJwkFile: public-keys/-RNgdUjduVCNV-y15KSAVZnF2gNjGb_02KQ2-MoMu4U.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 300 + verifierClockSkewSeconds: 30 +responseFormats: [signed-jws, sd-jwt-vc] +authorityProfiles: + example-requester: + kind: statutory + requesterTags: [example-requester] + grants: + - requirement: urn:example:requirement:answer:v1 + purpose: eligibility + audienceFrom: authenticated-requester + responseFormats: [signed-jws, sd-jwt-vc] + subjects: [{role: subject, selectorProfile: subject-reference-v1, valueOrigin: request}] diff --git a/products/evidence/reference/deployment-targets/environments/local/evidence/public-keys/-RNgdUjduVCNV-y15KSAVZnF2gNjGb_02KQ2-MoMu4U.jwk.json b/products/evidence/reference/deployment-targets/environments/local/evidence/public-keys/-RNgdUjduVCNV-y15KSAVZnF2gNjGb_02KQ2-MoMu4U.jwk.json new file mode 100644 index 000000000..27596f23b --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/local/evidence/public-keys/-RNgdUjduVCNV-y15KSAVZnF2gNjGb_02KQ2-MoMu4U.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"-RNgdUjduVCNV-y15KSAVZnF2gNjGb_02KQ2-MoMu4U","x":"3zUWEuqSgzHbjwNbXbhqJrTd75dHZPNseIbIS4eM5Ks","y":"XSfKJQ1wizjUKFf-WewDor4sPNt7XBQlnpWeiAPLM34"} diff --git a/products/evidence/reference/deployment-targets/environments/local/evidence/runtime.yaml b/products/evidence/reference/deployment-targets/environments/local/evidence/runtime.yaml new file mode 100644 index 000000000..9875b8a9b --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/local/evidence/runtime.yaml @@ -0,0 +1,18 @@ +version: 1 +bundleDirectory: /tmp/registry-evidence-local/bundle +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 16 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 10000 +secretProviders: + file: {root: /tmp/registry-evidence-local/secrets} +signer: + kind: local-jwk + privateKeyRef: secret:file/evidence-signing +auditStorage: {path: /tmp/registry-evidence-local/audit/evidence.jsonl, maximumFileBytes: 1048576} +outboundTls: {systemRoots: true, trustProfiles: {}} diff --git a/products/evidence/reference/deployment-targets/environments/local/mint/clients/example-requester.yaml b/products/evidence/reference/deployment-targets/environments/local/mint/clients/example-requester.yaml new file mode 100644 index 000000000..07c94236e --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/local/mint/clients/example-requester.yaml @@ -0,0 +1,6 @@ +clientId: example-requester-local +principal: urn:example:principal:requester +evidenceAudience: https://relying-party.local.example.org +requesterTags: [example-requester] +keys: + - {kty: OKP, crv: Ed25519, alg: EdDSA, kid: example-client-local-key, x: k61ZMTVQ46byu1FIuIPwG5kqnOl4NLZPPD9dB1zuov0} diff --git a/products/evidence/reference/deployment-targets/environments/local/mint/mint.yaml b/products/evidence/reference/deployment-targets/environments/local/mint/mint.yaml new file mode 100644 index 000000000..710b689b9 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/local/mint/mint.yaml @@ -0,0 +1,22 @@ +version: 1 +validationMode: supervised-local-development +issuer: http://127.0.0.1:8081 +listener: {address: 127.0.0.1, port: 8081} +signing: + algorithm: ES256 + activePublicJwkFile: public-keys/ikcxvSn8zzbLoIRz2qq6D8OyYi7Qi0o-NhZLTPuf3e4.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: {kind: local-jwk, privateKeyRef: secret:file/mint-signing} +secretProviders: + file: {root: /tmp/registry-mint-local/secrets} +audit: {path: /tmp/registry-mint-local/audit/mint.jsonl, maximumFileBytes: 1048576, hashKeyRef: secret:file/mint-audit-hmac, hashKeyVersion: 1} +accessTokens: + audiences: [evidence.local] + lifetimeSeconds: 300 + claims: {principal: sub, requesterTags: evidence_tags, evidenceAudience: evidence_audience, grantId: evidence_grant_id, grantAuthority: evidence_authority, actor: evidence_actor} +clientAssertion: + audience: http://127.0.0.1:8081/token + maximumLifetimeSeconds: 300 + algorithms: [EdDSA, ES256, RS256] +clients: {directory: clients} diff --git a/products/evidence/reference/deployment-targets/environments/local/mint/public-keys/ikcxvSn8zzbLoIRz2qq6D8OyYi7Qi0o-NhZLTPuf3e4.jwk.json b/products/evidence/reference/deployment-targets/environments/local/mint/public-keys/ikcxvSn8zzbLoIRz2qq6D8OyYi7Qi0o-NhZLTPuf3e4.jwk.json new file mode 100644 index 000000000..d3ea34552 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/local/mint/public-keys/ikcxvSn8zzbLoIRz2qq6D8OyYi7Qi0o-NhZLTPuf3e4.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"ikcxvSn8zzbLoIRz2qq6D8OyYi7Qi0o-NhZLTPuf3e4","x":"DRXjgiVzGkQCY0UKKuFj4mAF46l4D_kuwTdWHuMJWnA","y":"OS3dvs7kt24uibavkTM85uBrNOD5Ay27M3ibMFzEfxE"} diff --git a/products/evidence/reference/deployment-targets/environments/production/evidence/governance.yaml b/products/evidence/reference/deployment-targets/environments/production/evidence/governance.yaml new file mode 100644 index 000000000..f52f70859 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/production/evidence/governance.yaml @@ -0,0 +1,41 @@ +version: 1 +assuranceProfile: evidence-grade +service: {providerId: https://evidence.example.org, trustDomain: https://trust.example.org} +issuer: {id: https://authority.example.org} +authentication: + kind: oidc-access-token + issuer: https://mint.example.org + audiences: [evidence.example.org] + tokenTypes: [at+jwt] + algorithms: [ES256] + jwksUri: https://mint.example.org/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority + maximumTokenLifetimeSeconds: 300 + revokedKeyIds: [] +audit: {format: keyed-jsonl, hashSecretRef: secret:file/evidence-audit-hmac, hashKeyVersion: 1, failClosed: true} +subjectBinding: {secretRef: secret:file/evidence-subject-binding, keyVersion: 1} +rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} +signing: + format: flattened-jws-json + algorithm: ES256 + activePublicJwkFile: public-keys/PSmhXYjoKCBO6gCOMNHqMvnnsAC3m0aaLfXZDWJLmd4.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 300 + verifierClockSkewSeconds: 30 +responseFormats: [signed-jws, sd-jwt-vc] +authorityProfiles: + example-requester: + kind: statutory + requesterTags: [example-requester] + grants: + - requirement: urn:example:requirement:answer:v1 + purpose: eligibility + audienceFrom: authenticated-requester + responseFormats: [signed-jws, sd-jwt-vc] + subjects: [{role: subject, selectorProfile: subject-reference-v1, valueOrigin: request}] diff --git a/products/evidence/reference/deployment-targets/environments/production/evidence/public-keys/PSmhXYjoKCBO6gCOMNHqMvnnsAC3m0aaLfXZDWJLmd4.jwk.json b/products/evidence/reference/deployment-targets/environments/production/evidence/public-keys/PSmhXYjoKCBO6gCOMNHqMvnnsAC3m0aaLfXZDWJLmd4.jwk.json new file mode 100644 index 000000000..47edf0a71 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/production/evidence/public-keys/PSmhXYjoKCBO6gCOMNHqMvnnsAC3m0aaLfXZDWJLmd4.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"PSmhXYjoKCBO6gCOMNHqMvnnsAC3m0aaLfXZDWJLmd4","x":"GU78WxoliV0W7d3Fv9pV4J_7IGi5cvZuiqUPuuuRLHc","y":"i3XW7hxtKuo2w5HOyjENWCHbc83DsM-WGP9XJHZJhro"} diff --git a/products/evidence/reference/deployment-targets/environments/production/evidence/runtime.yaml b/products/evidence/reference/deployment-targets/environments/production/evidence/runtime.yaml new file mode 100644 index 000000000..a6b1ff1c7 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/production/evidence/runtime.yaml @@ -0,0 +1,22 @@ +version: 1 +bundleDirectory: /srv/registry-evidence/production/bundle +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: {root: /run/registry-evidence/secrets} +signer: + kind: transit + unixSocketPath: /run/registry-evidence/transit-proxy.sock + mount: transit + keyName: evidence-signing + keyVersion: 12 + timeoutMilliseconds: 2000 +auditStorage: {path: /var/lib/registry-evidence/production/evidence.jsonl, maximumFileBytes: 1073741824} +outboundTls: {systemRoots: true, trustProfiles: {}} diff --git a/products/evidence/reference/deployment-targets/environments/production/mint/clients/example-requester.yaml b/products/evidence/reference/deployment-targets/environments/production/mint/clients/example-requester.yaml new file mode 100644 index 000000000..4e1c24e9b --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/production/mint/clients/example-requester.yaml @@ -0,0 +1,6 @@ +clientId: example-requester-production +principal: urn:example:principal:requester +evidenceAudience: https://relying-party.example.org +requesterTags: [example-requester] +keys: + - {kty: OKP, crv: Ed25519, alg: EdDSA, kid: example-client-production-key, x: TSc3zB5_xvsDEIkPNxz0gsgSsKKHRGwn5eYDdODh_hk} diff --git a/products/evidence/reference/deployment-targets/environments/production/mint/mint.yaml b/products/evidence/reference/deployment-targets/environments/production/mint/mint.yaml new file mode 100644 index 000000000..0c446a14f --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/production/mint/mint.yaml @@ -0,0 +1,28 @@ +version: 1 +validationMode: strict +issuer: https://mint.example.org +listener: {address: 127.0.0.1, port: 8081} +signing: + algorithm: ES256 + activePublicJwkFile: public-keys/5riE1IWzcA-eKljEUHLqODpNCIOMMCdUP6ohajRoX4Q.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: transit + unixSocketPath: /run/registry-mint/transit-proxy.sock + mount: transit + keyName: mint-signing + keyVersion: 9 + timeoutMilliseconds: 2000 +secretProviders: + file: {root: /run/registry-mint/secrets} +audit: {path: /var/lib/registry-mint/production/mint.jsonl, maximumFileBytes: 1073741824, hashKeyRef: secret:file/mint-audit-hmac, hashKeyVersion: 1} +accessTokens: + audiences: [evidence.example.org] + lifetimeSeconds: 300 + claims: {principal: sub, requesterTags: evidence_tags, evidenceAudience: evidence_audience, grantId: evidence_grant_id, grantAuthority: evidence_authority, actor: evidence_actor} +clientAssertion: + audience: https://mint.example.org/token + maximumLifetimeSeconds: 300 + algorithms: [EdDSA, ES256, RS256] +clients: {directory: clients} diff --git a/products/evidence/reference/deployment-targets/environments/production/mint/public-keys/5riE1IWzcA-eKljEUHLqODpNCIOMMCdUP6ohajRoX4Q.jwk.json b/products/evidence/reference/deployment-targets/environments/production/mint/public-keys/5riE1IWzcA-eKljEUHLqODpNCIOMMCdUP6ohajRoX4Q.jwk.json new file mode 100644 index 000000000..def3da962 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/production/mint/public-keys/5riE1IWzcA-eKljEUHLqODpNCIOMMCdUP6ohajRoX4Q.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"5riE1IWzcA-eKljEUHLqODpNCIOMMCdUP6ohajRoX4Q","x":"go40IuCHDiuiQ-tKhnti7CTFt6ql8hfQIahfS-BIbew","y":"I-hpkfIUz0X0UiEV3JVpLHjGnX05qi75xG1fHKxsGVk"} diff --git a/products/evidence/reference/deployment-targets/environments/production/transit/policies/evidence-signing.hcl b/products/evidence/reference/deployment-targets/environments/production/transit/policies/evidence-signing.hcl new file mode 100644 index 000000000..615537e1c --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/production/transit/policies/evidence-signing.hcl @@ -0,0 +1,14 @@ +path "transit/keys/evidence-signing" { + capabilities = ["read"] +} + +path "transit/sign/evidence-signing/sha2-256" { + capabilities = ["update"] + required_parameters = ["input", "key_version", "marshaling_algorithm", "prehashed"] + allowed_parameters = { + "input" = [] + "key_version" = [12] + "marshaling_algorithm" = ["jws"] + "prehashed" = [true] + } +} diff --git a/products/evidence/reference/deployment-targets/environments/production/transit/policies/mint-signing.hcl b/products/evidence/reference/deployment-targets/environments/production/transit/policies/mint-signing.hcl new file mode 100644 index 000000000..85d40b5b2 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/production/transit/policies/mint-signing.hcl @@ -0,0 +1,14 @@ +path "transit/keys/mint-signing" { + capabilities = ["read"] +} + +path "transit/sign/mint-signing/sha2-256" { + capabilities = ["update"] + required_parameters = ["input", "key_version", "marshaling_algorithm", "prehashed"] + allowed_parameters = { + "input" = [] + "key_version" = [9] + "marshaling_algorithm" = ["jws"] + "prehashed" = [true] + } +} diff --git a/products/evidence/reference/deployment-targets/environments/production/transit/proxy-configs/evidence.hcl b/products/evidence/reference/deployment-targets/environments/production/transit/proxy-configs/evidence.hcl new file mode 100644 index 000000000..4430d34ec --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/production/transit/proxy-configs/evidence.hcl @@ -0,0 +1,32 @@ +pid_file = "/run/registry-evidence/transit-proxy.pid" + +vault { + address = "https://vault.example.org:8200" + ca_cert = "/etc/registry-evidence/transit/ca.pem" + retry { + num_retries = -1 + } +} + +auto_auth { + method "kubernetes" { + mount_path = "auth/kubernetes" + config = { + role = "registry-evidence-production" + token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token" + } + } +} + +api_proxy { + use_auto_auth_token = "force" +} + +listener "unix" { + address = "/run/registry-evidence/transit-proxy.sock" + tls_disable = true + socket_mode = "0660" + socket_user = "vault" + socket_group = "registry-evidence" + require_request_header = true +} diff --git a/products/evidence/reference/deployment-targets/environments/production/transit/proxy-configs/mint.hcl b/products/evidence/reference/deployment-targets/environments/production/transit/proxy-configs/mint.hcl new file mode 100644 index 000000000..ce2cc169c --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/production/transit/proxy-configs/mint.hcl @@ -0,0 +1,32 @@ +pid_file = "/run/registry-mint/transit-proxy.pid" + +vault { + address = "https://vault.example.org:8200" + ca_cert = "/etc/registry-mint/transit/ca.pem" + retry { + num_retries = -1 + } +} + +auto_auth { + method "kubernetes" { + mount_path = "auth/kubernetes" + config = { + role = "registry-mint-production" + token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token" + } + } +} + +api_proxy { + use_auto_auth_token = "force" +} + +listener "unix" { + address = "/run/registry-mint/transit-proxy.sock" + tls_disable = true + socket_mode = "0660" + socket_user = "vault" + socket_group = "registry-mint" + require_request_header = true +} diff --git a/products/evidence/reference/deployment-targets/environments/staging/evidence/governance.yaml b/products/evidence/reference/deployment-targets/environments/staging/evidence/governance.yaml new file mode 100644 index 000000000..18f3aeae4 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/staging/evidence/governance.yaml @@ -0,0 +1,41 @@ +version: 1 +assuranceProfile: production +service: {providerId: https://evidence.staging.example.org, trustDomain: https://trust.staging.example.org} +issuer: {id: https://authority.example.org} +authentication: + kind: oidc-access-token + issuer: https://mint.staging.example.org + audiences: [evidence.staging.example.org] + tokenTypes: [at+jwt] + algorithms: [ES256] + jwksUri: https://mint.staging.example.org/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority + maximumTokenLifetimeSeconds: 300 + revokedKeyIds: [] +audit: {format: keyed-jsonl, hashSecretRef: secret:file/evidence-audit-hmac, hashKeyVersion: 1, failClosed: true} +subjectBinding: {secretRef: secret:file/evidence-subject-binding, keyVersion: 1} +rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} +signing: + format: flattened-jws-json + algorithm: ES256 + activePublicJwkFile: public-keys/akFVJQnetT36BQl_Sjom_qEihokiSr_7ysxnQQcMN0Y.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 300 + verifierClockSkewSeconds: 30 +responseFormats: [signed-jws, sd-jwt-vc] +authorityProfiles: + example-requester: + kind: statutory + requesterTags: [example-requester] + grants: + - requirement: urn:example:requirement:answer:v1 + purpose: eligibility + audienceFrom: authenticated-requester + responseFormats: [signed-jws, sd-jwt-vc] + subjects: [{role: subject, selectorProfile: subject-reference-v1, valueOrigin: request}] diff --git a/products/evidence/reference/deployment-targets/environments/staging/evidence/public-keys/akFVJQnetT36BQl_Sjom_qEihokiSr_7ysxnQQcMN0Y.jwk.json b/products/evidence/reference/deployment-targets/environments/staging/evidence/public-keys/akFVJQnetT36BQl_Sjom_qEihokiSr_7ysxnQQcMN0Y.jwk.json new file mode 100644 index 000000000..aca3f7ff2 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/staging/evidence/public-keys/akFVJQnetT36BQl_Sjom_qEihokiSr_7ysxnQQcMN0Y.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"akFVJQnetT36BQl_Sjom_qEihokiSr_7ysxnQQcMN0Y","x":"qcqalh51QVy6YKJyl9P82MiCBXORSRL4WfK2oWwqBW8","y":"Slx3vfabo8XFd8M2qI4A14Bg76Uuj5hMtVa4SZZCezc"} diff --git a/products/evidence/reference/deployment-targets/environments/staging/evidence/runtime.yaml b/products/evidence/reference/deployment-targets/environments/staging/evidence/runtime.yaml new file mode 100644 index 000000000..038a5b6cc --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/staging/evidence/runtime.yaml @@ -0,0 +1,22 @@ +version: 1 +bundleDirectory: /srv/registry-evidence/staging/bundle +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: {root: /run/registry-evidence/secrets} +signer: + kind: transit + unixSocketPath: /run/registry-evidence/transit-proxy.sock + mount: transit + keyName: evidence-signing + keyVersion: 7 + timeoutMilliseconds: 2000 +auditStorage: {path: /var/lib/registry-evidence/staging/evidence.jsonl, maximumFileBytes: 1073741824} +outboundTls: {systemRoots: true, trustProfiles: {}} diff --git a/products/evidence/reference/deployment-targets/environments/staging/mint/clients/example-requester.yaml b/products/evidence/reference/deployment-targets/environments/staging/mint/clients/example-requester.yaml new file mode 100644 index 000000000..b87e9476e --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/staging/mint/clients/example-requester.yaml @@ -0,0 +1,6 @@ +clientId: example-requester-staging +principal: urn:example:principal:requester +evidenceAudience: https://relying-party.staging.example.org +requesterTags: [example-requester] +keys: + - {kty: OKP, crv: Ed25519, alg: EdDSA, kid: example-client-staging-key, x: Sm7nQbtGEU8lau5CDY7OwA5iidN4VwXkyRByi91I3ww} diff --git a/products/evidence/reference/deployment-targets/environments/staging/mint/mint.yaml b/products/evidence/reference/deployment-targets/environments/staging/mint/mint.yaml new file mode 100644 index 000000000..9bafae537 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/staging/mint/mint.yaml @@ -0,0 +1,28 @@ +version: 1 +validationMode: strict +issuer: https://mint.staging.example.org +listener: {address: 127.0.0.1, port: 8081} +signing: + algorithm: ES256 + activePublicJwkFile: public-keys/OpDYNpGmiwmJssZoDA8LScYy-4pym3al9Fow6MMsFA4.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] +signer: + kind: transit + unixSocketPath: /run/registry-mint/transit-proxy.sock + mount: transit + keyName: mint-signing + keyVersion: 5 + timeoutMilliseconds: 2000 +secretProviders: + file: {root: /run/registry-mint/secrets} +audit: {path: /var/lib/registry-mint/staging/mint.jsonl, maximumFileBytes: 1073741824, hashKeyRef: secret:file/mint-audit-hmac, hashKeyVersion: 1} +accessTokens: + audiences: [evidence.staging.example.org] + lifetimeSeconds: 300 + claims: {principal: sub, requesterTags: evidence_tags, evidenceAudience: evidence_audience, grantId: evidence_grant_id, grantAuthority: evidence_authority, actor: evidence_actor} +clientAssertion: + audience: https://mint.staging.example.org/token + maximumLifetimeSeconds: 300 + algorithms: [EdDSA, ES256, RS256] +clients: {directory: clients} diff --git a/products/evidence/reference/deployment-targets/environments/staging/mint/public-keys/OpDYNpGmiwmJssZoDA8LScYy-4pym3al9Fow6MMsFA4.jwk.json b/products/evidence/reference/deployment-targets/environments/staging/mint/public-keys/OpDYNpGmiwmJssZoDA8LScYy-4pym3al9Fow6MMsFA4.jwk.json new file mode 100644 index 000000000..d03a011c1 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/staging/mint/public-keys/OpDYNpGmiwmJssZoDA8LScYy-4pym3al9Fow6MMsFA4.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"OpDYNpGmiwmJssZoDA8LScYy-4pym3al9Fow6MMsFA4","x":"wTS_MckL0oFXqQGmLrHOiULNVRRTZvVt6wm7DEFBpKE","y":"aLHAbRXAbPHwbFh_qF4zefZmnRSiOZmJG3ukpHYELZY"} diff --git a/products/evidence/reference/deployment-targets/environments/staging/transit/policies/evidence-signing.hcl b/products/evidence/reference/deployment-targets/environments/staging/transit/policies/evidence-signing.hcl new file mode 100644 index 000000000..090201958 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/staging/transit/policies/evidence-signing.hcl @@ -0,0 +1,14 @@ +path "transit/keys/evidence-signing" { + capabilities = ["read"] +} + +path "transit/sign/evidence-signing/sha2-256" { + capabilities = ["update"] + required_parameters = ["input", "key_version", "marshaling_algorithm", "prehashed"] + allowed_parameters = { + "input" = [] + "key_version" = [7] + "marshaling_algorithm" = ["jws"] + "prehashed" = [true] + } +} diff --git a/products/evidence/reference/deployment-targets/environments/staging/transit/policies/mint-signing.hcl b/products/evidence/reference/deployment-targets/environments/staging/transit/policies/mint-signing.hcl new file mode 100644 index 000000000..e53abf612 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/staging/transit/policies/mint-signing.hcl @@ -0,0 +1,14 @@ +path "transit/keys/mint-signing" { + capabilities = ["read"] +} + +path "transit/sign/mint-signing/sha2-256" { + capabilities = ["update"] + required_parameters = ["input", "key_version", "marshaling_algorithm", "prehashed"] + allowed_parameters = { + "input" = [] + "key_version" = [5] + "marshaling_algorithm" = ["jws"] + "prehashed" = [true] + } +} diff --git a/products/evidence/reference/deployment-targets/environments/staging/transit/proxy-configs/evidence.hcl b/products/evidence/reference/deployment-targets/environments/staging/transit/proxy-configs/evidence.hcl new file mode 100644 index 000000000..8764bf2d0 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/staging/transit/proxy-configs/evidence.hcl @@ -0,0 +1,32 @@ +pid_file = "/run/registry-evidence/transit-proxy.pid" + +vault { + address = "https://vault.staging.example.org:8200" + ca_cert = "/etc/registry-evidence/transit/ca.pem" + retry { + num_retries = -1 + } +} + +auto_auth { + method "kubernetes" { + mount_path = "auth/kubernetes" + config = { + role = "registry-evidence-staging" + token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token" + } + } +} + +api_proxy { + use_auto_auth_token = "force" +} + +listener "unix" { + address = "/run/registry-evidence/transit-proxy.sock" + tls_disable = true + socket_mode = "0660" + socket_user = "vault" + socket_group = "registry-evidence" + require_request_header = true +} diff --git a/products/evidence/reference/deployment-targets/environments/staging/transit/proxy-configs/mint.hcl b/products/evidence/reference/deployment-targets/environments/staging/transit/proxy-configs/mint.hcl new file mode 100644 index 000000000..426905ac7 --- /dev/null +++ b/products/evidence/reference/deployment-targets/environments/staging/transit/proxy-configs/mint.hcl @@ -0,0 +1,32 @@ +pid_file = "/run/registry-mint/transit-proxy.pid" + +vault { + address = "https://vault.staging.example.org:8200" + ca_cert = "/etc/registry-mint/transit/ca.pem" + retry { + num_retries = -1 + } +} + +auto_auth { + method "kubernetes" { + mount_path = "auth/kubernetes" + config = { + role = "registry-mint-staging" + token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token" + } + } +} + +api_proxy { + use_auto_auth_token = "force" +} + +listener "unix" { + address = "/run/registry-mint/transit-proxy.sock" + tls_disable = true + socket_mode = "0660" + socket_user = "vault" + socket_group = "registry-mint" + require_request_header = true +} diff --git a/products/evidence/reference/deployment-targets/key_separation.py b/products/evidence/reference/deployment-targets/key_separation.py new file mode 100644 index 000000000..a0b8ec089 --- /dev/null +++ b/products/evidence/reference/deployment-targets/key_separation.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""Validate public-key shape and separation in the reference targets.""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import json +import pathlib +import re +import stat +import sys +from collections.abc import Iterable +from typing import Any + +try: + import yaml +except ImportError as error: # pragma: no cover - depends on the operator host + raise SystemExit( + "PyYAML is required to check deployment target client keys" + ) from error + + +MAX_CLIENT_FILE_BYTES = 256 * 1024 +PRIVATE_MEMBERS = frozenset({"d", "p", "q", "dp", "dq", "qi", "k", "oth"}) +BASE64URL = re.compile(r"^[A-Za-z0-9_-]+$") +P256_PRIME = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF +P256_B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B + + +class CheckError(ValueError): + """A target key document violates the checked public-key contract.""" + + +class UniqueKeyLoader(yaml.SafeLoader): + """Safe YAML loader which also rejects duplicate mapping members.""" + + +def _construct_unique_mapping( + loader: UniqueKeyLoader, node: yaml.nodes.MappingNode, deep: bool = False +) -> dict[Any, Any]: + loader.flatten_mapping(node) + result: dict[Any, Any] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + try: + duplicate = key in result + except TypeError as error: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + "found an unhashable mapping key", + key_node.start_mark, + ) from error + if duplicate: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + "found a duplicate mapping key", + key_node.start_mark, + ) + result[key] = loader.construct_object(value_node, deep=deep) + return result + + +UniqueKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping +) + + +def _decode_base64url( + value: Any, member: str, expected_bytes: int | None = None +) -> bytes: + if not isinstance(value, str) or not BASE64URL.fullmatch(value): + raise CheckError(f"{member} is not unpadded base64url") + padding = "=" * (-len(value) % 4) + try: + decoded = base64.b64decode(value + padding, altchars=b"-_", validate=True) + except (binascii.Error, ValueError) as error: + raise CheckError(f"{member} is not unpadded base64url") from error + encoded = base64.urlsafe_b64encode(decoded).rstrip(b"=").decode("ascii") + if encoded != value: + raise CheckError(f"{member} is not canonical base64url") + if expected_bytes is not None and len(decoded) != expected_bytes: + raise CheckError(f"{member} has the wrong public-coordinate length") + return decoded + + +def _required_string(key: dict[str, Any], member: str) -> str: + value = key.get(member) + if not isinstance(value, str) or not value: + raise CheckError(f"{member} must be a non-empty string") + return value + + +def _validate_kid(key: dict[str, Any]) -> str: + kid = _required_string(key, "kid") + if len(kid.encode("utf-8")) > 256 or kid.strip() != kid: + raise CheckError("kid must be 1..=256 non-whitespace bytes") + if any( + ord(character) < 0x20 or 0x7F <= ord(character) <= 0x9F for character in kid + ): + raise CheckError("kid contains a control character") + return kid + + +def _validate_optional_algorithm(key: dict[str, Any], expected: str) -> None: + algorithm = key.get("alg") + if algorithm is not None and algorithm != expected: + raise CheckError(f"alg must be {expected} when present") + + +def public_material(key: Any) -> tuple[str, dict[str, str]]: + """Return kid and complete RFC 7638 public material for a supported client key.""" + if not isinstance(key, dict) or not all(isinstance(member, str) for member in key): + raise CheckError("a client key must be a mapping with string members") + if PRIVATE_MEMBERS.intersection(key): + raise CheckError("client key contains private key material") + kid = _validate_kid(key) + key_type = _required_string(key, "kty") + + if key_type == "OKP": + if key.get("crv") != "Ed25519" or any( + member in key for member in ("y", "n", "e") + ): + raise CheckError("OKP client key must be an Ed25519 public JWK") + _validate_optional_algorithm(key, "EdDSA") + _decode_base64url(key.get("x"), "x", 32) + material = {"crv": "Ed25519", "kty": "OKP", "x": key["x"]} + elif key_type == "EC": + if key.get("crv") != "P-256" or any(member in key for member in ("n", "e")): + raise CheckError("EC client key must be a P-256 public JWK") + _validate_optional_algorithm(key, "ES256") + x_bytes = _decode_base64url(key.get("x"), "x", 32) + y_bytes = _decode_base64url(key.get("y"), "y", 32) + x_coordinate = int.from_bytes(x_bytes, "big") + y_coordinate = int.from_bytes(y_bytes, "big") + if not (x_coordinate < P256_PRIME and y_coordinate < P256_PRIME): + raise CheckError("EC public coordinate is outside P-256") + if ( + pow(y_coordinate, 2, P256_PRIME) + != (pow(x_coordinate, 3, P256_PRIME) - 3 * x_coordinate + P256_B) + % P256_PRIME + ): + raise CheckError("EC public coordinates are not a P-256 point") + material = {"crv": "P-256", "kty": "EC", "x": key["x"], "y": key["y"]} + elif key_type == "RSA": + if any(member in key for member in ("crv", "x", "y")): + raise CheckError("RSA client key contains another key type's members") + _validate_optional_algorithm(key, "RS256") + modulus = _decode_base64url(key.get("n"), "n") + exponent = _decode_base64url(key.get("e"), "e") + if not modulus or modulus[0] == 0 or not exponent or exponent[0] == 0: + raise CheckError( + "RSA public integers must be nonzero and minimally encoded" + ) + exponent_value = int.from_bytes(exponent, "big") + if exponent_value < 3 or exponent_value % 2 == 0: + raise CheckError( + "RSA public exponent must be an odd integer of at least three" + ) + material = {"e": key["e"], "kty": "RSA", "n": key["n"]} + else: + raise CheckError("client key type is not Ed25519, P-256, or RSA") + + return kid, material + + +def material_fingerprint(material: dict[str, str]) -> str: + canonical = json.dumps(material, separators=(",", ":"), sort_keys=True).encode( + "utf-8" + ) + return ( + base64.urlsafe_b64encode(hashlib.sha256(canonical).digest()) + .rstrip(b"=") + .decode() + ) + + +def _read_json(path: pathlib.Path) -> dict[str, Any]: + try: + pairs = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=list) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise CheckError(f"{path}: service JWK is unreadable or malformed") from error + if not isinstance(pairs, list) or any( + not isinstance(pair, tuple) for pair in pairs + ): + raise CheckError(f"{path}: service JWK must be an object") + result: dict[str, Any] = {} + for member, value in pairs: + if member in result: + raise CheckError(f"{path}: service JWK has a duplicate member") + result[member] = value + return result + + +def _read_client(path: pathlib.Path) -> dict[str, Any]: + try: + metadata = path.lstat() + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode): + raise CheckError(f"{path}: client registration is not a regular file") + if metadata.st_size > MAX_CLIENT_FILE_BYTES: + raise CheckError(f"{path}: client registration exceeds its byte limit") + document = yaml.load(path.read_text(encoding="utf-8"), Loader=UniqueKeyLoader) + except CheckError: + raise + except (OSError, UnicodeDecodeError, yaml.YAMLError) as error: + raise CheckError( + f"{path}: client registration YAML is unreadable or malformed" + ) from error + if not isinstance(document, dict): + raise CheckError(f"{path}: client registration must be a mapping") + return document + + +def _remember( + seen: dict[str, pathlib.Path], fingerprint: str, path: pathlib.Path +) -> None: + previous = seen.get(fingerprint) + if previous is not None: + raise CheckError(f"{path}: public key material reused from {previous}") + seen[fingerprint] = path + + +def check_client_file(path: pathlib.Path, seen: dict[str, pathlib.Path]) -> None: + document = _read_client(path) + keys = document.get("keys") + if not isinstance(keys, list) or not 1 <= len(keys) <= 8: + raise CheckError(f"{path}: between one and eight client keys are required") + kids: set[str] = set() + for key in keys: + try: + kid, material = public_material(key) + except CheckError as error: + raise CheckError(f"{path}: {error}") from error + if kid in kids: + raise CheckError( + f"{path}: client key ids must be unique within one registration" + ) + kids.add(kid) + _remember(seen, material_fingerprint(material), path) + + +def _service_keys(path: pathlib.Path) -> Iterable[pathlib.Path]: + keys = sorted(path.glob("*.jwk.json")) + if not keys: + raise CheckError( + f"{path}: at least one governed service public key is required" + ) + return keys + + +def check_targets(root: pathlib.Path) -> None: + environments_root = root / "environments" + try: + environments = sorted( + path for path in environments_root.iterdir() if path.is_dir() + ) + except OSError as error: + raise CheckError("deployment target environments are unavailable") from error + if not environments: + raise CheckError("at least one complete deployment environment is required") + + seen: dict[str, pathlib.Path] = {} + for environment in environments: + for service in ("evidence", "mint"): + public_keys = environment / service / "public-keys" + for path in _service_keys(public_keys): + key = _read_json(path) + if set(key) != {"kty", "crv", "alg", "kid", "x", "y"}: + raise CheckError(f"{path}: service JWK is not exact public ES256") + try: + kid, material = public_material(key) + except CheckError as error: + raise CheckError( + f"{path}: service JWK is invalid: {error}" + ) from error + if (key["kty"], key["crv"], key["alg"]) != ("EC", "P-256", "ES256"): + raise CheckError(f"{path}: service JWK is not EC P-256 ES256") + thumbprint = material_fingerprint(material) + if kid != thumbprint or path.name != f"{thumbprint}.jwk.json": + raise CheckError( + f"{path}: kid or filename is not its RFC 7638 thumbprint" + ) + _remember(seen, thumbprint, path) + + client_directory = environment / "mint" / "clients" + client_files = sorted(client_directory.glob("*.yaml")) + if not client_files: + raise CheckError( + f"{client_directory}: at least one client registration is required" + ) + for path in client_files: + check_client_file(path, seen) + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print("usage: key_separation.py ", file=sys.stderr) + return 2 + try: + check_targets(pathlib.Path(argv[1])) + except CheckError as error: + print(error, file=sys.stderr) + return 1 + print( + "Deployment target service and client public keys are distinct and correctly identified." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/products/evidence/reference/deployment-targets/shared/evidence-project/README.md b/products/evidence/reference/deployment-targets/shared/evidence-project/README.md new file mode 100644 index 000000000..9928c308f --- /dev/null +++ b/products/evidence/reference/deployment-targets/shared/evidence-project/README.md @@ -0,0 +1,11 @@ +# Shared Evidence project + +Place the ordinary editable Evidence question project here: selectors, fixed +sources, adapters, schemas, questions, derivations, codelists, and synthetic +fixtures. Keep environment identities, endpoints, public service keys, Transit +versions, runtime paths, and secret references in the complete environment +targets beside this directory. + +Application developers work here through `evidencectl new` and `evidencectl +dev`. Deployment operators select an environment target only when building and +handing off a candidate. diff --git a/products/evidence/reference/deployment-targets/test_key_separation.py b/products/evidence/reference/deployment-targets/test_key_separation.py new file mode 100644 index 000000000..463b206b9 --- /dev/null +++ b/products/evidence/reference/deployment-targets/test_key_separation.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Focused regression tests for deployment-target client-key parsing.""" + +from __future__ import annotations + +import base64 +import pathlib +import sys +import tempfile +import unittest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +from key_separation import CheckError, check_client_file + + +ED25519_X = "k61ZMTVQ46byu1FIuIPwG5kqnOl4NLZPPD9dB1zuov0" +P256_X = "3zUWEuqSgzHbjwNbXbhqJrTd75dHZPNseIbIS4eM5Ks" +P256_Y = "XSfKJQ1wizjUKFf-WewDor4sPNt7XBQlnpWeiAPLM34" +RSA_N = ( + base64.urlsafe_b64encode(((1 << 1023) + 643).to_bytes(128, "big")) + .rstrip(b"=") + .decode() +) + + +class ClientKeySeparationTests(unittest.TestCase): + def write(self, root: pathlib.Path, name: str, body: str) -> pathlib.Path: + path = root / name + path.write_text(body, encoding="utf-8") + return path + + def test_multiline_ed25519_es256_and_rs256_keys_are_structurally_parsed( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + registration = self.write( + root, + "client.yaml", + f"""clientId: example +keys: + - kty: OKP + crv: Ed25519 + alg: EdDSA + kid: ed-key + x: {ED25519_X} + - kty: EC + crv: P-256 + alg: ES256 + kid: ec-key + x: {P256_X} + y: {P256_Y} + - kty: RSA + alg: RS256 + kid: rsa-key + n: {RSA_N} + e: AQAB +""", + ) + seen: dict[str, pathlib.Path] = {} + check_client_file(registration, seen) + self.assertEqual(len(seen), 3) + + def test_malformed_yaml_and_public_material_are_rejected(self) -> None: + cases = { + "yaml.yaml": "keys: [\n", + "coordinate.yaml": """keys: + - kty: EC + crv: P-256 + alg: ES256 + kid: broken + x: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + y: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +""", + "duplicate-member.yaml": f"""keys: + - kty: OKP + crv: Ed25519 + kid: one + kid: two + x: {ED25519_X} +""", + } + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + for name, body in cases.items(): + with self.subTest(name=name): + with self.assertRaises(CheckError): + check_client_file(self.write(root, name, body), {}) + + def test_private_members_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = self.write( + pathlib.Path(directory), + "private.yaml", + f"""keys: + - kty: OKP + crv: Ed25519 + alg: EdDSA + kid: private + x: {ED25519_X} + d: {ED25519_X} +""", + ) + with self.assertRaisesRegex(CheckError, "private key material"): + check_client_file(path, {}) + + def test_reused_material_is_rejected_across_files_and_within_one_file(self) -> None: + key = f"""kty: OKP + crv: Ed25519 + alg: EdDSA + x: {ED25519_X}""" + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + first = self.write( + root, "first.yaml", f"keys:\n - {key}\n kid: first\n" + ) + second = self.write( + root, "second.yaml", f"keys:\n - {key}\n kid: second\n" + ) + seen: dict[str, pathlib.Path] = {} + check_client_file(first, seen) + with self.assertRaisesRegex(CheckError, "public key material reused"): + check_client_file(second, seen) + + within = self.write( + root, + "within.yaml", + f"keys:\n - {key}\n kid: one\n - {key}\n kid: two\n", + ) + with self.assertRaisesRegex(CheckError, "public key material reused"): + check_client_file(within, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md index 38c768abf..568a1a79c 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md +++ b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md @@ -7,7 +7,8 @@ Evidence starts from two closed, startup-only inputs: 1. `bundle/evidence.yaml` and its referenced bundle files define governed evidence semantics and source authority. 2. `runtime.yaml` binds that bundle to one process, filesystem, listener, audit - destination, secret mount, and local TLS trust files. + destination, secret mount, signer transport and pinned version, and local + TLS trust files. Both inputs are reviewed, validated completely before readiness, mounted read-only, and immutable for the process lifetime. Evidence computes stable @@ -31,7 +32,8 @@ Most adopters should need to edit only: - one derivation script per requirement; - the closed parameter and fact schemas beside those scripts; - sanitized fixtures; -- process-local paths and listener settings in `runtime.yaml`; and +- process-local paths, listener settings, and signer bindings in `runtime.yaml`; + and - secret and private-CA files outside the project. Changing Rust, defining a source-product plugin, or adding a product-specific @@ -122,16 +124,18 @@ artifact, or alternate evaluator is introduced by the assurance profile. | `authentication.requesterTagsClaim` | yes | Claim containing the requester tags matched against an authority profile. | | `authentication.evidenceAudienceClaim` | yes | Claim containing the exact evidence audience. The public request cannot choose another audience. | | `authentication.grantIdClaim`, `authentication.grantAuthorityClaim` | yes | Claims used only when an `authenticated-grant` origin is selected. The authority must equal the matched authority-profile id. | +| `authentication.maximumTokenLifetimeSeconds` | yes | Positive maximum accepted `exp - iat`, up to 86,400 seconds. Its presence requires `iat`, `exp > iat`, and an interval within the maximum. | +| `authentication.revokedKeyIds` | yes | Explicit emergency denylist, including an empty list. It is checked before cached JWKS key selection. | | `authentication.actorClaim` | no | Optional verified actor claim. Omission does not enable a fallback actor source. | ### Audit, subject binding, rates, and signing | Section | Required fields and rule | |---|---| -| `audit` | `format: keyed-jsonl`, file-only `hashSecretRef`, positive `hashKeyVersion`, and `failClosed: true`. The referenced file contains at least 32 raw secret bytes. The runtime file owns storage location. | -| `subjectBinding` | File-only `secretRef` and positive `keyVersion`. The referenced file contains at least 32 raw secret bytes. Rust derives audience-and-purpose-scoped bindings over the complete canonical role/profile/value bundle, never per-field hashes. | +| `audit` | `format: keyed-jsonl`, file-only `hashSecretRef`, positive `hashKeyVersion`, and `failClosed: true`. The referenced master contains at least 32 raw secret bytes. Rust HKDF-separates chain and identifier subkeys. The runtime file owns storage location. | +| `subjectBinding` | File-only `secretRef` and positive `keyVersion`. The referenced master contains at least 32 raw secret bytes, uses a distinct reference, and must resolve to bytes distinct from the audit master. Rust derives audience-and-purpose-scoped bindings over the complete canonical role/profile/value bundle, never per-field hashes. | | `rateLimits` | Positive `requestsPerPrincipalPerMinute`, `burstPerPrincipal`, and `failedSelectorAttemptsPerPrincipalAuthorityPerMinute`. Raw selector values never become rate-limit labels. | -| `signing` | Exact keys are `format: flattened-jws-json`, `algorithm: EdDSA`, `activeKeyId`, file-only `activeKeyRef`, `retiredPublicJwkFiles`, fixed `jwksPath`, `maximumAssertionValiditySeconds`, and `verifierClockSkewSeconds`. Missing signing material fails readiness; there is no unsigned fallback. | +| `signing` | Exact keys are `format: flattened-jws-json`, `algorithm: ES256`, `activePublicJwkFile`, `publishedPublicJwkFiles`, `revokedKeyIds`, fixed `jwksPath`, `maximumAssertionValiditySeconds`, and `verifierClockSkewSeconds`. Every exact public EC P-256 JWK has a 43-character RFC 7638 thumbprint `kid`; active, published, and revoked sets are disjoint. Missing signing material fails readiness; there is no unsigned fallback. | | `responseFormats` | Closed unique list of 1 through 3 entries drawn from `signed-jws`, `unsigned-json`, and `sd-jwt-vc`. `signed-jws` must always be present; a bundle that omits it is rejected at startup. Every other format additionally requires the matched grant to permit it, and signing material must still be ready even for an unsigned response. | ### Selector profiles @@ -354,8 +358,8 @@ header with `typ: at+jwt`; `application/at+jwt` requires that exact alternative. A sanitized shape for the reference projects is: ```json -{"alg":"EdDSA","kid":"deployment-key-id","typ":"at+jwt"} -{"iss":"https://identity.example","aud":"registry-evidence","exp":2000000000,"sub":"service-client","evidence_tags":["approved-requester"],"evidence_audience":"https://consumer.example"} +{"alg":"ES256","kid":"issuer-owned-key-id","typ":"at+jwt"} +{"iss":"https://identity.example","aud":"registry-evidence","iat":1999999700,"exp":2000000000,"sub":"service-client","evidence_tags":["approved-requester"],"evidence_audience":"https://consumer.example"} ``` These are decoded shapes, not usable tokens. The configured issuer, audience, @@ -500,9 +504,9 @@ allowed_outputs: [REGION-NORTH, REGION-SOUTH] Each document has 1 through 4,096 unique bounded codes. A mapping output must appear in `allowed_outputs`. Referencing configuration repeats the exact -artifact version and startup rejects a mismatch. Retired public keys live only -under `public-keys/` as public JWK JSON files; active private key material is a -secret and never a bundle artifact. +artifact version and startup rejects a mismatch. Active and temporarily +published service keys live under `public-keys/` as exact public P-256 JWK JSON +files. Private signing material is never a bundle artifact. ## Runtime configuration @@ -523,6 +527,13 @@ listener: secretProviders: file: root: /run/secrets/registry-evidence +signer: + kind: transit + unixSocketPath: /run/registry-evidence/transit-proxy.sock + mount: transit + keyName: evidence-signing + keyVersion: 7 + timeoutMilliseconds: 2000 auditStorage: path: /var/lib/registry-evidence/audit/evidence.jsonl maximumFileBytes: 1073741824 @@ -546,6 +557,7 @@ outboundTls: | `listener.requestTimeoutMilliseconds` | yes | 1 through 30,000 milliseconds for admission, concurrency queueing, and request-body collection. Once protected evaluation starts, this timer does not cancel it; source and OIDC boundaries have their own bounds, and the runtime preserves fail-closed audit and release ordering. | | `listener.shutdownGraceMilliseconds` | yes | 1 through 120,000 milliseconds. | | `secretProviders.file.root` | yes | Absolute root for logical `secret:file/...` references. Only regular, non-symlink, owner-only files below this root are accepted. | +| `signer` | yes | Closed runtime signer union. `production` and `evidence-grade` require a pinned Transit signer over a workload-local Unix socket. `local` requires `kind: local-jwk` with `privateKeyRef: secret:file/evidence-signing`. Startup validates provider controls and exact public-key agreement, then signs and verifies a challenge. | | `auditStorage.path` | yes | Absolute keyed-JSONL audit path on operator-owned durable storage. | | `auditStorage.maximumFileBytes` | yes | 1,048,576 through 1,099,511,627,776 bytes. Reaching the closed bound fails audit writes and therefore fails closed. | | `outboundTls.systemRoots` | yes | Literal `true`. | @@ -580,8 +592,8 @@ credentials to another authority. - Prefer literal map/array traversal. Dots in provider keys are literal. If a deployment must parameterize a nested path, pass a bounded array of literal segments and implement a bounded same-file helper. -- Keep one governed bundle per evidence policy revision and one runtime file per - environment. Never use environment variables or command arguments to +- Keep one complete governed bundle and runtime target per environment. Never + use overlays, symlinks, environment variables, or command arguments to override governed fields. - Run every fixture before accepting either input and again before deploying a changed bundle, runtime file, script, schema, codelist, CA file, or secret @@ -591,7 +603,10 @@ credentials to another authority. Treat an editable project like reviewed source code. `evidencectl new` creates empty `selectors/`, `sources/`, `adapters/`, `schemas/`, `questions/`, -`derivations/`, and `fixtures/` directories. It creates no deployment input. +`derivations/`, and `fixtures/` directories plus owner-only disposable local +P-256 Evidence signing material and distinct audit and subject-binding masters. +It creates no deployment input. `evidencectl dev` creates session-scoped P-256 +Mint, caller, and holder keys automatically. While authoring, use only synthetic responses and selectors. Add the smallest provider-shaped `prepare/2`, `extract/2`, and requirement `derive/3` scripts, then add exact positive, legitimate-false, boundary, unresolved, @@ -605,36 +620,41 @@ block, stable concept identifiers, and exactly one project-relative invents requirement, framework, Evidence Type, concept, or disclosure-family URIs. -Create one explicit `deployment-targets/production/` directory containing -`governance.yaml` and `runtime.yaml`. `governance.yaml` is closed, has -`version: 1` and `assuranceProfile: production`, and supplies the existing -bundle-shaped service, issuer, authentication, audit, subject-binding, -rate-limit, signing, response-format, and authority-profile values. It may not -contain selectors, sources, or requirements, which the compiler obtains from -the editable project. It permits logical `secret:file/` references only, -never secret values or absolute secret paths. `runtime.yaml` is the ordinary -closed runtime document; the build copies its bytes unchanged and the target -host remains authoritative for path, owner, permission, secret, and private-CA -validation. +Create explicit `deployment-targets//` directories containing +complete `governance.yaml` and `runtime.yaml` documents plus every governed +public JWK referenced by governance under `public-keys/`. `governance.yaml` is +closed, has `version: 1`, and supplies the existing bundle-shaped service, +issuer, authentication, audit, subject-binding, rate-limit, signing, +response-format, and authority-profile values. It may not contain selectors, +sources, or requirements, which the compiler obtains from the editable +project. It permits logical `secret:file/` references only, never secret +values or absolute secret paths. `runtime.yaml` is the ordinary closed runtime +document; the build copies its bytes unchanged and the target host remains +authoritative for path, owner, permission, secret, private-CA, and Transit +validation. Staging and production are separate complete targets built from +the same reviewed source revision. They are not overlays and do not inherit +from each other. Ready-to-copy layouts and Transit proxy-policy examples are +under [`../../deployment-targets/`](../../deployment-targets/). Run the create-only compiler with explicit target and output paths: ```sh evidencectl build \ --project \ - --target /deployment-targets/production \ + --target /deployment-targets/ \ --output ``` The compiler follows no authored symlink, accepts no reference outside allowed project directories, rejects unreferenced generated artifacts, and removes only its own failed private staging. It requires authenticated HTTPS sources, -complete authority, resolved review markers, complete governance, and complete -fixtures. It validates the unpublished bundle with private temporary secrets -through the real `evidence` binary, runs every fixture, and publishes nothing -on failure. It makes no identity-provider, source-data, or Mint call; opens no -listener; and writes no production audit event. The editable project and -`.evidence` local state remain unchanged. +complete authority, resolved review markers, complete governance, governed +public keys, and complete fixtures. It delegates its internal bundle-only check +and every fixture to the real `evidence` binary without generating a temporary +signing key or other validation secret, and publishes nothing on failure. It +makes no identity-provider, source-data, or Mint call; opens no listener; and +writes no production audit event. The editable project and `.evidence` local +state remain unchanged. The candidate contains `runtime.yaml` and `bundle/`, including only referenced adapters, derivations, schemas, codelists, fixtures, and public keys. Given diff --git a/products/evidence/reference/request-adapter/deployment-projects/FIXTURES.md b/products/evidence/reference/request-adapter/deployment-projects/FIXTURES.md index ae79b3fdb..a6d721373 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/FIXTURES.md +++ b/products/evidence/reference/request-adapter/deployment-projects/FIXTURES.md @@ -154,7 +154,7 @@ those stages apply. Omission is not treated as a wildcard. `expectedTransport` is accepted only for the `selectorOverrides` form. Successful `response` cases require `lookup: match`, `derivationRuns: true`, -and `signed: true`. The harness creates a fresh in-memory Ed25519 key for the +and `signed: true`. The harness creates a fresh in-memory P-256 key for the evaluation, signs the constructed Evidence, and verifies the JWS and exact payload policy. The private key is never read from deployment secrets, written to disk, or included in output. Unresolved and failing cases require diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/README.md b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/README.md index d0efab66b..1ad5d3e2c 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/README.md +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/README.md @@ -102,7 +102,6 @@ Required secret files beneath `/run/secrets/registry-evidence`, each owned by the service identity with mode `0600`, are: ```text -signing-ed25519-private-jwk audit-hmac-key subject-binding-hmac-key dhis2-username @@ -114,13 +113,15 @@ licence programme is served by a separate DHIS2 instance or a separate service account gives that source its own `baseUrl` and its own secret references. The audit and subject-binding files must contain independently generated raw -key material of at least 32 bytes each; they are not base64-decoded. The -signing file contains one private Ed25519 JWK. No secret value is stored in -this project. +key material of at least 32 bytes each; they are not base64-decoded. Production +signing uses the pinned P-256 version in Transit through the workload-local +Unix-socket proxy. Evidence receives no provider token or private signing key. +No secret value is stored in this project. Author with synthetic fixtures first, then promote the same reviewed `bundle/` bytes through staging and production. Bind environment-specific runtime paths, -credentials, private CA, and signing key in each environment. Staging must +credentials, private CA, public signing key, and pinned Transit version in each +environment. Staging must verify the configured `at+jwt` header and claims, readiness, one approved synthetic source lookup per requirement, audit durability, and JWS verification. See the diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml index 12178e6d9..9d2ad8145 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml @@ -10,13 +10,15 @@ authentication: issuer: https://identity.gov.example audiences: [registry-evidence] tokenTypes: [at+jwt] - algorithms: [EdDSA] + algorithms: [ES256] jwksUri: https://identity.gov.example/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience grantIdClaim: evidence_grant_id grantAuthorityClaim: evidence_authority + maximumTokenLifetimeSeconds: 300 + revokedKeyIds: [] audit: format: keyed-jsonl hashSecretRef: secret:file/audit-hmac-key @@ -31,12 +33,12 @@ rateLimits: failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10 signing: format: flattened-jws-json - algorithm: EdDSA - activeKeyId: evidence-signing-2026-01 - activeKeyRef: secret:file/signing-ed25519-private-jwk - retiredPublicJwkFiles: [] + algorithm: ES256 + activePublicJwkFile: public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] jwksPath: /.well-known/evidence/jwks.json - maximumAssertionValiditySeconds: 86400 + maximumAssertionValiditySeconds: 300 verifierClockSkewSeconds: 30 responseFormats: [signed-jws] @@ -200,7 +202,7 @@ requirements: referenceFrameworks: [urn:gov:example:framework:age-of-majority:v1] evidenceType: urn:gov:example:evidence-type:adult-status:v1 observationTimezone: Asia/Bangkok - validitySeconds: 86400 + validitySeconds: 300 derivation: script: derivations/adult-status.rhai selectorInputs: @@ -229,7 +231,7 @@ requirements: referenceFrameworks: [urn:gov:example:framework:professional-practice-licence:v1] evidenceType: urn:gov:example:evidence-type:professional-licence-status:v1 observationTimezone: Asia/Bangkok - validitySeconds: 43200 + validitySeconds: 300 derivation: script: derivations/professional-licence.rhai selectorInputs: diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json new file mode 100644 index 000000000..423462926 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"} diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/runtime.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/runtime.yaml index 0bb1dd4c2..fab6f7332 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/runtime.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/runtime.yaml @@ -12,6 +12,13 @@ listener: secretProviders: file: root: /run/secrets/registry-evidence +signer: + kind: transit + unixSocketPath: /run/registry-evidence/transit-proxy.sock + mount: transit + keyName: evidence-signing + keyVersion: 7 + timeoutMilliseconds: 2000 auditStorage: path: /var/lib/registry-evidence/audit/evidence.jsonl maximumFileBytes: 1073741824 diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/README.md b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/README.md index dea7589b5..4c66da9a0 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/README.md +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/README.md @@ -106,7 +106,6 @@ Required secret files beneath `/run/secrets/registry-evidence`, each owned by the service identity with mode `0600`, are: ```text -signing-ed25519-private-jwk audit-hmac-key subject-binding-hmac-key opencrvs-client-id @@ -114,13 +113,15 @@ opencrvs-client-secret ``` The audit and subject-binding files must contain independently generated raw -key material of at least 32 bytes each; they are not base64-decoded. The -signing file contains one private Ed25519 JWK. No credential or live subject -identifier is stored in this project. +key material of at least 32 bytes each; they are not base64-decoded. Production +signing uses the pinned P-256 version in Transit through the workload-local +Unix-socket proxy. Evidence receives no provider token or private signing key. +No credential or live subject identifier is stored in this project. Author with synthetic fixtures first, then promote the same reviewed `bundle/` bytes through staging and production. Bind environment-specific runtime paths, -credentials, private CA, and signing key in each environment. Staging must +credentials, private CA, public signing key, and pinned Transit version in each +environment. Staging must verify the configured `at+jwt` header and claims, OAuth bootstrap, readiness, one approved synthetic source lookup, audit durability, and JWS verification. See the [authoring and production-build workflow](../CONFIG.md#authoring-and-production-build-workflow). diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml index e3ab68c52..f0577c323 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml @@ -10,13 +10,15 @@ authentication: issuer: https://identity.gov.example audiences: [registry-evidence] tokenTypes: [at+jwt] - algorithms: [EdDSA] + algorithms: [ES256] jwksUri: https://identity.gov.example/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience grantIdClaim: evidence_grant_id grantAuthorityClaim: evidence_authority + maximumTokenLifetimeSeconds: 300 + revokedKeyIds: [] audit: format: keyed-jsonl hashSecretRef: secret:file/audit-hmac-key @@ -31,12 +33,12 @@ rateLimits: failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10 signing: format: flattened-jws-json - algorithm: EdDSA - activeKeyId: evidence-signing-2026-01 - activeKeyRef: secret:file/signing-ed25519-private-jwk - retiredPublicJwkFiles: [] + algorithm: ES256 + activePublicJwkFile: public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] jwksPath: /.well-known/evidence/jwks.json - maximumAssertionValiditySeconds: 86400 + maximumAssertionValiditySeconds: 300 verifierClockSkewSeconds: 30 responseFormats: [signed-jws] @@ -227,7 +229,7 @@ requirements: referenceFrameworks: [urn:gov:example:framework:age-of-majority:v1] evidenceType: urn:gov:example:evidence-type:adult-status:v1 observationTimezone: Asia/Bangkok - validitySeconds: 86400 + validitySeconds: 300 derivation: script: derivations/adult-status.rhai selectorInputs: @@ -258,7 +260,7 @@ requirements: selectorProfiles: [civil-person-reference-v1] referenceFrameworks: [urn:gov:example:framework:civil-registration-parent-record:v1] evidenceType: urn:gov:example:evidence-type:registered-parent-relationship:v1 - validitySeconds: 86400 + validitySeconds: 300 derivation: script: derivations/registered-parent-relationship.rhai selectorInputs: @@ -293,7 +295,7 @@ requirements: selectorProfiles: [opencrvs-tracking-id-v1] referenceFrameworks: [urn:gov:example:framework:civil-registration-parent-record:v1] evidenceType: urn:gov:example:evidence-type:registered-parent-references:v1 - validitySeconds: 86400 + validitySeconds: 300 derivation: script: derivations/registered-parent-references.rhai selectorInputs: diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json new file mode 100644 index 000000000..423462926 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"} diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/runtime.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/runtime.yaml index c50586401..2a1ae2703 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/runtime.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/runtime.yaml @@ -12,6 +12,13 @@ listener: secretProviders: file: root: /run/secrets/registry-evidence +signer: + kind: transit + unixSocketPath: /run/registry-evidence/transit-proxy.sock + mount: transit + keyName: evidence-signing + keyVersion: 7 + timeoutMilliseconds: 2000 auditStorage: path: /var/lib/registry-evidence/audit/evidence.jsonl maximumFileBytes: 1073741824 diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/README.md b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/README.md index 2b406ee38..efec745cf 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/README.md +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/README.md @@ -104,7 +104,6 @@ Required secret files beneath `/run/secrets/registry-evidence`, each owned by the service identity with mode `0600`, are: ```text -signing-ed25519-private-jwk audit-hmac-key subject-binding-hmac-key registry-api-client-id @@ -112,13 +111,15 @@ registry-api-client-secret ``` The audit and subject-binding files must contain independently generated raw -key material of at least 32 bytes each; they are not base64-decoded. The -signing file contains one private Ed25519 JWK. No secret value is stored in -this project. +key material of at least 32 bytes each; they are not base64-decoded. Production +signing uses the pinned P-256 version in Transit through the workload-local +Unix-socket proxy. Evidence receives no provider token or private signing key. +No secret value is stored in this project. Author with synthetic fixtures first, then promote the same reviewed `bundle/` bytes through staging and production. Bind environment-specific runtime paths, -credentials, and signing key in each environment. Staging must verify the +credentials, public signing key, and pinned Transit version in each environment. +Staging must verify the configured `at+jwt` header and claims, readiness, one approved synthetic source lookup, audit durability, and JWS verification. See the [authoring and production-build workflow](../CONFIG.md#authoring-and-production-build-workflow). diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/evidence.yaml index 2569af2e6..e15be1a38 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/evidence.yaml @@ -15,13 +15,15 @@ authentication: issuer: https://tokens.gov.example audiences: [registry-evidence] tokenTypes: [at+jwt] - algorithms: [EdDSA] + algorithms: [ES256] jwksUri: https://tokens.gov.example/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience grantIdClaim: evidence_grant_id grantAuthorityClaim: evidence_authority + maximumTokenLifetimeSeconds: 300 + revokedKeyIds: [] audit: format: keyed-jsonl hashSecretRef: secret:file/audit-hmac-key @@ -36,12 +38,12 @@ rateLimits: failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10 signing: format: flattened-jws-json - algorithm: EdDSA - activeKeyId: evidence-signing-2026-01 - activeKeyRef: secret:file/signing-ed25519-private-jwk - retiredPublicJwkFiles: [] + algorithm: ES256 + activePublicJwkFile: public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json + publishedPublicJwkFiles: [] + revokedKeyIds: [] jwksPath: /.well-known/evidence/jwks.json - maximumAssertionValiditySeconds: 86400 + maximumAssertionValiditySeconds: 300 verifierClockSkewSeconds: 30 responseFormats: [signed-jws] @@ -132,7 +134,7 @@ requirements: selectorProfiles: [residence-record-v1] referenceFrameworks: [urn:gov:example:framework:residence-region:v1] evidenceType: urn:gov:example:evidence-type:residence-region:v1 - validitySeconds: 86400 + validitySeconds: 300 derivation: script: derivations/residence-region.rhai selectorInputs: diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json new file mode 100644 index 000000000..423462926 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"} diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/runtime.yaml b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/runtime.yaml index 6d1cf4e77..0a681d56b 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/runtime.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/runtime.yaml @@ -12,6 +12,13 @@ listener: secretProviders: file: root: /run/secrets/registry-evidence +signer: + kind: transit + unixSocketPath: /run/registry-evidence/transit-proxy.sock + mount: transit + keyName: evidence-signing + keyVersion: 7 + timeoutMilliseconds: 2000 auditStorage: path: /var/lib/registry-evidence/audit/evidence.jsonl maximumFileBytes: 1073741824 diff --git a/products/evidence/scripts/compat/README.md b/products/evidence/scripts/compat/README.md new file mode 100644 index 000000000..f0e17909d --- /dev/null +++ b/products/evidence/scripts/compat/README.md @@ -0,0 +1,52 @@ +# Opt-in wallet verifier compatibility harnesses + +These harnesses are future-readiness checks, not wallet integrations and not +interoperability claims. They pin one upstream source tag and commit, then +require an operator-supplied adapter built from that exact upstream revision to +verify a stored Evidence SD-JWT VC against a pinned Evidence JWKS. + +The adapter protocol is intentionally small: + +```text + --registry-stack-version + verify --credential --jwks +``` + +The first command prints the exact expected version line shown by a failing +harness. The verify command exits zero only after the named third-party library +has parsed the complete credential, selected the supplied JWK by `kid`, and +verified the ES256 issuer signature and SD-JWT disclosures. It must not replace +that result with Registry Stack's own verifier. + +Each harness verifies the original, mutates the issuer signature, and requires +the same adapter to reject the mutation. A missing adapter, an upstream tag that +does not resolve to the pinned commit, acceptance of the mutated signature, or +any third-party limitation is a failure. This is why CI does not run these +networked checks by default and why the repository makes no compatibility +claim merely because the harness exists. + +Run after producing a credential and trusted JWKS with the local demo: + +```sh +EVIDENCE_WALLET_COMPAT=1 \ +WALTID_SD_JWT_VERIFY=/absolute/path/to/pinned-waltid-adapter \ +products/evidence/scripts/compat/waltid-sd-jwt-vc.sh \ + products/evidence/.sd-jwt-vc-demo/credential.txt \ + products/evidence/.sd-jwt-vc-demo/trusted.jwks.json + +EVIDENCE_WALLET_COMPAT=1 \ +INJI_SD_JWT_VERIFY=/absolute/path/to/pinned-inji-adapter \ +products/evidence/scripts/compat/inji-sd-jwt-vc.sh \ + products/evidence/.sd-jwt-vc-demo/credential.txt \ + products/evidence/.sd-jwt-vc-demo/trusted.jwks.json +``` + +The current pins are: + +- walt.id identity `v0.23.0`, commit + `ba72e32fb5aea2affc1315dfa8471c4ea0384ef6` +- MOSIP Inji VC verifier `v1.9.0`, commit + `cd5a1d79aa511922a787c7def797e50b2fb13c30` + +Pin updates are reviewed compatibility-profile changes. Do not float a branch, +tag, Maven version, container tag, or dependency range inside an adapter. diff --git a/products/evidence/scripts/compat/inji-sd-jwt-vc.sh b/products/evidence/scripts/compat/inji-sd-jwt-vc.sh new file mode 100755 index 000000000..e576edbd4 --- /dev/null +++ b/products/evidence/scripts/compat/inji-sd-jwt-vc.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_directory=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec "$script_directory/wallet-verifier-harness.sh" \ + mosip/vc-verifier \ + https://github.com/mosip/vc-verifier.git \ + v1.9.0 \ + cd5a1d79aa511922a787c7def797e50b2fb13c30 \ + INJI_SD_JWT_VERIFY \ + "$@" diff --git a/products/evidence/scripts/compat/wallet-verifier-harness.sh b/products/evidence/scripts/compat/wallet-verifier-harness.sh new file mode 100755 index 000000000..fc0b89a48 --- /dev/null +++ b/products/evidence/scripts/compat/wallet-verifier-harness.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ${EVIDENCE_WALLET_COMPAT:-0} != 1 ]]; then + printf 'SKIP: set EVIDENCE_WALLET_COMPAT=1 to run the opt-in compatibility harness.\n' + exit 0 +fi + +if (( $# != 7 )); then + printf 'internal usage: wallet-verifier-harness \n' >&2 + exit 2 +fi + +name=$1 +repository=$2 +tag=$3 +pinned_commit=$4 +adapter_environment=$5 +credential=$6 +jwks=$7 + +for tool in git python3; do + command -v "$tool" >/dev/null 2>&1 || { + printf '%s compatibility needs %s on PATH.\n' "$name" "$tool" >&2 + exit 1 + } +done +[[ -f $credential && -f $jwks ]] || { + printf '%s compatibility needs readable credential and JWKS files.\n' "$name" >&2 + exit 1 +} + +adapter=${!adapter_environment:-} +[[ -n $adapter && -x $adapter ]] || { + printf '%s must name an executable adapter in %s.\n' "$name" "$adapter_environment" >&2 + exit 1 +} + +resolved=$(git ls-remote --tags --refs "$repository" "refs/tags/$tag" | awk 'NR == 1 {print $1}') +[[ $resolved == "$pinned_commit" ]] || { + printf '%s upstream tag did not resolve to the reviewed commit.\n' "$name" >&2 + exit 1 +} + +expected_version="$name $tag $pinned_commit" +actual_version=$($adapter --registry-stack-version) +[[ $actual_version == "$expected_version" ]] || { + printf 'Adapter version mismatch. Expected exactly: %s\n' "$expected_version" >&2 + exit 1 +} + +"$adapter" verify --credential "$credential" --jwks "$jwks" + +compatibility_directory=$(mktemp -d) +tampered="$compatibility_directory/tampered.sd-jwt-vc" +trap 'rm -rf -- "$compatibility_directory"' EXIT HUP INT TERM +python3 - "$credential" "$tampered" <<'PY' +import pathlib +import sys + +source = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").strip() +parts = source.split("~") +jwt = parts[0].split(".") +if len(jwt) != 3 or not jwt[2]: + raise SystemExit("credential does not contain an issuer signature") +replacement = "A" if jwt[2][0] != "A" else "B" +jwt[2] = replacement + jwt[2][1:] +parts[0] = ".".join(jwt) +pathlib.Path(sys.argv[2]).write_text("~".join(parts), encoding="utf-8") +PY + +if "$adapter" verify --credential "$tampered" --jwks "$jwks" >/dev/null 2>&1; then + printf '%s accepted a mutated issuer signature. No compatibility claim is permitted.\n' "$name" >&2 + exit 1 +fi + +printf 'PASS: %s performed full third-party verification and rejected a mutated issuer signature.\n' "$name" diff --git a/products/evidence/scripts/compat/waltid-sd-jwt-vc.sh b/products/evidence/scripts/compat/waltid-sd-jwt-vc.sh new file mode 100755 index 000000000..d42f80c82 --- /dev/null +++ b/products/evidence/scripts/compat/waltid-sd-jwt-vc.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_directory=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec "$script_directory/wallet-verifier-harness.sh" \ + walt-id/waltid-identity \ + https://github.com/walt-id/waltid-identity.git \ + v0.23.0 \ + ba72e32fb5aea2affc1315dfa8471c4ea0384ef6 \ + WALTID_SD_JWT_VERIFY \ + "$@" diff --git a/products/evidence/scripts/sd-jwt-vc-demo.sh b/products/evidence/scripts/sd-jwt-vc-demo.sh index c7a86b4ed..0f701990e 100755 --- a/products/evidence/scripts/sd-jwt-vc-demo.sh +++ b/products/evidence/scripts/sd-jwt-vc-demo.sh @@ -7,8 +7,8 @@ # outside products/evidence/.sd-jwt-vc-demo/. # # The steps are the ones a relying party actually performs: request the signed -# default, request the same assertion as an SD-JWT VC, fetch the issuer's keys -# from the published metadata route, and re-verify the stored credential +# default, request the same assertion as an SD-JWT VC, fetch the issuer metadata +# and governed keys, and re-verify the stored credential # offline against a policy built from the accepted transaction. set -euo pipefail @@ -143,7 +143,7 @@ set -a . "$state_root/session.env" set +a -printf '1. Fetch the issuer keys from the published metadata route (no token)\n' +printf '1. Fetch the exact issuer metadata and its governed key set (no token)\n' run_curl "$( cat <"$state_root/trusted.jwks.json" +expected_issuer='urn:example:fixture:provider:evidence' +expected_jwks_uri="$expected_issuer/.well-known/evidence/jwks.json" +jq -e --arg issuer "$expected_issuer" --arg jwks_uri "$expected_jwks_uri" \ + '. == {issuer: $issuer, jwks_uri: $jwks_uri}' \ + "$state_root/issuer-metadata.json" >/dev/null printf ' issuer: %s\n' "$(jq -r .issuer "$state_root/issuer-metadata.json")" +printf ' jwks_uri: %s\n' "$(jq -r .jwks_uri "$state_root/issuer-metadata.json")" +run_curl "$( + cat <