From e065c111313b95dd82bdee4000d118d3a4c7359b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 18 Jul 2026 21:18:06 +0700 Subject: [PATCH 01/65] feat(notary): add generated config schema (#415) Signed-off-by: Jeremi Joslin --- Cargo.lock | 2 + crates/registry-notary-core/Cargo.toml | 1 + crates/registry-notary-core/src/config.rs | 2 + .../registry-notary-core/src/config/audit.rs | 2 +- .../registry-notary-core/src/config/auth.rs | 12 +- crates/registry-notary-core/src/config/cel.rs | 2 +- .../src/config/credential_status.rs | 2 +- .../src/config/evidence/claims.rs | 68 +- .../src/config/evidence/disclosure.rs | 4 +- .../src/config/evidence/limits.rs | 4 +- .../src/config/evidence/mod.rs | 2 +- .../src/config/evidence/relay.rs | 3 +- .../src/config/evidence/signing.rs | 8 +- .../src/config/federation.rs | 14 +- .../registry-notary-core/src/config/http.rs | 11 +- .../src/config/oid4vci.rs | 24 +- .../registry-notary-core/src/config/root.rs | 6 +- .../registry-notary-core/src/config/schema.rs | 174 + .../registry-notary-core/src/config/state.rs | 4 +- .../src/config/subject_access.rs | 26 +- crates/registry-notary-core/src/deployment.rs | 13 +- crates/registry-notary-core/src/model.rs | 59 +- crates/registry-notary/Cargo.toml | 1 + crates/registry-notary/README.md | 14 +- .../src/commands/healthcheck.rs | 20 - .../src/commands/healthcheck/tests.rs | 8 - crates/registry-notary/src/main.rs | 4 +- crates/registry-notary/tests/config_schema.rs | 579 +++ .../registry-platform-authcommon/src/lib.rs | 60 +- crates/registry-platform-crypto/src/lib.rs | 95 +- products/notary/CHANGELOG.md | 8 + .../notary/docs/operator-config-reference.md | 549 +++ products/notary/justfile | 10 +- schemas/registry-notary.config.schema.json | 3161 +++++++++++++++++ 34 files changed, 4751 insertions(+), 201 deletions(-) create mode 100644 crates/registry-notary-core/src/config/schema.rs create mode 100644 crates/registry-notary/tests/config_schema.rs create mode 100644 schemas/registry-notary.config.schema.json diff --git a/Cargo.lock b/Cargo.lock index b1aa11de4..5d7cbb118 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5256,6 +5256,7 @@ dependencies = [ "registry-notary-core", "registry-notary-server", "registry-platform-audit", + "registry-platform-authcommon", "registry-platform-config", "registry-platform-crypto", "registry-platform-ops", @@ -5315,6 +5316,7 @@ dependencies = [ "registry-platform-oid4vci", "registry-platform-ops", "registry-platform-sdjwt", + "schemars 1.2.1", "serde", "serde_json", "serde_norway", diff --git a/crates/registry-notary-core/Cargo.toml b/crates/registry-notary-core/Cargo.toml index e5d8e84af..c7b9cac39 100644 --- a/crates/registry-notary-core/Cargo.toml +++ b/crates/registry-notary-core/Cargo.toml @@ -22,6 +22,7 @@ registry-platform-httputil.workspace = true registry-platform-oid4vci.workspace = true registry-platform-ops.workspace = true registry-platform-sdjwt.workspace = true +schemars.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/crates/registry-notary-core/src/config.rs b/crates/registry-notary-core/src/config.rs index a6f083e3a..9e001e5e2 100644 --- a/crates/registry-notary-core/src/config.rs +++ b/crates/registry-notary-core/src/config.rs @@ -19,6 +19,7 @@ use registry_platform_oid4vci::{ CREDENTIAL_SIGNING_ALG_EDDSA, CRYPTOGRAPHIC_BINDING_METHOD_DID_JWK, SD_JWT_VC_FORMAT as OID4VCI_SD_JWT_VC_FORMAT, }; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::deployment::DeploymentConfig; @@ -38,6 +39,7 @@ mod federation; mod http; mod oid4vci; mod root; +pub mod schema; mod state; mod subject_access; diff --git a/crates/registry-notary-core/src/config/audit.rs b/crates/registry-notary-core/src/config/audit.rs index 6601100b3..d898404b9 100644 --- a/crates/registry-notary-core/src/config/audit.rs +++ b/crates/registry-notary-core/src/config/audit.rs @@ -3,7 +3,7 @@ use super::*; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct EvidenceAuditConfig { #[serde(default = "default_audit_sink")] diff --git a/crates/registry-notary-core/src/config/auth.rs b/crates/registry-notary-core/src/config/auth.rs index f6952e5a2..0a55962d8 100644 --- a/crates/registry-notary-core/src/config/auth.rs +++ b/crates/registry-notary-core/src/config/auth.rs @@ -3,14 +3,14 @@ use super::*; -#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RegistryNotaryCorsConfig { #[serde(default)] pub allowed_origins: Vec, } -#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct EvidenceAuthConfig { #[serde(default)] @@ -32,7 +32,7 @@ pub struct EvidenceAuthConfig { /// pre-authorized-code flow) signed with a dedicated `signing_keys` entry that /// MUST be distinct from any credential-signing key. The minted token's /// `iss`/`aud`/`typ`/alg pin the second verifier's trust anchor. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct AccessTokenSigningConfig { #[serde(default)] @@ -91,10 +91,11 @@ pub(super) const fn default_access_token_ttl_seconds() -> u64 { 300 } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct EvidenceCredentialConfig { pub id: String, + #[schemars(with = "schema::CredentialFingerprintSchema")] pub fingerprint: CredentialFingerprintRef, #[serde(default)] pub scopes: Vec, @@ -102,7 +103,7 @@ pub struct EvidenceCredentialConfig { pub authorization_details: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct EvidenceOidcAuthConfig { pub issuer: String, @@ -128,6 +129,7 @@ pub struct EvidenceOidcAuthConfig { #[serde(default = "default_oidc_principal_claim")] pub principal_claim: String, #[serde(default = "default_oidc_leeway", with = "humantime_serde")] + #[schemars(with = "schema::HumantimeDurationSchema")] pub leeway: Duration, #[serde(default)] pub allow_insecure_localhost: bool, diff --git a/crates/registry-notary-core/src/config/cel.rs b/crates/registry-notary-core/src/config/cel.rs index 966234060..991ebb3aa 100644 --- a/crates/registry-notary-core/src/config/cel.rs +++ b/crates/registry-notary-core/src/config/cel.rs @@ -3,7 +3,7 @@ use super::*; -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RegistryNotaryCelConfig { #[serde(default = "default_cel_mode")] diff --git a/crates/registry-notary-core/src/config/credential_status.rs b/crates/registry-notary-core/src/config/credential_status.rs index 420068fc8..fab4a0de1 100644 --- a/crates/registry-notary-core/src/config/credential_status.rs +++ b/crates/registry-notary-core/src/config/credential_status.rs @@ -7,7 +7,7 @@ pub const CREDENTIAL_STATUS_VALID: &str = "valid"; pub const CREDENTIAL_STATUS_SUSPENDED: &str = "suspended"; pub const CREDENTIAL_STATUS_REVOKED: &str = "revoked"; pub const CREDENTIAL_STATUS_EXPIRED: &str = "expired"; -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct CredentialStatusConfig { #[serde(default)] diff --git a/crates/registry-notary-core/src/config/evidence/claims.rs b/crates/registry-notary-core/src/config/evidence/claims.rs index 4cad742b8..be7bc6852 100644 --- a/crates/registry-notary-core/src/config/evidence/claims.rs +++ b/crates/registry-notary-core/src/config/evidence/claims.rs @@ -1,6 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 //! Claim definitions, rules, and operation configuration. +use std::borrow::Cow; + +use schemars::{Schema, SchemaGenerator}; + use super::*; pub const MAX_CLAIM_DEPENDENCY_NODES_V1: usize = 64; @@ -10,7 +14,7 @@ fn default_claim_formats() -> Vec { vec![FORMAT_CLAIM_RESULT_JSON.to_string()] } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ClaimDefinition { pub id: String, @@ -66,15 +70,6 @@ impl<'de> Deserialize<'de> for ClaimEvidenceMode { where Deserializer: serde::Deserializer<'de>, { - #[derive(Deserialize)] - #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] - enum SealedClaimEvidenceMode { - RegistryBacked { - consultations: BTreeMap, - }, - SelfAttested {}, - } - match SealedClaimEvidenceMode::deserialize(deserializer)? { SealedClaimEvidenceMode::RegistryBacked { consultations } => { Ok(Self::RegistryBacked { consultations }) @@ -84,6 +79,26 @@ impl<'de> Deserialize<'de> for ClaimEvidenceMode { } } +/// The tagged configuration wire contract shared by deserialization and schema generation. +#[derive(Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +enum SealedClaimEvidenceMode { + RegistryBacked { + consultations: BTreeMap, + }, + SelfAttested {}, +} + +impl JsonSchema for ClaimEvidenceMode { + fn schema_name() -> Cow<'static, str> { + "ClaimEvidenceMode".into() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + generator.subschema_for::() + } +} + impl ClaimEvidenceMode { #[must_use] pub const fn name(&self) -> &'static str { @@ -104,24 +119,25 @@ impl ClaimEvidenceMode { } } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RelayConsultationConfig { pub profile: RelayConsultationProfileRef, + #[schemars(with = "BTreeMap")] pub inputs: BTreeMap, /// Complete closed public output schema expected from the pinned profile. #[serde(default)] pub outputs: BTreeMap, } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RelayConsultationProfileRef { pub id: String, pub contract_hash: String, } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum RelayOutputContract { Boolean { @@ -167,7 +183,7 @@ impl RelayOutputContract { } } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RequestVariableConfig { pub from: String, @@ -175,7 +191,7 @@ pub struct RequestVariableConfig { pub value_type: RequestVariableType, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum RequestVariableType { Date, @@ -727,7 +743,7 @@ fn invalid_claim_evidence_mode( }) } -#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ClaimSemanticConfig { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -744,7 +760,7 @@ pub struct ClaimSemanticConfig { pub value_mapping: Option, } -#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ClaimValueConfig { #[serde(rename = "type", default)] @@ -759,7 +775,7 @@ fn is_false(value: &bool) -> bool { !*value } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ClaimInputConfig { pub name: String, @@ -767,7 +783,7 @@ pub struct ClaimInputConfig { pub input_type: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum RuleConfig { ConsultationOutput { @@ -855,7 +871,7 @@ pub(in crate::config) fn invalid_claim_semantics( }) } -#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct CelBindingsConfig { #[serde(default)] @@ -864,7 +880,7 @@ pub struct CelBindingsConfig { pub vars: BTreeMap, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ClaimBindingConfig { pub claim: String, @@ -872,7 +888,7 @@ pub struct ClaimBindingConfig { pub binding_type: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ClaimOperationsConfig { #[serde(default = "default_enabled_operation")] @@ -890,7 +906,7 @@ impl Default for ClaimOperationsConfig { } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct OperationConfig { #[serde(default)] @@ -901,7 +917,7 @@ pub(in crate::config) fn default_enabled_operation() -> OperationConfig { OperationConfig { enabled: true } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct BatchOperationConfig { #[serde(default)] @@ -919,7 +935,7 @@ impl Default for BatchOperationConfig { } } -#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct CccevConfig { #[serde(default)] @@ -930,7 +946,7 @@ pub struct CccevConfig { pub evidence_type_iri: Option, } -#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct OotsConfig { #[serde(default)] diff --git a/crates/registry-notary-core/src/config/evidence/disclosure.rs b/crates/registry-notary-core/src/config/evidence/disclosure.rs index d3d5b7a3a..f2ffd5ae2 100644 --- a/crates/registry-notary-core/src/config/evidence/disclosure.rs +++ b/crates/registry-notary-core/src/config/evidence/disclosure.rs @@ -3,7 +3,7 @@ use super::*; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct DisclosureConfig { #[serde(default = "default_disclosure_profile")] @@ -36,7 +36,7 @@ pub(in crate::config) fn default_disclosure_downgrade() -> String { "deny".to_string() } -#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct CredentialDisclosureConfig { #[serde(default)] diff --git a/crates/registry-notary-core/src/config/evidence/limits.rs b/crates/registry-notary-core/src/config/evidence/limits.rs index 90bd13061..4632f83df 100644 --- a/crates/registry-notary-core/src/config/evidence/limits.rs +++ b/crates/registry-notary-core/src/config/evidence/limits.rs @@ -4,7 +4,7 @@ use super::*; /// Per-request cap on concurrently evaluated subjects. -#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ConcurrencyConfig { #[serde(default = "default_concurrency_subjects")] @@ -33,7 +33,7 @@ const fn default_concurrency_subjects() -> usize { } /// Per-principal quota for machine `evaluate`/`batch_evaluate` traffic. -#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct MachineQuotaConfig { #[serde(default)] diff --git a/crates/registry-notary-core/src/config/evidence/mod.rs b/crates/registry-notary-core/src/config/evidence/mod.rs index 357d32016..8dc3a30b5 100644 --- a/crates/registry-notary-core/src/config/evidence/mod.rs +++ b/crates/registry-notary-core/src/config/evidence/mod.rs @@ -17,7 +17,7 @@ pub use signing::*; /// Registry Notary configuration. Disabled by default so existing /// Registry Relay deployments load unchanged. -#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct EvidenceConfig { #[serde(default)] diff --git a/crates/registry-notary-core/src/config/evidence/relay.rs b/crates/registry-notary-core/src/config/evidence/relay.rs index 83960d094..861afd7d3 100644 --- a/crates/registry-notary-core/src/config/evidence/relay.rs +++ b/crates/registry-notary-core/src/config/evidence/relay.rs @@ -18,13 +18,14 @@ const fn default_relay_max_in_flight() -> usize { 8 } -#[derive(Clone, Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RelayConnectionConfig { pub base_url: String, pub workload_client_id: String, pub token_file: PathBuf, #[serde(default)] + #[schemars(with = "Vec")] pub allowed_private_cidrs: Vec, #[serde(default)] pub allow_insecure_localhost: bool, diff --git a/crates/registry-notary-core/src/config/evidence/signing.rs b/crates/registry-notary-core/src/config/evidence/signing.rs index 0431e23ea..f0768d97c 100644 --- a/crates/registry-notary-core/src/config/evidence/signing.rs +++ b/crates/registry-notary-core/src/config/evidence/signing.rs @@ -55,7 +55,7 @@ pub(in crate::config) fn validate_signing_key_id(key_id: &str) -> Result<(), Evi Ok(()) } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct CredentialProfileConfig { pub format: String, @@ -72,12 +72,14 @@ pub struct CredentialProfileConfig { pub disclosure: CredentialDisclosureConfig, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SigningKeyConfig { + #[schemars(with = "schema::SigningKeyProviderSchema")] pub provider: SigningKeyProviderConfig, pub alg: String, pub kid: String, + #[schemars(with = "schema::SigningKeyStatusSchema")] pub status: SigningKeyStatus, #[serde(default, skip_serializing_if = "Option::is_none")] pub publish_until_unix_seconds: Option, @@ -291,7 +293,7 @@ pub(in crate::config) const fn default_credential_validity_seconds() -> i64 { 600 } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct HolderBindingConfig { #[serde(default = "default_holder_binding_mode")] diff --git a/crates/registry-notary-core/src/config/federation.rs b/crates/registry-notary-core/src/config/federation.rs index 8feb3275f..27453ea2a 100644 --- a/crates/registry-notary-core/src/config/federation.rs +++ b/crates/registry-notary-core/src/config/federation.rs @@ -7,7 +7,7 @@ pub const FEDERATION_PROTOCOL_V0_1: &str = "registry-notary-federation/v0.1"; pub const FEDERATION_REQUEST_JWT_TYP: &str = "registry-notary-request+jwt"; pub const FEDERATION_RESPONSE_JWT_TYP: &str = "registry-notary-response+jwt"; pub const FEDERATION_SIGNING_ALG_EDDSA: &str = "EdDSA"; -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct FederationConfig { #[serde(default)] @@ -229,20 +229,20 @@ impl FederationConfig { } } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct FederationSigningConfig { pub signing_key: String, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct FederationPairwiseSubjectHashConfig { #[serde(default)] pub secret_env: String, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct FederationResponseShapingConfig { #[serde(default = "default_minimum_denial_latency_ms")] @@ -261,7 +261,7 @@ pub(super) const fn default_minimum_denial_latency_ms() -> u64 { 250 } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct FederationEmergencyDenylistConfig { #[serde(default)] @@ -270,7 +270,7 @@ pub struct FederationEmergencyDenylistConfig { pub kids: Vec, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct FederationPeerConfig { pub node_id: String, @@ -290,7 +290,7 @@ pub struct FederationPeerConfig { pub evaluation_scopes: Vec, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct FederationEvaluationProfileConfig { pub id: String, diff --git a/crates/registry-notary-core/src/config/http.rs b/crates/registry-notary-core/src/config/http.rs index 2eddf65af..557a47ada 100644 --- a/crates/registry-notary-core/src/config/http.rs +++ b/crates/registry-notary-core/src/config/http.rs @@ -3,10 +3,11 @@ use super::*; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RegistryNotaryHttpConfig { #[serde(default = "default_bind_addr")] + #[schemars(with = "schema::SocketAddrSchema")] pub bind: SocketAddr, #[serde( default = "default_openapi_requires_auth", @@ -18,13 +19,16 @@ pub struct RegistryNotaryHttpConfig { #[serde(default)] pub cors: RegistryNotaryCorsConfig, #[serde(default = "default_request_timeout", with = "humantime_serde")] + #[schemars(with = "schema::HumantimeDurationSchema")] pub request_timeout: Duration, #[serde(default = "default_request_body_timeout", with = "humantime_serde")] + #[schemars(with = "schema::HumantimeDurationSchema")] pub request_body_timeout: Duration, #[serde( default = "default_http1_header_read_timeout", with = "humantime_serde" )] + #[schemars(with = "schema::HumantimeDurationSchema")] pub http1_header_read_timeout: Duration, #[serde(default = "default_max_connections")] pub max_connections: usize, @@ -96,7 +100,7 @@ pub(super) fn default_max_connections() -> usize { 1024 } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum RegistryNotaryAdminListenerMode { SharedWithPublic, @@ -115,12 +119,13 @@ impl RegistryNotaryAdminListenerMode { } } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RegistryNotaryAdminListenerConfig { #[serde(default, skip_serializing_if = "admin_listener_mode_is_default")] pub mode: RegistryNotaryAdminListenerMode, #[serde(default = "default_admin_bind_addr")] + #[schemars(with = "schema::SocketAddrSchema")] pub bind: SocketAddr, } diff --git a/crates/registry-notary-core/src/config/oid4vci.rs b/crates/registry-notary-core/src/config/oid4vci.rs index 60b68966a..a9b261a46 100644 --- a/crates/registry-notary-core/src/config/oid4vci.rs +++ b/crates/registry-notary-core/src/config/oid4vci.rs @@ -3,7 +3,7 @@ use super::*; -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Oid4vciConfig { #[serde(default)] @@ -197,7 +197,7 @@ impl Oid4vciConfig { } } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Oid4vciNonceConfig { #[serde(default)] @@ -228,7 +228,7 @@ pub(super) const fn default_oid4vci_nonce_ttl_seconds() -> u64 { 300 } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Oid4vciAuthorizationConfig { #[serde(default = "default_oid4vci_pkce_method")] @@ -261,7 +261,7 @@ pub(super) fn default_oid4vci_pkce_method() -> String { /// All fields default so existing configs that omit this block load unchanged /// with the flow disabled. When `enabled`, the eSignet RP login settings, the /// callback redirect, and the TTLs become required (validated cross-block). -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Oid4vciPreAuthorizedCodeConfig { #[serde(default)] @@ -318,7 +318,7 @@ pub(super) const fn default_pre_authorized_code_ttl_seconds() -> u64 { /// `tx_code` (PIN) policy for the pre-authorized-code grant. A `tx_code` is /// required by default because a code without a PIN is a bearer credential. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Oid4vciTxCodeConfig { #[serde(default = "default_tx_code_required")] @@ -368,7 +368,7 @@ pub(super) const fn default_tx_code_length() -> u64 { /// eSignet relying-party settings for the citizen login leg of the /// pre-authorized-code flow. -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Oid4vciEsignetRpConfig { /// Confidential client id the Notary presents to eSignet. @@ -455,7 +455,7 @@ pub(super) const fn default_login_state_ttl_seconds() -> u64 { const TX_CODE_INPUT_MODE_NUMERIC: &str = "numeric"; -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Oid4vciProofConfig { #[serde(default = "default_oid4vci_proof_max_age_seconds")] @@ -493,7 +493,7 @@ pub(super) const fn default_oid4vci_proof_max_clock_skew_seconds() -> u64 { 60 } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Oid4vciCredentialConfigurationConfig { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -513,7 +513,7 @@ pub struct Oid4vciCredentialConfigurationConfig { pub cryptographic_binding_methods_supported: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Oid4vciCredentialClaimConfig { pub id: String, @@ -849,7 +849,7 @@ pub(super) fn is_reserved_oid4vci_projection_output_name(value: &str) -> bool { RESERVED.contains(&value) } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Oid4vciIssuerDisplayConfig { pub name: String, @@ -870,7 +870,7 @@ impl Oid4vciIssuerDisplayConfig { } } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Oid4vciCredentialDisplayConfig { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -916,7 +916,7 @@ impl Oid4vciCredentialDisplayConfig { } } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Oid4vciDisplayImageConfig { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/registry-notary-core/src/config/root.rs b/crates/registry-notary-core/src/config/root.rs index b0d03d5c1..a55fc7d68 100644 --- a/crates/registry-notary-core/src/config/root.rs +++ b/crates/registry-notary-core/src/config/root.rs @@ -16,7 +16,7 @@ const MIN_RELAY_OUTER_REQUEST_TIMEOUT: Duration = Duration::from_secs( pub const CREDENTIAL_SIGNING_ALG_ES256: &str = "ES256"; pub const CLIENT_ASSERTION_SIGNING_ALG_RS256: &str = "RS256"; -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct StandaloneRegistryNotaryConfig { #[serde(default, skip_serializing_if = "instance_config_is_default")] @@ -45,7 +45,7 @@ pub struct StandaloneRegistryNotaryConfig { pub deployment: DeploymentConfig, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct NotaryInstanceConfig { #[serde(default = "default_instance_id")] @@ -64,7 +64,7 @@ pub struct NotaryInstanceConfig { /// /// Simple local deployments omit this block. Signed/governed apply requires it /// so anti-rollback state lives in an explicit durable location. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ConfigTrustConfig { pub trust_anchor_path: PathBuf, diff --git a/crates/registry-notary-core/src/config/schema.rs b/crates/registry-notary-core/src/config/schema.rs new file mode 100644 index 000000000..2fa478a0b --- /dev/null +++ b/crates/registry-notary-core/src/config/schema.rs @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Reproducible Draft 2020-12 schema for the complete Notary runtime config. + +#![allow( + dead_code, + reason = "schema adapter types are compile-time descriptions and are never constructed" +)] + +use std::borrow::Cow; + +use registry_platform_authcommon::CredentialFingerprintProvider; +use schemars::{generate::SchemaSettings, json_schema, JsonSchema, Schema, SchemaGenerator}; +use serde_json::{json, Value}; + +use super::{SigningKeyProviderConfig, SigningKeyStatus, StandaloneRegistryNotaryConfig}; + +/// Stable identifier for the product-owned Notary runtime configuration schema. +pub const CONFIG_SCHEMA_ID: &str = + "https://id.registrystack.org/schemas/registry-notary/registry-notary.config.schema.json"; + +/// Schema-only contract for values parsed by `humantime_serde`. +pub(crate) struct HumantimeDurationSchema; + +impl JsonSchema for HumantimeDurationSchema { + fn schema_name() -> Cow<'static, str> { + "HumantimeDuration".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "A humantime duration string. The runtime parser remains authoritative for its complete grammar.", + "type": "string" + }) + } +} + +/// Schema-only contract for YAML socket addresses parsed by `SocketAddr`. +pub(crate) struct SocketAddrSchema; + +impl JsonSchema for SocketAddrSchema { + fn schema_name() -> Cow<'static, str> { + "SocketAddr".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "A Rust SocketAddr string. The runtime parser remains authoritative for address and port validity.", + "type": "string" + }) + } +} + +/// Schema-only contract for `IpNet` CIDR values. +pub(crate) struct IpNetSchema; + +impl JsonSchema for IpNetSchema { + fn schema_name() -> Cow<'static, str> { + "IpNet".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "An IP network CIDR string. The runtime parser remains authoritative for address and prefix validity.", + "type": "string" + }) + } +} + +pub(crate) struct CredentialFingerprintSchema; + +impl JsonSchema for CredentialFingerprintSchema { + fn schema_name() -> Cow<'static, str> { + "CredentialFingerprintRef".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + // `CredentialFingerprintRef` has a custom deserializer. It accepts + // either provider together with zero, one, or both optional references; + // doctor/runtime validation decides whether the selected provider has a + // usable, non-ambiguous reference. Keep that division of responsibility + // instead of making the schema stricter than deserialization. + json_schema!({ + "type": "object", + "additionalProperties": false, + "required": ["provider"], + "properties": { + "provider": string_enum(CredentialFingerprintProvider::ALL.iter().map(|provider| provider.as_str())), + "name": { "type": ["string", "null"] }, + "path": { "type": ["string", "null"] } + } + }) + } +} + +pub(crate) struct SigningKeyProviderSchema; + +impl JsonSchema for SigningKeyProviderSchema { + fn schema_name() -> Cow<'static, str> { + "SigningKeyProviderSchema".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + string_enum( + SigningKeyProviderConfig::ALL + .iter() + .map(|provider| provider.as_str()), + ) + } +} + +pub(crate) struct SigningKeyStatusSchema; + +impl JsonSchema for SigningKeyStatusSchema { + fn schema_name() -> Cow<'static, str> { + "SigningKeyStatusSchema".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + string_enum(SigningKeyStatus::ALL.iter().map(|status| status.as_str())) + } +} + +fn string_enum(labels: impl Iterator) -> Schema { + json!({ + "type": "string", + "enum": labels.collect::>() + }) + .try_into() + .expect("a JSON object is always a valid JSON Schema") +} + +/// Schema-only representation of the string-only consultation-input deserializer. +pub(crate) struct RelayConsultationInputSchema; + +impl JsonSchema for RelayConsultationInputSchema { + fn schema_name() -> Cow<'static, str> { + "RelayConsultationInput".into() + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "A supported consultation input path. The runtime parser remains authoritative for exact stable-name bounds.", + "type": "string", + "minLength": 1 + }) + } +} + +/// Generate the deserialization contract for [`StandaloneRegistryNotaryConfig`]. +#[must_use] +pub fn document() -> Value { + let schema = SchemaSettings::draft2020_12() + .into_generator() + .into_root_schema_for::(); + let mut value = serde_json::to_value(schema).expect("JSON Schema serializes to JSON"); + let root = value.as_object_mut().expect("root schema is an object"); + root.insert( + "$id".to_string(), + Value::String(CONFIG_SCHEMA_ID.to_string()), + ); + root.insert( + "title".to_string(), + Value::String("Registry Notary config".to_string()), + ); + value +} + +/// Serialize the generated schema deterministically with exactly one trailing LF. +#[must_use] +pub fn document_json() -> String { + let mut output = serde_json::to_string_pretty(&document()).expect("JSON Schema serializes"); + output.push('\n'); + output +} diff --git a/crates/registry-notary-core/src/config/state.rs b/crates/registry-notary-core/src/config/state.rs index 2f9ad6167..b08e5dcbe 100644 --- a/crates/registry-notary-core/src/config/state.rs +++ b/crates/registry-notary-core/src/config/state.rs @@ -7,7 +7,7 @@ pub const STATE_STORAGE_IN_MEMORY: &str = "in_memory"; pub const STATE_STORAGE_POSTGRESQL: &str = "postgresql"; pub const STATE_POSTGRESQL_MAX_CONNECTIONS: usize = 256; -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct StateConfig { #[serde(default = "default_state_storage")] @@ -87,7 +87,7 @@ impl StateConfig { } } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct StatePostgresqlConfig { #[serde(default = "default_state_postgresql_url_env")] diff --git a/crates/registry-notary-core/src/config/subject_access.rs b/crates/registry-notary-core/src/config/subject_access.rs index 81208eba4..37e386abd 100644 --- a/crates/registry-notary-core/src/config/subject_access.rs +++ b/crates/registry-notary-core/src/config/subject_access.rs @@ -3,7 +3,7 @@ use super::*; -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjectAccessConfig { #[serde(default)] @@ -215,7 +215,7 @@ fn validate_subject_bound_registry_inputs( Ok(()) } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjectAccessDelegationConfig { #[serde(default)] @@ -273,7 +273,7 @@ impl SubjectAccessDelegationConfig { } } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjectAccessDelegatedRelationshipConfig { pub relationship_type: String, @@ -415,7 +415,7 @@ impl SubjectAccessDelegatedRelationshipConfig { } } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum SubjectAccessScopePolicy { #[default] @@ -424,7 +424,7 @@ pub enum SubjectAccessScopePolicy { Disabled, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjectAccessSubjectBindingConfig { #[serde(default)] @@ -470,7 +470,7 @@ impl SubjectAccessSubjectBindingConfig { } } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum SubjectAccessClaimSource { #[default] @@ -478,20 +478,20 @@ pub enum SubjectAccessClaimSource { Userinfo, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] pub enum SubjectId { #[default] SubjectId, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum SubjectBindingNormalize { #[default] Exact, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjectAccessCitizenClientsConfig { #[serde(default)] @@ -539,7 +539,7 @@ impl SubjectAccessCitizenClientsConfig { } } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjectAccessTokenPolicyConfig { #[serde(default)] @@ -558,7 +558,7 @@ pub struct SubjectAccessTokenPolicyConfig { pub max_clock_leeway_seconds: u64, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum SubjectAccessAssuranceClaimSource { #[default] @@ -606,7 +606,7 @@ impl SubjectAccessTokenPolicyConfig { } } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjectAccessOperationsConfig { #[serde(default)] @@ -633,7 +633,7 @@ impl SubjectAccessOperationsConfig { } } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct SubjectAccessRateLimitsConfig { #[serde(default)] diff --git a/crates/registry-notary-core/src/deployment.rs b/crates/registry-notary-core/src/deployment.rs index 5edb0e7e2..c1310642c 100644 --- a/crates/registry-notary-core/src/deployment.rs +++ b/crates/registry-notary-core/src/deployment.rs @@ -11,6 +11,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// The set of deployment profiles an operator can declare. @@ -18,7 +19,7 @@ use serde::{Deserialize, Serialize}; /// Frozen at introduction; new profiles may be added but existing ones never /// change meaning. Deserialization is strict: an unknown profile string fails, /// which surfaces as a startup error. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum DeploymentProfile { Local, @@ -42,7 +43,7 @@ impl DeploymentProfile { /// /// `startup_fail` and `readiness_fail` are hard gates and bind only on declared /// profiles. `finding_error` and `finding_warn` surface as posture findings. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum GateSeverity { StartupFail, @@ -70,7 +71,7 @@ impl GateSeverity { } /// Status of a finding in posture output. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum DeploymentFindingStatus { Active, @@ -91,7 +92,7 @@ impl DeploymentFindingStatus { /// An absent profile means an undeclared deployment, which refuses startup. The /// `multi_instance` flag is an operator declaration that the instance is one of /// several sharing the same workload; it is never inferred. -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct DeploymentConfig { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -116,7 +117,7 @@ impl DeploymentConfig { /// observe directly. Each flag defaults to `false`, meaning "no evidence /// declared", which keeps the corresponding gate active until the operator /// asserts the control is in place out of band. -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct DeploymentEvidenceConfig { /// Operator asserts audit log events are shipped off-host (for example to @@ -163,7 +164,7 @@ impl DeploymentEvidenceConfig { /// /// A waiver names exactly one finding id, a free-text reason, and a mandatory /// expiry date (`YYYY-MM-DD`). Reasons must not contain secrets. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct DeploymentWaiverConfig { pub finding: String, diff --git a/crates/registry-notary-core/src/model.rs b/crates/registry-notary-core/src/model.rs index 90b2ac7fd..4cbad1481 100644 --- a/crates/registry-notary-core/src/model.rs +++ b/crates/registry-notary-core/src/model.rs @@ -1,10 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 //! Registry Notary request, response, and view types. +use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::marker::PhantomData; +use schemars::{JsonSchema, Schema, SchemaGenerator}; use serde::de::{self, Visitor}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Value; @@ -145,7 +147,7 @@ impl EvidenceAuthProfileId { } } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AccessMode { Unknown, @@ -746,26 +748,26 @@ impl std::ops::Deref for ClaimRef { } } +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ClaimRefObject { + id: String, + #[serde(default)] + version: Option, +} + +#[derive(Deserialize, JsonSchema)] +#[serde(untagged)] +enum WireClaimRef { + Id(String), + Object(ClaimRefObject), +} + impl<'de> Deserialize<'de> for ClaimRef { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct ClaimRefObject { - id: String, - #[serde(default)] - version: Option, - } - - #[derive(Deserialize)] - #[serde(untagged)] - enum WireClaimRef { - Id(String), - Object(ClaimRefObject), - } - match WireClaimRef::deserialize(deserializer)? { WireClaimRef::Id(id) => Ok(Self::new(id)), WireClaimRef::Object(object) => Ok(Self { @@ -776,6 +778,16 @@ impl<'de> Deserialize<'de> for ClaimRef { } } +impl JsonSchema for ClaimRef { + fn schema_name() -> Cow<'static, str> { + "ClaimRef".into() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + generator.subschema_for::() + } +} + /// Frozen minimal actor/delegation envelope for `on_behalf_of`. /// /// Replaces the previous free-form `Option`. This is the beta-frozen @@ -1626,7 +1638,8 @@ const fn subject_access_access_mode() -> AccessMode { AccessMode::SubjectBound } -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct EvidenceAuthorizationDetails { #[serde(rename = "type")] pub detail_type: String, @@ -1663,25 +1676,29 @@ pub struct EvidenceAuthorizationDetails { pub assisted_access_context: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct EvidenceAuthorizationSubject { pub binding_claim: String, pub id_type: String, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct EvidenceAuthorizationTarget { pub id_type: String, pub id: String, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct EvidenceAuthorizationRelationship { pub relationship_type: String, pub proof_claim: String, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct EvidenceAssistedAccessContext { pub channel: String, } diff --git a/crates/registry-notary/Cargo.toml b/crates/registry-notary/Cargo.toml index cd085920a..3129efc76 100644 --- a/crates/registry-notary/Cargo.toml +++ b/crates/registry-notary/Cargo.toml @@ -51,5 +51,6 @@ ulid.workspace = true axum-test = { version = "20" } chrono = { version = "0.4" } jsonschema.workspace = true +registry-platform-authcommon.workspace = true tempfile = { version = "3" } wiremock = { version = "0.6" } diff --git a/crates/registry-notary/README.md b/crates/registry-notary/README.md index ac9d72cec..e6bb8ab9c 100644 --- a/crates/registry-notary/README.md +++ b/crates/registry-notary/README.md @@ -26,7 +26,7 @@ The crate builds the `registry-notary` binary. | `hash-api-key` | Generate or hash a Registry Notary API key. | | `demo-issuer-key` | Generate a demo Ed25519 issuer JWK for local VC smoke tests. | | `healthcheck` | Probe the local HTTP health endpoint and exit non-zero when unhealthy. | -| `schema` | Print a lightweight JSON schema for top-level config discovery. | +| `schema` | Print the complete Draft 2020-12 standalone configuration schema. | ## Typical Use @@ -48,6 +48,18 @@ Probe the container health endpoint without requiring curl in the image: cargo run -p registry-notary -- healthcheck --url http://127.0.0.1:8080/healthz ``` +Print the committed configuration contract: + +```sh +cargo run -p registry-notary -- schema > schemas/registry-notary.config.schema.json +``` + +The repository artifact is regenerated and checked with +`cd products/notary && just config-schema-check`. The schema covers YAML +structure and scalar types. `registry-notary doctor` remains authoritative +for environment and secret availability, filesystem and Relay access, +deployment gates, and cross-field validation. + ## Features - Default: no CEL runtime. diff --git a/crates/registry-notary/src/commands/healthcheck.rs b/crates/registry-notary/src/commands/healthcheck.rs index 4eb30ce1c..9199ecc13 100644 --- a/crates/registry-notary/src/commands/healthcheck.rs +++ b/crates/registry-notary/src/commands/healthcheck.rs @@ -17,26 +17,6 @@ pub(crate) async fn run_healthcheck( } } -pub(crate) fn lightweight_schema() -> Value { - json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Registry Notary standalone config", - "type": "object", - "required": ["auth", "evidence"], - "properties": { - "server": { "type": "object" }, - "auth": { "type": "object" }, - "audit": { "type": "object" }, - "state": { "type": "object" }, - "credential_status": { "type": "object" }, - "subject_access": { "type": "object" }, - "oid4vci": { "type": "object" }, - "evidence": { "type": "object" }, - "federation": { "type": "object" } - }, - "additionalProperties": false - }) -} #[cfg(test)] #[path = "healthcheck/tests.rs"] mod tests; diff --git a/crates/registry-notary/src/commands/healthcheck/tests.rs b/crates/registry-notary/src/commands/healthcheck/tests.rs index 3cedb04ac..9b51043b6 100644 --- a/crates/registry-notary/src/commands/healthcheck/tests.rs +++ b/crates/registry-notary/src/commands/healthcheck/tests.rs @@ -93,11 +93,3 @@ async fn healthcheck_fails_for_non_success_status() { .expect_err("healthcheck fails"); assert!(err.to_string().contains("HTTP 503")); } - -#[test] -fn lightweight_schema_exposes_top_level_config_sections() { - let schema = lightweight_schema(); - assert_eq!(schema["additionalProperties"], json!(false)); - assert!(schema["properties"]["evidence"].is_object()); - assert!(schema["properties"]["auth"].is_object()); -} diff --git a/crates/registry-notary/src/main.rs b/crates/registry-notary/src/main.rs index 7de9ad167..196de46bd 100644 --- a/crates/registry-notary/src/main.rs +++ b/crates/registry-notary/src/main.rs @@ -187,7 +187,7 @@ enum Command { CelWorker, /// Print machine-readable build metadata and compiled capabilities. BuildInfo, - /// Print a lightweight JSON schema for top-level config discovery. + /// Print the complete Draft 2020-12 standalone configuration schema. Schema, } @@ -357,7 +357,7 @@ async fn run(args: Args) -> Result> { Ok(ExitCode::SUCCESS) } Some(Command::Schema) => { - println!("{}", serde_json::to_string_pretty(&lightweight_schema())?); + print!("{}", registry_notary_core::config::schema::document_json()); Ok(ExitCode::SUCCESS) } } diff --git a/crates/registry-notary/tests/config_schema.rs b/crates/registry-notary/tests/config_schema.rs new file mode 100644 index 000000000..6274ad051 --- /dev/null +++ b/crates/registry-notary/tests/config_schema.rs @@ -0,0 +1,579 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeSet, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use jsonschema::{Draft, JSONSchema}; +use registry_notary_core::config::schema::{document, document_json, CONFIG_SCHEMA_ID}; +use registry_notary_core::{ + ClaimEvidenceMode, SigningKeyProviderConfig, SigningKeyStatus, StandaloneRegistryNotaryConfig, +}; +use registry_platform_authcommon::CredentialFingerprintProvider; +use serde_json::{json, Value}; + +const SCHEMA_ARTIFACT: &str = "schemas/registry-notary.config.schema.json"; +const CONFIG_REFERENCE: &str = "products/notary/docs/operator-config-reference.md"; +const KEY_PATHS_START: &str = "{/* registry-notary-config-key-paths:start */}"; +const KEY_PATHS_END: &str = "{/* registry-notary-config-key-paths:end */}"; + +fn stack_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn parse_yaml(path: &Path) -> Value { + let text = fs::read_to_string(path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + serde_norway::from_str(&text) + .unwrap_or_else(|error| panic!("failed to parse {} as YAML: {error}", path.display())) +} + +fn compile_schema(schema: &Value) -> JSONSchema { + JSONSchema::options() + .with_draft(Draft::Draft202012) + .compile(schema) + .unwrap_or_else(|error| panic!("Notary schema must compile as Draft 2020-12: {error}")) +} + +fn assert_valid(schema: &Value, instance: &Value, label: &str) { + let compiled = compile_schema(schema); + if let Err(errors) = compiled.validate(instance) { + let details = errors.map(|error| error.to_string()).collect::>(); + panic!("{label} must validate against the Notary schema: {details:#?}"); + }; +} + +fn assert_invalid(schema: &Value, instance: &Value, label: &str) { + assert!( + !compile_schema(schema).is_valid(instance), + "{label} must be rejected by the Notary schema" + ); +} + +fn assert_runtime_deserializes(instance: &Value, label: &str) { + let yaml = serde_norway::to_string(instance) + .unwrap_or_else(|error| panic!("failed to serialize {label} as YAML: {error}")); + serde_norway::from_str::(&yaml) + .unwrap_or_else(|error| panic!("{label} must deserialize at runtime: {error}")); +} + +fn assert_runtime_rejects(instance: &Value, label: &str) { + let yaml = serde_norway::to_string(instance) + .unwrap_or_else(|error| panic!("failed to serialize {label} as YAML: {error}")); + assert!( + serde_norway::from_str::(&yaml).is_err(), + "{label} must be rejected during runtime deserialization" + ); +} + +fn maintained_runtime_fixtures() -> Vec { + let profiles = stack_root().join("crates/registry-relay/profiles"); + let mut fixtures = fs::read_dir(profiles) + .expect("profiles directory exists") + .filter_map(Result::ok) + .map(|entry| entry.path().join("notary-config.example.yaml")) + .filter(|path| path.is_file()) + .collect::>(); + fixtures.sort(); + fixtures +} + +fn example_config() -> Value { + parse_yaml(&stack_root().join( + "crates/registry-relay/profiles/dhis2-2.41.9-enrollment-status/notary-config.example.yaml", + )) +} + +fn with_authorization_details(mut config: Value) -> Value { + config["auth"]["api_keys"][0]["authorization_details"] = json!({ + "type": "registry_notary_authorization", + "schema_version": "1", + "subject": { "binding_claim": "sub", "id_type": "person_id" }, + "target": { "id_type": "person_id", "id": "target-123" }, + "relationship": { "relationship_type": "guardian", "proof_claim": "guardianship" }, + "assisted_access_context": { "channel": "service_desk" } + }); + config +} + +fn string_enum(schema: &Value, pointer: &str) -> BTreeSet { + schema + .pointer(pointer) + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("missing string enum at {pointer}")) + .iter() + .map(|value| { + value + .as_str() + .unwrap_or_else(|| panic!("non-string enum value at {pointer}")) + .to_string() + }) + .collect() +} + +fn collect_tag_constants(root: &Value, schema: &Value, values: &mut BTreeSet) { + if let Some(reference) = schema.get("$ref").and_then(Value::as_str) { + let target = root + .pointer(reference.strip_prefix('#').expect("local schema reference")) + .unwrap_or_else(|| panic!("unresolved schema reference {reference}")); + collect_tag_constants(root, target, values); + } + for combinator in ["allOf", "anyOf", "oneOf"] { + if let Some(branches) = schema.get(combinator).and_then(Value::as_array) { + for branch in branches { + collect_tag_constants(root, branch, values); + } + } + } + if let Some(value) = schema + .get("properties") + .and_then(|properties| properties.get("type")) + .and_then(|tag| tag.get("const")) + .and_then(Value::as_str) + { + values.insert(value.to_string()); + } +} + +fn collect_key_paths( + root: &Value, + schema: &Value, + prefix: &str, + paths: &mut BTreeSet, + visited_refs: &mut HashSet, +) { + if let Some(reference) = schema.get("$ref").and_then(Value::as_str) { + if visited_refs.insert(reference.to_string()) { + let target = root + .pointer(reference.strip_prefix('#').expect("local schema reference")) + .unwrap_or_else(|| panic!("unresolved schema reference {reference}")); + collect_key_paths(root, target, prefix, paths, visited_refs); + visited_refs.remove(reference); + } + } + + for combinator in ["allOf", "anyOf", "oneOf"] { + if let Some(branches) = schema.get(combinator).and_then(Value::as_array) { + for branch in branches { + collect_key_paths(root, branch, prefix, paths, visited_refs); + } + } + } + + if let Some(properties) = schema.get("properties").and_then(Value::as_object) { + for (name, property) in properties { + let property_path = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}.{name}") + }; + paths.insert(property_path.clone()); + collect_key_paths(root, property, &property_path, paths, visited_refs); + } + } + + if let Some(items) = schema.get("items").filter(|value| value.is_object()) { + let item_path = format!("{prefix}[]"); + paths.insert(item_path.clone()); + collect_key_paths(root, items, &item_path, paths, visited_refs); + } + + if let Some(values) = schema + .get("additionalProperties") + .filter(|value| value.is_object()) + { + let value_path = format!("{prefix}.*"); + paths.insert(value_path.clone()); + collect_key_paths(root, values, &value_path, paths, visited_refs); + } +} + +fn documented_key_paths(reference: &str) -> BTreeSet { + let Some((_, tail)) = reference.split_once(KEY_PATHS_START) else { + return BTreeSet::new(); + }; + let (block, _) = tail + .split_once(KEY_PATHS_END) + .unwrap_or_else(|| panic!("missing {KEY_PATHS_END}")); + block + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && *line != "```text" && *line != "```") + .map(str::to_string) + .collect() +} + +#[test] +fn generated_schema_is_draft_2020_12_with_stable_id_and_no_byte_drift() { + let generated = document(); + compile_schema(&generated); + assert_eq!( + generated["$schema"], + "https://json-schema.org/draft/2020-12/schema" + ); + assert_eq!(generated["$id"], CONFIG_SCHEMA_ID); + + let artifact = fs::read_to_string(stack_root().join(SCHEMA_ARTIFACT)) + .expect("committed Notary schema exists"); + assert_eq!(artifact, document_json()); + assert!(artifact.ends_with('\n')); + assert!(!artifact.ends_with("\n\n")); +} + +#[test] +fn schema_command_is_exactly_the_committed_artifact() { + let output = Command::new(env!("CARGO_BIN_EXE_registry-notary")) + .arg("schema") + .output() + .expect("schema command runs"); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + assert_eq!( + output.stdout, + fs::read(stack_root().join(SCHEMA_ARTIFACT)).expect("committed Notary schema exists") + ); +} + +#[test] +fn maintained_runtime_config_fixtures_validate_and_deserialize() { + let schema = document(); + let fixtures = maintained_runtime_fixtures(); + assert!( + fixtures.len() >= 2, + "fixture discovery unexpectedly narrowed" + ); + for fixture in fixtures { + let instance = parse_yaml(&fixture); + assert_valid(&schema, &instance, &fixture.display().to_string()); + assert_runtime_deserializes(&instance, &fixture.display().to_string()); + } +} + +#[test] +fn strict_nested_objects_and_tagged_variants_match_runtime_deserialization() { + let schema = document(); + + let mut unknown_nested = example_config(); + unknown_nested["evidence"]["claims"][0]["evidence_mode"]["unexpected"] = json!(true); + assert_invalid(&schema, &unknown_nested, "unknown evidence-mode field"); + assert_runtime_rejects(&unknown_nested, "unknown evidence-mode field"); + + let mut unknown_fingerprint = example_config(); + unknown_fingerprint["auth"]["api_keys"][0]["fingerprint"]["unexpected"] = json!(true); + assert_invalid(&schema, &unknown_fingerprint, "unknown fingerprint field"); + assert_runtime_rejects(&unknown_fingerprint, "unknown fingerprint field"); + + let mut unknown_signing_provider = example_config(); + unknown_signing_provider["evidence"]["signing_keys"] = json!({ + "issuer": { + "provider": "not_a_provider", + "alg": "EdDSA", + "kid": "did:web:issuer.example#key", + "status": "active" + } + }); + assert_invalid( + &schema, + &unknown_signing_provider, + "unknown signing-key provider", + ); + assert_runtime_rejects(&unknown_signing_provider, "unknown signing-key provider"); + + let mut unknown_signing_status = example_config(); + unknown_signing_status["evidence"]["signing_keys"] = json!({ + "issuer": { + "provider": "local_jwk_env", + "alg": "EdDSA", + "kid": "did:web:issuer.example#key", + "status": "not_a_status" + } + }); + assert_invalid( + &schema, + &unknown_signing_status, + "unknown signing-key status", + ); + assert_runtime_rejects(&unknown_signing_status, "unknown signing-key status"); +} + +#[test] +fn authorization_details_are_closed_at_every_policy_level() { + let schema = document(); + let valid = with_authorization_details(example_config()); + assert_valid(&schema, &valid, "strict authorization-details config"); + assert_runtime_deserializes(&valid, "strict authorization-details config"); + + let mut root_unknown = valid.clone(); + root_unknown["auth"]["api_keys"][0]["authorization_details"]["unexpected"] = json!(true); + assert_invalid( + &schema, + &root_unknown, + "unknown authorization-details field", + ); + assert_runtime_rejects(&root_unknown, "unknown authorization-details field"); + + let mut subject_unknown = valid.clone(); + subject_unknown["auth"]["api_keys"][0]["authorization_details"]["subject"]["unexpected"] = + json!(true); + assert_invalid( + &schema, + &subject_unknown, + "unknown authorization subject field", + ); + assert_runtime_rejects(&subject_unknown, "unknown authorization subject field"); + + let mut target_unknown = valid.clone(); + target_unknown["auth"]["api_keys"][0]["authorization_details"]["target"]["unexpected"] = + json!(true); + assert_invalid( + &schema, + &target_unknown, + "unknown authorization target field", + ); + assert_runtime_rejects(&target_unknown, "unknown authorization target field"); + + let mut relationship_unknown = valid.clone(); + relationship_unknown["auth"]["api_keys"][0]["authorization_details"]["relationship"] + ["unexpected"] = json!(true); + assert_invalid( + &schema, + &relationship_unknown, + "unknown authorization relationship field", + ); + assert_runtime_rejects( + &relationship_unknown, + "unknown authorization relationship field", + ); + + let mut context_unknown = valid; + context_unknown["auth"]["api_keys"][0]["authorization_details"]["assisted_access_context"] + ["unexpected"] = json!(true); + assert_invalid( + &schema, + &context_unknown, + "unknown assisted-access context field", + ); + assert_runtime_rejects(&context_unknown, "unknown assisted-access context field"); +} + +#[test] +fn claim_ref_shorthand_and_object_forms_match_runtime_deserialization() { + let schema = document(); + let mut valid = with_authorization_details(example_config()); + valid["auth"]["api_keys"][0]["authorization_details"]["claims"] = + json!(["enrollment", { "id": "status", "version": "1" }]); + assert_valid( + &schema, + &valid, + "claim-reference shorthand and object forms", + ); + assert_runtime_deserializes(&valid, "claim-reference shorthand and object forms"); + + let mut missing_id = valid.clone(); + missing_id["auth"]["api_keys"][0]["authorization_details"]["claims"] = + json!([{ "version": "1" }]); + assert_invalid(&schema, &missing_id, "claim-reference object without id"); + assert_runtime_rejects(&missing_id, "claim-reference object without id"); + + let mut unknown_field = valid; + unknown_field["auth"]["api_keys"][0]["authorization_details"]["claims"] = + json!([{ "id": "status", "unexpected": true }]); + assert_invalid( + &schema, + &unknown_field, + "claim-reference object with unknown field", + ); + assert_runtime_rejects(&unknown_field, "claim-reference object with unknown field"); +} + +#[test] +fn adapter_rosters_are_generated_from_runtime_labels() { + let schema = document(); + let runtime_fingerprint_providers = CredentialFingerprintProvider::ALL + .iter() + .map(|provider| provider.as_str().to_string()) + .collect(); + assert_eq!( + string_enum( + &schema, + "/$defs/CredentialFingerprintRef/properties/provider/enum", + ), + runtime_fingerprint_providers, + "credential fingerprint provider schema must consume the runtime roster" + ); + for provider in CredentialFingerprintProvider::ALL { + let label = serde_json::to_value(provider).expect("provider serializes"); + assert_eq!(label, json!(provider.as_str())); + assert_eq!( + serde_json::from_value::(label) + .expect("provider label deserializes"), + *provider + ); + } + + let runtime_key_providers = SigningKeyProviderConfig::ALL + .iter() + .map(|provider| provider.as_str().to_string()) + .collect(); + assert_eq!( + string_enum(&schema, "/$defs/SigningKeyProviderSchema/enum"), + runtime_key_providers, + "signing-key provider schema must consume the runtime roster" + ); + for provider in SigningKeyProviderConfig::ALL { + let label = serde_json::to_value(provider).expect("provider serializes"); + assert_eq!(label, json!(provider.as_str())); + assert_eq!( + serde_json::from_value::(label) + .expect("provider label deserializes"), + *provider + ); + } + + let runtime_key_statuses = SigningKeyStatus::ALL + .iter() + .map(|status| status.as_str().to_string()) + .collect(); + assert_eq!( + string_enum(&schema, "/$defs/SigningKeyStatusSchema/enum"), + runtime_key_statuses, + "signing-key status schema must consume the runtime roster" + ); + for status in SigningKeyStatus::ALL { + let label = serde_json::to_value(status).expect("status serializes"); + assert_eq!(label, json!(status.as_str())); + assert_eq!( + serde_json::from_value::(label).expect("status label deserializes"), + *status + ); + } + + let runtime_evidence_modes = [ + serde_json::to_value(ClaimEvidenceMode::RegistryBacked { + consultations: Default::default(), + }) + .expect("registry-backed mode serializes"), + serde_json::to_value(ClaimEvidenceMode::SelfAttested) + .expect("self-attested mode serializes"), + ] + .into_iter() + .map(|value| value["type"].as_str().expect("mode type label").to_string()) + .collect(); + let mut schema_evidence_modes = BTreeSet::new(); + collect_tag_constants( + &schema, + schema + .pointer("/$defs/ClaimEvidenceMode") + .expect("claim evidence-mode schema exists"), + &mut schema_evidence_modes, + ); + assert_eq!( + schema_evidence_modes, runtime_evidence_modes, + "claim evidence-mode schema must share the runtime tagged contract" + ); +} + +#[test] +fn adapter_variant_rosters_and_scalar_shapes_match_runtime_deserialization() { + let schema = document(); + + let valid = example_config(); + assert_valid(&schema, &valid, "maintained registry-backed config"); + assert_runtime_deserializes(&valid, "maintained registry-backed config"); + + let mut invalid_duration = example_config(); + invalid_duration["server"]["request_timeout"] = json!("not-a-duration"); + assert_valid( + &schema, + &invalid_duration, + "duration shape delegated to the runtime parser", + ); + assert_runtime_rejects(&invalid_duration, "invalid duration"); + + let mut invalid_socket = example_config(); + invalid_socket["server"]["bind"] = json!("not-a-socket"); + assert_valid( + &schema, + &invalid_socket, + "socket shape delegated to the runtime parser", + ); + assert_runtime_rejects(&invalid_socket, "invalid socket address"); + + let mut invalid_cidr = example_config(); + invalid_cidr["evidence"]["relay"]["allowed_private_cidrs"] = json!(["not-a-cidr"]); + assert_valid( + &schema, + &invalid_cidr, + "CIDR shape delegated to the runtime parser", + ); + assert_runtime_rejects(&invalid_cidr, "invalid private CIDR"); + + let mut deferred_fingerprint_shape = example_config(); + deferred_fingerprint_shape["auth"]["api_keys"][0]["fingerprint"] = + json!({"provider": "env", "path": "/mounted/fingerprint"}); + assert_valid( + &schema, + &deferred_fingerprint_shape, + "credential-fingerprint reference shape deferred to runtime validation", + ); + assert_runtime_deserializes( + &deferred_fingerprint_shape, + "credential-fingerprint reference shape deferred to runtime validation", + ); + + let mut self_attested = example_config(); + self_attested["evidence"]["claims"][0]["evidence_mode"] = json!({"type": "self_attested"}); + self_attested["evidence"] + .as_object_mut() + .expect("evidence is an object") + .remove("credential_profiles"); + self_attested + .as_object_mut() + .expect("config is an object") + .remove("oid4vci"); + if let Some(subject_access) = self_attested + .get_mut("subject_access") + .and_then(Value::as_object_mut) + { + subject_access.remove("credential_profiles"); + } + assert_valid( + &schema, + &self_attested, + "evaluation-only self-attested claim", + ); + assert_runtime_deserializes(&self_attested, "evaluation-only self-attested claim"); +} + +#[test] +fn schema_contains_no_concrete_secret_values() { + let schema = document(); + let text = serde_json::to_string(&schema).expect("schema serializes"); + for secret in [ + "REGISTRY_NOTARY_DHIS2_API_KEY_HASH", + "REGISTRY_NOTARY_AUDIT_HASH_SECRET", + "/var/run/secrets/registry-notary/relay-access-token", + ] { + assert!( + !text.contains(secret), + "schema must not contain fixture secret material or paths: {secret}" + ); + } +} + +#[test] +fn schema_and_configuration_reference_have_exact_bidirectional_key_path_parity() { + let schema = document(); + let mut schema_paths = BTreeSet::new(); + collect_key_paths(&schema, &schema, "", &mut schema_paths, &mut HashSet::new()); + let reference = fs::read_to_string(stack_root().join(CONFIG_REFERENCE)) + .expect("configuration reference exists"); + let documented_paths = documented_key_paths(&reference); + assert_eq!( + documented_paths, + schema_paths, + "configuration key paths differ; generated schema paths follow:\n{}", + schema_paths.iter().cloned().collect::>().join("\n") + ); +} diff --git a/crates/registry-platform-authcommon/src/lib.rs b/crates/registry-platform-authcommon/src/lib.rs index 4d2a502be..0f201b9d4 100644 --- a/crates/registry-platform-authcommon/src/lib.rs +++ b/crates/registry-platform-authcommon/src/lib.rs @@ -172,25 +172,51 @@ pub fn validate_api_key_entropy(plaintext: &str) -> Result<(), EntropyError> { Ok(()) } -/// Provider for a canonical credential fingerprint. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum CredentialFingerprintProvider { - /// Resolve from an environment variable. - Env, - /// Resolve from a local file. - File, -} +/// Define a closed string vocabulary once for its Rust enum, parser labels, +/// diagnostics, and schema consumers. +macro_rules! define_string_roster { + ( + $(#[$enum_meta:meta])* + $visibility:vis enum $name:ident { + $( + $(#[$variant_meta:meta])* + $variant:ident => $label:literal, + )+ + } + ) => { + $(#[$enum_meta])* + $visibility enum $name { + $( + $(#[$variant_meta])* + #[serde(rename = $label)] + $variant, + )+ + } + + impl $name { + /// Every label accepted by the parser, in stable declaration order. + pub const ALL: &'static [Self] = &[$(Self::$variant),+]; -impl CredentialFingerprintProvider { - /// Stable provider label for redacted diagnostics. - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Env => "env", - Self::File => "file", + /// Stable provider label for redacted diagnostics. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + $(Self::$variant => $label,)+ + } + } } + }; +} + +define_string_roster! { + #[doc = "Provider for a canonical credential fingerprint."] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] + #[non_exhaustive] + pub enum CredentialFingerprintProvider { + /// Resolve from an environment variable. + Env => "env", + /// Resolve from a local file. + File => "file", } } diff --git a/crates/registry-platform-crypto/src/lib.rs b/crates/registry-platform-crypto/src/lib.rs index c1c013f81..a80a7de6f 100644 --- a/crates/registry-platform-crypto/src/lib.rs +++ b/crates/registry-platform-crypto/src/lib.rs @@ -58,56 +58,67 @@ pub enum SigningAlgorithm { Rs256, } -/// Shared, public provider-kind vocabulary for signing keys. -/// -/// Provider-specific connection fields remain product-local so simple local -/// config, PKCS#11, KMS, and future provider syntax can evolve independently. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum KeyProviderKind { - LocalJwkEnv, - FileWatch, - Pkcs11, - LocalPkcs12File, - Kms, - WorkloadIdentity, -} +/// Define a closed string vocabulary once for its Rust enum, parser labels, +/// diagnostics, and schema consumers. +macro_rules! define_string_roster { + ( + $(#[$enum_meta:meta])* + $visibility:vis enum $name:ident { + $( + $(#[$variant_meta:meta])* + $variant:ident => $label:literal, + )+ + } + ) => { + $(#[$enum_meta])* + $visibility enum $name { + $( + $(#[$variant_meta])* + #[serde(rename = $label)] + $variant, + )+ + } -impl KeyProviderKind { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::LocalJwkEnv => "local_jwk_env", - Self::FileWatch => "file_watch", - Self::Pkcs11 => "pkcs11", - Self::LocalPkcs12File => "local_pkcs12_file", - Self::Kms => "kms", - Self::WorkloadIdentity => "workload_identity", + impl $name { + /// Every label accepted by the parser, in stable declaration order. + pub const ALL: &'static [Self] = &[$(Self::$variant),+]; + + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + $(Self::$variant => $label,)+ + } + } } - } + }; } -/// Shared lifecycle status for a configured signing key. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -pub enum KeyStatus { - Active, - PublishOnly, - Disabled, +define_string_roster! { + #[doc = "Shared, public provider-kind vocabulary for signing keys.\n\nProvider-specific connection fields remain product-local so simple local config, PKCS#11, KMS, and future provider syntax can evolve independently."] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] + #[non_exhaustive] + pub enum KeyProviderKind { + LocalJwkEnv => "local_jwk_env", + FileWatch => "file_watch", + Pkcs11 => "pkcs11", + LocalPkcs12File => "local_pkcs12_file", + Kms => "kms", + WorkloadIdentity => "workload_identity", + } } -impl KeyStatus { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Active => "active", - Self::PublishOnly => "publish_only", - Self::Disabled => "disabled", - } +define_string_roster! { + #[doc = "Shared lifecycle status for a configured signing key."] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] + #[non_exhaustive] + pub enum KeyStatus { + Active => "active", + PublishOnly => "publish_only", + Disabled => "disabled", } +} +impl KeyStatus { #[must_use] pub const fn may_sign(self) -> bool { matches!(self, Self::Active) diff --git a/products/notary/CHANGELOG.md b/products/notary/CHANGELOG.md index fb01b0944..1c7eca06a 100644 --- a/products/notary/CHANGELOG.md +++ b/products/notary/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added the committed Draft 2020-12 Registry Notary runtime configuration + schema at `schemas/registry-notary.config.schema.json`. `registry-notary + schema`, `just config-schema-generate`, and the schema drift check all use + the production `StandaloneRegistryNotaryConfig` deserialization graph. + The operator reference now has a bidirectional key-path contract check. + ## [0.11.0] - 2026-07-18 ### Added diff --git a/products/notary/docs/operator-config-reference.md b/products/notary/docs/operator-config-reference.md index 708145126..f7db8af3e 100644 --- a/products/notary/docs/operator-config-reference.md +++ b/products/notary/docs/operator-config-reference.md @@ -6,6 +6,550 @@ access is represented only by one Relay connection plus compiler-produced consultation expectations. Direct registry connections and adapter runtimes are not valid Notary configuration. +The complete deserialization-oriented Draft 2020-12 schema is committed at +[`schemas/registry-notary.config.schema.json`](../../../schemas/registry-notary.config.schema.json). +Reproduce it with `just config-schema-generate`, verify drift with `just +config-schema-check`, or print the exact same bytes with `registry-notary +schema`. The schema checks document structure, closed objects, tagged +variants, scalar types, and key paths. JSON Schema deliberately leaves custom +duration, socket-address, CIDR, and consultation-input grammars to the same +runtime parsers that deserialize them, avoiding a narrower duplicate grammar. +`registry-notary doctor` remains authoritative for environment and secret +availability, filesystem and Relay access, deployment gates, and cross-field +runtime validation. + +{/* registry-notary-config-key-paths:start */} +```text +audit +audit.hash_secret_env +audit.max_files +audit.max_size_mb +audit.path +audit.sink +audit.syslog_socket_path +auth +auth.access_token_signing +auth.access_token_signing.access_token_ttl_seconds +auth.access_token_signing.allowed_algorithms +auth.access_token_signing.allowed_algorithms[] +auth.access_token_signing.audiences +auth.access_token_signing.audiences[] +auth.access_token_signing.enabled +auth.access_token_signing.issuer +auth.access_token_signing.signing_key_id +auth.access_token_signing.token_typ +auth.access_token_signing.verification_key_ids +auth.access_token_signing.verification_key_ids[] +auth.api_keys +auth.api_keys[] +auth.api_keys[].authorization_details +auth.api_keys[].authorization_details.access_mode +auth.api_keys[].authorization_details.actions +auth.api_keys[].authorization_details.actions[] +auth.api_keys[].authorization_details.assisted_access_context +auth.api_keys[].authorization_details.assisted_access_context.channel +auth.api_keys[].authorization_details.assurance_level +auth.api_keys[].authorization_details.claims +auth.api_keys[].authorization_details.claims[] +auth.api_keys[].authorization_details.claims[].id +auth.api_keys[].authorization_details.claims[].version +auth.api_keys[].authorization_details.consent_ref +auth.api_keys[].authorization_details.disclosure +auth.api_keys[].authorization_details.format +auth.api_keys[].authorization_details.jurisdiction +auth.api_keys[].authorization_details.legal_basis_ref +auth.api_keys[].authorization_details.locations +auth.api_keys[].authorization_details.locations[] +auth.api_keys[].authorization_details.purpose +auth.api_keys[].authorization_details.relationship +auth.api_keys[].authorization_details.relationship.proof_claim +auth.api_keys[].authorization_details.relationship.relationship_type +auth.api_keys[].authorization_details.schema_version +auth.api_keys[].authorization_details.subject +auth.api_keys[].authorization_details.subject.binding_claim +auth.api_keys[].authorization_details.subject.id_type +auth.api_keys[].authorization_details.target +auth.api_keys[].authorization_details.target.id +auth.api_keys[].authorization_details.target.id_type +auth.api_keys[].authorization_details.type +auth.api_keys[].fingerprint +auth.api_keys[].fingerprint.name +auth.api_keys[].fingerprint.path +auth.api_keys[].fingerprint.provider +auth.api_keys[].id +auth.api_keys[].scopes +auth.api_keys[].scopes[] +auth.bearer_tokens +auth.bearer_tokens[] +auth.bearer_tokens[].authorization_details +auth.bearer_tokens[].authorization_details.access_mode +auth.bearer_tokens[].authorization_details.actions +auth.bearer_tokens[].authorization_details.actions[] +auth.bearer_tokens[].authorization_details.assisted_access_context +auth.bearer_tokens[].authorization_details.assisted_access_context.channel +auth.bearer_tokens[].authorization_details.assurance_level +auth.bearer_tokens[].authorization_details.claims +auth.bearer_tokens[].authorization_details.claims[] +auth.bearer_tokens[].authorization_details.claims[].id +auth.bearer_tokens[].authorization_details.claims[].version +auth.bearer_tokens[].authorization_details.consent_ref +auth.bearer_tokens[].authorization_details.disclosure +auth.bearer_tokens[].authorization_details.format +auth.bearer_tokens[].authorization_details.jurisdiction +auth.bearer_tokens[].authorization_details.legal_basis_ref +auth.bearer_tokens[].authorization_details.locations +auth.bearer_tokens[].authorization_details.locations[] +auth.bearer_tokens[].authorization_details.purpose +auth.bearer_tokens[].authorization_details.relationship +auth.bearer_tokens[].authorization_details.relationship.proof_claim +auth.bearer_tokens[].authorization_details.relationship.relationship_type +auth.bearer_tokens[].authorization_details.schema_version +auth.bearer_tokens[].authorization_details.subject +auth.bearer_tokens[].authorization_details.subject.binding_claim +auth.bearer_tokens[].authorization_details.subject.id_type +auth.bearer_tokens[].authorization_details.target +auth.bearer_tokens[].authorization_details.target.id +auth.bearer_tokens[].authorization_details.target.id_type +auth.bearer_tokens[].authorization_details.type +auth.bearer_tokens[].fingerprint +auth.bearer_tokens[].fingerprint.name +auth.bearer_tokens[].fingerprint.path +auth.bearer_tokens[].fingerprint.provider +auth.bearer_tokens[].id +auth.bearer_tokens[].scopes +auth.bearer_tokens[].scopes[] +auth.oidc +auth.oidc.allow_insecure_localhost +auth.oidc.allowed_algorithms +auth.oidc.allowed_algorithms[] +auth.oidc.allowed_clients +auth.oidc.allowed_clients[] +auth.oidc.allowed_token_types +auth.oidc.allowed_token_types[] +auth.oidc.audiences +auth.oidc.audiences[] +auth.oidc.issuer +auth.oidc.jwks_url +auth.oidc.leeway +auth.oidc.principal_claim +auth.oidc.scope_claim +auth.oidc.scope_map +auth.oidc.scope_map.* +auth.oidc.scope_map.*[] +auth.oidc.scope_separator +auth.oidc.userinfo_endpoint +auth.oidc.userinfo_issuers +auth.oidc.userinfo_issuers[] +cel +cel.allow_regex +cel.eval_timeout_ms +cel.max_binding_json_bytes +cel.max_expression_bytes +cel.max_list_items +cel.max_object_depth +cel.max_object_keys +cel.max_result_json_bytes +cel.max_string_bytes +cel.mode +cel.worker_count +cel.worker_memory_bytes +cel.worker_stderr_bytes +config_trust +config_trust.antirollback_state_path +config_trust.break_glass_override_path +config_trust.bundle_path +config_trust.trust_anchor_path +credential_status +credential_status.base_url +credential_status.enabled +credential_status.retention_seconds +deployment +deployment.evidence +deployment.evidence.audit_ack_cursor_path +deployment.evidence.audit_ack_max_age_secs +deployment.evidence.audit_offhost_shipping +deployment.evidence.signer_custody_approved +deployment.multi_instance +deployment.profile +deployment.waivers +deployment.waivers[] +deployment.waivers[].expires +deployment.waivers[].finding +deployment.waivers[].reason +evidence +evidence.allowed_purposes +evidence.allowed_purposes[] +evidence.api_base_url +evidence.api_version +evidence.claims +evidence.claims[] +evidence.claims[].cccev +evidence.claims[].cccev.evidence_type +evidence.claims[].cccev.evidence_type_iri +evidence.claims[].cccev.requirement_type +evidence.claims[].credential_profiles +evidence.claims[].credential_profiles[] +evidence.claims[].depends_on +evidence.claims[].depends_on[] +evidence.claims[].disclosure +evidence.claims[].disclosure.allowed +evidence.claims[].disclosure.allowed[] +evidence.claims[].disclosure.default +evidence.claims[].disclosure.downgrade +evidence.claims[].evidence_mode +evidence.claims[].evidence_mode.consultations +evidence.claims[].evidence_mode.consultations.* +evidence.claims[].evidence_mode.consultations.*.inputs +evidence.claims[].evidence_mode.consultations.*.inputs.* +evidence.claims[].evidence_mode.consultations.*.outputs +evidence.claims[].evidence_mode.consultations.*.outputs.* +evidence.claims[].evidence_mode.consultations.*.outputs.*.max_bytes +evidence.claims[].evidence_mode.consultations.*.outputs.*.maximum +evidence.claims[].evidence_mode.consultations.*.outputs.*.minimum +evidence.claims[].evidence_mode.consultations.*.outputs.*.nullable +evidence.claims[].evidence_mode.consultations.*.outputs.*.type +evidence.claims[].evidence_mode.consultations.*.profile +evidence.claims[].evidence_mode.consultations.*.profile.contract_hash +evidence.claims[].evidence_mode.consultations.*.profile.id +evidence.claims[].evidence_mode.type +evidence.claims[].formats +evidence.claims[].formats[] +evidence.claims[].id +evidence.claims[].inputs +evidence.claims[].inputs[] +evidence.claims[].inputs[].name +evidence.claims[].inputs[].type +evidence.claims[].oots +evidence.claims[].oots.authentication_level_of_assurance +evidence.claims[].oots.enabled +evidence.claims[].oots.evidence_type_classification +evidence.claims[].oots.evidence_type_list +evidence.claims[].oots.languages +evidence.claims[].oots.languages[] +evidence.claims[].oots.reference_framework +evidence.claims[].oots.requirement +evidence.claims[].operations +evidence.claims[].operations.batch_evaluate +evidence.claims[].operations.batch_evaluate.enabled +evidence.claims[].operations.batch_evaluate.max_subjects +evidence.claims[].operations.evaluate +evidence.claims[].operations.evaluate.enabled +evidence.claims[].purpose +evidence.claims[].required_scopes +evidence.claims[].required_scopes[] +evidence.claims[].rule +evidence.claims[].rule.bindings +evidence.claims[].rule.bindings.claims +evidence.claims[].rule.bindings.claims.* +evidence.claims[].rule.bindings.claims.*.binding_type +evidence.claims[].rule.bindings.claims.*.claim +evidence.claims[].rule.bindings.vars +evidence.claims[].rule.consultation +evidence.claims[].rule.expression +evidence.claims[].rule.output +evidence.claims[].rule.type +evidence.claims[].semantics +evidence.claims[].semantics.concept +evidence.claims[].semantics.derived_from +evidence.claims[].semantics.derived_from[] +evidence.claims[].semantics.predicate +evidence.claims[].semantics.property +evidence.claims[].semantics.value_mapping +evidence.claims[].semantics.vocabulary +evidence.claims[].subject_type +evidence.claims[].title +evidence.claims[].value +evidence.claims[].value.nullable +evidence.claims[].value.type +evidence.claims[].value.unit +evidence.claims[].version +evidence.claims_url +evidence.concurrency +evidence.concurrency.subjects +evidence.credential_profiles +evidence.credential_profiles.* +evidence.credential_profiles.*.allowed_claims +evidence.credential_profiles.*.allowed_claims[] +evidence.credential_profiles.*.disclosure +evidence.credential_profiles.*.disclosure.allowed +evidence.credential_profiles.*.disclosure.allowed[] +evidence.credential_profiles.*.format +evidence.credential_profiles.*.holder_binding +evidence.credential_profiles.*.holder_binding.allowed_did_methods +evidence.credential_profiles.*.holder_binding.allowed_did_methods[] +evidence.credential_profiles.*.holder_binding.mode +evidence.credential_profiles.*.holder_binding.proof_of_possession +evidence.credential_profiles.*.issuer +evidence.credential_profiles.*.signing_key +evidence.credential_profiles.*.validity_seconds +evidence.credential_profiles.*.vct +evidence.enabled +evidence.formats_url +evidence.inline_batch_limit +evidence.machine_quota +evidence.machine_quota.enabled +evidence.machine_quota.subjects_per_minute +evidence.max_credential_validity_seconds +evidence.relay +evidence.relay.allow_insecure_localhost +evidence.relay.allowed_private_cidrs +evidence.relay.allowed_private_cidrs[] +evidence.relay.base_url +evidence.relay.max_in_flight +evidence.relay.token_file +evidence.relay.workload_client_id +evidence.service_id +evidence.signing_keys +evidence.signing_keys.* +evidence.signing_keys.*.alg +evidence.signing_keys.*.key_id_hex +evidence.signing_keys.*.key_label +evidence.signing_keys.*.kid +evidence.signing_keys.*.module_path +evidence.signing_keys.*.password_env +evidence.signing_keys.*.path +evidence.signing_keys.*.pin_env +evidence.signing_keys.*.private_jwk_env +evidence.signing_keys.*.provider +evidence.signing_keys.*.public_jwk_env +evidence.signing_keys.*.publish_until_unix_seconds +evidence.signing_keys.*.status +evidence.signing_keys.*.token_label +evidence.variables +evidence.variables.* +evidence.variables.*.from +evidence.variables.*.type +federation +federation.clock_leeway_seconds +federation.emergency_denylist +federation.emergency_denylist.kids +federation.emergency_denylist.kids[] +federation.emergency_denylist.node_ids +federation.emergency_denylist.node_ids[] +federation.enabled +federation.evaluation_profiles +federation.evaluation_profiles[] +federation.evaluation_profiles[].assurance_level +federation.evaluation_profiles[].claim_id +federation.evaluation_profiles[].consent_ref +federation.evaluation_profiles[].disclosure +federation.evaluation_profiles[].id +federation.evaluation_profiles[].jurisdiction +federation.evaluation_profiles[].legal_basis_ref +federation.evaluation_profiles[].max_claim_result_age_seconds +federation.evaluation_profiles[].ruleset +federation.evaluation_profiles[].subject_id_type +federation.federation_api +federation.inbound_body_limit_bytes +federation.issuer +federation.jwks_uri +federation.max_request_lifetime_seconds +federation.node_id +federation.pairwise_subject_hash +federation.pairwise_subject_hash.secret_env +federation.peers +federation.peers[] +federation.peers[].allow_insecure_localhost +federation.peers[].allow_insecure_private_network +federation.peers[].allowed_profiles +federation.peers[].allowed_profiles[] +federation.peers[].allowed_protocol_versions +federation.peers[].allowed_protocol_versions[] +federation.peers[].allowed_purposes +federation.peers[].allowed_purposes[] +federation.peers[].evaluation_scopes +federation.peers[].evaluation_scopes[] +federation.peers[].issuer +federation.peers[].jwks_uri +federation.peers[].node_id +federation.response_shaping +federation.response_shaping.minimum_denial_latency_ms +federation.signing +federation.signing.signing_key +federation.supported_protocol_versions +federation.supported_protocol_versions[] +instance +instance.environment +instance.id +instance.jurisdiction +instance.owner +instance.public_base_url +oid4vci +oid4vci.accepted_token_audiences +oid4vci.accepted_token_audiences[] +oid4vci.authorization +oid4vci.authorization.require_pkce_method +oid4vci.authorization_servers +oid4vci.authorization_servers[] +oid4vci.credential_configurations +oid4vci.credential_configurations.* +oid4vci.credential_configurations.*.claim_id +oid4vci.credential_configurations.*.claims +oid4vci.credential_configurations.*.claims[] +oid4vci.credential_configurations.*.claims[].display_name +oid4vci.credential_configurations.*.claims[].id +oid4vci.credential_configurations.*.claims[].output_path +oid4vci.credential_configurations.*.claims[].output_path[] +oid4vci.credential_configurations.*.claims[].sd +oid4vci.credential_configurations.*.credential_profile +oid4vci.credential_configurations.*.cryptographic_binding_methods_supported +oid4vci.credential_configurations.*.cryptographic_binding_methods_supported[] +oid4vci.credential_configurations.*.display +oid4vci.credential_configurations.*.display.background_color +oid4vci.credential_configurations.*.display.background_image +oid4vci.credential_configurations.*.display.background_image.alt_text +oid4vci.credential_configurations.*.display.background_image.uri +oid4vci.credential_configurations.*.display.background_image.url +oid4vci.credential_configurations.*.display.description +oid4vci.credential_configurations.*.display.locale +oid4vci.credential_configurations.*.display.logo +oid4vci.credential_configurations.*.display.logo.alt_text +oid4vci.credential_configurations.*.display.logo.uri +oid4vci.credential_configurations.*.display.logo.url +oid4vci.credential_configurations.*.display.secondary_image +oid4vci.credential_configurations.*.display.secondary_image.alt_text +oid4vci.credential_configurations.*.display.secondary_image.uri +oid4vci.credential_configurations.*.display.secondary_image.url +oid4vci.credential_configurations.*.display.text_color +oid4vci.credential_configurations.*.display_name +oid4vci.credential_configurations.*.format +oid4vci.credential_configurations.*.proof_signing_alg_values_supported +oid4vci.credential_configurations.*.proof_signing_alg_values_supported[] +oid4vci.credential_configurations.*.scope +oid4vci.credential_configurations.*.vct +oid4vci.credential_endpoint +oid4vci.credential_issuer +oid4vci.display +oid4vci.display[] +oid4vci.display[].locale +oid4vci.display[].logo +oid4vci.display[].logo.alt_text +oid4vci.display[].logo.uri +oid4vci.display[].logo.url +oid4vci.display[].name +oid4vci.enabled +oid4vci.nonce +oid4vci.nonce.enabled +oid4vci.nonce.ttl_seconds +oid4vci.nonce_endpoint +oid4vci.offer_endpoint +oid4vci.pre_authorized_code +oid4vci.pre_authorized_code.enabled +oid4vci.pre_authorized_code.esignet +oid4vci.pre_authorized_code.esignet.allow_insecure_localhost +oid4vci.pre_authorized_code.esignet.authorize_url +oid4vci.pre_authorized_code.esignet.client_id +oid4vci.pre_authorized_code.esignet.client_signing_key_id +oid4vci.pre_authorized_code.esignet.issuer +oid4vci.pre_authorized_code.esignet.jwks_uri +oid4vci.pre_authorized_code.esignet.login_state_ttl_seconds +oid4vci.pre_authorized_code.esignet.redirect_uri +oid4vci.pre_authorized_code.esignet.scopes +oid4vci.pre_authorized_code.esignet.scopes[] +oid4vci.pre_authorized_code.esignet.token_url +oid4vci.pre_authorized_code.esignet.userinfo_url +oid4vci.pre_authorized_code.pre_authorized_code_ttl_seconds +oid4vci.pre_authorized_code.tx_code +oid4vci.pre_authorized_code.tx_code.input_mode +oid4vci.pre_authorized_code.tx_code.length +oid4vci.pre_authorized_code.tx_code.required +oid4vci.proof +oid4vci.proof.max_age_seconds +oid4vci.proof.max_clock_skew_seconds +server +server.admin_listener +server.admin_listener.bind +server.admin_listener.mode +server.bind +server.cors +server.cors.allowed_origins +server.cors.allowed_origins[] +server.http1_header_read_timeout +server.max_connections +server.openapi_requires_auth +server.request_body_timeout +server.request_timeout +server.trusted_proxy_ips +server.trusted_proxy_ips[] +state +state.postgresql +state.postgresql.connect_timeout_ms +state.postgresql.max_connections +state.postgresql.operation_timeout_ms +state.postgresql.root_certificate_path +state.postgresql.sensitive_state_key_env +state.postgresql.url_env +state.storage +subject_access +subject_access.allowed_claims +subject_access.allowed_claims[] +subject_access.allowed_disclosures +subject_access.allowed_disclosures[] +subject_access.allowed_formats +subject_access.allowed_formats[] +subject_access.allowed_operations +subject_access.allowed_operations.batch_evaluate +subject_access.allowed_operations.evaluate +subject_access.allowed_operations.issue_credential +subject_access.allowed_operations.render +subject_access.allowed_purposes +subject_access.allowed_purposes[] +subject_access.allowed_wallet_origins +subject_access.allowed_wallet_origins[] +subject_access.citizen_clients +subject_access.citizen_clients.allowed_audiences +subject_access.citizen_clients.allowed_audiences[] +subject_access.citizen_clients.allowed_client_ids +subject_access.citizen_clients.allowed_client_ids[] +subject_access.credential_profiles +subject_access.credential_profiles[] +subject_access.delegation +subject_access.delegation.allowed_relationships +subject_access.delegation.allowed_relationships[] +subject_access.delegation.allowed_relationships[].allowed_claims +subject_access.delegation.allowed_relationships[].allowed_claims[] +subject_access.delegation.allowed_relationships[].allowed_disclosures +subject_access.delegation.allowed_relationships[].allowed_disclosures[] +subject_access.delegation.allowed_relationships[].allowed_formats +subject_access.delegation.allowed_relationships[].allowed_formats[] +subject_access.delegation.allowed_relationships[].allowed_purposes +subject_access.delegation.allowed_relationships[].allowed_purposes[] +subject_access.delegation.allowed_relationships[].credential_profiles +subject_access.delegation.allowed_relationships[].credential_profiles[] +subject_access.delegation.allowed_relationships[].proof_claim +subject_access.delegation.allowed_relationships[].relationship_type +subject_access.delegation.allowed_relationships[].target_id_type +subject_access.delegation.enabled +subject_access.enabled +subject_access.rate_limits +subject_access.rate_limits.credential_issuance_per_principal_per_hour +subject_access.rate_limits.invalid_token_per_client_address_per_minute +subject_access.rate_limits.per_holder_per_hour +subject_access.rate_limits.per_principal_per_minute +subject_access.rate_limits.subject_mismatch_per_principal_per_hour +subject_access.rate_limits.tx_code_attempts_per_code_per_minute +subject_access.required_scopes +subject_access.required_scopes[] +subject_access.scope_policy +subject_access.subject_binding +subject_access.subject_binding.allow_sub_as_civil_id +subject_access.subject_binding.claim_source +subject_access.subject_binding.id_type +subject_access.subject_binding.normalize +subject_access.subject_binding.request_field +subject_access.subject_binding.token_claim +subject_access.token_policy +subject_access.token_policy.assurance_claim_source +subject_access.token_policy.max_access_token_lifetime_seconds +subject_access.token_policy.max_auth_age_seconds +subject_access.token_policy.max_clock_leeway_seconds +subject_access.token_policy.max_credential_validity_seconds +subject_access.token_policy.max_evaluation_age_seconds +subject_access.token_policy.required_acr_values +subject_access.token_policy.required_acr_values[] +``` +{/* registry-notary-config-key-paths:end */} + ## Top-level areas | Area | Responsibility | @@ -102,6 +646,11 @@ together fails before either credential is authenticated. Static bearer tokens cannot be configured alongside OIDC because both use the `Authorization: Bearer` transport. +`authorization_details` is a closed authorization-policy object, including its +subject, target, relationship, and assisted-access nested objects. Unknown +fields are rejected at load time so a misspelled or unreviewed policy input +cannot silently weaken caller binding. + Citizen and wallet flows use the self-attestation subject-binding policy. Delegated access must bind requester, target, relationship, purpose, and authorization details before evaluation. A delegated Relay proof consultation diff --git a/products/notary/justfile b/products/notary/justfile index 390e54278..5bbc999bc 100644 --- a/products/notary/justfile +++ b/products/notary/justfile @@ -47,6 +47,14 @@ audit: openapi-generate: cargo run -q -p registry-notary -- openapi +# Reproduce the committed Draft 2020-12 runtime configuration schema. +config-schema-generate: + cargo run --quiet --locked -p registry-notary -- schema > ../../schemas/registry-notary.config.schema.json + +# Fail when the product-owned runtime schema has drifted from its config graph. +config-schema-check: + tmp_schema="$(mktemp)"; trap 'rm -f "$tmp_schema"' EXIT; cargo run --quiet --locked -p registry-notary -- schema > "$tmp_schema"; cmp -s ../../schemas/registry-notary.config.schema.json "$tmp_schema" || { diff -u ../../schemas/registry-notary.config.schema.json "$tmp_schema"; exit 1; } + # Validate the committed OpenAPI baseline. openapi-check: python3 scripts/check_security_assurance.py openapi-baseline @@ -69,7 +77,7 @@ security: ./scripts/check-security.sh # Run the monorepo CI preflight for Registry Notary crates. -ci-preflight: +ci-preflight: config-schema-check ./scripts/ci-preflight.sh # Run the full local CI gate. diff --git a/schemas/registry-notary.config.schema.json b/schemas/registry-notary.config.schema.json new file mode 100644 index 000000000..ae4cee761 --- /dev/null +++ b/schemas/registry-notary.config.schema.json @@ -0,0 +1,3161 @@ +{ + "$defs": { + "AccessMode": { + "enum": [ + "unknown", + "machine_client", + "subject_bound", + "delegated_attestation" + ], + "type": "string" + }, + "AccessTokenSigningConfig": { + "additionalProperties": false, + "description": "Self-issued access-token signing configuration.\n\nWhen `enabled`, the Notary mints its own access tokens (for the\npre-authorized-code flow) signed with a dedicated `signing_keys` entry that\nMUST be distinct from any credential-signing key. The minted token's\n`iss`/`aud`/`typ`/alg pin the second verifier's trust anchor.", + "properties": { + "access_token_ttl_seconds": { + "default": 300, + "description": "Access-token lifetime in seconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "allowed_algorithms": { + "default": [ + "EdDSA" + ], + "description": "Allowed signing algorithms. Only EdDSA is supported.", + "items": { + "type": "string" + }, + "type": "array" + }, + "audiences": { + "default": [], + "description": "Audiences (`aud`) accepted for Notary-minted access tokens.", + "items": { + "type": "string" + }, + "type": "array" + }, + "enabled": { + "default": false, + "type": "boolean" + }, + "issuer": { + "default": "", + "description": "Issuer (`iss`) the Notary stamps into its own access tokens.", + "type": "string" + }, + "signing_key_id": { + "default": "", + "description": "`evidence.signing_keys` entry used to sign access tokens. Must be a\ndedicated key, never a credential-signing key.", + "type": "string" + }, + "token_typ": { + "default": "registry-notary-access+jwt", + "description": "Header `typ` stamped into Notary access tokens, distinct from the\ncredential `typ` so a token cannot be replayed as another class.", + "type": "string" + }, + "verification_key_ids": { + "description": "Additional publish-only `evidence.signing_keys` entries accepted for\nverifying previously minted Notary access tokens and pre-authorized\ncodes during a governed key rotation.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "BatchOperationConfig": { + "additionalProperties": false, + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "max_subjects": { + "default": 100, + "format": "uint", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "CccevConfig": { + "additionalProperties": false, + "properties": { + "evidence_type": { + "type": [ + "string", + "null" + ] + }, + "evidence_type_iri": { + "type": [ + "string", + "null" + ] + }, + "requirement_type": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "CelBindingsConfig": { + "additionalProperties": false, + "properties": { + "claims": { + "additionalProperties": { + "$ref": "#/$defs/ClaimBindingConfig" + }, + "default": {}, + "type": "object" + }, + "vars": { + "additionalProperties": true, + "default": {}, + "type": "object" + } + }, + "type": "object" + }, + "ClaimBindingConfig": { + "additionalProperties": false, + "properties": { + "binding_type": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "claim": { + "type": "string" + } + }, + "required": [ + "claim" + ], + "type": "object" + }, + "ClaimDefinition": { + "additionalProperties": false, + "properties": { + "cccev": { + "anyOf": [ + { + "$ref": "#/$defs/CccevConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "credential_profiles": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "depends_on": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "disclosure": { + "$ref": "#/$defs/DisclosureConfig", + "default": { + "allowed": [ + "redacted" + ], + "default": "redacted", + "downgrade": "deny" + } + }, + "evidence_mode": { + "$ref": "#/$defs/ClaimEvidenceMode", + "description": "Sealed provenance choice. This field is intentionally required so an\nomitted connection can never turn a registry-backed claim into a\nsource-free claim." + }, + "formats": { + "default": [ + "application/vnd.registry-notary.claim-result+json" + ], + "description": "Omitting this field keeps existing authored claims renderable using the\ncanonical claim-result representation. An explicitly empty list is\nrejected during configuration validation.", + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "inputs": { + "default": [], + "items": { + "$ref": "#/$defs/ClaimInputConfig" + }, + "type": "array" + }, + "oots": { + "anyOf": [ + { + "$ref": "#/$defs/OotsConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "operations": { + "$ref": "#/$defs/ClaimOperationsConfig", + "default": { + "batch_evaluate": { + "enabled": false, + "max_subjects": 100 + }, + "evaluate": { + "enabled": true + } + } + }, + "purpose": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "required_scopes": { + "default": [], + "description": "Caller scopes checked before any registry consultation is dispatched.", + "items": { + "type": "string" + }, + "type": "array" + }, + "rule": { + "$ref": "#/$defs/RuleConfig" + }, + "semantics": { + "anyOf": [ + { + "$ref": "#/$defs/ClaimSemanticConfig" + }, + { + "type": "null" + } + ] + }, + "subject_type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "value": { + "$ref": "#/$defs/ClaimValueConfig", + "default": { + "type": "", + "unit": null + } + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "title", + "version", + "subject_type", + "evidence_mode", + "rule" + ], + "type": "object" + }, + "ClaimEvidenceMode": { + "$ref": "#/$defs/SealedClaimEvidenceMode" + }, + "ClaimInputConfig": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + }, + "ClaimOperationsConfig": { + "additionalProperties": false, + "properties": { + "batch_evaluate": { + "$ref": "#/$defs/BatchOperationConfig", + "default": { + "enabled": false, + "max_subjects": 100 + } + }, + "evaluate": { + "$ref": "#/$defs/OperationConfig", + "default": { + "enabled": true + } + } + }, + "type": "object" + }, + "ClaimRef": { + "$ref": "#/$defs/WireClaimRef" + }, + "ClaimRefObject": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "version": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ClaimSemanticConfig": { + "additionalProperties": false, + "properties": { + "concept": { + "type": [ + "string", + "null" + ] + }, + "derived_from": { + "items": { + "type": "string" + }, + "type": "array" + }, + "predicate": { + "type": [ + "string", + "null" + ] + }, + "property": { + "type": [ + "string", + "null" + ] + }, + "value_mapping": { + "type": [ + "string", + "null" + ] + }, + "vocabulary": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ClaimValueConfig": { + "additionalProperties": false, + "properties": { + "nullable": { + "type": "boolean" + }, + "type": { + "default": "", + "type": "string" + }, + "unit": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "ConcurrencyConfig": { + "additionalProperties": false, + "description": "Per-request cap on concurrently evaluated subjects.", + "properties": { + "subjects": { + "default": 16, + "format": "uint", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "ConfigTrustConfig": { + "additionalProperties": false, + "description": "Optional governed-configuration local trust state.\n\nSimple local deployments omit this block. Signed/governed apply requires it\nso anti-rollback state lives in an explicit durable location.", + "properties": { + "antirollback_state_path": { + "type": "string" + }, + "break_glass_override_path": { + "type": [ + "string", + "null" + ] + }, + "bundle_path": { + "type": "string" + }, + "trust_anchor_path": { + "type": "string" + } + }, + "required": [ + "trust_anchor_path", + "bundle_path", + "antirollback_state_path" + ], + "type": "object" + }, + "CredentialDisclosureConfig": { + "additionalProperties": false, + "properties": { + "allowed": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "CredentialFingerprintRef": { + "additionalProperties": false, + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "provider": { + "enum": [ + "env", + "file" + ], + "type": "string" + } + }, + "required": [ + "provider" + ], + "type": "object" + }, + "CredentialProfileConfig": { + "additionalProperties": false, + "properties": { + "allowed_claims": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "disclosure": { + "$ref": "#/$defs/CredentialDisclosureConfig", + "default": { + "allowed": [] + } + }, + "format": { + "type": "string" + }, + "holder_binding": { + "$ref": "#/$defs/HolderBindingConfig", + "default": { + "allowed_did_methods": [ + "did:jwk" + ], + "mode": "did", + "proof_of_possession": null + } + }, + "issuer": { + "type": "string" + }, + "signing_key": { + "type": "string" + }, + "validity_seconds": { + "default": 600, + "format": "int64", + "type": "integer" + }, + "vct": { + "type": "string" + } + }, + "required": [ + "format", + "issuer", + "signing_key", + "vct" + ], + "type": "object" + }, + "CredentialStatusConfig": { + "additionalProperties": false, + "properties": { + "base_url": { + "default": "", + "type": "string" + }, + "enabled": { + "default": false, + "type": "boolean" + }, + "retention_seconds": { + "default": 86400, + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "DeploymentConfig": { + "additionalProperties": false, + "description": "The operator-declared `deployment` config block.\n\nAn absent profile means an undeclared deployment, which refuses startup. The\n`multi_instance` flag is an operator declaration that the instance is one of\nseveral sharing the same workload; it is never inferred.", + "properties": { + "evidence": { + "$ref": "#/$defs/DeploymentEvidenceConfig", + "default": { + "audit_offhost_shipping": false, + "signer_custody_approved": false + }, + "description": "Operator declarations of assurance evidence the runtime cannot observe\nfor itself. Absent declarations leave the corresponding gates active." + }, + "multi_instance": { + "default": false, + "type": "boolean" + }, + "profile": { + "anyOf": [ + { + "$ref": "#/$defs/DeploymentProfile" + }, + { + "type": "null" + } + ] + }, + "waivers": { + "items": { + "$ref": "#/$defs/DeploymentWaiverConfig" + }, + "type": "array" + } + }, + "type": "object" + }, + "DeploymentEvidenceConfig": { + "additionalProperties": false, + "description": "Operator-asserted assurance evidence for conditions the runtime cannot\nobserve directly. Each flag defaults to `false`, meaning \"no evidence\ndeclared\", which keeps the corresponding gate active until the operator\nasserts the control is in place out of band.", + "properties": { + "audit_ack_cursor_path": { + "description": "Optional path to a `registry.audit.ack_cursor.v1` file maintained by\nwhatever ships audit events off-host. Runtime health requires both a\nfresh timestamp and a watermark equal to the live keyed audit-chain tail.", + "type": [ + "string", + "null" + ] + }, + "audit_ack_max_age_secs": { + "description": "Optional freshness window, in seconds, for the off-host ack cursor.\nUnset defaults to [`DEFAULT_AUDIT_ACK_MAX_AGE`] (900s). Meaningless\nwithout `audit_ack_cursor_path`; config load rejects that combination.\n\n[`DEFAULT_AUDIT_ACK_MAX_AGE`]: registry_platform_ops::DEFAULT_AUDIT_ACK_MAX_AGE", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "audit_offhost_shipping": { + "default": false, + "description": "Operator asserts audit log events are shipped off-host (for example to\na log aggregator or SIEM) so a local file sink does not cap retention.", + "type": "boolean" + }, + "signer_custody_approved": { + "default": false, + "description": "Operator asserts a production review has approved signer custody for\nthis deployment. Provider kind is not proof of custody: PKCS#11 modules\ncan be backed by either hardware or software tokens.", + "type": "boolean" + } + }, + "type": "object" + }, + "DeploymentProfile": { + "description": "The set of deployment profiles an operator can declare.\n\nFrozen at introduction; new profiles may be added but existing ones never\nchange meaning. Deserialization is strict: an unknown profile string fails,\nwhich surfaces as a startup error.", + "enum": [ + "local", + "hosted_lab", + "production", + "evidence_grade" + ], + "type": "string" + }, + "DeploymentWaiverConfig": { + "additionalProperties": false, + "description": "One operator-configured waiver.\n\nA waiver names exactly one finding id, a free-text reason, and a mandatory\nexpiry date (`YYYY-MM-DD`). Reasons must not contain secrets.", + "properties": { + "expires": { + "type": "string" + }, + "finding": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "finding", + "reason", + "expires" + ], + "type": "object" + }, + "DisclosureConfig": { + "additionalProperties": false, + "properties": { + "allowed": { + "default": [ + "redacted" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "default": { + "default": "redacted", + "type": "string" + }, + "downgrade": { + "default": "deny", + "type": "string" + } + }, + "type": "object" + }, + "EvidenceAssistedAccessContext": { + "additionalProperties": false, + "properties": { + "channel": { + "type": "string" + } + }, + "required": [ + "channel" + ], + "type": "object" + }, + "EvidenceAuditConfig": { + "additionalProperties": false, + "properties": { + "hash_secret_env": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "max_files": { + "default": null, + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "max_size_mb": { + "default": null, + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "path": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "sink": { + "default": "stdout", + "type": "string" + }, + "syslog_socket_path": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "EvidenceAuthConfig": { + "additionalProperties": false, + "properties": { + "access_token_signing": { + "$ref": "#/$defs/AccessTokenSigningConfig", + "default": { + "access_token_ttl_seconds": 300, + "allowed_algorithms": [ + "EdDSA" + ], + "audiences": [], + "enabled": false, + "issuer": "", + "signing_key_id": "", + "token_typ": "registry-notary-access+jwt" + }, + "description": "Trust anchor for Notary-minted access tokens (the pre-authorized-code\nflow's second verifier). Disabled by default so existing configs load\nunchanged." + }, + "api_keys": { + "default": [], + "items": { + "$ref": "#/$defs/EvidenceCredentialConfig" + }, + "type": "array" + }, + "bearer_tokens": { + "default": [], + "items": { + "$ref": "#/$defs/EvidenceCredentialConfig" + }, + "type": "array" + }, + "oidc": { + "anyOf": [ + { + "$ref": "#/$defs/EvidenceOidcAuthConfig" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "type": "object" + }, + "EvidenceAuthorizationDetails": { + "additionalProperties": false, + "properties": { + "access_mode": { + "anyOf": [ + { + "$ref": "#/$defs/AccessMode" + }, + { + "type": "null" + } + ] + }, + "actions": { + "items": { + "type": "string" + }, + "type": "array" + }, + "assisted_access_context": { + "anyOf": [ + { + "$ref": "#/$defs/EvidenceAssistedAccessContext" + }, + { + "type": "null" + } + ] + }, + "assurance_level": { + "type": [ + "string", + "null" + ] + }, + "claims": { + "items": { + "$ref": "#/$defs/ClaimRef" + }, + "type": "array" + }, + "consent_ref": { + "type": [ + "string", + "null" + ] + }, + "disclosure": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "jurisdiction": { + "type": [ + "string", + "null" + ] + }, + "legal_basis_ref": { + "type": [ + "string", + "null" + ] + }, + "locations": { + "items": { + "type": "string" + }, + "type": "array" + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "relationship": { + "anyOf": [ + { + "$ref": "#/$defs/EvidenceAuthorizationRelationship" + }, + { + "type": "null" + } + ] + }, + "schema_version": { + "type": "string" + }, + "subject": { + "anyOf": [ + { + "$ref": "#/$defs/EvidenceAuthorizationSubject" + }, + { + "type": "null" + } + ] + }, + "target": { + "anyOf": [ + { + "$ref": "#/$defs/EvidenceAuthorizationTarget" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "schema_version" + ], + "type": "object" + }, + "EvidenceAuthorizationRelationship": { + "additionalProperties": false, + "properties": { + "proof_claim": { + "type": "string" + }, + "relationship_type": { + "type": "string" + } + }, + "required": [ + "relationship_type", + "proof_claim" + ], + "type": "object" + }, + "EvidenceAuthorizationSubject": { + "additionalProperties": false, + "properties": { + "binding_claim": { + "type": "string" + }, + "id_type": { + "type": "string" + } + }, + "required": [ + "binding_claim", + "id_type" + ], + "type": "object" + }, + "EvidenceAuthorizationTarget": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "id_type": { + "type": "string" + } + }, + "required": [ + "id_type", + "id" + ], + "type": "object" + }, + "EvidenceConfig": { + "additionalProperties": false, + "description": "Registry Notary configuration. Disabled by default so existing\nRegistry Relay deployments load unchanged.", + "properties": { + "allowed_purposes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "api_base_url": { + "default": "/", + "type": "string" + }, + "api_version": { + "default": "2026-05", + "type": "string" + }, + "claims": { + "default": [], + "items": { + "$ref": "#/$defs/ClaimDefinition" + }, + "type": "array" + }, + "claims_url": { + "default": "/v1/claims", + "type": "string" + }, + "concurrency": { + "$ref": "#/$defs/ConcurrencyConfig", + "default": { + "subjects": 16 + }, + "description": "Per-request cap for concurrently evaluated subjects." + }, + "credential_profiles": { + "additionalProperties": { + "$ref": "#/$defs/CredentialProfileConfig" + }, + "default": {}, + "type": "object" + }, + "enabled": { + "default": false, + "type": "boolean" + }, + "formats_url": { + "default": "/v1/formats", + "type": "string" + }, + "inline_batch_limit": { + "default": 100, + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "machine_quota": { + "$ref": "#/$defs/MachineQuotaConfig", + "default": { + "enabled": false, + "subjects_per_minute": 6000 + }, + "description": "Per-principal budget for machine `evaluate`/`batch_evaluate` traffic,\ncounted in subjects (a single evaluate consumes 1; a batch consumes\n`items.len()`) over a fixed one-minute window. Disabled by default." + }, + "max_credential_validity_seconds": { + "default": 600, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "relay": { + "anyOf": [ + { + "$ref": "#/$defs/RelayConnectionConfig" + }, + { + "type": "null" + } + ], + "description": "The one Registry Relay connection available to registry-backed claims.\nAuthentication remains a reloadable local file reference; core never\nloads the bearer token value." + }, + "service_id": { + "default": "registry-notary", + "type": "string" + }, + "signing_keys": { + "additionalProperties": { + "$ref": "#/$defs/SigningKeyConfig" + }, + "default": {}, + "type": "object" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/RequestVariableConfig" + }, + "default": {}, + "description": "Closed union of request variables declared by authored services.", + "type": "object" + } + }, + "type": "object" + }, + "EvidenceCredentialConfig": { + "additionalProperties": false, + "properties": { + "authorization_details": { + "anyOf": [ + { + "$ref": "#/$defs/EvidenceAuthorizationDetails" + }, + { + "type": "null" + } + ] + }, + "fingerprint": { + "$ref": "#/$defs/CredentialFingerprintRef" + }, + "id": { + "type": "string" + }, + "scopes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "id", + "fingerprint" + ], + "type": "object" + }, + "EvidenceOidcAuthConfig": { + "additionalProperties": false, + "properties": { + "allow_insecure_localhost": { + "default": false, + "type": "boolean" + }, + "allowed_algorithms": { + "default": [ + "EdDSA" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_clients": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_token_types": { + "default": [ + "JWT" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "audiences": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "issuer": { + "type": "string" + }, + "jwks_url": { + "type": "string" + }, + "leeway": { + "$ref": "#/$defs/HumantimeDuration", + "default": "1m" + }, + "principal_claim": { + "default": "sub", + "type": "string" + }, + "scope_claim": { + "default": "scope", + "type": "string" + }, + "scope_map": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default": {}, + "type": "object" + }, + "scope_separator": { + "default": " ", + "type": "string" + }, + "userinfo_endpoint": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "userinfo_issuers": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "issuer", + "jwks_url" + ], + "type": "object" + }, + "FederationConfig": { + "additionalProperties": false, + "properties": { + "clock_leeway_seconds": { + "default": 60, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "emergency_denylist": { + "$ref": "#/$defs/FederationEmergencyDenylistConfig", + "default": { + "kids": [], + "node_ids": [] + } + }, + "enabled": { + "default": false, + "type": "boolean" + }, + "evaluation_profiles": { + "default": [], + "items": { + "$ref": "#/$defs/FederationEvaluationProfileConfig" + }, + "type": "array" + }, + "federation_api": { + "default": "", + "type": "string" + }, + "inbound_body_limit_bytes": { + "default": 16384, + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "issuer": { + "default": "", + "type": "string" + }, + "jwks_uri": { + "default": "", + "type": "string" + }, + "max_request_lifetime_seconds": { + "default": 300, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "node_id": { + "default": "", + "type": "string" + }, + "pairwise_subject_hash": { + "$ref": "#/$defs/FederationPairwiseSubjectHashConfig", + "default": { + "secret_env": "" + } + }, + "peers": { + "default": [], + "items": { + "$ref": "#/$defs/FederationPeerConfig" + }, + "type": "array" + }, + "response_shaping": { + "$ref": "#/$defs/FederationResponseShapingConfig", + "default": { + "minimum_denial_latency_ms": 250 + } + }, + "signing": { + "$ref": "#/$defs/FederationSigningConfig", + "default": { + "signing_key": "" + } + }, + "supported_protocol_versions": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "FederationEmergencyDenylistConfig": { + "additionalProperties": false, + "properties": { + "kids": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "node_ids": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "FederationEvaluationProfileConfig": { + "additionalProperties": false, + "properties": { + "assurance_level": { + "type": [ + "string", + "null" + ] + }, + "claim_id": { + "type": "string" + }, + "consent_ref": { + "type": [ + "string", + "null" + ] + }, + "disclosure": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "jurisdiction": { + "type": [ + "string", + "null" + ] + }, + "legal_basis_ref": { + "type": [ + "string", + "null" + ] + }, + "max_claim_result_age_seconds": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "ruleset": { + "type": "string" + }, + "subject_id_type": { + "type": "string" + } + }, + "required": [ + "id", + "ruleset", + "claim_id", + "subject_id_type" + ], + "type": "object" + }, + "FederationPairwiseSubjectHashConfig": { + "additionalProperties": false, + "properties": { + "secret_env": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "FederationPeerConfig": { + "additionalProperties": false, + "properties": { + "allow_insecure_localhost": { + "default": false, + "type": "boolean" + }, + "allow_insecure_private_network": { + "default": false, + "type": "boolean" + }, + "allowed_profiles": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_protocol_versions": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_purposes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "evaluation_scopes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "issuer": { + "type": "string" + }, + "jwks_uri": { + "type": "string" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "node_id", + "issuer", + "jwks_uri" + ], + "type": "object" + }, + "FederationResponseShapingConfig": { + "additionalProperties": false, + "properties": { + "minimum_denial_latency_ms": { + "default": 250, + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "FederationSigningConfig": { + "additionalProperties": false, + "properties": { + "signing_key": { + "type": "string" + } + }, + "required": [ + "signing_key" + ], + "type": "object" + }, + "HolderBindingConfig": { + "additionalProperties": false, + "properties": { + "allowed_did_methods": { + "default": [ + "did:jwk" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "mode": { + "default": "did", + "type": "string" + }, + "proof_of_possession": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "HumantimeDuration": { + "description": "A humantime duration string. The runtime parser remains authoritative for its complete grammar.", + "type": "string" + }, + "IpNet": { + "description": "An IP network CIDR string. The runtime parser remains authoritative for address and prefix validity.", + "type": "string" + }, + "MachineQuotaConfig": { + "additionalProperties": false, + "description": "Per-principal quota for machine `evaluate`/`batch_evaluate` traffic.", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "subjects_per_minute": { + "default": 6000, + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "NotaryInstanceConfig": { + "additionalProperties": false, + "properties": { + "environment": { + "default": "development", + "type": "string" + }, + "id": { + "default": "registry-notary-standalone", + "type": "string" + }, + "jurisdiction": { + "type": [ + "string", + "null" + ] + }, + "owner": { + "type": [ + "string", + "null" + ] + }, + "public_base_url": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "Oid4vciAuthorizationConfig": { + "additionalProperties": false, + "properties": { + "require_pkce_method": { + "default": "S256", + "type": "string" + } + }, + "type": "object" + }, + "Oid4vciConfig": { + "additionalProperties": false, + "properties": { + "accepted_token_audiences": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "authorization": { + "$ref": "#/$defs/Oid4vciAuthorizationConfig", + "default": { + "require_pkce_method": "S256" + } + }, + "authorization_servers": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "credential_configurations": { + "additionalProperties": { + "$ref": "#/$defs/Oid4vciCredentialConfigurationConfig" + }, + "default": {}, + "type": "object" + }, + "credential_endpoint": { + "default": "", + "type": "string" + }, + "credential_issuer": { + "default": "", + "type": "string" + }, + "display": { + "default": [], + "items": { + "$ref": "#/$defs/Oid4vciIssuerDisplayConfig" + }, + "type": "array" + }, + "enabled": { + "default": false, + "type": "boolean" + }, + "nonce": { + "$ref": "#/$defs/Oid4vciNonceConfig", + "default": { + "enabled": false, + "ttl_seconds": 300 + } + }, + "nonce_endpoint": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "offer_endpoint": { + "default": "", + "type": "string" + }, + "pre_authorized_code": { + "$ref": "#/$defs/Oid4vciPreAuthorizedCodeConfig", + "default": { + "enabled": false, + "esignet": { + "allow_insecure_localhost": false, + "authorize_url": "", + "client_id": "", + "client_signing_key_id": "", + "issuer": "", + "jwks_uri": "", + "login_state_ttl_seconds": 0, + "redirect_uri": "", + "scopes": [], + "token_url": "", + "userinfo_url": "" + }, + "pre_authorized_code_ttl_seconds": 300, + "tx_code": { + "input_mode": "numeric", + "length": 6, + "required": true + } + }, + "description": "Pre-authorized-code flow settings. Disabled by default; offers fall\nback to the `authorization_code` grant unless this is enabled." + }, + "proof": { + "$ref": "#/$defs/Oid4vciProofConfig", + "default": { + "max_age_seconds": 300, + "max_clock_skew_seconds": 60 + } + } + }, + "type": "object" + }, + "Oid4vciCredentialClaimConfig": { + "additionalProperties": false, + "properties": { + "display_name": { + "type": "string" + }, + "id": { + "type": "string" + }, + "output_path": { + "items": { + "type": "string" + }, + "type": "array" + }, + "sd": { + "type": "string" + } + }, + "required": [ + "id", + "output_path", + "display_name", + "sd" + ], + "type": "object" + }, + "Oid4vciCredentialConfigurationConfig": { + "additionalProperties": false, + "properties": { + "claim_id": { + "type": [ + "string", + "null" + ] + }, + "claims": { + "items": { + "$ref": "#/$defs/Oid4vciCredentialClaimConfig" + }, + "type": "array" + }, + "credential_profile": { + "type": "string" + }, + "cryptographic_binding_methods_supported": { + "default": [ + "did:jwk" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "display": { + "$ref": "#/$defs/Oid4vciCredentialDisplayConfig", + "default": {} + }, + "display_name": { + "type": "string" + }, + "format": { + "type": "string" + }, + "proof_signing_alg_values_supported": { + "default": [ + "EdDSA" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "scope": { + "type": "string" + }, + "vct": { + "type": "string" + } + }, + "required": [ + "credential_profile", + "format", + "scope", + "vct", + "display_name" + ], + "type": "object" + }, + "Oid4vciCredentialDisplayConfig": { + "additionalProperties": false, + "properties": { + "background_color": { + "type": [ + "string", + "null" + ] + }, + "background_image": { + "anyOf": [ + { + "$ref": "#/$defs/Oid4vciDisplayImageConfig" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "locale": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/$defs/Oid4vciDisplayImageConfig" + }, + { + "type": "null" + } + ] + }, + "secondary_image": { + "anyOf": [ + { + "$ref": "#/$defs/Oid4vciDisplayImageConfig" + }, + { + "type": "null" + } + ] + }, + "text_color": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "Oid4vciDisplayImageConfig": { + "additionalProperties": false, + "properties": { + "alt_text": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "Oid4vciEsignetRpConfig": { + "additionalProperties": false, + "description": "eSignet relying-party settings for the citizen login leg of the\npre-authorized-code flow.", + "properties": { + "allow_insecure_localhost": { + "default": false, + "description": "Allow `http` loopback URLs for the eSignet endpoints and JWKS transport.\nFor local development and tests only.", + "type": "boolean" + }, + "authorize_url": { + "default": "", + "description": "eSignet authorize endpoint.", + "type": "string" + }, + "client_id": { + "default": "", + "description": "Confidential client id the Notary presents to eSignet.", + "type": "string" + }, + "client_signing_key_id": { + "default": "", + "description": "`evidence.signing_keys` entry used to sign the eSignet\n`private_key_jwt` client assertion.", + "type": "string" + }, + "issuer": { + "default": "", + "description": "eSignet OIDC issuer, pinned when validating the returned `id_token`.", + "type": "string" + }, + "jwks_uri": { + "default": "", + "description": "eSignet JWKS URI, used to resolve the `id_token` signing key by `kid`.", + "type": "string" + }, + "login_state_ttl_seconds": { + "default": 300, + "description": "Lifetime of the short-lived login state (PKCE verifier + nonce +\nselection) reserved between `offer/start` and `offer/callback`.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "redirect_uri": { + "default": "", + "description": "Notary callback the citizen browser is redirected back to.", + "type": "string" + }, + "scopes": { + "default": [], + "description": "OAuth scopes requested at eSignet.", + "items": { + "type": "string" + }, + "type": "array" + }, + "token_url": { + "default": "", + "description": "eSignet token endpoint.", + "type": "string" + }, + "userinfo_url": { + "default": "", + "description": "eSignet userinfo endpoint. Required when the subject-binding claim is\nsourced from userinfo rather than the `id_token`; the callback fetches\nthe userinfo JWS with the eSignet access token and reads the binding\nclaim from it.", + "type": "string" + } + }, + "type": "object" + }, + "Oid4vciIssuerDisplayConfig": { + "additionalProperties": false, + "properties": { + "locale": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/$defs/Oid4vciDisplayImageConfig" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "Oid4vciNonceConfig": { + "additionalProperties": false, + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "ttl_seconds": { + "default": 300, + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "Oid4vciPreAuthorizedCodeConfig": { + "additionalProperties": false, + "description": "Pre-authorized-code flow configuration.\n\nAll fields default so existing configs that omit this block load unchanged\nwith the flow disabled. When `enabled`, the eSignet RP login settings, the\ncallback redirect, and the TTLs become required (validated cross-block).", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "esignet": { + "$ref": "#/$defs/Oid4vciEsignetRpConfig", + "default": { + "allow_insecure_localhost": false, + "authorize_url": "", + "client_id": "", + "client_signing_key_id": "", + "issuer": "", + "jwks_uri": "", + "login_state_ttl_seconds": 0, + "redirect_uri": "", + "scopes": [], + "token_url": "", + "userinfo_url": "" + }, + "description": "eSignet RP settings for the citizen login leg." + }, + "pre_authorized_code_ttl_seconds": { + "default": 300, + "description": "Pre-authorized-code lifetime in seconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "tx_code": { + "$ref": "#/$defs/Oid4vciTxCodeConfig", + "default": { + "input_mode": "numeric", + "length": 6, + "required": true + } + } + }, + "type": "object" + }, + "Oid4vciProofConfig": { + "additionalProperties": false, + "properties": { + "max_age_seconds": { + "default": 300, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "max_clock_skew_seconds": { + "default": 60, + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "Oid4vciTxCodeConfig": { + "additionalProperties": false, + "description": "`tx_code` (PIN) policy for the pre-authorized-code grant. A `tx_code` is\nrequired by default because a code without a PIN is a bearer credential.", + "properties": { + "input_mode": { + "default": "numeric", + "type": "string" + }, + "length": { + "default": 6, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "required": { + "default": true, + "type": "boolean" + } + }, + "type": "object" + }, + "OotsConfig": { + "additionalProperties": false, + "properties": { + "authentication_level_of_assurance": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "enabled": { + "default": false, + "type": "boolean" + }, + "evidence_type_classification": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "evidence_type_list": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "languages": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "reference_framework": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "requirement": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "OperationConfig": { + "additionalProperties": false, + "properties": { + "enabled": { + "default": false, + "type": "boolean" + } + }, + "type": "object" + }, + "RegistryNotaryAdminListenerConfig": { + "additionalProperties": false, + "properties": { + "bind": { + "$ref": "#/$defs/SocketAddr", + "default": "127.0.0.1:8082" + }, + "mode": { + "$ref": "#/$defs/RegistryNotaryAdminListenerMode" + } + }, + "type": "object" + }, + "RegistryNotaryAdminListenerMode": { + "enum": [ + "shared_with_public", + "dedicated", + "disabled" + ], + "type": "string" + }, + "RegistryNotaryCelConfig": { + "additionalProperties": false, + "properties": { + "allow_regex": { + "default": false, + "type": "boolean" + }, + "eval_timeout_ms": { + "default": 2000, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "max_binding_json_bytes": { + "default": 65536, + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "max_expression_bytes": { + "default": 8192, + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "max_list_items": { + "default": 1024, + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "max_object_depth": { + "default": 16, + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "max_object_keys": { + "default": 256, + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "max_result_json_bytes": { + "default": 16384, + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "max_string_bytes": { + "default": 16384, + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "mode": { + "default": "worker", + "type": "string" + }, + "worker_count": { + "default": 2, + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "worker_memory_bytes": { + "default": 134217728, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "worker_stderr_bytes": { + "default": 1024, + "format": "uint", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "RegistryNotaryCorsConfig": { + "additionalProperties": false, + "properties": { + "allowed_origins": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "RegistryNotaryHttpConfig": { + "additionalProperties": false, + "properties": { + "admin_listener": { + "$ref": "#/$defs/RegistryNotaryAdminListenerConfig" + }, + "bind": { + "$ref": "#/$defs/SocketAddr", + "default": "127.0.0.1:8081" + }, + "cors": { + "$ref": "#/$defs/RegistryNotaryCorsConfig", + "default": { + "allowed_origins": [] + } + }, + "http1_header_read_timeout": { + "$ref": "#/$defs/HumantimeDuration", + "default": "10s" + }, + "max_connections": { + "default": 1024, + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "openapi_requires_auth": { + "type": "boolean" + }, + "request_body_timeout": { + "$ref": "#/$defs/HumantimeDuration", + "default": "10s" + }, + "request_timeout": { + "$ref": "#/$defs/HumantimeDuration", + "default": "30s" + }, + "trusted_proxy_ips": { + "items": { + "format": "ip", + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "RelayConnectionConfig": { + "additionalProperties": false, + "properties": { + "allow_insecure_localhost": { + "default": false, + "type": "boolean" + }, + "allowed_private_cidrs": { + "default": [], + "items": { + "$ref": "#/$defs/IpNet" + }, + "type": "array" + }, + "base_url": { + "type": "string" + }, + "max_in_flight": { + "default": 8, + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "token_file": { + "type": "string" + }, + "workload_client_id": { + "type": "string" + } + }, + "required": [ + "base_url", + "workload_client_id", + "token_file" + ], + "type": "object" + }, + "RelayConsultationConfig": { + "additionalProperties": false, + "properties": { + "inputs": { + "additionalProperties": { + "$ref": "#/$defs/RelayConsultationInput" + }, + "type": "object" + }, + "outputs": { + "additionalProperties": { + "$ref": "#/$defs/RelayOutputContract" + }, + "default": {}, + "description": "Complete closed public output schema expected from the pinned profile.", + "type": "object" + }, + "profile": { + "$ref": "#/$defs/RelayConsultationProfileRef" + } + }, + "required": [ + "profile", + "inputs" + ], + "type": "object" + }, + "RelayConsultationInput": { + "description": "A supported consultation input path. The runtime parser remains authoritative for exact stable-name bounds.", + "minLength": 1, + "type": "string" + }, + "RelayConsultationProfileRef": { + "additionalProperties": false, + "properties": { + "contract_hash": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "contract_hash" + ], + "type": "object" + }, + "RelayOutputContract": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "nullable": { + "default": false, + "type": "boolean" + }, + "type": { + "const": "boolean", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "maximum": { + "format": "int64", + "type": "integer" + }, + "minimum": { + "format": "int64", + "type": "integer" + }, + "nullable": { + "default": false, + "type": "boolean" + }, + "type": { + "const": "integer", + "type": "string" + } + }, + "required": [ + "type", + "minimum", + "maximum" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "max_bytes": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "nullable": { + "default": false, + "type": "boolean" + }, + "type": { + "const": "string", + "type": "string" + } + }, + "required": [ + "type", + "max_bytes" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "nullable": { + "default": false, + "type": "boolean" + }, + "type": { + "const": "date", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "RequestVariableConfig": { + "additionalProperties": false, + "properties": { + "from": { + "type": "string" + }, + "type": { + "$ref": "#/$defs/RequestVariableType" + } + }, + "required": [ + "from", + "type" + ], + "type": "object" + }, + "RequestVariableType": { + "enum": [ + "date" + ], + "type": "string" + }, + "RuleConfig": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "consultation": { + "type": "string" + }, + "output": { + "type": "string" + }, + "type": { + "const": "consultation_output", + "type": "string" + } + }, + "required": [ + "type", + "consultation", + "output" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "consultation": { + "type": "string" + }, + "type": { + "const": "consultation_matched", + "type": "string" + } + }, + "required": [ + "type", + "consultation" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "bindings": { + "$ref": "#/$defs/CelBindingsConfig", + "default": { + "claims": {}, + "vars": {} + } + }, + "expression": { + "type": "string" + }, + "type": { + "const": "cel", + "type": "string" + } + }, + "required": [ + "type", + "expression" + ], + "type": "object" + } + ] + }, + "SealedClaimEvidenceMode": { + "description": "The tagged configuration wire contract shared by deserialization and schema generation.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "consultations": { + "additionalProperties": { + "$ref": "#/$defs/RelayConsultationConfig" + }, + "type": "object" + }, + "type": { + "const": "registry_backed", + "type": "string" + } + }, + "required": [ + "type", + "consultations" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "type": { + "const": "self_attested", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "SigningKeyConfig": { + "additionalProperties": false, + "properties": { + "alg": { + "type": "string" + }, + "key_id_hex": { + "default": "", + "type": "string" + }, + "key_label": { + "default": "", + "type": "string" + }, + "kid": { + "type": "string" + }, + "module_path": { + "default": "", + "type": "string" + }, + "password_env": { + "default": "", + "type": "string" + }, + "path": { + "default": "", + "type": "string" + }, + "pin_env": { + "default": "", + "type": "string" + }, + "private_jwk_env": { + "default": "", + "type": "string" + }, + "provider": { + "$ref": "#/$defs/SigningKeyProviderSchema" + }, + "public_jwk_env": { + "default": "", + "type": "string" + }, + "publish_until_unix_seconds": { + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "status": { + "$ref": "#/$defs/SigningKeyStatusSchema" + }, + "token_label": { + "default": "", + "type": "string" + } + }, + "required": [ + "provider", + "alg", + "kid", + "status" + ], + "type": "object" + }, + "SigningKeyProviderSchema": { + "enum": [ + "local_jwk_env", + "file_watch", + "pkcs11", + "local_pkcs12_file", + "kms", + "workload_identity" + ], + "type": "string" + }, + "SigningKeyStatusSchema": { + "enum": [ + "active", + "publish_only", + "disabled" + ], + "type": "string" + }, + "SocketAddr": { + "description": "A Rust SocketAddr string. The runtime parser remains authoritative for address and port validity.", + "type": "string" + }, + "StateConfig": { + "additionalProperties": false, + "properties": { + "postgresql": { + "$ref": "#/$defs/StatePostgresqlConfig" + }, + "storage": { + "default": "postgresql", + "type": "string" + } + }, + "type": "object" + }, + "StatePostgresqlConfig": { + "additionalProperties": false, + "properties": { + "connect_timeout_ms": { + "default": 5000, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "max_connections": { + "default": 16, + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "operation_timeout_ms": { + "default": 2000, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "root_certificate_path": { + "type": [ + "string", + "null" + ] + }, + "sensitive_state_key_env": { + "default": "REGISTRY_NOTARY_SENSITIVE_STATE_KEY", + "type": "string" + }, + "url_env": { + "default": "REGISTRY_NOTARY_POSTGRES_URL", + "type": "string" + } + }, + "type": "object" + }, + "SubjectAccessAssuranceClaimSource": { + "enum": [ + "access_token", + "id_token" + ], + "type": "string" + }, + "SubjectAccessCitizenClientsConfig": { + "additionalProperties": false, + "properties": { + "allowed_audiences": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_client_ids": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "SubjectAccessClaimSource": { + "enum": [ + "access_token", + "userinfo" + ], + "type": "string" + }, + "SubjectAccessConfig": { + "additionalProperties": false, + "properties": { + "allowed_claims": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_disclosures": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_formats": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_operations": { + "$ref": "#/$defs/SubjectAccessOperationsConfig", + "default": { + "batch_evaluate": false, + "evaluate": false, + "issue_credential": false, + "render": false + } + }, + "allowed_purposes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_wallet_origins": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "citizen_clients": { + "$ref": "#/$defs/SubjectAccessCitizenClientsConfig", + "default": { + "allowed_audiences": [], + "allowed_client_ids": [] + } + }, + "credential_profiles": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "delegation": { + "$ref": "#/$defs/SubjectAccessDelegationConfig", + "default": { + "allowed_relationships": [], + "enabled": false + } + }, + "enabled": { + "default": false, + "type": "boolean" + }, + "rate_limits": { + "$ref": "#/$defs/SubjectAccessRateLimitsConfig", + "default": { + "credential_issuance_per_principal_per_hour": 0, + "invalid_token_per_client_address_per_minute": 0, + "per_holder_per_hour": 0, + "per_principal_per_minute": 0, + "subject_mismatch_per_principal_per_hour": 0, + "tx_code_attempts_per_code_per_minute": 0 + } + }, + "required_scopes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "scope_policy": { + "$ref": "#/$defs/SubjectAccessScopePolicy", + "default": "required" + }, + "subject_binding": { + "$ref": "#/$defs/SubjectAccessSubjectBindingConfig", + "default": { + "allow_sub_as_civil_id": false, + "claim_source": "access_token", + "id_type": "", + "normalize": "exact", + "request_field": "SubjectId", + "token_claim": "" + } + }, + "token_policy": { + "$ref": "#/$defs/SubjectAccessTokenPolicyConfig", + "default": { + "assurance_claim_source": "access_token", + "max_access_token_lifetime_seconds": 0, + "max_auth_age_seconds": 0, + "max_clock_leeway_seconds": 0, + "max_credential_validity_seconds": 0, + "max_evaluation_age_seconds": 0, + "required_acr_values": [] + } + } + }, + "type": "object" + }, + "SubjectAccessDelegatedRelationshipConfig": { + "additionalProperties": false, + "properties": { + "allowed_claims": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_disclosures": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_formats": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowed_purposes": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "credential_profiles": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "proof_claim": { + "type": "string" + }, + "relationship_type": { + "type": "string" + }, + "target_id_type": { + "default": null, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "relationship_type", + "proof_claim" + ], + "type": "object" + }, + "SubjectAccessDelegationConfig": { + "additionalProperties": false, + "properties": { + "allowed_relationships": { + "default": [], + "items": { + "$ref": "#/$defs/SubjectAccessDelegatedRelationshipConfig" + }, + "type": "array" + }, + "enabled": { + "default": false, + "type": "boolean" + } + }, + "type": "object" + }, + "SubjectAccessOperationsConfig": { + "additionalProperties": false, + "properties": { + "batch_evaluate": { + "default": false, + "type": "boolean" + }, + "evaluate": { + "default": false, + "type": "boolean" + }, + "issue_credential": { + "default": false, + "type": "boolean" + }, + "render": { + "default": false, + "type": "boolean" + } + }, + "type": "object" + }, + "SubjectAccessRateLimitsConfig": { + "additionalProperties": false, + "properties": { + "credential_issuance_per_principal_per_hour": { + "default": 0, + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "invalid_token_per_client_address_per_minute": { + "default": 0, + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "per_holder_per_hour": { + "default": 0, + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "per_principal_per_minute": { + "default": 0, + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "subject_mismatch_per_principal_per_hour": { + "default": 0, + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "tx_code_attempts_per_code_per_minute": { + "default": 0, + "description": "Per-minute cap on `tx_code` attempts against a single\n`pre-authorized_code`. Bounds brute force of the numeric PIN at the\npre-authorized-code token endpoint. Defaults to zero so existing\nconfigs that do not enable pre-auth still validate; it must be greater\nthan zero only when the pre-authorized-code flow is enabled.", + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "SubjectAccessScopePolicy": { + "enum": [ + "required", + "optional", + "disabled" + ], + "type": "string" + }, + "SubjectAccessSubjectBindingConfig": { + "additionalProperties": false, + "properties": { + "allow_sub_as_civil_id": { + "default": false, + "type": "boolean" + }, + "claim_source": { + "$ref": "#/$defs/SubjectAccessClaimSource", + "default": "access_token" + }, + "id_type": { + "default": "", + "type": "string" + }, + "normalize": { + "$ref": "#/$defs/SubjectBindingNormalize", + "default": "exact" + }, + "request_field": { + "$ref": "#/$defs/SubjectId", + "default": "SubjectId" + }, + "token_claim": { + "default": "", + "type": "string" + } + }, + "type": "object" + }, + "SubjectAccessTokenPolicyConfig": { + "additionalProperties": false, + "properties": { + "assurance_claim_source": { + "$ref": "#/$defs/SubjectAccessAssuranceClaimSource", + "default": "access_token" + }, + "max_access_token_lifetime_seconds": { + "default": 0, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "max_auth_age_seconds": { + "default": 0, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "max_clock_leeway_seconds": { + "default": 0, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "max_credential_validity_seconds": { + "default": 0, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "max_evaluation_age_seconds": { + "default": 0, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "required_acr_values": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "SubjectBindingNormalize": { + "enum": [ + "exact" + ], + "type": "string" + }, + "SubjectId": { + "enum": [ + "SubjectId" + ], + "type": "string" + }, + "WireClaimRef": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/ClaimRefObject" + } + ] + } + }, + "$id": "https://id.registrystack.org/schemas/registry-notary/registry-notary.config.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "audit": { + "$ref": "#/$defs/EvidenceAuditConfig", + "default": { + "hash_secret_env": null, + "max_files": null, + "max_size_mb": null, + "path": null, + "sink": "stdout", + "syslog_socket_path": null + } + }, + "auth": { + "$ref": "#/$defs/EvidenceAuthConfig" + }, + "cel": { + "$ref": "#/$defs/RegistryNotaryCelConfig" + }, + "config_trust": { + "anyOf": [ + { + "$ref": "#/$defs/ConfigTrustConfig" + }, + { + "type": "null" + } + ] + }, + "credential_status": { + "$ref": "#/$defs/CredentialStatusConfig" + }, + "deployment": { + "$ref": "#/$defs/DeploymentConfig" + }, + "evidence": { + "$ref": "#/$defs/EvidenceConfig" + }, + "federation": { + "$ref": "#/$defs/FederationConfig" + }, + "instance": { + "$ref": "#/$defs/NotaryInstanceConfig" + }, + "oid4vci": { + "$ref": "#/$defs/Oid4vciConfig" + }, + "server": { + "$ref": "#/$defs/RegistryNotaryHttpConfig", + "default": { + "bind": "127.0.0.1:8081", + "cors": { + "allowed_origins": [] + }, + "http1_header_read_timeout": "10s", + "max_connections": 1024, + "request_body_timeout": "10s", + "request_timeout": "30s" + } + }, + "state": { + "$ref": "#/$defs/StateConfig" + }, + "subject_access": { + "$ref": "#/$defs/SubjectAccessConfig" + } + }, + "required": [ + "evidence", + "auth" + ], + "title": "Registry Notary config", + "type": "object" +} From 89a48a2e3be481866c1aad0eb3741d1dc930871b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 14:07:10 +0700 Subject: [PATCH 02/65] fix(notary): preserve authorization metadata extensions (#416) Preserve forward-compatible authorization metadata while keeping authorization decisions limited to modeled fields. Keep ClaimRef strict and align runtime parsing, generated schema, tests, and operator documentation. Corrects the Rust workspace regression introduced by #415. --- crates/registry-notary-core/src/model.rs | 10 ++--- crates/registry-notary/tests/config_schema.rs | 38 +++++++++---------- .../notary/docs/operator-config-reference.md | 20 ++++++---- schemas/registry-notary.config.schema.json | 6 +-- 4 files changed, 38 insertions(+), 36 deletions(-) diff --git a/crates/registry-notary-core/src/model.rs b/crates/registry-notary-core/src/model.rs index 4cbad1481..66ab67387 100644 --- a/crates/registry-notary-core/src/model.rs +++ b/crates/registry-notary-core/src/model.rs @@ -1638,8 +1638,12 @@ const fn subject_access_access_mode() -> AccessMode { AccessMode::SubjectBound } +/// Versioned authorization fields shared by static configuration and token/OIDC JSON. +/// +/// Unknown metadata is intentionally ignored for forward-compatible interoperability. +/// Authorization decisions consume only the modeled fields and must never infer authority +/// from an unrecognized extension. #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] pub struct EvidenceAuthorizationDetails { #[serde(rename = "type")] pub detail_type: String, @@ -1677,28 +1681,24 @@ pub struct EvidenceAuthorizationDetails { } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] pub struct EvidenceAuthorizationSubject { pub binding_claim: String, pub id_type: String, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] pub struct EvidenceAuthorizationTarget { pub id_type: String, pub id: String, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] pub struct EvidenceAuthorizationRelationship { pub relationship_type: String, pub proof_claim: String, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] pub struct EvidenceAssistedAccessContext { pub channel: String, } diff --git a/crates/registry-notary/tests/config_schema.rs b/crates/registry-notary/tests/config_schema.rs index 6274ad051..8643a363d 100644 --- a/crates/registry-notary/tests/config_schema.rs +++ b/crates/registry-notary/tests/config_schema.rs @@ -298,63 +298,63 @@ fn strict_nested_objects_and_tagged_variants_match_runtime_deserialization() { } #[test] -fn authorization_details_are_closed_at_every_policy_level() { +fn authorization_details_preserve_extension_compatibility_at_every_policy_level() { let schema = document(); let valid = with_authorization_details(example_config()); - assert_valid(&schema, &valid, "strict authorization-details config"); - assert_runtime_deserializes(&valid, "strict authorization-details config"); + assert_valid(&schema, &valid, "authorization-details config"); + assert_runtime_deserializes(&valid, "authorization-details config"); let mut root_unknown = valid.clone(); root_unknown["auth"]["api_keys"][0]["authorization_details"]["unexpected"] = json!(true); - assert_invalid( + assert_valid( &schema, &root_unknown, - "unknown authorization-details field", + "future authorization-details metadata", ); - assert_runtime_rejects(&root_unknown, "unknown authorization-details field"); + assert_runtime_deserializes(&root_unknown, "future authorization-details metadata"); let mut subject_unknown = valid.clone(); subject_unknown["auth"]["api_keys"][0]["authorization_details"]["subject"]["unexpected"] = json!(true); - assert_invalid( + assert_valid( &schema, &subject_unknown, - "unknown authorization subject field", + "future authorization subject metadata", ); - assert_runtime_rejects(&subject_unknown, "unknown authorization subject field"); + assert_runtime_deserializes(&subject_unknown, "future authorization subject metadata"); let mut target_unknown = valid.clone(); target_unknown["auth"]["api_keys"][0]["authorization_details"]["target"]["unexpected"] = json!(true); - assert_invalid( + assert_valid( &schema, &target_unknown, - "unknown authorization target field", + "future authorization target metadata", ); - assert_runtime_rejects(&target_unknown, "unknown authorization target field"); + assert_runtime_deserializes(&target_unknown, "future authorization target metadata"); let mut relationship_unknown = valid.clone(); relationship_unknown["auth"]["api_keys"][0]["authorization_details"]["relationship"] ["unexpected"] = json!(true); - assert_invalid( + assert_valid( &schema, &relationship_unknown, - "unknown authorization relationship field", + "future authorization relationship metadata", ); - assert_runtime_rejects( + assert_runtime_deserializes( &relationship_unknown, - "unknown authorization relationship field", + "future authorization relationship metadata", ); let mut context_unknown = valid; context_unknown["auth"]["api_keys"][0]["authorization_details"]["assisted_access_context"] ["unexpected"] = json!(true); - assert_invalid( + assert_valid( &schema, &context_unknown, - "unknown assisted-access context field", + "future assisted-access context metadata", ); - assert_runtime_rejects(&context_unknown, "unknown assisted-access context field"); + assert_runtime_deserializes(&context_unknown, "future assisted-access context metadata"); } #[test] diff --git a/products/notary/docs/operator-config-reference.md b/products/notary/docs/operator-config-reference.md index f7db8af3e..54d0d1a89 100644 --- a/products/notary/docs/operator-config-reference.md +++ b/products/notary/docs/operator-config-reference.md @@ -567,9 +567,11 @@ subject_access.token_policy.required_acr_values[] | `oid4vci` | Wallet-facing issuance facade | | `federation` | Static-peer delegated evaluation | -Unknown fields are rejected. Use the generated configuration from Registry -Stack project authoring as the source of truth rather than maintaining a -second handwritten example. +Unknown configuration fields are rejected except forward-compatible metadata +inside `authorization_details`, as described under Authentication and +delegation. Use the generated configuration from Registry Stack project +authoring as the source of truth rather than maintaining a second handwritten +example. ## Environment expansion @@ -646,10 +648,14 @@ together fails before either credential is authenticated. Static bearer tokens cannot be configured alongside OIDC because both use the `Authorization: Bearer` transport. -`authorization_details` is a closed authorization-policy object, including its -subject, target, relationship, and assisted-access nested objects. Unknown -fields are rejected at load time so a misspelled or unreviewed policy input -cannot silently weaken caller binding. +`authorization_details` is a versioned interoperability object shared by +static configuration and token or OIDC JSON. Known authorization fields, +including subject, target, relationship, assisted access, and exact claim +references, are enforced by the authorization checks. Future metadata fields +at those object levels are accepted and ignored so an additive producer does +not break an older Notary. Unrecognized metadata never grants authority. +`ClaimRef` objects remain closed because an extra field there could conceal a +misspelled claim or version selector. Citizen and wallet flows use the self-attestation subject-binding policy. Delegated access must bind requester, target, relationship, purpose, and diff --git a/schemas/registry-notary.config.schema.json b/schemas/registry-notary.config.schema.json index ae4cee761..9f3a8b0bf 100644 --- a/schemas/registry-notary.config.schema.json +++ b/schemas/registry-notary.config.schema.json @@ -683,7 +683,6 @@ "type": "object" }, "EvidenceAssistedAccessContext": { - "additionalProperties": false, "properties": { "channel": { "type": "string" @@ -790,7 +789,7 @@ "type": "object" }, "EvidenceAuthorizationDetails": { - "additionalProperties": false, + "description": "Versioned authorization fields shared by static configuration and token/OIDC JSON.\n\nUnknown metadata is intentionally ignored for forward-compatible interoperability.\nAuthorization decisions consume only the modeled fields and must never infer authority\nfrom an unrecognized extension.", "properties": { "access_mode": { "anyOf": [ @@ -916,7 +915,6 @@ "type": "object" }, "EvidenceAuthorizationRelationship": { - "additionalProperties": false, "properties": { "proof_claim": { "type": "string" @@ -932,7 +930,6 @@ "type": "object" }, "EvidenceAuthorizationSubject": { - "additionalProperties": false, "properties": { "binding_claim": { "type": "string" @@ -948,7 +945,6 @@ "type": "object" }, "EvidenceAuthorizationTarget": { - "additionalProperties": false, "properties": { "id": { "type": "string" From 22996a1f1eda7a92812444d7f1baf35fe826c7d5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 15:52:14 +0700 Subject: [PATCH 03/65] fix(registryctl): default reports to human output Signed-off-by: Jeremi Joslin --- crates/registryctl/CHANGELOG.md | 8 +- crates/registryctl/README.md | 22 +- .../project-starters/bounded-http/README.md | 6 +- crates/registryctl/src/lib.rs | 181 ++++-- crates/registryctl/src/main.rs | 551 ++++++++++++++++-- crates/registryctl/src/project_authoring.rs | 3 + .../src/project_authoring/commands.rs | 3 + .../src/project_authoring/diagnostics.rs | 1 + .../src/project_authoring/editor.rs | 2 + .../src/project_authoring/model.rs | 2 + .../src/templates/project_readme.md | 6 +- .../project-authoring/dhis2-tracker/README.md | 6 +- .../fhir-r4-coverage-active/README.md | 6 +- .../project-authoring/opencrvs/README.md | 6 +- .../snapshot-exact/README.md | 6 +- crates/registryctl/tests/init_output.rs | 210 ++++++- crates/registryctl/tests/project_authoring.rs | 1 + 17 files changed, 911 insertions(+), 109 deletions(-) diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md index db8ef4d52..0842d6a5a 100644 --- a/crates/registryctl/CHANGELOG.md +++ b/crates/registryctl/CHANGELOG.md @@ -42,9 +42,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed -- `registryctl init --from` and `registryctl init relay` now print concise human-readable results - with tailored next commands. Both forms accept `--format json` for the versioned - `registryctl.init.v1` machine-readable report. +- Interactive report commands now print concise human-readable results by default. This covers + project initialization, Notary add-on setup, offline tests, checks, editor setup, builds, + doctor validation, Config Bundle operations, and trust-anchor operations. Add `--format json` + for versioned machine-readable reports. Artifact and protocol streams keep their existing + formats. ### Removed diff --git a/crates/registryctl/README.md b/crates/registryctl/README.md index ea6bdfc0e..95a1908cc 100644 --- a/crates/registryctl/README.md +++ b/crates/registryctl/README.md @@ -19,22 +19,22 @@ Then create and start your first secured spreadsheet API: ```sh registryctl init relay my-first-api --sample benefits cd my-first-api -registryctl doctor --profile local --format json +registryctl doctor --profile local registryctl start registryctl smoke ``` -Initialization prints the created project, notable artifacts, and next commands. Add -`--format json` to either `init relay` or `init --from` for the versioned -`registryctl.init.v1` machine-readable report. +Interactive report commands print concise human-readable results. Add `--format json` when +another program needs a versioned report. Artifact and protocol commands, including authoring +schemas, editor metadata, the language server, and logs, retain their native output formats. The generated project contains a local Registry Relay configuration, sample XLSX workbook, Compose file, project manifest, local demo credentials, and an optional Bruno API collection. -Run `registryctl doctor --format json` before starting a generated stack or -after editing config. It calls the product-owned validators, redacts local -secret values, and returns a machine-readable report for troubleshooting. +Run `registryctl doctor` before starting a generated stack or after editing config. It calls the +product-owned validators and redacts local secret values. Add `--format json` when another program +needs the versioned diagnostic report. For the full walkthroughs, use the Registry Docs tutorials: @@ -136,10 +136,10 @@ operation and requires the lock for that registryctl version. ## Update checks -`registryctl` checks GitHub releases at most once per day for normal -human-facing commands and prints an upgrade notice to stderr when a newer -release is available. It skips the automatic check in CI and while running -`registryctl doctor` so JSON output stays quiet. +`registryctl` checks GitHub releases at most once per day for normal human-facing commands and +prints an upgrade notice to stderr when a newer release is available. It skips the automatic check +in CI and while running `registryctl doctor`, so doctor diagnostics are not accompanied by an +update notice. Run an explicit check at any time: diff --git a/crates/registryctl/assets/project-starters/bounded-http/README.md b/crates/registryctl/assets/project-starters/bounded-http/README.md index 144af3be8..6999b62cc 100644 --- a/crates/registryctl/assets/project-starters/bounded-http/README.md +++ b/crates/registryctl/assets/project-starters/bounded-http/README.md @@ -13,9 +13,9 @@ registryctl check --project-dir . --environment local --explain registryctl build --project-dir . --environment local ``` -`check` is human-readable by default. Use `--format json` only for machine -consumers. Editor setup uses the five schemas copied from this `registryctl` -build for VS Code and Zed. +`authoring editor`, `test`, `check`, and `build` are human-readable by default. Use `--format json` +with those report commands only for machine consumers. Editor setup uses the five schemas copied +from this `registryctl` build for VS Code and Zed. Edit `integrations/person-record/integration.yaml` and its synthetic fixtures. Keep real destinations and credentials only in `environments/` secret bindings. diff --git a/crates/registryctl/src/lib.rs b/crates/registryctl/src/lib.rs index 0da8be6aa..733049205 100644 --- a/crates/registryctl/src/lib.rs +++ b/crates/registryctl/src/lib.rs @@ -79,6 +79,7 @@ const PROJECT_SCHEMA_VERSION: &str = "registryctl/v1"; const CONFIG_BUNDLE_SIGNATURE_SCHEMA: &str = "registry.platform.config_bundle_signatures.v1"; const CONFIG_TRUST_ANCHOR_SCHEMA: &str = "registry.platform.config_trust_anchor.v1"; const INIT_REPORT_SCHEMA_VERSION: &str = "registryctl.init.v1"; +const ADD_NOTARY_REPORT_SCHEMA_VERSION: &str = "registryctl.add_notary.v1"; #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] @@ -388,6 +389,7 @@ pub struct AnchorReport { #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] pub enum DoctorFormat { + Human, Json, } @@ -1405,13 +1407,124 @@ pub fn doctor_project( format: DoctorFormat, deployment_profile: Option, ) -> Result<()> { - let report = run_doctor_report_with_path(project_dir, format, deployment_profile, None)?; - let json = - serde_json::to_string_pretty(&report).context("failed to render doctor report JSON")?; - println!("{json}"); + let report = run_doctor_report_with_path(project_dir, deployment_profile, None)?; + match format { + DoctorFormat::Human => println!("{}", render_doctor_report(&report)), + DoctorFormat::Json => { + let json = serde_json::to_string_pretty(&report) + .context("failed to render doctor report JSON")?; + println!("{json}"); + } + } ensure_doctor_report_ok(&report) } +fn render_doctor_report(report: &DoctorReport) -> String { + use std::fmt::Write as _; + + let mut output = format!("Registry Stack doctor: {}", report.status.as_str()); + let _ = write!( + output, + "\nProject: {}\nProfile: {}", + human_line_value(&report.project.path), + human_line_value(&report.project.profile) + ); + for product in &report.products { + let _ = write!( + output, + "\n{}: {} ({} errors, {} warnings)", + human_line_value(&product.product), + product.status.as_str(), + product.report.summary.error_count, + product.report.summary.warning_count + ); + if let Some(path) = &product.report.source.path { + let _ = write!(output, "\n Config: {}", human_line_value(path)); + } + if !product.report.required_env.is_empty() { + let present = product + .report + .required_env + .iter() + .filter(|entry| entry.status.as_str() == "present") + .count(); + let missing = product + .report + .required_env + .iter() + .filter(|entry| entry.status.as_str() == "missing") + .count(); + let not_checked = product.report.required_env.len() - present - missing; + let _ = write!( + output, + "\n Required environment: {present} present, {missing} missing, {not_checked} not checked" + ); + } + if !product.report.context_constraints.is_empty() { + let _ = write!( + output, + "\n Context constraints: {}", + product.report.context_constraints.len() + ); + } + if let Some(shipping) = &product.report.audit_shipping { + let _ = write!( + output, + "\n Audit shipping: sink={}, target={}, health={}", + human_line_value(&shipping.sink_type), + human_line_value(&shipping.shipping_target), + shipping + .shipping_health + .as_deref() + .map_or("not observed".to_string(), human_line_value) + ); + } + for diagnostic in &product.report.diagnostics { + let _ = write!( + output, + "\n [{}] {}: {}", + diagnostic.severity.as_str(), + human_line_value(&diagnostic.code), + human_line_value(&diagnostic.message) + ); + if let Some(path) = &diagnostic.path { + let _ = write!(output, " ({})", human_line_value(path)); + } + } + } + if !report.cross_product_diagnostics.is_empty() { + output.push_str("\nCross-product diagnostics:"); + for diagnostic in &report.cross_product_diagnostics { + let _ = write!( + output, + "\n [{}] {}: {}", + diagnostic.severity.as_str(), + human_line_value(&diagnostic.code), + human_line_value(&diagnostic.message) + ); + } + } + output +} + +fn human_line_value(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + character if character.is_control() => { + use std::fmt::Write as _; + write!(escaped, "\\u{:04x}", character as u32) + .expect("writing to a String cannot fail"); + } + character => escaped.push(character), + } + } + escaped +} + struct ProductDoctorInvocation { product: &'static str, binary: &'static str, @@ -1422,13 +1535,9 @@ struct ProductDoctorInvocation { fn run_doctor_report_with_path( project_dir: &Path, - format: DoctorFormat, deployment_profile: Option, path: Option<&Path>, ) -> Result { - match format { - DoctorFormat::Json => {} - } let project = Project::load(project_dir)?; let secrets_path = project_dir.join(&project.local.secrets_env); let secrets = LocalEnv::load(&secrets_path)?; @@ -1649,7 +1758,7 @@ fn run_product_doctor( "registryctl.product_doctor.binary_missing", DiagnosticSeverity::Error, format!( - "Install {} and ensure it is on PATH, then rerun `registryctl doctor --format json`.", + "Install {} and ensure it is on PATH, then rerun `registryctl doctor`.", invocation.binary ), generated_at, @@ -1819,10 +1928,11 @@ impl SecretRedactor { #[derive(Debug, Serialize)] pub struct AddNotaryReport { - status: &'static str, - project: String, - notary_url: &'static str, - claim_file: &'static str, + pub schema_version: &'static str, + pub status: &'static str, + pub project: String, + pub notary_url: &'static str, + pub claim_file: &'static str, } /// Adds the local tutorial Notary journey to a generated spreadsheet project. @@ -1949,6 +2059,7 @@ pub fn add_notary_to_project( } Ok(AddNotaryReport { + schema_version: ADD_NOTARY_REPORT_SCHEMA_VERSION, status: "added", project: project.project.name, notary_url: NOTARY_BASE_URL, @@ -4503,8 +4614,8 @@ mod tests { ); let readme = fs::read_to_string(project.join("README.md")).unwrap(); - assert!(readme.contains("registryctl doctor --profile local --format json")); - assert!(readme.contains("redacts local secret values")); + assert!(readme.contains("registryctl doctor --profile local")); + assert!(readme.contains("redacts local secret")); assert!(readme.contains("Back up that file before upgrades")); assert!(readme.contains("Notary evaluation state is in memory")); assert!(readme.contains("may contain cached source rows")); @@ -5327,14 +5438,19 @@ mod tests { ), ); - let report = - run_doctor_report_with_path(&project_dir, DoctorFormat::Json, None, Some(&fake_bin)) - .unwrap(); + let report = run_doctor_report_with_path(&project_dir, None, Some(&fake_bin)).unwrap(); assert_eq!(report.status, ReportStatus::Ok); assert_eq!(report.products.len(), 1); assert_eq!(report.products[0].product, "registry-relay"); assert_eq!(report.products[0].status, ReportStatus::Ok); + let human = render_doctor_report(&report); + assert!(human.starts_with("Registry Stack doctor: ok\n"), "{human}"); + assert!(human.contains("Profile: project"), "{human}"); + assert!( + human.contains("registry-relay: ok (0 errors, 0 warnings)"), + "{human}" + ); let json = serde_json::to_value(&report).unwrap(); assert_eq!(json["project"]["profile"], "project"); let args = fs::read_to_string(temp.path().join("relay.args")).unwrap(); @@ -5375,7 +5491,6 @@ mod tests { let report = run_doctor_report_with_path( &project_dir, - DoctorFormat::Json, Some(DeploymentProfile::Local), Some(&fake_bin), ) @@ -5405,9 +5520,7 @@ mod tests { let empty_path = temp.path().join("empty-path"); fs::create_dir_all(&empty_path).unwrap(); - let report = - run_doctor_report_with_path(&project_dir, DoctorFormat::Json, None, Some(&empty_path)) - .unwrap(); + let report = run_doctor_report_with_path(&project_dir, None, Some(&empty_path)).unwrap(); assert_eq!(report.status, ReportStatus::Error); assert_eq!(report.products[0].status, ReportStatus::NotRun); @@ -5445,9 +5558,7 @@ mod tests { &format!("{secret_prints}exit 17\n"), ); - let report = - run_doctor_report_with_path(&project_dir, DoctorFormat::Json, None, Some(&fake_bin)) - .unwrap(); + let report = run_doctor_report_with_path(&project_dir, None, Some(&fake_bin)).unwrap(); let json = serde_json::to_string(&report).unwrap(); assert_eq!(report.status, ReportStatus::Error); @@ -5528,9 +5639,7 @@ mod tests { ), ); - let report = - run_doctor_report_with_path(&project_dir, DoctorFormat::Json, None, Some(&fake_bin)) - .unwrap(); + let report = run_doctor_report_with_path(&project_dir, None, Some(&fake_bin)).unwrap(); let json = serde_json::to_value(&report).unwrap(); assert_eq!(report.status, ReportStatus::Error); @@ -5582,9 +5691,7 @@ mod tests { ), ); - let report = - run_doctor_report_with_path(&project_dir, DoctorFormat::Json, None, Some(&fake_bin)) - .unwrap(); + let report = run_doctor_report_with_path(&project_dir, None, Some(&fake_bin)).unwrap(); let json = serde_json::to_value(&report).unwrap(); let shipping = &json["products"][0]["report"]["audit_shipping"]; @@ -5610,9 +5717,7 @@ mod tests { ), ); - let report = - run_doctor_report_with_path(&project_dir, DoctorFormat::Json, None, Some(&fake_bin)) - .unwrap(); + let report = run_doctor_report_with_path(&project_dir, None, Some(&fake_bin)).unwrap(); let json = serde_json::to_value(&report).unwrap(); assert_eq!( @@ -5635,6 +5740,14 @@ mod tests { ); } + #[test] + fn doctor_human_values_cannot_inject_terminal_lines() { + assert_eq!( + human_line_value("line\nreturn\r tab\t escape\u{1b}"), + "line\\nreturn\\r tab\\t escape\\u001b" + ); + } + fn write_fake_product(path: &Path, body: &str) { fs::write(path, format!("#!/bin/sh\n{body}")).unwrap(); let mut permissions = fs::metadata(path).unwrap().permissions(); diff --git a/crates/registryctl/src/main.rs b/crates/registryctl/src/main.rs index b0e76185e..4bb8720ef 100644 --- a/crates/registryctl/src/main.rs +++ b/crates/registryctl/src/main.rs @@ -4,10 +4,11 @@ use anyhow::{Context, Result}; use clap::{Parser, Subcommand, ValueEnum}; use registryctl::{ - BundleSignOptions, DeploymentProfile, DoctorFormat, InitProjectKind, InitReport, InitSource, + AddNotaryReport, AnchorReport, BundleInspectReport, BundleSignOptions, BundleSignReport, + BundleVerifyReport, DeploymentProfile, DoctorFormat, InitProjectKind, InitReport, InitSource, ProjectBuildOptions, ProjectCheckOptions, ProjectCommandReport, ProjectEditorSetupOptions, - ProjectInitOptions, ProjectSchemaKind, ProjectStarter, ProjectTestOptions, - ProjectTestSelection, Sample, + ProjectEditorSetupReport, ProjectInitOptions, ProjectSchemaKind, ProjectStarter, + ProjectTestOptions, ProjectTestSelection, Sample, }; fn main() -> Result<()> { @@ -61,13 +62,12 @@ fn main() -> Result<()> { OutputFormat::Json => print_json(&report)?, } } - Commands::Add { command } => match command { + Commands::Add { format, command } => match command { AddCommand::Notary => { let image_lock = registryctl::load_registryctl_image_lock()?; - print_json(®istryctl::add_notary_to_project( - &std::env::current_dir()?, - &image_lock, - )?)?; + let report = + registryctl::add_notary_to_project(&std::env::current_dir()?, &image_lock)?; + print_formatted_report(format, &report, render_add_notary_report)?; } }, Commands::Test { @@ -78,8 +78,12 @@ fn main() -> Result<()> { fixture, trace, watch, + format, } => { if watch { + if format == OutputFormat::Json { + anyhow::bail!("test --watch supports only human output"); + } return watch_project_tests( ProjectTestOptions { project_directory: project_dir, @@ -93,7 +97,7 @@ fn main() -> Result<()> { }, ); } - print_json(®istryctl::test_registry_project_selected( + let report = registryctl::test_registry_project_selected( &ProjectTestOptions { project_directory: project_dir, environment, @@ -104,7 +108,8 @@ fn main() -> Result<()> { fixture, trace, }, - )?)? + )?; + print_formatted_report(format, &report, |report| render_test_report(report, trace))?; } Commands::Check { project_dir, @@ -158,11 +163,16 @@ fn main() -> Result<()> { ), }, AuthoringCommand::Schema { kind } => print!("{}", kind.document()), - AuthoringCommand::Editor { project_dir } => print_json( - ®istryctl::setup_registry_project_editor(&ProjectEditorSetupOptions { - project_directory: project_dir, - })?, - )?, + AuthoringCommand::Editor { + project_dir, + format, + } => { + let report = + registryctl::setup_registry_project_editor(&ProjectEditorSetupOptions { + project_directory: project_dir, + })?; + print_formatted_report(format, &report, render_editor_setup_report)?; + } AuthoringCommand::LanguageServer => { tokio::runtime::Builder::new_current_thread() .enable_all() @@ -175,14 +185,16 @@ fn main() -> Result<()> { environment, against, anchor, - } => print_json(®istryctl::build_registry_project( - &ProjectBuildOptions { + format, + } => { + let report = registryctl::build_registry_project(&ProjectBuildOptions { project_directory: project_dir, environment, against, anchor, - }, - )?)?, + })?; + print_formatted_report(format, &report, render_build_report)?; + } Commands::Start => registryctl::start_project(&std::env::current_dir()?)?, Commands::Stop => registryctl::stop_project(&std::env::current_dir()?)?, Commands::Restart => registryctl::restart_project(&std::env::current_dir()?)?, @@ -193,18 +205,17 @@ fn main() -> Result<()> { registryctl::doctor_project(&std::env::current_dir()?, format, profile)? } Commands::Logs => registryctl::logs_project(&std::env::current_dir()?)?, - Commands::Bundle { command } => match command { + Commands::Bundle { format, command } => match command { BundleCommand::Inspect { bundle_dir } => { - print_json(®istryctl::inspect_config_bundle(&bundle_dir)?)?; + let report = registryctl::inspect_config_bundle(&bundle_dir)?; + print_formatted_report(format, &report, render_bundle_inspect_report)?; } BundleCommand::Verify { bundle_dir, anchor_path, } => { - print_json(®istryctl::verify_config_bundle_cli( - &bundle_dir, - &anchor_path, - )?)?; + let report = registryctl::verify_config_bundle_cli(&bundle_dir, &anchor_path)?; + print_formatted_report(format, &report, render_bundle_verify_report)?; } BundleCommand::Sign { input, @@ -217,7 +228,7 @@ fn main() -> Result<()> { bundle_id, out, } => { - print_json(®istryctl::sign_config_bundle(BundleSignOptions { + let report = registryctl::sign_config_bundle(BundleSignOptions { input, key, product, @@ -227,10 +238,11 @@ fn main() -> Result<()> { sequence, bundle_id, out, - })?)?; + })?; + print_formatted_report(format, &report, render_bundle_sign_report)?; } }, - Commands::Anchor { command } => match command { + Commands::Anchor { format, command } => match command { AnchorCommand::Init { anchor_path, product, @@ -238,27 +250,33 @@ fn main() -> Result<()> { stream_id, instance_id, } => { - print_json(®istryctl::init_config_anchor( + let report = registryctl::init_config_anchor( &anchor_path, product, environment, stream_id, instance_id, - )?)?; + )?; + print_formatted_report(format, &report, |report| { + render_anchor_report(report, "Initialized") + })?; } AnchorCommand::AddKey { anchor_path, jwk_path, disabled, } => { - print_json(®istryctl::add_config_anchor_key( - &anchor_path, - &jwk_path, - !disabled, - )?)?; + let report = + registryctl::add_config_anchor_key(&anchor_path, &jwk_path, !disabled)?; + print_formatted_report(format, &report, |report| { + render_anchor_report(report, "Updated") + })?; } AnchorCommand::RemoveKey { anchor_path, kid } => { - print_json(®istryctl::remove_config_anchor_key(&anchor_path, &kid)?)?; + let report = registryctl::remove_config_anchor_key(&anchor_path, &kid)?; + print_formatted_report(format, &report, |report| { + render_anchor_report(report, "Updated") + })?; } }, Commands::Bruno { command } => match command { @@ -359,6 +377,248 @@ fn print_json(value: &T) -> Result<()> { Ok(()) } +fn print_formatted_report( + format: OutputFormat, + report: &T, + render_human: impl FnOnce(&T) -> Result, +) -> Result<()> { + match format { + OutputFormat::Human => println!("{}", render_human(report)?), + OutputFormat::Json => print_json(report)?, + } + Ok(()) +} + +fn render_add_notary_report(report: &AddNotaryReport) -> Result { + use std::fmt::Write as _; + + let mut output = String::new(); + writeln!(output, "Added Registry Notary to {:?}.", report.project)?; + writeln!( + output, + " Claim: {}", + human_path(std::path::Path::new(report.claim_file)) + )?; + writeln!( + output, + " Notary API after start: {}", + human_line(report.notary_url) + )?; + writeln!(output, "\nNext:")?; + writeln!(output, " registryctl start")?; + Ok(output.trim_end().to_string()) +} + +fn render_test_report(report: &ProjectCommandReport, trace: bool) -> Result { + use std::fmt::Write as _; + + let mut output = render_test_summary(report); + if trace { + for fixture in &report.fixtures { + write!( + output, + "\n {} {}.{}", + if fixture.passed { "PASS" } else { "FAIL" }, + human_line(&fixture.integration), + human_line(&fixture.fixture) + )?; + for (label, values) in [ + ("inputs", &fixture.inputs), + ("calls", &fixture.calls), + ("outputs", &fixture.outputs), + ("claims", &fixture.claims), + ] { + if !values.is_empty() { + write!(output, "\n {label}: {}", human_list(values))?; + } + } + if let Some(outcome) = &fixture.outcome { + write!(output, "\n outcome: {}", human_line(outcome))?; + } + if let Some(expected_error) = &fixture.expected_error { + write!( + output, + "\n expected error: {}", + human_line(expected_error) + )?; + } + if let Some(source_access) = fixture.source_access { + write!(output, "\n source access: {source_access}")?; + } + } + } + Ok(output) +} + +fn render_build_report(report: &ProjectCommandReport) -> Result { + use std::fmt::Write as _; + + let passed = report + .fixtures + .iter() + .filter(|fixture| fixture.passed) + .count(); + let mut output = String::new(); + writeln!(output, "Built Registry Stack project {:?}.", report.project)?; + writeln!( + output, + " Environment: {}", + report + .environment + .as_deref() + .map_or_else(|| "none".to_string(), human_line) + )?; + if let Some(path) = &report.output { + writeln!( + output, + " Output: {}", + human_path(std::path::Path::new(path)) + )?; + } + writeln!( + output, + " Fixtures: {passed}/{} passed", + report.fixtures.len() + )?; + writeln!(output, " Baseline: {}", human_line(report.baseline))?; + writeln!( + output, + " Semantic changes: {}", + if report.semantic_changes.is_empty() { + if report.baseline == "initial_without_baseline" { + "not compared (initial review)".to_string() + } else { + "none".to_string() + } + } else { + report + .semantic_changes + .iter() + .map(|change| human_line(change.dimension)) + .collect::>() + .join(", ") + } + )?; + Ok(output.trim_end().to_string()) +} + +fn render_editor_setup_report(report: &ProjectEditorSetupReport) -> Result { + use std::fmt::Write as _; + + let mut output = String::new(); + writeln!( + output, + "Configured Registry Stack editor support for {}.", + human_path(std::path::Path::new(&report.project_directory)) + )?; + writeln!(output, " Generated files: {}", report.files.len())?; + for file in &report.files { + let path = std::path::Path::new(&report.project_directory).join(file); + writeln!(output, " {}", human_path(&path))?; + } + Ok(output.trim_end().to_string()) +} + +fn render_bundle_inspect_report(report: &BundleInspectReport) -> Result { + use std::fmt::Write as _; + + let manifest = &report.manifest; + let mut output = String::new(); + writeln!(output, "Config Bundle {:?}.", manifest.bundle_id)?; + writeln!(output, " Product: {}", human_line(&manifest.product))?; + writeln!( + output, + " Environment: {}", + human_line(&manifest.environment) + )?; + writeln!(output, " Stream: {}", human_line(&manifest.stream_id))?; + if let Some(instance_id) = &manifest.instance_id { + writeln!(output, " Instance: {}", human_line(instance_id))?; + } + writeln!(output, " Sequence: {}", manifest.sequence)?; + writeln!( + output, + " Config hash: {}", + human_line(&manifest.config_hash) + )?; + writeln!(output, " Files: {}", manifest.files.len())?; + writeln!(output, " Signatures: {}", report.signature_count)?; + if !report.signature_kids.is_empty() { + writeln!(output, " Signers: {}", human_list(&report.signature_kids))?; + } + Ok(output.trim_end().to_string()) +} + +fn render_bundle_verify_report(report: &BundleVerifyReport) -> Result { + use std::fmt::Write as _; + + let mut output = String::new(); + writeln!(output, "Verified Config Bundle {:?}.", report.bundle_id)?; + writeln!(output, " Product: {}", human_line(&report.product))?; + writeln!(output, " Environment: {}", human_line(&report.environment))?; + writeln!(output, " Stream: {}", human_line(&report.stream_id))?; + if let Some(instance_id) = &report.instance_id { + writeln!(output, " Instance: {}", human_line(instance_id))?; + } + writeln!(output, " Sequence: {}", report.sequence)?; + writeln!(output, " Config: {}", human_path(&report.config_path))?; + writeln!(output, " Config hash: {}", human_line(&report.config_hash))?; + writeln!(output, " Signers: {}", human_list(&report.signer_kids))?; + Ok(output.trim_end().to_string()) +} + +fn render_bundle_sign_report(report: &BundleSignReport) -> Result { + use std::fmt::Write as _; + + let mut output = String::new(); + writeln!( + output, + "Signed Config Bundle at {}.", + human_path(&report.bundle_dir) + )?; + writeln!(output, " Manifest: {}", human_path(&report.manifest_path))?; + writeln!( + output, + " Signature: {}", + human_path(&report.signature_path) + )?; + writeln!( + output, + " Config: {}", + human_path(std::path::Path::new(&report.config_path)) + )?; + writeln!(output, " Config hash: {}", human_line(&report.config_hash))?; + writeln!( + output, + " Signer: {} ({})", + human_line(&report.kid), + human_line(&report.alg) + )?; + writeln!(output, " Signatures: {}", report.signature_count)?; + Ok(output.trim_end().to_string()) +} + +fn render_anchor_report(report: &AnchorReport, action: &str) -> Result { + use std::fmt::Write as _; + + let mut output = String::new(); + writeln!( + output, + "{action} Config Bundle trust anchor at {}.", + human_path(&report.anchor_path) + )?; + writeln!(output, " Product: {}", human_line(&report.product))?; + writeln!(output, " Environment: {}", human_line(&report.environment))?; + writeln!(output, " Stream: {}", human_line(&report.stream_id))?; + writeln!(output, " Instance: {}", human_line(&report.instance_id))?; + writeln!( + output, + " Signers: {} enabled, {} total", + report.enabled_signer_count, report.signer_count + )?; + Ok(output.trim_end().to_string()) +} + fn render_init_report(report: &InitReport) -> Result { use std::fmt::Write as _; @@ -401,7 +661,7 @@ fn render_init_report(report: &InitReport) -> Result { writeln!(output, " registryctl test --project-dir .")?; } InitProjectKind::RelaySpreadsheetApi => { - writeln!(output, " registryctl doctor --profile local --format json")?; + writeln!(output, " registryctl doctor --profile local")?; writeln!(output, " registryctl start")?; } } @@ -440,6 +700,36 @@ fn human_path(path: &std::path::Path) -> String { } } +fn human_line(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + character if character.is_control() => { + use std::fmt::Write as _; + write!(escaped, "\\u{:04x}", character as u32) + .expect("writing to a String cannot fail"); + } + character => escaped.push(character), + } + } + escaped +} + +fn human_list(values: &[String]) -> String { + if values.is_empty() { + "none".to_string() + } else { + values + .iter() + .map(|value| human_line(value)) + .collect::>() + .join(", ") + } +} + fn render_test_summary(report: &ProjectCommandReport) -> String { let passed = report .fixtures @@ -801,6 +1091,9 @@ enum AuthoringCommand { /// Project workspace root containing registry-stack.yaml. #[arg(long, default_value = ".")] project_dir: PathBuf, + /// Human-readable result, or machine-readable JSON. + #[arg(long, value_enum, default_value = "human")] + format: OutputFormat, }, /// Run cross-file Registry Stack navigation over the Language Server Protocol. LanguageServer, @@ -838,6 +1131,9 @@ enum Commands { }, /// Add another local Registry Stack product to the current project. Add { + /// Human-readable result, or machine-readable JSON. + #[arg(long, value_enum, default_value = "human", global = true)] + format: OutputFormat, #[command(subcommand)] command: AddCommand, }, @@ -864,6 +1160,9 @@ enum Commands { /// Rerun the selected offline scope when authored files change. #[arg(long, conflicts_with_all = ["live", "trace"])] watch: bool, + /// Human-readable result, or machine-readable JSON. + #[arg(long, value_enum, default_value = "human")] + format: OutputFormat, }, /// Validate and explain generated Relay and Notary configuration. Check { @@ -905,6 +1204,9 @@ enum Commands { /// Trust anchor for --against. #[arg(long)] anchor: Option, + /// Human-readable result, or machine-readable JSON. + #[arg(long, value_enum, default_value = "human")] + format: OutputFormat, }, /// Start the local Registry Stack project. Start, @@ -918,24 +1220,30 @@ enum Commands { Open, /// Run built-in local smoke checks. Smoke, - /// Run product doctor validation and print a JSON report. + /// Run product doctor validation. Doctor { /// Deployment profile override to pass through to product doctor commands. #[arg(long, value_enum)] profile: Option, /// Output format. - #[arg(long, value_enum, default_value_t = DoctorFormat::Json)] + #[arg(long, value_enum, default_value_t = DoctorFormat::Human)] format: DoctorFormat, }, /// Stream Compose logs for the local project. Logs, /// Work with Registry Config Bundle directories. Bundle { + /// Human-readable result, or machine-readable JSON. + #[arg(long, value_enum, default_value = "human", global = true)] + format: OutputFormat, #[command(subcommand)] command: BundleCommand, }, /// Work with Registry Config Bundle trust anchors. Anchor { + /// Human-readable result, or machine-readable JSON. + #[arg(long, value_enum, default_value = "human", global = true)] + format: OutputFormat, #[command(subcommand)] command: AnchorCommand, }, @@ -1033,6 +1341,7 @@ mod tests { fixture: None, trace: false, watch: false, + format: OutputFormat::Human, } if project_dir == std::path::Path::new("registry-project") && environment == "staging" )); @@ -1058,6 +1367,7 @@ mod tests { fixture: Some(fixture), trace: false, watch: true, + format: OutputFormat::Human, } if project_dir == std::path::Path::new("registry-project") && integration == "person-record" && fixture == "active-person" @@ -1177,14 +1487,20 @@ mod tests { assert!(matches!( editor.command, Commands::Authoring { - command: AuthoringCommand::Editor { project_dir } + command: AuthoringCommand::Editor { + project_dir, + format: OutputFormat::Human + } } if project_dir == std::path::Path::new("registry-project") )); let default_editor = Cli::try_parse_from(["registryctl", "authoring", "editor"]).unwrap(); assert!(matches!( default_editor.command, Commands::Authoring { - command: AuthoringCommand::Editor { project_dir } + command: AuthoringCommand::Editor { + project_dir, + format: OutputFormat::Human + } } if project_dir == std::path::Path::new(".") )); @@ -1213,6 +1529,7 @@ mod tests { environment, against: None, anchor: None, + format: OutputFormat::Human, } if project_dir == std::path::Path::new("registry-project") && environment == "staging" )); } @@ -1311,6 +1628,98 @@ mod tests { assert!(notary_rendered.contains("Relay source authority: not applicable")); } + #[test] + fn human_bundle_and_anchor_reports_surface_operator_decisions() { + let manifest = registry_platform_config::ConfigBundleManifest { + schema: "registry.platform.config_bundle.v1".to_string(), + product: "registry-notary".to_string(), + environment: "production".to_string(), + stream_id: "civil-registry".to_string(), + instance_id: Some("notary-011".to_string()), + bundle_id: "rollout-3".to_string(), + sequence: 42, + previous_config_hash: None, + config_hash: "sha256:config".to_string(), + files: vec![registry_platform_config::ConfigBundleFile { + path: "config/notary.yaml".to_string(), + sha256: "sha256:file".to_string(), + }], + created_at: "2026-07-19T00:00:00Z".to_string(), + }; + let inspect = render_bundle_inspect_report(&BundleInspectReport { + schema_version: "registryctl.config_bundle.inspect.v1".to_string(), + manifest, + signature_count: 1, + signature_kids: vec!["signer-1".to_string()], + }) + .expect("inspect report renders"); + for expected in [ + "Config Bundle \"rollout-3\".", + "Product: registry-notary", + "Sequence: 42", + "Signatures: 1", + "Signers: signer-1", + ] { + assert!(inspect.contains(expected), "missing {expected}: {inspect}"); + } + + let verify = render_bundle_verify_report(&BundleVerifyReport { + schema_version: "registryctl.config_bundle.verify.v1".to_string(), + product: "registry-notary".to_string(), + environment: "production".to_string(), + stream_id: "civil-registry".to_string(), + instance_id: Some("notary-011".to_string()), + bundle_id: "rollout-3".to_string(), + sequence: 42, + config_path: PathBuf::from("bundle/config/notary.yaml"), + config_hash: "sha256:config".to_string(), + signer_kids: vec!["signer-1".to_string()], + }) + .expect("verify report renders"); + assert!(verify.starts_with("Verified Config Bundle \"rollout-3\".")); + assert!(verify.contains("Config: bundle/config/notary.yaml")); + + let sign = render_bundle_sign_report(&BundleSignReport { + schema_version: "registryctl.config_bundle.sign.v1".to_string(), + bundle_dir: PathBuf::from("bundle"), + manifest_path: PathBuf::from("bundle/manifest.json"), + signature_path: PathBuf::from("bundle/manifest.sig.json"), + config_path: "config/notary.yaml".to_string(), + config_hash: "sha256:config".to_string(), + kid: "signer-1".to_string(), + alg: "EdDSA".to_string(), + signature_count: 1, + }) + .expect("sign report renders"); + assert!(sign.starts_with("Signed Config Bundle at bundle.")); + assert!(sign.contains("Signer: signer-1 (EdDSA)")); + + let anchor = render_anchor_report( + &AnchorReport { + schema_version: "registryctl.config_anchor.v1".to_string(), + anchor_path: PathBuf::from("trust-anchor.json"), + product: "registry-notary".to_string(), + environment: "production".to_string(), + stream_id: "civil-registry".to_string(), + instance_id: "notary-011".to_string(), + signer_count: 2, + enabled_signer_count: 1, + }, + "Updated", + ) + .expect("anchor report renders"); + assert!(anchor.starts_with("Updated Config Bundle trust anchor at trust-anchor.json.")); + assert!(anchor.contains("Signers: 1 enabled, 2 total")); + } + + #[test] + fn human_report_values_cannot_inject_terminal_lines() { + assert_eq!( + human_line("line\nreturn\r tab\t escape\u{1b}"), + "line\\nreturn\\r tab\\t escape\\u001b" + ); + } + #[test] fn project_test_watch_reruns_each_maintained_fixture_journey_after_an_authored_change() { fn copy_directory(source: &std::path::Path, destination: &std::path::Path) -> Result<()> { @@ -1483,6 +1892,15 @@ mod tests { #[test] fn doctor_cli_accepts_profile_and_json_format() { + let default = Cli::try_parse_from(["registryctl", "doctor"]).unwrap(); + assert!(matches!( + default.command, + Commands::Doctor { + format: DoctorFormat::Human, + profile: None + } + )); + let cli = Cli::try_parse_from([ "registryctl", "doctor", @@ -1521,6 +1939,7 @@ mod tests { assert!(matches!( cli.command, Commands::Add { + format: OutputFormat::Human, command: AddCommand::Notary } )); @@ -1561,11 +1980,30 @@ mod tests { assert!(matches!( inspect.command, Commands::Bundle { + format: OutputFormat::Human, command: BundleCommand::Inspect { .. } } )); assert!(!inspect.command.should_check_for_updates()); + let json_inspect = Cli::try_parse_from([ + "registryctl", + "bundle", + "inspect", + "--bundle-dir", + "bundle", + "--format", + "json", + ]) + .unwrap(); + assert!(matches!( + json_inspect.command, + Commands::Bundle { + format: OutputFormat::Json, + command: BundleCommand::Inspect { .. } + } + )); + let verify = Cli::try_parse_from([ "registryctl", "bundle", @@ -1579,6 +2017,7 @@ mod tests { assert!(matches!( verify.command, Commands::Bundle { + format: OutputFormat::Human, command: BundleCommand::Verify { .. } } )); @@ -1608,6 +2047,7 @@ mod tests { assert!(matches!( sign.command, Commands::Bundle { + format: OutputFormat::Human, command: BundleCommand::Sign { .. } } )); @@ -1634,11 +2074,38 @@ mod tests { assert!(matches!( init.command, Commands::Anchor { + format: OutputFormat::Human, command: AnchorCommand::Init { .. } } )); assert!(!init.command.should_check_for_updates()); + let json_init = Cli::try_parse_from([ + "registryctl", + "anchor", + "init", + "--anchor-path", + "trust_anchor.json", + "--product", + "registry-notary", + "--environment", + "production", + "--stream-id", + "civil-registry", + "--instance-id", + "notary-011", + "--format", + "json", + ]) + .unwrap(); + assert!(matches!( + json_init.command, + Commands::Anchor { + format: OutputFormat::Json, + command: AnchorCommand::Init { .. } + } + )); + let add = Cli::try_parse_from([ "registryctl", "anchor", @@ -1653,6 +2120,7 @@ mod tests { assert!(matches!( add.command, Commands::Anchor { + format: OutputFormat::Human, command: AnchorCommand::AddKey { disabled: true, .. } } )); @@ -1670,6 +2138,7 @@ mod tests { assert!(matches!( remove.command, Commands::Anchor { + format: OutputFormat::Human, command: AnchorCommand::RemoveKey { .. } } )); diff --git a/crates/registryctl/src/project_authoring.rs b/crates/registryctl/src/project_authoring.rs index b8e6c5cd0..d603b20fc 100644 --- a/crates/registryctl/src/project_authoring.rs +++ b/crates/registryctl/src/project_authoring.rs @@ -37,6 +37,9 @@ static SNAPSHOT_STARTER: include_dir::Dir<'_> = include_dir::include_dir!( const PROJECT_FILE: &str = "registry-stack.yaml"; const BUILD_ROOT: &str = ".registry-stack/build"; +const PROJECT_COMMAND_REPORT_SCHEMA_VERSION: &str = "registryctl.project_command.v1"; +const PROJECT_DIAGNOSTICS_SCHEMA_VERSION: &str = "registryctl.project_diagnostics.v1"; +const PROJECT_EDITOR_REPORT_SCHEMA_VERSION: &str = "registryctl.project_editor.v1"; const REVIEW_SCHEMA: &str = "registry.project.review.v1"; const APPROVAL_STATE_SCHEMA: &str = "registry.project.approval-state.v1"; const APPROVAL_REVIEW_PATH: &str = "approval/review.json"; diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index d415f9c3c..c4c33625b 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -106,6 +106,7 @@ pub fn test_registry_project_selected( reports.push(execute_governed_live_test(&loaded)?); } Ok(ProjectCommandReport { + schema_version: PROJECT_COMMAND_REPORT_SCHEMA_VERSION, status: "passed", project: loaded.project.registry.id.clone(), environment: loaded.environment_name.clone(), @@ -737,6 +738,7 @@ pub fn check_registry_project(options: &ProjectCheckOptions) -> Result, @@ -233,6 +234,7 @@ pub fn setup_registry_project_editor( publish_project_editor_files(&root, &files, &states)?; Ok(ProjectEditorSetupReport { + schema_version: PROJECT_EDITOR_REPORT_SCHEMA_VERSION, status: "configured", project_directory: root.display().to_string(), files: files diff --git a/crates/registryctl/src/project_authoring/model.rs b/crates/registryctl/src/project_authoring/model.rs index 439dba371..846631bd0 100644 --- a/crates/registryctl/src/project_authoring/model.rs +++ b/crates/registryctl/src/project_authoring/model.rs @@ -83,6 +83,7 @@ pub struct ProjectBuildOptions { #[derive(Debug, Clone, Serialize)] #[serde(deny_unknown_fields)] pub struct ProjectCommandReport { + pub schema_version: &'static str, pub status: &'static str, pub project: String, pub environment: Option, @@ -99,6 +100,7 @@ pub struct ProjectCommandReport { #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct ProjectAuthoringDiagnostics { + pub schema_version: &'static str, pub status: &'static str, pub diagnostics: Vec, } diff --git a/crates/registryctl/src/templates/project_readme.md b/crates/registryctl/src/templates/project_readme.md index 704473a0c..97bcffe32 100644 --- a/crates/registryctl/src/templates/project_readme.md +++ b/crates/registryctl/src/templates/project_readme.md @@ -53,7 +53,7 @@ grant the identity scope only to a role permitted to read those fields. ```sh registryctl open sed -n '1,520p' relay/config.yaml -registryctl doctor --profile local --format json +registryctl doctor --profile local ``` The generated local demo credentials live in `secrets/local.env`. They are for @@ -79,8 +79,8 @@ Before exposing this project through a reverse proxy, IAM, and front rate limiter, follow https://docs.registrystack.org/operate/single-node-compose-behind-proxy/. -Run `registryctl doctor --format json` after config edits. It calls the Relay -validator and redacts local secret values in the report. Then run +Run `registryctl doctor` after config edits. It calls the Relay validator and redacts local secret +values in the report. Add `--format json` for automation. Then run `registryctl restart` so the running containers pick up the edited config; a plain `start` leaves running containers unchanged. diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md index d7e0fe563..f4b8d785e 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md @@ -14,9 +14,9 @@ registryctl build --project-dir . --environment local registryctl authoring xw --format reference ``` -`check` is human-readable by default. Use `--format json` only for machine -consumers. Editor setup uses the five schemas copied from this `registryctl` -build for VS Code and Zed. +`authoring editor`, `test`, `check`, and `build` are human-readable by default. Use `--format json` +with those report commands only for machine consumers. Editor setup uses the five schemas copied +from this `registryctl` build for VS Code and Zed. Edit the reviewed `adapter.rhai`, integration contract, and synthetic fixtures together. Keep source credentials in the environment binding. diff --git a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/README.md b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/README.md index 4887712ea..9cf788aa3 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/README.md @@ -13,9 +13,9 @@ registryctl build --project-dir . --environment local registryctl authoring xw --format reference ``` -`check` is human-readable by default. Use `--format json` only for machine -consumers. Editor setup uses the five schemas copied from this `registryctl` -build for VS Code and Zed. +`authoring editor`, `test`, `check`, and `build` are human-readable by default. Use `--format json` +with those report commands only for machine consumers. Editor setup uses the five schemas copied +from this `registryctl` build for VS Code and Zed. The fixtures are synthetic and offline. Keep source authority, authentication, private-network admission, and TLS bindings in `environments/`. diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/README.md b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/README.md index 1d7c4c471..a2a9312d3 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/README.md @@ -13,9 +13,9 @@ registryctl build --project-dir . --environment local registryctl authoring xw --format reference ``` -`check` is human-readable by default. Use `--format json` only for machine -consumers. Editor setup uses the five schemas copied from this `registryctl` -build for VS Code and Zed. +`authoring editor`, `test`, `check`, and `build` are human-readable by default. Use `--format json` +with those report commands only for machine consumers. Editor setup uses the five schemas copied +from this `registryctl` build for VS Code and Zed. Project-owned Rhai handles traversal and normalization. Relay owns signature, correlation, selector, sender, receiver, and cardinality verification. diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md index 75bd95c58..ce4b7c562 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md @@ -12,9 +12,9 @@ registryctl check --project-dir . --environment local --explain registryctl build --project-dir . --environment local ``` -`check` is human-readable by default. Use `--format json` only for machine -consumers. Editor setup uses the five schemas copied from this `registryctl` -build for VS Code and Zed. +`authoring editor`, `test`, `check`, and `build` are human-readable by default. Use `--format json` +with those report commands only for machine consumers. Editor setup uses the five schemas copied +from this `registryctl` build for VS Code and Zed. Add a records service only when the project intentionally publishes the entity through Relay's governed records API. diff --git a/crates/registryctl/tests/init_output.rs b/crates/registryctl/tests/init_output.rs index 46a4295c4..b7bb3dc77 100644 --- a/crates/registryctl/tests/init_output.rs +++ b/crates/registryctl/tests/init_output.rs @@ -286,7 +286,7 @@ fn relay_init_defaults_to_the_same_human_result_structure() { assert_eq!( stdout, format!( - "Initialized Relay spreadsheet API \"my-first-api\".\n Directory: {}\n Sample: benefits\n Bruno collection: {}\n\nNext:\n cd {}\n registryctl doctor --profile local --format json\n registryctl start\n", + "Initialized Relay spreadsheet API \"my-first-api\".\n Directory: {}\n Sample: benefits\n Bruno collection: {}\n\nNext:\n cd {}\n registryctl doctor --profile local\n registryctl start\n", project.display(), project.join("bruno/registry-api").display(), project.display(), @@ -318,7 +318,7 @@ fn relay_init_human_artifact_paths_are_line_safe_and_shell_usable() { assert_eq!( stdout, format!( - "Initialized Relay spreadsheet API \"my-first-api\".\n Directory: {rendered_project}\n Sample: benefits\n Bruno collection: {rendered_bruno}\n\nNext:\n cd {rendered_project}\n registryctl doctor --profile local --format json\n registryctl start\n", + "Initialized Relay spreadsheet API \"my-first-api\".\n Directory: {rendered_project}\n Sample: benefits\n Bruno collection: {rendered_bruno}\n\nNext:\n cd {rendered_project}\n registryctl doctor --profile local\n registryctl start\n", ) ); assert_stdout_has_no_terminal_controls(&stdout); @@ -439,3 +439,209 @@ fn relay_init_accepts_json_format_after_the_subcommand_without_mixed_output() { ); assert!(report["artifacts"].get("editor_manifest").is_none()); } + +#[test] +fn add_notary_defaults_to_human_output_and_keeps_versioned_json_opt_in() { + let temporary = TempDir::new().expect("temporary directory"); + let image_lock = write_image_lock(&temporary); + + let human_project = temporary.path().join("my-first-api"); + let init = run_registryctl( + &[ + "init", + "relay", + human_project.to_str().expect("UTF-8 project path"), + ], + Some(&image_lock), + ); + assert_success(&init); + let human = run_registryctl_in(Some(&human_project), &["add", "notary"], Some(&image_lock)); + assert_success(&human); + assert_eq!( + String::from_utf8(human.stdout).expect("UTF-8 output"), + "Added Registry Notary to \"my-first-api\".\n Claim: notary/project/registry-stack.yaml\n Notary API after start: http://127.0.0.1:4255\n\nNext:\n registryctl start\n" + ); + + let json_project = temporary.path().join("my-first-api-json"); + let init = run_registryctl( + &[ + "init", + "relay", + json_project.to_str().expect("UTF-8 project path"), + ], + Some(&image_lock), + ); + assert_success(&init); + let json_output = run_registryctl_in( + Some(&json_project), + &["add", "notary", "--format", "json"], + Some(&image_lock), + ); + assert_success(&json_output); + let report: Value = + serde_json::from_slice(&json_output.stdout).expect("add notary emits only JSON"); + assert_eq!(report["schema_version"], "registryctl.add_notary.v1"); + assert_eq!(report["status"], "added"); + assert_eq!(report["project"], "my-first-api-json"); + assert_eq!(report["notary_url"], "http://127.0.0.1:4255"); + assert_eq!(report["claim_file"], "notary/project/registry-stack.yaml"); +} + +#[test] +fn project_commands_default_to_human_output_and_keep_versioned_json_opt_in() { + let temporary = TempDir::new().expect("temporary directory"); + let project = temporary.path().join("registry-project"); + let init = run_registryctl( + &[ + "init", + "--from", + "http", + "--project-dir", + project.to_str().expect("UTF-8 project path"), + ], + None, + ); + assert_success(&init); + + let editor = run_registryctl( + &[ + "authoring", + "editor", + "--project-dir", + project.to_str().expect("UTF-8 project path"), + ], + None, + ); + assert_success(&editor); + let editor = String::from_utf8(editor.stdout).expect("UTF-8 editor output"); + assert!(editor.starts_with("Configured Registry Stack editor support for ")); + assert!(editor.contains("\n Generated files: ")); + + let json_editor = run_registryctl( + &[ + "authoring", + "editor", + "--project-dir", + project.to_str().expect("UTF-8 project path"), + "--format", + "json", + ], + None, + ); + assert_success(&json_editor); + let json_editor: Value = + serde_json::from_slice(&json_editor.stdout).expect("editor setup emits only JSON"); + assert_eq!( + json_editor["schema_version"], + "registryctl.project_editor.v1" + ); + assert_eq!(json_editor["status"], "configured"); + + let test = run_registryctl( + &[ + "test", + "--project-dir", + project.to_str().expect("UTF-8 project path"), + ], + None, + ); + assert_success(&test); + let test = String::from_utf8(test.stdout).expect("UTF-8 test output"); + assert!(test.starts_with("PASS: "), "{test}"); + assert!(test.ends_with(" fixtures passed\n"), "{test}"); + + let json_watch = run_registryctl( + &[ + "test", + "--project-dir", + project.to_str().expect("UTF-8 project path"), + "--watch", + "--format", + "json", + ], + None, + ); + assert!(!json_watch.status.success()); + assert!(String::from_utf8_lossy(&json_watch.stderr) + .contains("test --watch supports only human output")); + + let trace = run_registryctl( + &[ + "test", + "--project-dir", + project.to_str().expect("UTF-8 project path"), + "--integration", + "person-record", + "--fixture", + "active-person", + "--trace", + ], + None, + ); + assert_success(&trace); + let trace = String::from_utf8(trace.stdout).expect("UTF-8 trace output"); + assert!( + trace.contains("\n PASS person-record.active-person"), + "{trace}" + ); + assert!(trace.contains("\n inputs: person_id"), "{trace}"); + assert!(trace.contains("\n outputs: active"), "{trace}"); + + let json_test = run_registryctl( + &[ + "test", + "--project-dir", + project.to_str().expect("UTF-8 project path"), + "--format", + "json", + ], + None, + ); + assert_success(&json_test); + let json_test: Value = serde_json::from_slice(&json_test.stdout).expect("test emits only JSON"); + assert_eq!( + json_test["schema_version"], + "registryctl.project_command.v1" + ); + assert_eq!(json_test["status"], "passed"); + + let build = run_registryctl( + &[ + "build", + "--project-dir", + project.to_str().expect("UTF-8 project path"), + "--environment", + "local", + ], + None, + ); + assert_success(&build); + let build = String::from_utf8(build.stdout).expect("UTF-8 build output"); + assert!( + build.starts_with("Built Registry Stack project \"fictional-citizen-registry\".\n"), + "{build}" + ); + assert!(build.contains("\n Environment: local\n"), "{build}"); + assert!(build.contains("\n Output: "), "{build}"); + + let json_build = run_registryctl( + &[ + "build", + "--project-dir", + project.to_str().expect("UTF-8 project path"), + "--environment", + "local", + "--format", + "json", + ], + None, + ); + assert_success(&json_build); + let json_build: Value = + serde_json::from_slice(&json_build.stdout).expect("build emits only JSON"); + assert_eq!( + json_build["schema_version"], + "registryctl.project_command.v1" + ); + assert_eq!(json_build["status"], "built"); +} diff --git a/crates/registryctl/tests/project_authoring.rs b/crates/registryctl/tests/project_authoring.rs index 6470af0ec..6f5a37cc4 100644 --- a/crates/registryctl/tests/project_authoring.rs +++ b/crates/registryctl/tests/project_authoring.rs @@ -576,6 +576,7 @@ fn project_check_cli_renders_the_same_typed_diagnostic_in_human_and_json() { let human = String::from_utf8(human.stdout).expect("human output is UTF-8"); let json: serde_json::Value = serde_json::from_slice(&json.stdout).expect("JSON output is typed diagnostics"); + assert_eq!(json["schema_version"], "registryctl.project_diagnostics.v1"); let diagnostics = json["diagnostics"] .as_array() .expect("diagnostics is an array"); From 53095b95eece766211183f1fe435d829078e8242 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 15:52:21 +0700 Subject: [PATCH 04/65] docs(registryctl): document human report defaults Signed-off-by: Jeremi Joslin --- .../scripts/check-registryctl-tutorials.sh | 2 +- .../docs/operate/backup-and-restore.mdx | 7 +-- .../single-node-compose-behind-proxy.mdx | 6 +-- .../content/docs/reference/registryctl.mdx | 44 ++++++++++++++----- .../tutorials/author-registry-project.mdx | 42 +++++++++--------- ...nfigure-project-api-key-authentication.mdx | 7 ++- ...igure-project-snapshot-materialization.mdx | 6 +-- ...blish-spreadsheet-secured-registry-api.mdx | 4 +- .../tutorials/verify-claim-registry-api.mdx | 20 +++++---- .../docs/tutorials/verify-opencrvs-claims.mdx | 14 +++--- 10 files changed, 88 insertions(+), 64 deletions(-) diff --git a/docs/site/scripts/check-registryctl-tutorials.sh b/docs/site/scripts/check-registryctl-tutorials.sh index d6ba03ef1..9e2a15088 100644 --- a/docs/site/scripts/check-registryctl-tutorials.sh +++ b/docs/site/scripts/check-registryctl-tutorials.sh @@ -395,7 +395,7 @@ run_notary_tutorial() { cd "$project_dir" run_block 'Notary 1: Add Notary to the project' "$blocks/01.sh" success - assert_json_fence_subset "$LAST_OUTPUT" "$NOTARY_TUTORIAL" 'Add Notary to the project' 1 + assert_fence_lines "$LAST_OUTPUT" "$NOTARY_TUTORIAL" 'Add Notary to the project' text 1 assert_contains "$LAST_OUTPUT" http://127.0.0.1:4255 notary/project/registry-stack.yaml run_block 'Notary 2: Inspect the claim' "$blocks/02.sh" success assert_fence_lines "$LAST_OUTPUT" "$NOTARY_TUTORIAL" 'Inspect the claim' yaml 1 diff --git a/docs/site/src/content/docs/operate/backup-and-restore.mdx b/docs/site/src/content/docs/operate/backup-and-restore.mdx index c8922b061..e0ae8659e 100644 --- a/docs/site/src/content/docs/operate/backup-and-restore.mdx +++ b/docs/site/src/content/docs/operate/backup-and-restore.mdx @@ -7,7 +7,7 @@ source_repos: - registry-stack - registry-relay - registry-notary -last_reviewed: "2026-07-09" +last_reviewed: "2026-07-19" doc_type: how-to locale: en standards_referenced: [] @@ -302,13 +302,14 @@ Start the deployment and run product checks: ```sh registryctl start -registryctl doctor --profile local --format json +registryctl doctor --profile local if [ -f registryctl.yaml ] && grep -q '^relay:' registryctl.yaml; then registryctl smoke fi ``` -Expected output from `doctor` is a JSON report with no `startup_fail` findings. +The human-readable `doctor` report must contain no `startup_fail` findings. Add `--format json` +when another program needs the versioned report. Run `registryctl smoke` only when `registryctl.yaml` declares Relay. For a Notary deployment, run `registry-notary doctor --config --format json`, then exercise an authenticated claim or credential journey appropriate to that deployment. `registryctl` does not diff --git a/docs/site/src/content/docs/operate/single-node-compose-behind-proxy.mdx b/docs/site/src/content/docs/operate/single-node-compose-behind-proxy.mdx index 9f8bc6621..1b3bc4935 100644 --- a/docs/site/src/content/docs/operate/single-node-compose-behind-proxy.mdx +++ b/docs/site/src/content/docs/operate/single-node-compose-behind-proxy.mdx @@ -7,7 +7,7 @@ source_repos: - registry-stack - registry-relay - registry-notary -last_reviewed: "2026-07-09" +last_reviewed: "2026-07-19" doc_type: how-to locale: en standards_referenced: [] @@ -74,8 +74,8 @@ registryctl build \ --environment ``` -The test and build JSON reports contain `"status": "passed"` and `"status": "built"`. -The human-readable check report marks the project `valid`. +The human-readable test report starts with `PASS`, the check report marks the project `valid`, and +the build report identifies the generated output directory. Confirm the selected topology emits only the expected product inputs. A combined project emits separate Relay and Notary inputs with the same approval state. It does not emit a signed project-level root. diff --git a/docs/site/src/content/docs/reference/registryctl.mdx b/docs/site/src/content/docs/reference/registryctl.mdx index ab40fcbf6..42a58ca1c 100644 --- a/docs/site/src/content/docs/reference/registryctl.mdx +++ b/docs/site/src/content/docs/reference/registryctl.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-07-14" +last_reviewed: "2026-07-19" doc_type: reference locale: en standards_referenced: [] @@ -39,7 +39,8 @@ complete workflow and generated trust boundary. | `init --format json` | Emit the versioned machine-readable initialization report instead of the default human-readable result. | | `test --project-dir ` | Compile and run every integration fixture through the Relay decoder and configured claim evaluator offline. Defaults to the current directory. | | `test --integration --fixture ` | Select one integration fixture. `--fixture` requires `--integration`. | -| `test --trace` | Include the safe synthetic interaction trace in the JSON report. | +| `test --trace` | Include the safe synthetic interaction trace in the human or JSON report. | +| `test --format json` | Emit the versioned machine-readable test report instead of the default human-readable result. | | `test --watch` | Rerun the selected offline scope when an authored project file changes and print a concise pass/fail summary. Cannot be combined with `--trace` or `--live`. | | `test --environment ` | Include one explicit environment binding in offline validation. | | `test --environment --live` | After offline fixtures pass, send one governed evaluation through a deployed Notary. The environment must be explicitly non-production. | @@ -51,16 +52,29 @@ complete workflow and generated trust boundary. | `authoring xw --format editor` | Print generated editor metadata for the `xw.v1` function surface. | | `authoring schema --kind ` | Print a strict project, environment, integration, fixture, or entity JSON Schema. | | `authoring editor --project-dir ` | Create or verify version-matched VS Code and Zed schema configuration. Defaults to the current directory. | +| `authoring editor --format json` | Emit the versioned machine-readable editor setup report. | | `authoring language-server` | Run Registry Stack navigation and reference diagnostics over the Language Server Protocol on standard input and output. | | `build --project-dir --environment ` | Emit deterministic unsigned Config Bundle input directories for the products in the selected topology. | | `build --against --anchor ` | Build against an explicitly verified signed baseline. Both flags are required together. | +| `build --format json` | Emit the versioned machine-readable build report instead of the default human-readable result. | + +Interactive report commands print concise human-readable results by default. Add `--format json` +when another program needs a report containing only versioned JSON on standard output. This policy +covers `init`, `add notary`, non-watch `test`, `check`, `authoring editor`, `build`, `doctor`, +`bundle`, and `anchor`. Watch mode prints one concise pass/fail summary per run and rejects JSON +formatting. Artifact and protocol streams retain their native formats, including `authoring xw`, +`authoring schema`, `authoring language-server`, and `logs`. + +| JSON command result | Schema version | +| --- | --- | +| `init` | `registryctl.init.v1` | +| `add notary` | `registryctl.add_notary.v1` | +| Successful `test`, `check`, or `build` | `registryctl.project_command.v1` | +| Invalid-project `check` | `registryctl.project_diagnostics.v1` | +| `authoring editor` | `registryctl.project_editor.v1` | +| `doctor` | `registryctl.validation.report.v1` | +| `bundle` or `anchor` | The operation-specific `schema_version` in the report | -`init` prints a concise human-readable result and tailored next commands by default. -Use `--format json` with either initialization form to emit only a `registryctl.init.v1` report on -standard output. The JSON report contains the project identity and kind, destination, -initialization source, and notable artifact paths. Non-watch `test` and `build` print JSON reports. -`check` prints a human-readable review report by default and accepts `--format json` for -automation. Watch mode prints one concise pass/fail summary per run. An initialized starter records its starter ID, Registry Stack release, and authored-content digest in `registry-stack.yaml`. `init` verifies that digest, and `check --explain` reports `matches` or `diverged` together with the current @@ -242,12 +256,15 @@ submanifests is future work and is not emitted by the current CLI. | `bundle sign --instance-id ` | Optionally bind the bundle to one product instance. | | `bundle sign --sequence --bundle-id --out ` | Set the monotonic sequence and bundle identity, then create the output directory. | | `bundle verify --bundle-dir --anchor-path ` | Verify the signed closure, signature, trust anchor, and configured identity bindings. | +| `bundle --format json ` | Emit the operation-specific machine-readable report. The global flag is also accepted after the subcommand. | +| `anchor --format json ` | Emit the versioned machine-readable trust-anchor report. The global flag is also accepted after the subcommand. | | `check --against --anchor ` | Verify a signed baseline before calculating semantic changes. Both flags are required. | | `build --against --anchor ` | Verify a signed baseline before producing updated unsigned inputs. Both flags are required. | `bundle inspect --bundle-dir ` reports manifest and signature metadata without replacing verification. Trust-anchor creation and key changes use the existing `anchor` subcommands shown by -`registryctl anchor --help`. +`registryctl anchor --help`. Bundle and trust-anchor operations print human-readable results by +default and accept `--format json` for automation. {/* Evidence: crates/registryctl/src/main.rs, BundleCommand and AnchorCommand. */} @@ -287,6 +304,9 @@ consultation Relay and local Notary services, and generates local-only evaluator credentials. It refuses an existing Notary directory or a second invocation instead of overwriting authored work. +The default result identifies the authored claim, the Notary URL available after start, and the +next command. Add `--format json` for the `registryctl.add_notary.v1` report. + `registryctl start` and `registryctl restart` validate and rebuild this add-on from the authored project before starting it. Registryctl validates the fixed local runtime overrides with the production product models and publishes one complete generated bundle, so services do not observe @@ -314,9 +334,9 @@ Inspect commands report on a running or generated project. | --- | --- | | `open` | Open or print the local API docs URL. | | `logs` | Stream Compose logs for the local project. | -| `doctor` | Run product doctor validation and print a JSON report. | +| `doctor` | Run product doctor validation and print a human-readable diagnostic report. | | `doctor --profile ` | Deployment profile override passed through to product doctor commands. | -| `doctor --format ` | Output format. Defaults to `json`. | +| `doctor --format json` | Emit the versioned machine-readable diagnostic report. | ## Testing @@ -339,7 +359,7 @@ Smoke commands run built-in local checks and write a JSON result file. ## Update check -`registryctl` checks GitHub releases at most once per day for normal human-facing commands and prints an upgrade notice to standard error when a newer release is available. It skips the automatic check in continuous integration and while running `doctor` and the explicit update-check commands, so JSON output stays quiet. +`registryctl` checks GitHub releases at most once per day for normal human-facing commands and prints an upgrade notice to standard error when a newer release is available. It skips the automatic check in continuous integration and while running `doctor` and the explicit update-check commands, so doctor diagnostics are not accompanied by an update notice. | Subcommand | Purpose | | --- | --- | diff --git a/docs/site/src/content/docs/tutorials/author-registry-project.mdx b/docs/site/src/content/docs/tutorials/author-registry-project.mdx index 28e9f5e86..7e2681904 100644 --- a/docs/site/src/content/docs/tutorials/author-registry-project.mdx +++ b/docs/site/src/content/docs/tutorials/author-registry-project.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-07-14" +last_reviewed: "2026-07-19" doc_type: tutorial locale: en standards_referenced: [] @@ -125,25 +125,23 @@ registryctl test \ --trace ``` -The JSON report identifies the selected fixture, typed input, source call, minimized output, claim -results, and derived security cases. Its primary fixture contains: - -```json -{ - "integration": "person-record", - "fixture": "active-person", - "inputs": ["person_id"], - "calls": ["request"], - "outputs": ["active"], - "claims": ["person-active", "person-record-exists"], - "outcome": "match", - "passed": true -} +The human-readable report identifies the selected fixture, typed input, source call, minimized +output, claim results, and derived security cases. The report starts with: + +```text +PASS: 7/7 fixtures passed + PASS person-record.active-person + inputs: person_id + calls: call=1 operation=request method=GET path=/people/* query=[fields] headers=[] body=none + outputs: active + claims: person-active, person-record-exists + outcome: match ``` The same report includes derived malformed-decoding, byte-ceiling, timeout, authorization-before-source, and output-minimization cases. The command does not contact Registry -Relay, Registry Notary, or a source registry. +Relay, Registry Notary, or a source registry. Add `--format json` when another program needs the +versioned fixture report. ## Watch the selected fixture @@ -257,10 +255,10 @@ cases: registryctl test --project-dir registry-project ``` -The report must again contain: +The report starts with `PASS` and states how many fixtures passed: ```text -"status": "passed" +PASS: / fixtures passed ``` Failures identify the authored file and fixture. @@ -304,12 +302,16 @@ registryctl build \ --environment local ``` -The JSON report contains: +The human-readable report starts with the built project and output directory: ```text -"status": "built" +Built Registry Stack project "fictional-citizen-registry". + Environment: local + Output: registry-project/.registry-stack/build/local ``` +Add `--format json` when another program needs the versioned build report. + The output root contains: ```text diff --git a/docs/site/src/content/docs/tutorials/configure-project-api-key-authentication.mdx b/docs/site/src/content/docs/tutorials/configure-project-api-key-authentication.mdx index 6cc4c631f..12e972686 100644 --- a/docs/site/src/content/docs/tutorials/configure-project-api-key-authentication.mdx +++ b/docs/site/src/content/docs/tutorials/configure-project-api-key-authentication.mdx @@ -5,7 +5,7 @@ status: draft owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-07-12" +last_reviewed: "2026-07-19" doc_type: how-to locale: en standards_referenced: [] @@ -121,9 +121,8 @@ registryctl check \ --explain ``` -The first JSON report contains `"status": "passed"`. -The second human-readable report marks the project `valid` and lists `operator_security` among the -required reviews for a query credential. +The first human-readable report starts with `PASS`. The second marks the project `valid` and lists +`operator_security` among the required reviews for a query credential. The redacted explanation names the credential type, reference, and generation without the secret value. diff --git a/docs/site/src/content/docs/tutorials/configure-project-snapshot-materialization.mdx b/docs/site/src/content/docs/tutorials/configure-project-snapshot-materialization.mdx index 965cbdeae..7e435c05a 100644 --- a/docs/site/src/content/docs/tutorials/configure-project-snapshot-materialization.mdx +++ b/docs/site/src/content/docs/tutorials/configure-project-snapshot-materialization.mdx @@ -5,7 +5,7 @@ status: draft owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-07-12" +last_reviewed: "2026-07-19" doc_type: how-to locale: en standards_referenced: [] @@ -186,8 +186,8 @@ registryctl build \ --environment ``` -The test and build JSON reports contain `"status": "passed"` and `"status": "built"`. -The human-readable check report marks the project `valid`. +The human-readable test report starts with `PASS`, the check report marks the project `valid`, and +the build report identifies the generated output directory. Confirm that the explanation names one logical materialization and that no reviewable integration pack or consultation contract contains the provider path or physical column names. diff --git a/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx b/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx index ff2e6094e..22b1bf41b 100644 --- a/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx +++ b/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx @@ -6,7 +6,7 @@ owner: registry-docs source_repos: - registry-stack - registry-relay -last_reviewed: "2026-07-17" +last_reviewed: "2026-07-19" doc_type: tutorial locale: en standards_referenced: @@ -83,7 +83,7 @@ Initialized Relay spreadsheet API "my-first-api". Next: cd my-first-api - registryctl doctor --profile local --format json + registryctl doctor --profile local registryctl start ``` diff --git a/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx b/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx index 6dffdaf40..5bde9e4f9 100644 --- a/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx +++ b/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx @@ -7,7 +7,7 @@ source_repos: - registry-stack - registry-relay - registry-notary -last_reviewed: "2026-07-17" +last_reviewed: "2026-07-19" doc_type: tutorial locale: en standards_referenced: [] @@ -54,17 +54,19 @@ From the `my-first-api` directory left by the first tutorial, add the local Nota registryctl add notary ``` -The command reports the authored claim file and local Notary URL: +The command reports the authored claim file, local Notary URL, and next command: -```json -{ - "status": "added", - "project": "my-first-api", - "notary_url": "http://127.0.0.1:4255", - "claim_file": "notary/project/registry-stack.yaml" -} +```text +Added Registry Notary to "my-first-api". + Claim: notary/project/registry-stack.yaml + Notary API after start: http://127.0.0.1:4255 + +Next: + registryctl start ``` +Add `--format json` when another program needs the versioned add-on report. + The add-on creates an editable Registry Stack project under `notary/project/`. It also adds a private consultation Relay that reads the same workbook. Relay still owns registry access. Notary receives only the minimized consultation result needed to evaluate the claim. diff --git a/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx b/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx index 6fb937527..f87240cfd 100644 --- a/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx +++ b/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx @@ -5,7 +5,7 @@ status: draft owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-06-26" +last_reviewed: "2026-07-19" doc_type: how-to locale: en standards_referenced: @@ -84,10 +84,10 @@ Run every fixture without a network connection: registryctl test --project-dir opencrvs-project ``` -The JSON report contains: +The human-readable report starts with: ```text -"status": "passed" +PASS: / fixtures passed ``` The match fixture proves the exact identifier and minimized birth-record outputs. The no-match and @@ -138,10 +138,10 @@ registryctl build \ --environment local ``` -The report contains `"status": "built"`. The output has separate Relay and Notary Config Bundle -input directories under `.registry-stack/build/local/private/`. It contains secret references, -not secret values. Package, sign, verify, and activate each product input through that product's -Config Bundle workflow. +The human-readable report starts with `Built Registry Stack project` and identifies the output +directory. The output has separate Relay and Notary Config Bundle input directories under +`.registry-stack/build/local/private/`. It contains secret references, not secret values. Package, +sign, verify, and activate each product input through that product's Config Bundle workflow. ## Verify the boundary From 3d9c090a2487236e6b140adc9d0b5ca1ee28d937 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:48:03 +0700 Subject: [PATCH 05/65] feat(notary): enforce bounded batch evaluation Signed-off-by: Jeremi Joslin --- crates/registry-notary-client/src/client.rs | 12 +- crates/registry-notary-client/src/error.rs | 4 + .../tests/client_contract.rs | 56 ++- .../registry-notary-core/src/config/errors.rs | 2 + .../src/config/evidence/claims.rs | 1 + .../src/config/evidence/mod.rs | 30 +- .../registry-notary-core/src/config/root.rs | 1 + .../src/config/tests/root.rs | 50 +++ crates/registry-notary-core/src/model.rs | 55 ++- .../registry-notary-server/src/api/audit.rs | 83 +++-- .../src/api/evaluations.rs | 6 +- .../src/api/tests/audit.rs | 112 ++++++ crates/registry-notary-server/src/openapi.rs | 41 ++- crates/registry-notary-server/src/problem.rs | 2 +- .../src/runtime/access.rs | 3 +- .../src/runtime/catalog.rs | 3 + .../src/runtime/evaluation.rs | 50 ++- .../src/runtime/tests/evaluation.rs | 341 ++++++++++++++++++ .../tests/standalone_http/http_contracts.rs | 17 + .../openapi/registry-notary.openapi.json | 14 +- schemas/registry-notary.config.schema.json | 6 +- 21 files changed, 836 insertions(+), 53 deletions(-) diff --git a/crates/registry-notary-client/src/client.rs b/crates/registry-notary-client/src/client.rs index 53623e26f..9b195a571 100644 --- a/crates/registry-notary-client/src/client.rs +++ b/crates/registry-notary-client/src/client.rs @@ -8,7 +8,7 @@ use std::time::{Duration, Instant}; use registry_notary_core::{ BatchEvaluateRequest, ClaimRef, CredentialIssueRequest, EvaluateRequest, EvidenceEntity, EvidenceIdentifier, EvidenceOnBehalfOf, EvidenceRelationship, RenderEvaluationRequest, - RenderRequest, RequestVariables, FORMAT_CLAIM_RESULT_JSON, + RenderRequest, RequestVariables, FORMAT_CLAIM_RESULT_JSON, MAX_BATCH_EVALUATION_MEMBERS_V1, }; use registry_platform_httputil::read_bounded; use reqwest::{Method, StatusCode, Url}; @@ -534,13 +534,21 @@ impl RegistryNotaryClient { /// Submit a raw typed [`BatchEvaluateRequest`]. /// /// Batch evaluation is the only POST route where the client allows - /// `Idempotency-Key`; retries require that key. + /// `Idempotency-Key`; retries require that key. Requests above the hard + /// 100-member platform ceiling fail locally before transport. pub async fn batch_evaluate_request( &self, mut request: BatchEvaluateRequest, options: RequestOptions, ) -> Result, NotaryClientError> { + if request.items.len() > MAX_BATCH_EVALUATION_MEMBERS_V1 { + return Err(NotaryClientBuildError::BatchTooLarge { + actual: request.items.len(), + maximum: MAX_BATCH_EVALUATION_MEMBERS_V1, + } + .into()); + } let mut options = self.prepare_purpose(options, request.purpose.as_deref())?; options.accept = options .accept diff --git a/crates/registry-notary-client/src/error.rs b/crates/registry-notary-client/src/error.rs index 9054212ed..c3cdec691 100644 --- a/crates/registry-notary-client/src/error.rs +++ b/crates/registry-notary-client/src/error.rs @@ -31,6 +31,9 @@ pub enum NotaryClientBuildError { /// An idempotency key was supplied on a route that ignores it. #[error("idempotency key is not supported for this route")] UnsupportedIdempotencyKey, + /// The request exceeds the hard Registry Notary batch ceiling. + #[error("batch contains {actual} members; the hard maximum is {maximum}")] + BatchTooLarge { actual: usize, maximum: usize }, } /// RFC 9457 Problem Details emitted by Registry Notary. @@ -313,6 +316,7 @@ impl NotaryClientError { NotaryClientBuildError::UnsupportedIdempotencyKey => { "request.unsupported_idempotency_key" } + NotaryClientBuildError::BatchTooLarge { .. } => "batch.too_large", } .to_string(), ), diff --git a/crates/registry-notary-client/tests/client_contract.rs b/crates/registry-notary-client/tests/client_contract.rs index 0c1e42336..868b508e1 100644 --- a/crates/registry-notary-client/tests/client_contract.rs +++ b/crates/registry-notary-client/tests/client_contract.rs @@ -18,7 +18,9 @@ use registry_notary_client::{ CredentialIssueResponse, NotaryClientBuildError, NotaryClientError, NotaryResponse, RegistryNotaryClient, RequestOptions, RetryPolicy, }; -use registry_notary_core::{BatchEvaluateResponse, BatchStatus, FORMAT_CLAIM_RESULT_JSON}; +use registry_notary_core::{ + BatchEvaluateResponse, BatchStatus, FORMAT_CLAIM_RESULT_JSON, MAX_BATCH_EVALUATION_MEMBERS_V1, +}; use secrecy::SecretString; use serde_json::json; use tokio::net::TcpListener; @@ -642,6 +644,58 @@ async fn raw_batch_preserves_body_only_purpose() { assert_eq!(response.body.batch_id, "batch-1"); } +#[tokio::test] +async fn typed_batch_rejects_platform_ceiling_plus_one_before_transport() { + let calls = Arc::new(AtomicUsize::new(0)); + let app = Router::new() + .route( + "/v1/batch-evaluations", + post(|State(calls): State>| async move { + calls.fetch_add(1, Ordering::SeqCst); + body_purpose_batch_handler( + HeaderMap::new(), + Bytes::from_static(br#"{"purpose":"body-purpose"}"#), + ) + .await + }), + ) + .with_state(Arc::clone(&calls)); + let base = spawn(app).await; + let client = RegistryNotaryClient::builder(base) + .bearer_token("bearer-secret") + .build() + .expect("client builds"); + let item = registry_notary_core::BatchEvaluateItemRequest::from( + registry_notary_core::BatchSubjectRequest { + id: "subject-1".to_string(), + id_type: None, + purpose: None, + }, + ); + let error = client + .batch_evaluate_request( + registry_notary_core::BatchEvaluateRequest { + items: vec![item; MAX_BATCH_EVALUATION_MEMBERS_V1 + 1], + claims: vec![registry_notary_core::ClaimRef::new("claim-a")], + disclosure: None, + format: None, + purpose: Some("body-purpose".to_string()), + }, + RequestOptions::default(), + ) + .await + .expect_err("the typed client rejects the hard ceiling plus one"); + + assert!(matches!( + error, + NotaryClientError::Build(NotaryClientBuildError::BatchTooLarge { + actual, + maximum: MAX_BATCH_EVALUATION_MEMBERS_V1, + }) if actual == MAX_BATCH_EVALUATION_MEMBERS_V1 + 1 + )); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} + #[tokio::test] async fn raw_credential_issue_preserves_body_only_purpose() { let app = Router::new().route( diff --git a/crates/registry-notary-core/src/config/errors.rs b/crates/registry-notary-core/src/config/errors.rs index 815a52d31..8c631a595 100644 --- a/crates/registry-notary-core/src/config/errors.rs +++ b/crates/registry-notary-core/src/config/errors.rs @@ -97,6 +97,8 @@ pub enum EvidenceConfigError { InvalidConcurrency, #[error("invalid evidence.machine_quota config: {reason}")] InvalidMachineQuotaConfig { reason: String }, + #[error("invalid evidence batch config: {reason}")] + InvalidBatchConfig { reason: String }, /// Credential holder binding only works with did:jwk because holder_jwk() /// only implements did:jwk resolution. Restrict allowed_did_methods to /// ["did:jwk"] or leave it empty when holder binding is disabled. diff --git a/crates/registry-notary-core/src/config/evidence/claims.rs b/crates/registry-notary-core/src/config/evidence/claims.rs index be7bc6852..51c3fa8a8 100644 --- a/crates/registry-notary-core/src/config/evidence/claims.rs +++ b/crates/registry-notary-core/src/config/evidence/claims.rs @@ -923,6 +923,7 @@ pub struct BatchOperationConfig { #[serde(default)] pub enabled: bool, #[serde(default = "default_inline_batch_limit")] + #[schemars(range(min = 1, max = 100))] pub max_subjects: usize, } diff --git a/crates/registry-notary-core/src/config/evidence/mod.rs b/crates/registry-notary-core/src/config/evidence/mod.rs index 8dc3a30b5..bda2e3d08 100644 --- a/crates/registry-notary-core/src/config/evidence/mod.rs +++ b/crates/registry-notary-core/src/config/evidence/mod.rs @@ -15,6 +15,11 @@ pub use limits::*; pub use relay::*; pub use signing::*; +/// Hard 1.0 platform ceiling for synchronous batch evaluation members. +/// +/// Operator settings may reduce this value, but never raise it. +pub const MAX_BATCH_EVALUATION_MEMBERS_V1: usize = 100; + /// Registry Notary configuration. Disabled by default so existing /// Registry Relay deployments load unchanged. #[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] @@ -33,6 +38,7 @@ pub struct EvidenceConfig { #[serde(default = "default_formats_url")] pub formats_url: String, #[serde(default = "default_inline_batch_limit")] + #[schemars(range(min = 1, max = 100))] pub inline_batch_limit: usize, #[serde(default = "default_max_credential_validity_seconds")] pub max_credential_validity_seconds: u64, @@ -67,6 +73,28 @@ pub(in crate::config) const fn default_max_credential_validity_seconds() -> u64 } impl EvidenceConfig { + pub(in crate::config) fn validate_batch_limits(&self) -> Result<(), EvidenceConfigError> { + if !(1..=MAX_BATCH_EVALUATION_MEMBERS_V1).contains(&self.inline_batch_limit) { + return Err(EvidenceConfigError::InvalidBatchConfig { + reason: format!( + "evidence.inline_batch_limit must be between 1 and {MAX_BATCH_EVALUATION_MEMBERS_V1}" + ), + }); + } + for claim in &self.claims { + let max_subjects = claim.operations.batch_evaluate.max_subjects; + if !(1..=MAX_BATCH_EVALUATION_MEMBERS_V1).contains(&max_subjects) { + return Err(EvidenceConfigError::InvalidBatchConfig { + reason: format!( + "claim '{}' operations.batch_evaluate.max_subjects must be between 1 and {MAX_BATCH_EVALUATION_MEMBERS_V1}", + claim.id + ), + }); + } + } + Ok(()) + } + pub(in crate::config) fn validate_signing_keys(&self) -> Result<(), EvidenceConfigError> { let mut published_kids = HashSet::new(); for (key_id, key) in &self.signing_keys { @@ -163,5 +191,5 @@ pub(in crate::config) fn default_formats_url() -> String { } pub(in crate::config) const fn default_inline_batch_limit() -> usize { - 100 + MAX_BATCH_EVALUATION_MEMBERS_V1 } diff --git a/crates/registry-notary-core/src/config/root.rs b/crates/registry-notary-core/src/config/root.rs index a55fc7d68..7f50698ab 100644 --- a/crates/registry-notary-core/src/config/root.rs +++ b/crates/registry-notary-core/src/config/root.rs @@ -154,6 +154,7 @@ impl StandaloneRegistryNotaryConfig { } self.evidence.concurrency.validate()?; self.evidence.machine_quota.validate()?; + self.evidence.validate_batch_limits()?; if let Some(relay) = &self.evidence.relay { relay.validate()?; if !self diff --git a/crates/registry-notary-core/src/config/tests/root.rs b/crates/registry-notary-core/src/config/tests/root.rs index 1129b41aa..abb364f58 100644 --- a/crates/registry-notary-core/src/config/tests/root.rs +++ b/crates/registry-notary-core/src/config/tests/root.rs @@ -3,6 +3,56 @@ use super::*; #[allow(unused_imports)] use super::{auth::*, credentials::*, infrastructure::*, issuance::*, preauth::*}; +#[test] +pub(super) fn batch_limits_accept_only_lower_or_equal_platform_overrides() { + let mut config = valid_subject_access_config(); + config.evidence.inline_batch_limit = MAX_BATCH_EVALUATION_MEMBERS_V1; + config.evidence.claims[0] + .operations + .batch_evaluate + .max_subjects = MAX_BATCH_EVALUATION_MEMBERS_V1; + config + .validate() + .expect("the hard platform ceiling is a valid operator value"); + + config.evidence.inline_batch_limit = 17; + config.evidence.claims[0] + .operations + .batch_evaluate + .max_subjects = 9; + config + .validate() + .expect("operators may lower either batch limit"); +} + +#[test] +pub(super) fn batch_limits_reject_zero_and_values_above_the_platform_ceiling() { + for invalid in [0, MAX_BATCH_EVALUATION_MEMBERS_V1 + 1] { + let mut config = valid_subject_access_config(); + config.evidence.inline_batch_limit = invalid; + let error = config + .validate() + .expect_err("the global batch override must stay within the platform range"); + assert!(matches!( + error, + EvidenceConfigError::InvalidBatchConfig { .. } + )); + + let mut config = valid_subject_access_config(); + config.evidence.claims[0] + .operations + .batch_evaluate + .max_subjects = invalid; + let error = config + .validate() + .expect_err("the claim batch override must stay within the platform range"); + assert!(matches!( + error, + EvidenceConfigError::InvalidBatchConfig { .. } + )); + } +} + #[test] pub(super) fn gate_input_defaults_are_low_risk_for_minimal_config() { let config = minimal_config(); diff --git a/crates/registry-notary-core/src/model.rs b/crates/registry-notary-core/src/model.rs index 66ab67387..d284f78c2 100644 --- a/crates/registry-notary-core/src/model.rs +++ b/crates/registry-notary-core/src/model.rs @@ -1214,6 +1214,29 @@ pub struct BatchItemResponse { pub status: BatchItemStatus, pub claim_results: Vec, pub errors: Vec, + /// Request-local consultation evidence for restricted audit assembly. + /// This is never serialized into the public response or durable state. + #[doc(hidden)] + #[serde(skip)] + pub runtime_audit: BatchItemRuntimeAudit, +} + +/// Request-local, value-free consultation evidence for a batch member. +#[doc(hidden)] +#[derive(Clone, Default)] +pub struct BatchItemRuntimeAudit { + pub relay_forwarded_count: u64, + pub relay_consultation_ids: Vec, +} + +impl std::fmt::Debug for BatchItemRuntimeAudit { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BatchItemRuntimeAudit") + .field("relay_forwarded_count", &self.relay_forwarded_count) + .field("relay_consultation_ids", &"[REDACTED]") + .finish() + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1901,9 +1924,19 @@ pub struct ConfigAuditEvent { pub local_approval_rate_limit_identity: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize)] pub struct EvidenceBatchItemAuditEvent { pub input_index: usize, + #[serde(default)] + pub outcome: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_code: Option, + #[serde(default)] + pub relay_consultation_count: u64, + #[serde(default)] + pub forwarded: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub relay_consultation_ids: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub target_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1914,6 +1947,20 @@ pub struct EvidenceBatchItemAuditEvent { pub requester_ref_hash: Option>, } +impl std::fmt::Debug for EvidenceBatchItemAuditEvent { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EvidenceBatchItemAuditEvent") + .field("input_index", &self.input_index) + .field("outcome", &self.outcome) + .field("error_code", &self.error_code) + .field("relay_consultation_count", &self.relay_consultation_count) + .field("forwarded", &self.forwarded) + .field("relay_consultation_ids", &"[REDACTED]") + .finish_non_exhaustive() + } +} + #[cfg(test)] mod tests { use super::*; @@ -2492,6 +2539,11 @@ mod tests { redacted_fields: None, batch_items: Some(vec![EvidenceBatchItemAuditEvent { input_index: 0, + outcome: "failed".to_string(), + error_code: Some("evidence.not_available".to_string()), + relay_consultation_count: 1, + forwarded: true, + relay_consultation_ids: vec!["01JRELAYBATCHSENSITIVE".to_string()], target_type: Some("person".to_string()), target_ref_hash: Some(Hashed::from_hash("hmac-sha256:batch-target")), requester_type: Some("person".to_string()), @@ -2507,6 +2559,7 @@ mod tests { ); let debug = format!("{event:?}"); assert!(!debug.contains("01JRELAYCORRELATIONSENSITIVE")); + assert!(!debug.contains("01JRELAYBATCHSENSITIVE")); assert!(debug.contains("relay_consultation_ids: \"[REDACTED]\"")); assert_eq!(value["access_mode"], json!("subject_bound")); assert_eq!( diff --git a/crates/registry-notary-server/src/api/audit.rs b/crates/registry-notary-server/src/api/audit.rs index 2adc2ab8a..4a68d153b 100644 --- a/crates/registry-notary-server/src/api/audit.rs +++ b/crates/registry-notary-server/src/api/audit.rs @@ -167,7 +167,7 @@ pub(super) fn attach_batch_evaluate_response_audit( response: &mut Response, keys: &SubjectAccessRateLimitKeys, _evidence: &EvidenceConfig, - _request: &BatchEvaluateRequest, + request: &BatchEvaluateRequest, result: ®istry_notary_core::BatchEvaluateResponse, audit_purposes: Option<&[String]>, ) -> Result<(), EvidenceError> { @@ -175,12 +175,36 @@ pub(super) fn attach_batch_evaluate_response_audit( return Ok(()); }; let mut batch_items = Vec::with_capacity(result.items.len()); + let mut relay_consultation_ids = BTreeSet::new(); + let mut relay_forwarded_count = 0u64; for item in &result.items { + let request_item = request + .items + .get(item.input_index) + .ok_or(EvidenceError::InvalidRequest)?; let purpose_scope = audit_purposes .and_then(|purposes| purposes.get(item.input_index)) .map(String::as_str); + relay_forwarded_count = + relay_forwarded_count.saturating_add(item.runtime_audit.relay_forwarded_count); + relay_consultation_ids.extend(item.runtime_audit.relay_consultation_ids.iter().cloned()); batch_items.push(EvidenceBatchItemAuditEvent { input_index: item.input_index, + outcome: match item.status { + registry_notary_core::BatchItemStatus::Succeeded => "succeeded", + registry_notary_core::BatchItemStatus::Failed => "failed", + } + .to_string(), + error_code: item + .errors + .first() + .map(|error| error.audit_code.as_ref().unwrap_or(&error.code).clone()), + relay_consultation_count: u64::try_from( + item.runtime_audit.relay_consultation_ids.len(), + ) + .unwrap_or(u64::MAX), + forwarded: item.runtime_audit.relay_forwarded_count > 0, + relay_consultation_ids: item.runtime_audit.relay_consultation_ids.clone(), target_type: Some(item.target_ref.entity_type.clone()) .filter(|entity_type| !entity_type.is_empty()), target_ref_hash: if item.errors.is_empty() { @@ -192,29 +216,42 @@ pub(super) fn attach_batch_evaluate_response_audit( &item.target_ref.handle, )?) } else { - None + hash_audit_matching_attempt(keys, "target", purpose_scope, &request_item.target)? }, - requester_type: item - .requester_ref + requester_type: request_item + .requester .as_ref() .map(|requester| requester.entity_type.clone()), - requester_ref_hash: item - .requester_ref - .as_ref() - .filter(|_| item.errors.is_empty()) - .map(|requester| { - hash_audit_handle( - keys, - "requester", - requester.entity_type.as_str(), - purpose_scope, - &requester.handle, - ) - }) - .transpose()?, + requester_ref_hash: if item.errors.is_empty() { + item.requester_ref + .as_ref() + .map(|requester| { + hash_audit_handle( + keys, + "requester", + requester.entity_type.as_str(), + purpose_scope, + &requester.handle, + ) + }) + .transpose()? + } else { + request_item + .requester + .as_ref() + .map(|requester| { + hash_audit_matching_attempt(keys, "requester", purpose_scope, requester) + }) + .transpose()? + .flatten() + }, }); } audit.batch_items = Some(batch_items); + audit.relay_consultation_count = + Some(u64::try_from(relay_consultation_ids.len()).unwrap_or(u64::MAX)); + audit.forwarded = Some(relay_forwarded_count > 0); + audit.relay_consultation_ids = relay_consultation_ids.into_iter().collect(); Ok(()) } @@ -232,13 +269,17 @@ pub(super) fn hash_audit_handle( } pub(super) fn hash_audit_matching_attempt( - _keys: &SubjectAccessRateLimitKeys, + keys: &SubjectAccessRateLimitKeys, role: &str, purpose_scope: Option<&str>, entity: &EvidenceEntity, ) -> Result>, EvidenceError> { - let _ = canonical_audit_identifier_input(role, purpose_scope, entity)?; - Ok(None) + let Some(input) = canonical_audit_identifier_input(role, purpose_scope, entity)? else { + return Ok(None); + }; + keys.audit_pseudonym_ref("matching-attempt-v1", &input) + .map(|hash| Some(Hashed::from_hash(hash.as_str().to_string()))) + .map_err(|error| error.evidence_error()) } pub(super) fn canonical_audit_handle_input( diff --git a/crates/registry-notary-server/src/api/evaluations.rs b/crates/registry-notary-server/src/api/evaluations.rs index 3386072ad..3e9e406dc 100644 --- a/crates/registry-notary-server/src/api/evaluations.rs +++ b/crates/registry-notary-server/src/api/evaluations.rs @@ -365,6 +365,9 @@ pub(super) async fn batch_evaluate( &request.items, ); let audit_request = request.clone(); + if let Err(error) = validate_batch_subject_limit(evidence, &request) { + return evidence_error_response(error); + } let registry_backed_batch = match registry_backed_batch_requested(evidence, &request) { Ok(value) => value, Err(error) => return evidence_error_response(error), @@ -374,9 +377,6 @@ pub(super) async fn batch_evaluate( { return evidence_error_response(EvidenceError::ConsultationInvalidRequest); } - if let Err(error) = validate_batch_subject_limit(evidence, &request) { - return evidence_error_response(error); - } let batch_cost = u32::try_from(request.items.len()).unwrap_or(u32::MAX); let runtime = state.runtime(); let evaluation_future = runtime.batch_evaluate( diff --git a/crates/registry-notary-server/src/api/tests/audit.rs b/crates/registry-notary-server/src/api/tests/audit.rs index 05f5c93fa..58ceed308 100644 --- a/crates/registry-notary-server/src/api/tests/audit.rs +++ b/crates/registry-notary-server/src/api/tests/audit.rs @@ -113,6 +113,118 @@ fn canonical_audit_identifier_input_sorts_identifiers_and_explicit_empty_fields( assert!(canonical.find("animal_ear_tag") < canonical.find("national_id")); } +#[test] +fn failed_matching_attempts_receive_stable_purpose_scoped_keyed_pseudonyms() { + let keys = SubjectAccessRateLimitKeys::new(AuditKeyHasher::unkeyed_dev_only()); + let entity = EvidenceEntity::with_identifier("Person", "national_id", "NID-1001"); + + let first = hash_audit_matching_attempt(&keys, "target", Some("program-a"), &entity) + .expect("matching attempt hashes") + .expect("matching input produces a pseudonym"); + let repeated = hash_audit_matching_attempt(&keys, "target", Some("program-a"), &entity) + .expect("matching attempt hashes") + .expect("matching input produces a pseudonym"); + let other_purpose = hash_audit_matching_attempt(&keys, "target", Some("program-b"), &entity) + .expect("matching attempt hashes") + .expect("matching input produces a pseudonym"); + + assert_eq!(first, repeated); + assert_ne!(first, other_purpose); + assert!(!serde_json::to_string(&first) + .expect("pseudonym serializes") + .contains("NID-1001")); +} + +#[test] +fn failed_batch_member_audit_is_value_free_and_keeps_consultation_evidence() { + let keys = SubjectAccessRateLimitKeys::new(AuditKeyHasher::unkeyed_dev_only()); + let request = registry_notary_core::BatchEvaluateRequest { + items: vec![registry_notary_core::BatchEvaluateItemRequest { + requester: Some(EvidenceEntity::with_identifier( + "Organization", + "organization_id", + "ORG-SENSITIVE", + )), + target: EvidenceEntity::with_identifier("Person", "national_id", "NID-SENSITIVE"), + relationship: None, + on_behalf_of: None, + purpose: Some("program-a".to_string()), + }], + claims: vec![ClaimRef::from("person-is-alive")], + disclosure: Some("predicate".to_string()), + format: Some(FORMAT_CLAIM_RESULT_JSON.to_string()), + purpose: Some("program-a".to_string()), + }; + let result = registry_notary_core::BatchEvaluateResponse { + batch_id: "batch-1".to_string(), + status: registry_notary_core::BatchStatus::Completed, + claims: vec!["person-is-alive".to_string()], + items: vec![registry_notary_core::BatchItemResponse { + input_index: 0, + target_ref: registry_notary_core::TargetRefView { + entity_type: "Person".to_string(), + handle: "rnref:v1:failed-target".to_string(), + identifier_schemes: vec!["national_id".to_string()], + profile: None, + }, + requester_ref: None, + evaluation_id: None, + status: registry_notary_core::BatchItemStatus::Failed, + claim_results: Vec::new(), + errors: vec![registry_notary_core::BatchItemError { + code: "evidence.not_available".to_string(), + title: "Evidence not available".to_string(), + retryable: true, + audit_code: Some("relay.transport_failed".to_string()), + }], + runtime_audit: registry_notary_core::BatchItemRuntimeAudit { + relay_forwarded_count: 1, + relay_consultation_ids: vec!["01JRELAYBATCHAUDIT".to_string()], + }, + }], + summary: registry_notary_core::BatchSummary { + succeeded: 0, + failed: 1, + }, + }; + let mut response = StatusCode::OK.into_response(); + attach_evidence_audit_with_purposes( + &mut response, + "batch_evaluate", + None, + &["person-is-alive".to_string()], + Some(1), + Some(vec!["program-a".to_string()]), + ); + + attach_batch_evaluate_response_audit( + &mut response, + &keys, + &evidence_config(), + &request, + &result, + Some(&["program-a".to_string()]), + ) + .expect("batch audit attaches"); + + let audit = response + .extensions() + .get::() + .expect("audit context is attached"); + let item = &audit.batch_items.as_ref().expect("batch items exist")[0]; + assert_eq!(item.outcome, "failed"); + assert_eq!(item.error_code.as_deref(), Some("relay.transport_failed")); + assert_eq!(item.relay_consultation_count, 1); + assert!(item.forwarded); + assert!(item.target_ref_hash.is_some()); + assert!(item.requester_ref_hash.is_some()); + assert_eq!(audit.relay_consultation_count, Some(1)); + assert_eq!(audit.forwarded, Some(true)); + let serialized = serde_json::to_string(&audit.batch_items).expect("batch audit serializes"); + assert!(!serialized.contains("NID-SENSITIVE")); + assert!(!serialized.contains("ORG-SENSITIVE")); +} + #[test] fn credential_audit_context_links_stored_target_and_requester_refs() { let keys = SubjectAccessRateLimitKeys::new(AuditKeyHasher::unkeyed_dev_only()); diff --git a/crates/registry-notary-server/src/openapi.rs b/crates/registry-notary-server/src/openapi.rs index 490e75baa..d4e40d67b 100644 --- a/crates/registry-notary-server/src/openapi.rs +++ b/crates/registry-notary-server/src/openapi.rs @@ -606,7 +606,7 @@ fn build_openapi_document() -> Value { "401": { "description": "Missing or invalid credential" }, "403": { "description": "Not authorized for requested claim, purpose, disclosure, or format" }, "406": { "description": "Requested format is not acceptable" }, - "413": { "description": "Request body or batch is too large" }, + "413": { "description": "Request body is too large" }, "429": { "description": "Self-attestation request is rate limited, or the machine evaluation quota was exceeded" }, "503": { "description": "Required Relay consultation or operational dependency is unavailable" } } @@ -679,7 +679,7 @@ fn build_openapi_document() -> Value { "403": { "description": "Not authorized for requested claim, purpose, disclosure, or format" }, "406": { "description": "Requested format is not acceptable" }, "409": { "description": "Idempotency key conflicts with another request body" }, - "413": { "description": "Request body or batch is too large" }, + "413": { "description": "The request exceeds the hard 100-member ceiling or a lower configured batch limit. Rejection occurs before quota, idempotency, Relay, source, or retained-state side effects." }, "429": { "description": "Self-attestation request is rate limited, or the machine evaluation quota was exceeded" }, "503": { "description": "Required Relay consultation or operational dependency is unavailable" } } @@ -1716,6 +1716,19 @@ fn add_response_examples(document: &mut Value) { "post", &["406", "409", "413", "429", "503"], ); + set_problem_response( + document, + "/v1/batch-evaluations", + "post", + "413", + "Batch too large", + problem_example( + 413, + "batch.too_large", + "Batch too large", + "the batch exceeds the platform or configured member limit", + ), + ); set_json_response( document, "/v1/evaluations/{evaluation_id}/render", @@ -1937,6 +1950,8 @@ fn batch_evaluate_request_schema() -> Value { "properties": { "items": { "type": "array", + "minItems": 1, + "maxItems": registry_notary_core::MAX_BATCH_EVALUATION_MEMBERS_V1, "items": { "$ref": "#/components/schemas/BatchEvaluateItemRequest" } }, "claims": { @@ -3920,10 +3935,20 @@ mod tests { "/admin/v1/config/verify", "/admin/v1/config/dry-run", "/admin/v1/config/apply", + "/v1/batch-credentials", + "/v1/credentials/batch", + "/oid4vci/batch-credential", + "/oid4vci/batch-credentials", ] { assert!( !paths.contains_key(route), - "removed admin config route is still documented: {route}" + "unsupported or removed route is documented: {route}" + ); + } + for route in paths.keys().filter(|route| route.contains("batch")) { + assert_eq!( + route, "/v1/batch-evaluations", + "Notary must not expose Relay, credential, or OID4VCI batch routes" ); } } @@ -4256,6 +4281,16 @@ mod tests { let batch_request = &doc["components"]["schemas"]["BatchEvaluateRequest"]["properties"]; assert!(batch_request.get("subjects").is_none()); assert!(batch_request.get("items").is_some()); + assert_eq!(batch_request["items"]["minItems"], json!(1)); + assert_eq!( + batch_request["items"]["maxItems"], + json!(registry_notary_core::MAX_BATCH_EVALUATION_MEMBERS_V1) + ); + assert_eq!( + doc["paths"]["/v1/batch-evaluations"]["post"]["responses"]["413"]["content"] + ["application/problem+json"]["example"]["code"], + json!("batch.too_large") + ); } #[test] diff --git a/crates/registry-notary-server/src/problem.rs b/crates/registry-notary-server/src/problem.rs index 53daf2875..c3bfc345e 100644 --- a/crates/registry-notary-server/src/problem.rs +++ b/crates/registry-notary-server/src/problem.rs @@ -97,7 +97,7 @@ pub(crate) fn evidence_detail(error: &EvidenceError) -> &'static str { EvidenceError::PolicyDenied { .. } => "the configured policy denied the evidence request", EvidenceError::ProfileUnsupported => "the requested profile is not supported", EvidenceError::EvidenceNotAvailable => "the evidence is not available", - EvidenceError::BatchTooLarge => "the batch exceeds the configured inline limit", + EvidenceError::BatchTooLarge => "the batch exceeds the platform or configured member limit", EvidenceError::EvaluationNotFound => "the evaluation id is unknown or expired", EvidenceError::EvaluationBindingMismatch => { "the request exceeds the original evaluation binding" diff --git a/crates/registry-notary-server/src/runtime/access.rs b/crates/registry-notary-server/src/runtime/access.rs index 144627892..0b2efe9cd 100644 --- a/crates/registry-notary-server/src/runtime/access.rs +++ b/crates/registry-notary-server/src/runtime/access.rs @@ -368,7 +368,8 @@ pub(super) fn max_batch_subjects( claims: &[ClaimRef], claim_versions: &ClaimVersionSelections, ) -> Result { - let mut max = config.inline_batch_limit; + let mut max = + registry_notary_core::MAX_BATCH_EVALUATION_MEMBERS_V1.min(config.inline_batch_limit); for claim_id in claims { let claim = find_claim_for_selection(config, claim_id, claim_versions)?; if !claim.operations.batch_evaluate.enabled { diff --git a/crates/registry-notary-server/src/runtime/catalog.rs b/crates/registry-notary-server/src/runtime/catalog.rs index e275beedb..d815c66c5 100644 --- a/crates/registry-notary-server/src/runtime/catalog.rs +++ b/crates/registry-notary-server/src/runtime/catalog.rs @@ -40,6 +40,9 @@ pub(crate) fn validate_batch_subject_limit( if request.claims.is_empty() || request.items.is_empty() { return Err(EvidenceError::InvalidRequest); } + if request.items.len() > registry_notary_core::MAX_BATCH_EVALUATION_MEMBERS_V1 { + return Err(EvidenceError::BatchTooLarge); + } let claim_versions = requested_claim_versions(&request.claims)?; let max_subjects = max_batch_subjects(config, &request.claims, &claim_versions)?; if request.items.len() > max_subjects { diff --git a/crates/registry-notary-server/src/runtime/evaluation.rs b/crates/registry-notary-server/src/runtime/evaluation.rs index 7a63b03b3..2cfbb25ea 100644 --- a/crates/registry-notary-server/src/runtime/evaluation.rs +++ b/crates/registry-notary-server/src/runtime/evaluation.rs @@ -24,6 +24,7 @@ struct PreparedRegistryBatchItem { format: String, evaluation_id: String, relay_plan: Arc, + audit: Arc, evaluation_capability: EvaluationCapability, } @@ -32,6 +33,12 @@ struct EvaluatedRegistryClaims { issuance_provenance: Option, } +fn clear_batch_runtime_audit(response: &mut BatchEvaluateResponse) { + for item in &mut response.items { + item.runtime_audit = Default::default(); + } +} + pub(crate) fn registry_backed_batch_requested( evidence: &EvidenceConfig, request: &BatchEvaluateRequest, @@ -605,6 +612,14 @@ impl RegistryNotaryRuntime { if request.claims.is_empty() || request.items.is_empty() { return Err(EvidenceError::InvalidRequest); } + if request.items.len() > registry_notary_core::MAX_BATCH_EVALUATION_MEMBERS_V1 { + return Err(EvidenceError::BatchTooLarge); + } + let claim_versions = requested_claim_versions(&request.claims)?; + let max_subjects = max_batch_subjects(&evidence, &request.claims, &claim_versions)?; + if request.items.len() > max_subjects { + return Err(EvidenceError::BatchTooLarge); + } let registry_backed_batch = registry_backed_batch_requested(&evidence, &request)?; if registry_backed_batch && options @@ -617,16 +632,11 @@ impl RegistryNotaryRuntime { let scoped_key = options .idempotency_key .map(|key| batch_idempotency_key(&principal.principal_id, key)); - let claim_versions = requested_claim_versions(&request.claims)?; let evaluation_capability = evaluation_capability_for_principal( &self.subject_access_rate_keys, principal, &request_claim_ids, )?; - let max_subjects = max_batch_subjects(&evidence, &request.claims, &claim_versions)?; - if request.items.len() > max_subjects { - return Err(EvidenceError::BatchTooLarge); - } let batch_purpose = resolve_batch_default_purpose(options.header_purpose, request.purpose.as_deref())?; let subject_purposes = @@ -701,7 +711,10 @@ impl RegistryNotaryRuntime { ) .await? { - BatchIdempotencyReservation::Replay(response) => return Ok(response), + BatchIdempotencyReservation::Replay(mut response) => { + clear_batch_runtime_audit(&mut response); + return Ok(response); + } BatchIdempotencyReservation::Owner(owner) => Some(owner), } } else { @@ -851,6 +864,7 @@ impl RegistryNotaryRuntime { status: BatchItemStatus::Succeeded, claim_results, errors: Vec::new(), + runtime_audit: Default::default(), }); } Err(error) => { @@ -873,6 +887,7 @@ impl RegistryNotaryRuntime { status: BatchItemStatus::Failed, claim_results: Vec::new(), errors: vec![batch_item_error(&error)], + runtime_audit: Default::default(), }); } } @@ -974,7 +989,7 @@ impl RegistryNotaryRuntime { purpose, evaluation_ulid, self.activated_relay.as_ref(), - audit, + Arc::clone(&audit), Some((outer_idempotency_key, input_index)), &evaluation_capability, )? @@ -1006,6 +1021,7 @@ impl RegistryNotaryRuntime { format: format.clone(), evaluation_id: evaluation_ulid.to_string(), relay_plan, + audit, evaluation_capability: evaluation_capability.clone(), }); } @@ -1019,7 +1035,10 @@ impl RegistryNotaryRuntime { ) .await? { - BatchIdempotencyReservation::Replay(response) => return Ok(response), + BatchIdempotencyReservation::Replay(mut response) => { + clear_batch_runtime_audit(&mut response); + return Ok(response); + } BatchIdempotencyReservation::Owner(owner) => owner, }; let quota_charged_by_reservation = idempotency_owner.quota_charged(); @@ -1051,6 +1070,7 @@ impl RegistryNotaryRuntime { let cel_concurrency = cel_concurrency.as_ref().map(Arc::clone); join_set.spawn(async move { let input_index = item.input_index; + let audit = Arc::clone(&item.audit); let _permit = permit_semaphore .acquire_owned() .await @@ -1063,7 +1083,7 @@ impl RegistryNotaryRuntime { cel_concurrency, ) .await; - Ok::<_, EvidenceError>((input_index, result)) + Ok::<_, EvidenceError>((input_index, result, audit.snapshot())) }); } @@ -1073,8 +1093,8 @@ impl RegistryNotaryRuntime { let mut failed = 0usize; let mut retained_evaluations = Vec::new(); while let Some(joined) = join_set.join_next().await { - let (input_index, result) = match joined { - Ok(Ok(pair)) => pair, + let (input_index, result, runtime_audit) = match joined { + Ok(Ok(item_result)) => item_result, Ok(Err(error)) => return Err(error), Err(join_error) if join_error.is_panic() => { tracing::error!( @@ -1095,6 +1115,12 @@ impl RegistryNotaryRuntime { entity_ref_view(&self.subject_access_rate_keys, "requester", requester) }) .transpose()?; + let relay_forwarded_count = runtime_audit.relay_forwarded_count(); + let (_, relay_consultation_ids) = runtime_audit.into_parts(); + let runtime_audit = registry_notary_core::BatchItemRuntimeAudit { + relay_forwarded_count, + relay_consultation_ids, + }; match result { Ok(evaluated) => { let results = evaluated.views; @@ -1133,6 +1159,7 @@ impl RegistryNotaryRuntime { status: BatchItemStatus::Succeeded, claim_results, errors: Vec::new(), + runtime_audit, }); } Err(error) => { @@ -1145,6 +1172,7 @@ impl RegistryNotaryRuntime { status: BatchItemStatus::Failed, claim_results: Vec::new(), errors: vec![batch_item_error(&error)], + runtime_audit, }); } } diff --git a/crates/registry-notary-server/src/runtime/tests/evaluation.rs b/crates/registry-notary-server/src/runtime/tests/evaluation.rs index 9932b1c29..c371c99ba 100644 --- a/crates/registry-notary-server/src/runtime/tests/evaluation.rs +++ b/crates/registry-notary-server/src/runtime/tests/evaluation.rs @@ -56,6 +56,47 @@ impl ActivatedRelayConsultations for FixedRelayConsultation { } } +#[derive(Debug, Default)] +struct MixedCompletionRelay { + calls: AtomicU64, +} + +#[async_trait::async_trait] +impl ActivatedRelayConsultations for MixedCompletionRelay { + async fn check_ready(&self) -> Result<(), crate::relay_client::RelayClientError> { + Ok(()) + } + + fn validate( + &self, + _key: &ConsultationGroupKeyV1, + ) -> Result<(), crate::relay_client::RelayClientError> { + Ok(()) + } + + async fn execute( + &self, + key: &ConsultationGroupKeyV1, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + let target = key + .canonical_inputs() + .get("tracked_entity") + .map(|value| value.as_str()) + .ok_or(crate::relay_client::RelayClientError::InvalidRequest)?; + if target == "person-2" { + return Err(crate::relay_client::RelayClientError::TransportUnavailable); + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + RuntimeRelayConsultationResult::new( + Ulid::from_parts(2, 2), + RuntimeRelayOutcome::Match, + Some(status_match_data()?), + OffsetDateTime::UNIX_EPOCH, + ) + } +} + #[derive(Debug, Default)] struct UniqueRelayConsultation { calls: AtomicU64, @@ -1614,6 +1655,164 @@ async fn registry_batch_requires_outer_key_before_relay_work() { assert_eq!(activated.calls.load(Ordering::SeqCst), 0); } +#[tokio::test] +async fn registry_batch_hard_ceiling_rejects_before_quota_idempotency_relay_or_state() { + let mut claim = registry_claim( + "enrollment-status", + RuleConfig::ConsultationOutput { + consultation: "enrollment".to_string(), + output: "registration_status".to_string(), + }, + "string", + ); + enable_registry_batch(&mut claim); + // Bypass the config loader deliberately. The runtime ceiling must still be + // authoritative if an EvidenceConfig is assembled directly in-process. + claim.operations.batch_evaluate.max_subjects = + registry_notary_core::MAX_BATCH_EVALUATION_MEMBERS_V1 + 1; + let disabled_claim = registry_claim( + "disabled-status", + RuleConfig::ConsultationOutput { + consultation: "enrollment".to_string(), + output: "registration_status".to_string(), + }, + "string", + ); + let mut evidence = (*test_evidence(vec![claim, disabled_claim])).clone(); + evidence.allowed_purposes = vec!["test".to_string()]; + evidence.inline_batch_limit = registry_notary_core::MAX_BATCH_EVALUATION_MEMBERS_V1 + 1; + let evidence = Arc::new(evidence); + let activated = Arc::new(FixedRelayConsultation { + calls: AtomicU64::new(0), + outcome: RuntimeRelayOutcome::Match, + }); + let bound: Arc = activated.clone(); + let runtime = RegistryNotaryRuntime::new().with_activated_relay(Some(bound)); + let store = EvidenceStore::default(); + let mut principal = machine_principal(); + principal.scopes = vec!["registry:evidence".to_string()]; + let quota = crate::MachineQuotaLimiter::new(registry_notary_core::MachineQuotaConfig { + enabled: true, + subjects_per_minute: 1, + }); + let mut request = registry_batch_request(vec![ClaimRef::from("enrollment-status")]); + request.items = vec![ + request.items[0].clone(); + registry_notary_core::MAX_BATCH_EVALUATION_MEMBERS_V1 + 1 + ]; + + for claim in ["missing-claim", "disabled-status"] { + let mut invalid_claim_request = request.clone(); + invalid_claim_request.claims = vec![ClaimRef::from(claim)]; + let error = runtime + .batch_evaluate( + Arc::clone(&evidence), + &store, + &principal, + invalid_claim_request, + BatchEvaluateOptions::default(), + ) + .await + .expect_err("the hard ceiling precedes claim lookup and operation checks"); + assert!(matches!(error, EvidenceError::BatchTooLarge)); + } + + let error_without_key = runtime + .batch_evaluate( + Arc::clone(&evidence), + &store, + &principal, + request.clone(), + BatchEvaluateOptions { + owner_quota: Some(("a, request.items.len() as u32)), + ..BatchEvaluateOptions::default() + }, + ) + .await + .expect_err("the size rejection precedes registry idempotency-key validation"); + assert!(matches!(error_without_key, EvidenceError::BatchTooLarge)); + + let error = runtime + .batch_evaluate( + Arc::clone(&evidence), + &store, + &principal, + request.clone(), + BatchEvaluateOptions { + idempotency_key: Some("hard-ceiling-key"), + owner_quota: Some(("a, request.items.len() as u32)), + ..BatchEvaluateOptions::default() + }, + ) + .await + .expect_err("the platform ceiling cannot be raised by runtime config"); + + assert!(matches!(error, EvidenceError::BatchTooLarge)); + assert_eq!(activated.calls.load(Ordering::SeqCst), 0); + + request.items.truncate(1); + let response = runtime + .batch_evaluate( + evidence, + &store, + &principal, + request, + BatchEvaluateOptions { + idempotency_key: Some("hard-ceiling-key"), + owner_quota: Some(("a, 1)), + ..BatchEvaluateOptions::default() + }, + ) + .await + .expect("rejection neither consumes quota nor binds the idempotency key"); + + assert!(matches!(response.items[0].status, BatchItemStatus::Succeeded)); + assert_eq!(activated.calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn registry_batch_effective_limit_uses_the_lowest_operator_override() { + for (inline_limit, claim_limit) in [(2, 4), (4, 2)] { + let mut claim = registry_claim( + "enrollment-status", + RuleConfig::ConsultationOutput { + consultation: "enrollment".to_string(), + output: "registration_status".to_string(), + }, + "string", + ); + enable_registry_batch(&mut claim); + claim.operations.batch_evaluate.max_subjects = claim_limit; + let mut evidence = (*test_evidence(vec![claim])).clone(); + evidence.allowed_purposes = vec!["test".to_string()]; + evidence.inline_batch_limit = inline_limit; + let activated = Arc::new(FixedRelayConsultation { + calls: AtomicU64::new(0), + outcome: RuntimeRelayOutcome::Match, + }); + let bound: Arc = activated.clone(); + let runtime = RegistryNotaryRuntime::new().with_activated_relay(Some(bound)); + let mut principal = machine_principal(); + principal.scopes = vec!["registry:evidence".to_string()]; + let mut request = registry_batch_request(vec![ClaimRef::from("enrollment-status")]); + request.items.push(request.items[0].clone()); + + let error = runtime + .batch_evaluate( + Arc::new(evidence), + &EvidenceStore::default(), + &principal, + request, + BatchEvaluateOptions::default(), + ) + .await + .expect_err("the lowest configured limit rejects three members"); + + assert!(matches!(error, EvidenceError::BatchTooLarge)); + assert_eq!(activated.calls.load(Ordering::SeqCst), 0); + } +} + #[tokio::test] async fn registry_batch_preflights_every_item_before_first_relay_call() { let mut claim = registry_claim( @@ -1657,6 +1856,133 @@ async fn registry_batch_preflights_every_item_before_first_relay_call() { assert_eq!(activated.calls.load(Ordering::SeqCst), 0); } +#[tokio::test] +async fn registry_batch_rejects_more_than_256_aggregate_groups_before_dispatch() { + let mut claims = Vec::new(); + let mut claim_refs = Vec::new(); + for index in 0..16 { + let claim_id = format!("grouped-claim-{index}"); + let mut claim = registry_claim( + &claim_id, + RuleConfig::ConsultationOutput { + consultation: "enrollment".to_string(), + output: "registration_status".to_string(), + }, + "string", + ); + enable_registry_batch(&mut claim); + claim.operations.batch_evaluate.max_subjects = 17; + let ClaimEvidenceMode::RegistryBacked { consultations } = &mut claim.evidence_mode else { + panic!("claim is registry backed") + }; + consultations + .get_mut("enrollment") + .expect("consultation exists") + .profile + .id = format!("test.batch.profile-{index}"); + claim_refs.push(ClaimRef::from(claim_id.as_str())); + claims.push(claim); + } + let mut evidence = (*test_evidence(claims)).clone(); + evidence.allowed_purposes = vec!["test".to_string()]; + evidence.inline_batch_limit = 17; + let activated = Arc::new(FixedRelayConsultation { + calls: AtomicU64::new(0), + outcome: RuntimeRelayOutcome::Match, + }); + let bound: Arc = activated.clone(); + let runtime = RegistryNotaryRuntime::new().with_activated_relay(Some(bound)); + let mut principal = machine_principal(); + principal.scopes = vec!["registry:evidence".to_string()]; + let mut request = registry_batch_request(claim_refs); + request.items = vec![request.items[0].clone(); 17]; + + let error = runtime + .batch_evaluate( + Arc::new(evidence), + &EvidenceStore::default(), + &principal, + request, + BatchEvaluateOptions { + idempotency_key: Some("too-many-aggregate-groups"), + ..BatchEvaluateOptions::default() + }, + ) + .await + .expect_err("17 items with 16 groups each exceed the aggregate bound"); + + assert!(matches!(error, EvidenceError::ConsultationInvalidRequest)); + assert_eq!(activated.calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn registry_batch_returns_ordered_mixed_member_outcomes_after_admission() { + let mut claim = registry_claim( + "enrollment-status", + RuleConfig::ConsultationOutput { + consultation: "enrollment".to_string(), + output: "registration_status".to_string(), + }, + "string", + ); + enable_registry_batch(&mut claim); + let mut evidence = (*test_evidence(vec![claim])).clone(); + evidence.allowed_purposes = vec!["test".to_string()]; + evidence.inline_batch_limit = 4; + let activated = Arc::new(MixedCompletionRelay::default()); + let bound: Arc = activated.clone(); + let runtime = RegistryNotaryRuntime::new().with_activated_relay(Some(bound)); + let mut principal = machine_principal(); + principal.scopes = vec!["registry:evidence".to_string()]; + let mut request = registry_batch_request(vec![ClaimRef::from("enrollment-status")]); + request.items[1] = registry_notary_core::BatchEvaluateItemRequest::from( + registry_notary_core::BatchSubjectRequest { + id: "person-2".to_string(), + id_type: None, + purpose: None, + }, + ); + + let response = runtime + .batch_evaluate( + Arc::new(evidence), + &EvidenceStore::default(), + &principal, + request, + BatchEvaluateOptions { + idempotency_key: Some("mixed-completion-key"), + ..BatchEvaluateOptions::default() + }, + ) + .await + .expect("admitted member failures stay in the ordered response"); + + assert_eq!(activated.calls.load(Ordering::SeqCst), 2); + assert_eq!(response.summary.succeeded, 1); + assert_eq!(response.summary.failed, 1); + assert_eq!(response.items[0].input_index, 0); + assert!(matches!( + response.items[0].status, + BatchItemStatus::Succeeded + )); + assert_eq!(response.items[1].input_index, 1); + assert!(matches!( + response.items[1].status, + BatchItemStatus::Failed + )); + assert_eq!(response.items[1].errors.len(), 1); + assert_eq!(response.items[1].errors[0].code, "evidence.not_available"); + assert!(response.items[1].claim_results.is_empty()); + assert_eq!( + response.items[1].runtime_audit.relay_forwarded_count, + 1 + ); + assert!(response.items[1] + .runtime_audit + .relay_consultation_ids + .is_empty()); +} + #[tokio::test] async fn registry_batch_quota_denial_does_not_bind_a_fresh_idempotency_key() { let mut claim = registry_claim( @@ -1856,6 +2182,17 @@ async fn registry_batch_coalesces_within_items_never_across_duplicates_and_repla .items .iter() .all(|item| matches!(item.status, BatchItemStatus::Succeeded))); + assert!(first.items.iter().all(|item| { + item.runtime_audit.relay_forwarded_count == 1 + && item.runtime_audit.relay_consultation_ids.len() == 1 + })); + let public_response = serde_json::to_string(&first).expect("batch response serializes"); + assert!(!public_response.contains("runtime_audit")); + assert!(!first.items[0] + .runtime_audit + .relay_consultation_ids + .iter() + .any(|id| public_response.contains(id))); assert_ne!(first.items[0].evaluation_id, first.items[1].evaluation_id); for item in &first.items { let evaluation_id = item @@ -1921,6 +2258,10 @@ async fn registry_batch_coalesces_within_items_never_across_duplicates_and_repla .await .expect("the exact outer-key replay returns the stored response"); assert_eq!(replay.batch_id, first.batch_id); + assert!(replay.items.iter().all(|item| { + item.runtime_audit.relay_forwarded_count == 0 + && item.runtime_audit.relay_consultation_ids.is_empty() + })); assert_eq!( activated .child_identities diff --git a/crates/registry-notary-server/tests/standalone_http/http_contracts.rs b/crates/registry-notary-server/tests/standalone_http/http_contracts.rs index 7657e7cf0..9581ff248 100644 --- a/crates/registry-notary-server/tests/standalone_http/http_contracts.rs +++ b/crates/registry-notary-server/tests/standalone_http/http_contracts.rs @@ -593,6 +593,23 @@ pub(super) async fn openapi_json_handler_denies_without_runtime_state_by_default .assert_status(StatusCode::UNAUTHORIZED); } +#[tokio::test] +pub(super) async fn public_router_exposes_no_batch_credential_or_oid4vci_route() { + let server = TestServer::new(registry_notary_server::api::public_router()); + + for route in [ + "/v1/batch-credentials", + "/v1/credentials/batch", + "/oid4vci/batch-credential", + "/oid4vci/batch-credentials", + ] { + server + .post(route) + .await + .assert_status(StatusCode::NOT_FOUND); + } +} + #[tokio::test] pub(super) async fn standalone_server_serves_docs_shell_without_auth() { set_audit_secret(); diff --git a/products/notary/openapi/registry-notary.openapi.json b/products/notary/openapi/registry-notary.openapi.json index 083a894a8..6aa791020 100644 --- a/products/notary/openapi/registry-notary.openapi.json +++ b/products/notary/openapi/registry-notary.openapi.json @@ -145,6 +145,8 @@ "items": { "$ref": "#/components/schemas/BatchEvaluateItemRequest" }, + "maxItems": 100, + "minItems": 1, "type": "array" }, "purpose": { @@ -3780,19 +3782,19 @@ "content": { "application/problem+json": { "example": { - "code": "request.too_large", - "detail": "the request body or batch is too large", + "code": "batch.too_large", + "detail": "the batch exceeds the platform or configured member limit", "request_id": "01J00000000000000000000000", "status": 413, - "title": "Request too large", - "type": "https://id.registrystack.org/problems/registry-notary/request/too_large" + "title": "Batch too large", + "type": "https://id.registrystack.org/problems/registry-notary/batch/too_large" }, "schema": { "$ref": "#/components/schemas/ProblemDetails" } } }, - "description": "Request body or batch is too large" + "description": "The request exceeds the hard 100-member ceiling or a lower configured batch limit. Rejection occurs before quota, idempotency, Relay, source, or retained-state side effects." }, "429": { "content": { @@ -4525,7 +4527,7 @@ } } }, - "description": "Request body or batch is too large" + "description": "Request body is too large" }, "429": { "content": { diff --git a/schemas/registry-notary.config.schema.json b/schemas/registry-notary.config.schema.json index 9f3a8b0bf..070d9c177 100644 --- a/schemas/registry-notary.config.schema.json +++ b/schemas/registry-notary.config.schema.json @@ -77,7 +77,8 @@ "max_subjects": { "default": 100, "format": "uint", - "minimum": 0, + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -1014,7 +1015,8 @@ "inline_batch_limit": { "default": 100, "format": "uint", - "minimum": 0, + "maximum": 100, + "minimum": 1, "type": "integer" }, "machine_quota": { From d70b276c67f2fc622ae165120ed109f0b058ad13 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:48:08 +0700 Subject: [PATCH 06/65] docs(notary): define bounded batch contract Signed-off-by: Jeremi Joslin --- products/notary/CHANGELOG.md | 4 + products/notary/docs/api-reference.md | 37 ++++++ products/notary/docs/client-sdk-guide.md | 14 +++ .../notary/docs/operator-config-reference.md | 20 ++++ products/notary/specs/README.md | 4 + .../specs/adr-audit-pseudonym-redesign.md | 46 ++++---- .../specs/bounded-batch-evaluation-v1.md | 105 ++++++++++++++++++ 7 files changed, 203 insertions(+), 27 deletions(-) create mode 100644 products/notary/specs/bounded-batch-evaluation-v1.md diff --git a/products/notary/CHANGELOG.md b/products/notary/CHANGELOG.md index 1c7eca06a..069c53aa9 100644 --- a/products/notary/CHANGELOG.md +++ b/products/notary/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added the bounded batch evaluation v1 contract. Batch evaluation has an + immutable 100-member platform ceiling with lower-only global and per-claim + configuration, client and OpenAPI bounds, and pre-side-effect HTTP 413 + rejection using `batch.too_large`. - Added the committed Draft 2020-12 Registry Notary runtime configuration schema at `schemas/registry-notary.config.schema.json`. `registry-notary schema`, `just config-schema-generate`, and the schema drift check all use diff --git a/products/notary/docs/api-reference.md b/products/notary/docs/api-reference.md index 615197cdc..3ca649eda 100644 --- a/products/notary/docs/api-reference.md +++ b/products/notary/docs/api-reference.md @@ -22,6 +22,43 @@ product boundaries that clients must preserve. Notary exposes no registry-source, adapter, or sidecar API. Relay consultations are private product-to-product calls governed by compiler-produced contracts. +## Bounded batch evaluation + +`POST /v1/batch-evaluations` repeats one claim set over an ordered list of +targets. It is the only batch surface. Notary does not expose Relay batch, +batch credential issuance, or OID4VCI batch credential routes. + +The hard platform ceiling is 100 items. Operators may lower it globally with +`evidence.inline_batch_limit` and per selected claim with +`operations.batch_evaluate.max_subjects`. The effective limit is the lowest of +100 and every applicable configured limit. A larger request returns HTTP 413 +with `batch.too_large` before quota, idempotency, Relay, source, or retained +state changes. + +Processing has two phases. Notary first validates and plans every item without +side effects. An authorization, purpose, identity, format, claim, or +consultation-planning error rejects the whole request. After admission, items +execute with bounded concurrency and return an ordered HTTP 200 response. Each +item is independently `succeeded` or `failed`, and failures contain only closed +value-free errors. + +Registry-backed batches require a caller-scoped `Idempotency-Key`. If a request +is cancelled or its response is lost, retry the identical body with the same +key. Cancellation returns no partial response and retains no partial +evaluations, although Relay may have observed work already dispatched. A +completed replay returns the stored response without another source dispatch. + +The 100-item limit composes with the 1 MiB inbound request limit, 64 KiB +per-Relay-result limit, 256 consultation-group limit, 25 second Relay deadline, +30 second default outer deadline, default concurrency of 16 items and 8 Relay +operations, and the Rust client's 16 MiB response-read limit. Use smaller +batches for large member results. There is no separate aggregate Relay byte +counter. + +Each member preserves the authorization, purpose, consent, minimization, +provenance, and value-free audit rules of single evaluation. A successful +member may be used only through the normal one-evaluation issuance flow. + ## Client behavior Use the Rust, Python, or Node client for bounded response reads, problem diff --git a/products/notary/docs/client-sdk-guide.md b/products/notary/docs/client-sdk-guide.md index bffd199a0..a26d12884 100644 --- a/products/notary/docs/client-sdk-guide.md +++ b/products/notary/docs/client-sdk-guide.md @@ -578,6 +578,20 @@ supplied. `POST /v1/evaluations`, `POST /v1/evaluations/{evaluation_id}/render`, and `POST /v1/credentials` reject a request that carries `Idempotency-Key` with `400 Bad Request`; do not send that header outside batch evaluation. +One request may contain at most 100 items, and the deployment or any selected +claim may advertise a lower limit. The Rust typed client rejects more than 100 +items before transport. All clients surface a server-side lower-limit rejection +as HTTP 413 with `batch.too_large`. + +For a high-volume workload, split targets into stable ordered slices of no more +than 100, or smaller slices when member results are large. Assign one unique +idempotency key to each slice and persist that key with the slice definition. +Every retry after a timeout, cancellation, process restart, or lost response +must send the identical slice with the same key. Do not reuse a key for the +next slice. Items in the HTTP 200 response remain in request order and may be a +mix of `succeeded` and `failed`; retrying a completed slice replays that result +instead of dispatching its registry consultations again. + ```python from registry_notary import RetryPolicy diff --git a/products/notary/docs/operator-config-reference.md b/products/notary/docs/operator-config-reference.md index 54d0d1a89..56e311c2d 100644 --- a/products/notary/docs/operator-config-reference.md +++ b/products/notary/docs/operator-config-reference.md @@ -18,6 +18,26 @@ runtime parsers that deserialize them, avoiding a narrower duplicate grammar. availability, filesystem and Relay access, deployment gates, and cross-field runtime validation. +## Bounded batch settings + +The batch platform ceiling is 100 items and cannot be raised. Set +`evidence.inline_batch_limit` to lower the service-wide limit, then use +`evidence.claims[].operations.batch_evaluate.max_subjects` to lower it for an +individual claim. Both fields accept only integers from 1 through 100. Notary +rejects zero or values above 100 at configuration load. + +For a request, the effective limit is the lowest of 100, the global value, and +the value of every selected claim. A request above that limit is rejected +before quota, idempotency, Relay, source, or retained-state side effects. + +`evidence.concurrency.subjects` controls parallel member work and defaults to +16. Relay concurrency is independently bounded by the Relay client and +defaults to 8. Keep the outer `server.request_timeout` at least five seconds +above the 25 second Relay operation deadline for registry-backed evaluation; +the default is 30 seconds. These controls compose with the 1 MiB inbound body, +64 KiB per-Relay-result, and 256 consultation-group limits. They do not replace +the 100-member ceiling. + {/* registry-notary-config-key-paths:start */} ```text audit diff --git a/products/notary/specs/README.md b/products/notary/specs/README.md index 047649131..dafdfd698 100644 --- a/products/notary/specs/README.md +++ b/products/notary/specs/README.md @@ -11,6 +11,10 @@ that still tracks open work. accepted design record for versioned audit pseudonym domains, canonical identifier inputs, no-match behavior, retention, key rotation, erasure, and federation pairwise alignment. **[archived: reconciled design record]** +- [`bounded-batch-evaluation-v1.md`](bounded-batch-evaluation-v1.md): + hard limits, two-phase processing, per-member audit, cancellation, replay, + and issuance boundaries for the synchronous batch evaluation surface. + **[implemented]** - [`citizen-self-attestation-spec.md`](citizen-self-attestation-spec.md): citizen self-attestation behavior, guard order, privacy controls, rate limits, and historical source-free credential issuance design. Current diff --git a/products/notary/specs/adr-audit-pseudonym-redesign.md b/products/notary/specs/adr-audit-pseudonym-redesign.md index 84e476414..2bde893d0 100644 --- a/products/notary/specs/adr-audit-pseudonym-redesign.md +++ b/products/notary/specs/adr-audit-pseudonym-redesign.md @@ -50,7 +50,7 @@ normalization rules were used. | Domain id | Event use | Purpose | | --- | --- | --- | | `matched-reference-v1` | routine successful or policy-denied evaluations after a provider match | Link audit events for the same matched requester or target within the same purpose scope without exposing source identifiers. | -| `matching-attempt-v1` | optional no-match repeat-probe detection | Short-lived, purpose-scoped correlation of failed matching attempts when explicitly enabled by deployment policy. | +| `matching-attempt-v1` | failed evaluation attempts | Purpose-scoped correlation of failed matching attempts without retaining raw identifiers. | | `investigation-reference-v1` | elevated investigation or abuse-response events | Separately authorized handle for incident review, never emitted by routine evaluation paths. | The platform hash primitive frames the HMAC input as: @@ -83,10 +83,10 @@ entity being pseudonymized, the canonical object is: } ``` -The raw-identifier-array path is the `matching-attempt-v1` class. It is used -only for optional repeat-probe detection and produces no durable pseudonym: the -matching-attempt hasher derives the canonical input but returns no pseudonym -value, so no long-lived handle is emitted from raw target attributes. +The raw-identifier-array path is the `matching-attempt-v1` class. It produces a +purpose-scoped keyed pseudonym for a failed attempt when the request contains a +target or requester identifier. The canonical input exists only in memory and +the serialized audit record contains only the HMAC result. Canonicalization rules: @@ -107,26 +107,18 @@ Canonicalization rules: identifiers must never appear in serialized audit events or logs. They may appear only inside the in-memory HMAC input during pseudonym generation. -### No-Match Behavior +### Failed-Attempt Behavior -Pure no-match failures do not emit a durable attribute-derived entity -pseudonym. Routine no-match audit events record request metadata, profile, -purpose, requester authentication context, match status, and redacted failure -reason only. +Failed single and batch evaluation attempts use `matching-attempt-v1` when the +request includes a target or requester identifier. The pseudonym is scoped by +role, entity type, and purpose, and uses a separate domain from matched +references. An entity without a usable identifier produces no pseudonym. -If a deployment needs repeat-probe detection, it must explicitly enable the -`matching-attempt-v1` domain with all of these controls: - -- a dedicated matching-attempt key distinct from the routine audit key; -- retention no longer than the configured abuse-detection window; -- purpose scope included in the canonical input; -- no federation handle, matched-reference pseudonym, or investigation - pseudonym derived from the same no-match input; -- operator documentation that the handle is for abuse detection, not subject - identity. - -If repeat-probe correlation is disabled, no no-match event contains a -long-lived pseudonym derived from target attributes. +Routine audit records include request metadata, purpose, authentication +context, value-free outcome and error codes, and the keyed pseudonym. They do +not include raw identifiers, claim values, consultation inputs, or consultation +outputs. The pseudonym is an audit correlation handle, not subject identity and +not an input to federation or investigation handles. ### Retention, Key Rotation, And Erasure @@ -139,8 +131,8 @@ Retention classes: - `matched-reference-v1`: follows the routine audit retention period for the deployment and purpose scope. -- `matching-attempt-v1`: expires at the shorter of the routine audit retention - period or the configured abuse-detection window. +- `matching-attempt-v1`: follows the routine audit retention period for the + deployment and purpose scope. - `investigation-reference-v1`: expires under the case or legal-hold policy that authorized the investigation event. When no legal hold exists, it must not outlive routine audit retention. @@ -181,8 +173,8 @@ different, non-interchangeable values for federation and audit. - Implementations get deterministic matched-reference audit correlation without retaining raw identifiers. -- No-match events are private by default and require an explicit short-lived - abuse-detection configuration before any attribute-derived handle is emitted. +- Failed-attempt events remain value-free while supporting purpose-scoped + correlation through a distinct keyed pseudonym domain. - Operators must manage audit pseudonym keys as retention and erasure controls, not only as generic application secrets. - Federation remains aligned with the pairwise subject hash design while diff --git a/products/notary/specs/bounded-batch-evaluation-v1.md b/products/notary/specs/bounded-batch-evaluation-v1.md new file mode 100644 index 000000000..81a081072 --- /dev/null +++ b/products/notary/specs/bounded-batch-evaluation-v1.md @@ -0,0 +1,105 @@ +# Bounded batch evaluation v1 + +Status: runtime contract for `POST /v1/batch-evaluations` + +## Scope + +Batch evaluation is a bounded transport convenience for repeating the existing +single-evaluation policy over multiple targets. It does not introduce a Relay +batch protocol, batch credential issuance, or an OID4VCI batch route. Every +member keeps the authorization, purpose, consent, provenance, minimization, +and audit requirements of a corresponding single evaluation. + +The concrete threat is amplification. An apparently small request can consume +quota, reserve durable idempotency state, dispatch private consultations, and +retain results for many targets. The invariant is that admission for the whole +batch is bounded and side-effect free. Only admitted members may enter the +side-effecting execution phase. + +## Limits + +The platform ceiling is 100 members. It is not configurable upward. The +effective member limit is: + +```text +min( + 100, + evidence.inline_batch_limit, + selected_claim_1.operations.batch_evaluate.max_subjects, + ..., + selected_claim_n.operations.batch_evaluate.max_subjects +) +``` + +Both configurable limits accept only `1..=100`. Configuration loading rejects +zero and values above 100. A request above the effective limit returns HTTP +413 with `batch.too_large` before quota debit, idempotency reservation, Relay +dispatch, source access, or retained evaluation state. + +The member limit composes with existing independent bounds: + +- the HTTP request body is at most 1 MiB; +- one Relay result is at most 64 KiB; +- one batch may form at most 256 consultation groups; +- one Relay operation has a 25 second deadline; +- the outer request timeout defaults to 30 seconds and must preserve the Relay + deadline plus the runtime reserve; +- member concurrency defaults to 16 and Relay concurrency defaults to 8; and +- the Rust client accepts at most a 16 MiB response. + +There is intentionally no aggregate byte counter across all Relay results. +Operators and clients should use smaller batches when member results approach +the per-result or response-read bounds. + +## Two-phase processing + +Phase one performs pure admission and planning for every member. It validates +the member count, claims and versions, operation enablement, authorization, +purpose, disclosure, format, identity shape, relationship, consultation +contracts, and consultation-group bound. Any phase-one failure rejects the +whole request. No member is dispatched. + +Phase two performs admitted work with bounded concurrency. The response is an +ordered HTTP 200 result whose `items` preserve request order. Each item is +either `succeeded` with its normal minimized claim results or `failed` with a +closed, value-free error. A member's operational failure does not turn into a +claim value or `no_match`, and it does not erase the outcomes of other +members. + +## Identity, audit, and replay + +Registry-backed batches require one caller-scoped `Idempotency-Key`. The +runtime derives deterministic child identities from the caller, outer key, +request digest, and member index. The same caller must retry an interrupted or +lost-acknowledgement request with the same outer key and identical body. +Completed requests replay their stored ordered response. A changed body, +caller scope, or execution-defining configuration conflicts with the existing +key. + +Cancellation returns no partial response and commits no partial retained +evaluations. Relay work already dispatched before cancellation may have been +observed by Relay or its source. A retry therefore reuses the deterministic +child identity while allowing a new transport attempt. + +Audit records are value-free. Each member records its input index, outcome, +requester and target types, keyed requester and target pseudonyms when an +identifier is available, and consultation evidence. Failed attempts use the +same keyed-pseudonym construction as successful attempts. Raw identifiers, +consultation inputs, consultation outputs, and claim values must never enter +the audit record or ordinary logs. + +## Issuance boundary + +Batch evaluation produces evaluation responses only. It cannot issue a batch +credential and it cannot use an OID4VCI batch credential route. A successful +member may be retained only under the normal single-evaluation retention and +issuance rules. Any later credential request is a separate authorized action +over one retained evaluation. + +## Release evidence + +Before a release candidate is promoted, the frozen candidate must execute +10,000 repeated bounded batches with exact request and response digests. The +evidence must cover process restart, a lost acknowledgement, cancellation +followed by retry, stable child identities, ordered results, and the absence of +extra Relay dispatch on a completed replay. From 364dbf7f3f771878354a4f6071182af8f921eb72 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:34:02 +0700 Subject: [PATCH 07/65] fix(relay): migrate maintained images to Debian 13 Signed-off-by: Jeremi Joslin --- crates/registry-relay/Dockerfile | 4 +-- crates/registry-relay/Dockerfile.demo | 22 ++++++------ crates/registry-relay/docs/ops.md | 7 +++- .../registry-relay/docs/security-assurance.md | 34 +++++++++++++++++++ .../scripts/check_docker_build_contract.py | 6 ++-- .../scripts/run-live-consultation-journey.sh | 2 +- 6 files changed, 56 insertions(+), 19 deletions(-) diff --git a/crates/registry-relay/Dockerfile b/crates/registry-relay/Dockerfile index 5f6cb6bbc..c719b8c8a 100644 --- a/crates/registry-relay/Dockerfile +++ b/crates/registry-relay/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1.7 # Keep the tag for humans and the digest for reproducible pulls. -FROM rust:1-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1 AS builder +FROM rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 AS builder WORKDIR /workspace/registry_relay COPY Cargo.toml Cargo.lock ./ @@ -34,7 +34,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ chown -R 65532:65532 /workspace/runtime-root # Distroless cc keeps glibc and CA certificates while dropping shell/package tools. -FROM gcr.io/distroless/cc-debian12:nonroot@sha256:bd2899c12b335c827750ccf2359879eab09c09b206023dcebea408947d54127c AS runtime +FROM gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 AS runtime COPY --from=builder --chown=65532:65532 /workspace/runtime-root/ / COPY --from=builder /usr/local/bin/registry-relay /usr/local/bin/registry-relay diff --git a/crates/registry-relay/Dockerfile.demo b/crates/registry-relay/Dockerfile.demo index 613029f45..e0e7ad9b0 100644 --- a/crates/registry-relay/Dockerfile.demo +++ b/crates/registry-relay/Dockerfile.demo @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1.7 # Keep the tag for humans and the digest for reproducible pulls. -FROM rust:1-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1 AS builder +FROM rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 AS builder WORKDIR /workspace/registry_relay COPY Cargo.toml Cargo.lock ./ @@ -15,23 +15,21 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/workspace/registry_relay/target \ cargo build --release --locked --features spdci-api-standards,standards-cel-mapping,attribute-release && \ cp /workspace/registry_relay/target/release/registry-relay /usr/local/bin/registry-relay && \ - cp /workspace/registry_relay/target/release/registry-relay-rhai-worker /usr/local/bin/registry-relay-rhai-worker + cp /workspace/registry_relay/target/release/registry-relay-rhai-worker /usr/local/bin/registry-relay-rhai-worker && \ + mkdir -p \ + /workspace/runtime-root/etc/registry-relay \ + /workspace/runtime-root/var/lib/registry-relay/cache \ + /workspace/runtime-root/var/lib/registry-relay/data \ + /workspace/runtime-root/var/log/registry-relay && \ + chown -R 65532:65532 /workspace/runtime-root -FROM debian:bookworm-slim@sha256:0104b334637a5f19aa9c983a91b54c89887c0984081f2068983107a6f6c21eeb AS runtime - -RUN groupadd --system --gid 10001 registry_relay && \ - useradd --system --uid 10001 --gid registry_relay --home-dir /var/lib/registry-relay --shell /usr/sbin/nologin registry_relay && \ - mkdir -p /etc/registry-relay /var/lib/registry-relay/cache /var/lib/registry-relay/data && \ - chown -R registry_relay:registry_relay /var/lib/registry-relay && \ - apt-get update && \ - apt-get install -y --no-install-recommends ca-certificates && \ - rm -rf /var/lib/apt/lists/* +FROM gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 AS runtime +COPY --from=builder --chown=65532:65532 /workspace/runtime-root/ / COPY --from=builder /usr/local/bin/registry-relay /usr/local/bin/registry-relay COPY --from=builder /usr/local/bin/registry-relay-rhai-worker /usr/local/bin/registry-relay-rhai-worker COPY LICENSE /licenses/registry-relay/LICENSE -USER registry_relay:registry_relay WORKDIR /var/lib/registry-relay ENV REGISTRY_RELAY_CONFIG=/etc/registry-relay/config.yaml diff --git a/crates/registry-relay/docs/ops.md b/crates/registry-relay/docs/ops.md index 9028fcccd..ea1f74d60 100644 --- a/crates/registry-relay/docs/ops.md +++ b/crates/registry-relay/docs/ops.md @@ -132,7 +132,8 @@ checklist carry a one-line note. ### Container runtime policy -- [ ] Production images use the `Dockerfile` (distroless `cc-debian12:nonroot`, +- [ ] Production images use the `Dockerfile` (Distroless + `cc-debian13:nonroot`, UID/GID `65532:65532`). `Dockerfile.demo` is not used as production runtime evidence. - [ ] No shell, package manager, `curl`, or `wget` dependencies are present in @@ -141,6 +142,10 @@ checklist carry a one-line note. by UID/GID `65532:65532`. See [Deployment model](#deployment-model). - [ ] TLS client behavior is verified after any base-image change by exercising an HTTPS OIDC JWKS/discovery path or a PostgreSQL TLS connection. +- [ ] The container starts with a read-only root filesystem after writable + cache, data, and audit paths are mounted explicitly; both the Relay process + and `registry-relay-rhai-worker` execute as UID/GID `65532:65532`, and + readiness succeeds with the intended configuration and CA roots. ### Readiness gates diff --git a/crates/registry-relay/docs/security-assurance.md b/crates/registry-relay/docs/security-assurance.md index c2bfe2818..4a0a49602 100644 --- a/crates/registry-relay/docs/security-assurance.md +++ b/crates/registry-relay/docs/security-assurance.md @@ -17,6 +17,40 @@ well-known maintained actions, with `zizmor`, the reviewed advisory baseline, and code review enforcing least-privilege permissions and safe event handling instead of a blanket SHA-only pin policy. +## Container base lifecycle + +Maintained Relay builders and final images use Debian 13. Final production and +demo images use the shell-free Distroless `cc-debian13:nonroot` base and run as +UID/GID `65532:65532`. Debian 13 receives full Debian support through August 9, +2028 and LTS through June 30, 2030. Registry Stack must select a successor base +before the applicable support window ends. The upstream lifecycle is recorded +at . + +All upstream bases are pinned to multi-architecture image-index digests. An +immutable digest makes a build input repeatable, but it does not make that input +perpetually current. Release operators refresh the Rust builder, preparation, +and Distroless digests together before each release candidate and whenever an +upstream security update or scan finding requires it. Run the repository gate +after every refresh: + +```sh +python3 release/scripts/check-debian13-images.py +``` + +Changing the builder OS intentionally changes the release build input and may +change linked binary bytes even when Rust sources and the Rust toolchain version +do not change. Repeatability is therefore established by two clean builds with +the same new builder digest and lockfiles, comparing the generated +`dist/image-bin/SHA256SUMS`; it is not established by matching hashes produced +with the retired builder. The exact pushed candidate still needs its normal +digest-bound SBOM, Grype, release-capsule, and standalone implementer evidence. + +For each candidate, execute the image with a read-only root filesystem and only +the documented cache, data, and audit mounts writable. Confirm that the Relay +binary and Rhai worker run as `65532:65532`, CA roots support an HTTPS discovery +or PostgreSQL TLS journey, and readiness succeeds. These runtime results belong +to the exact candidate digest; the source checks do not substitute for them. + ## Repository controls you can audit - Route exposure waivers: [`security/exposure-manifest.json`](../security/exposure-manifest.json). diff --git a/crates/registry-relay/scripts/check_docker_build_contract.py b/crates/registry-relay/scripts/check_docker_build_contract.py index f3404c3a5..3c4555df2 100755 --- a/crates/registry-relay/scripts/check_docker_build_contract.py +++ b/crates/registry-relay/scripts/check_docker_build_contract.py @@ -171,8 +171,8 @@ def main() -> int: failures.extend( require_runtime( dockerfile, - "FROM gcr.io/distroless/cc-debian12:nonroot@sha256:", - "distroless nonroot runtime base", + "FROM gcr.io/distroless/cc-debian13:nonroot@sha256:", + "Distroless Debian 13 nonroot runtime base", ) ) failures.extend( @@ -204,7 +204,7 @@ def main() -> int: ) ) for needle, detail in [ - ("debian:bookworm-slim", "Debian slim runtime base"), + ("debian:trixie-slim", "Debian slim runtime base"), ("apt-get", "package manager in runtime"), ("groupadd", "runtime group creation"), ("useradd", "runtime user creation"), diff --git a/crates/registry-relay/scripts/run-live-consultation-journey.sh b/crates/registry-relay/scripts/run-live-consultation-journey.sh index 20bf549a8..72ec2bcec 100755 --- a/crates/registry-relay/scripts/run-live-consultation-journey.sh +++ b/crates/registry-relay/scripts/run-live-consultation-journey.sh @@ -279,7 +279,7 @@ if [[ "$product" == "rhai" ]]; then --volume "$HOME/.cargo/git:/usr/local/cargo/git" \ --volume registry-relay-linux-target:/target \ --workdir /workspace \ - rust:1.95-bookworm \ + rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 \ sh -eu -c \ 'cp /live-postgres-ca/ca.crt /usr/local/share/ca-certificates/registry-live-source.crt update-ca-certificates >/dev/null 2>&1 From b39177fff7603a7d51b7acd830ae3dfd42209038 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:34:13 +0700 Subject: [PATCH 08/65] fix(notary): use Debian 13 distroless runtime Signed-off-by: Jeremi Joslin --- products/notary/Dockerfile | 12 ++++-- products/notary/docs/security-assurance.md | 38 +++++++++++++++- products/notary/justfile | 4 +- .../scripts/check_security_assurance.py | 43 +++++++++++++++++++ .../tests/security_assurance_check_test.py | 33 ++++++++++++++ 5 files changed, 124 insertions(+), 6 deletions(-) diff --git a/products/notary/Dockerfile b/products/notary/Dockerfile index 282074cae..ce66e484d 100644 --- a/products/notary/Dockerfile +++ b/products/notary/Dockerfile @@ -3,7 +3,7 @@ # syntax=docker/dockerfile:1.7 # Keep the tag for humans and the digest for reproducible pulls. -FROM rust:1-bookworm@sha256:54152db00aafd37bc5ce1f3585aaa42f17cdd5c8e5ef9eabfbc0718b42bce312 AS builder +FROM rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 AS builder WORKDIR /workspace/registry-notary COPY --from=registry-platform Cargo.toml README.md LICENSE /workspace/registry-platform/ @@ -27,13 +27,19 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ CARGO_TARGET_DIR=/workspace/target cargo build --release --locked -p registry-notary-server --bin registry-notary-cel-worker --features "$REGISTRY_NOTARY_FEATURES" \ && cp /workspace/target/release/registry-notary-cel-worker /workspace/out/registry-notary-cel-worker ;; \ *) true ;; \ - esac + esac \ + && mkdir -p \ + /workspace/runtime-root/etc/registry-notary \ + /workspace/runtime-root/var/lib/registry-notary \ + /workspace/runtime-root/var/log/registry-notary \ + && chown -R 65532:65532 /workspace/runtime-root # Runtime policy: the main Notary image is a distroless Rust service image. # Keep the runtime shell-free, non-root, and compatible with binary healthchecks # and JSON-array entrypoints. CI enforces this policy. -FROM gcr.io/distroless/cc-debian12:nonroot@sha256:b0ae8e989418b458e0f25489bc3be523718938a2b70864cc0f6a00af1ddbd985 AS runtime +FROM gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 AS runtime +COPY --from=builder --chown=65532:65532 /workspace/runtime-root/ / COPY --from=builder /workspace/out/ /usr/local/bin/ ENV REGISTRY_NOTARY_BIND=0.0.0.0:8080 diff --git a/products/notary/docs/security-assurance.md b/products/notary/docs/security-assurance.md index 494d8c23c..1fdc88330 100644 --- a/products/notary/docs/security-assurance.md +++ b/products/notary/docs/security-assurance.md @@ -15,11 +15,47 @@ smoke, SBOM, and Grype critical-vulnerability gate. ## Container runtime policy The main Registry Notary image is a distroless Rust service image. Its runtime -stage must remain `gcr.io/distroless/cc-debian12:nonroot` pinned by digest, +stage must remain `gcr.io/distroless/cc-debian13:nonroot` pinned by digest, shell-free, package-manager-free, and compatible with a binary healthcheck and JSON-array entrypoint. The container CI guard enforces the runtime base, `registry-notary healthcheck`, and `ENTRYPOINT ["/usr/local/bin/registry-notary"]`. +Debian 13 receives full Debian support through August 9, 2028 and LTS through +June 30, 2030. Registry Stack must select a successor base before the applicable +support window ends. The upstream lifecycle is recorded at +. The Rust builder, runtime-preparation, +and Distroless bases are pinned to multi-architecture image-index digests. An +immutable digest makes a build input repeatable, but it does not make that input +perpetually current. Release operators refresh all three pins together before +each release candidate and whenever an upstream security update or scan finding +requires it, then run: + +```sh +python3 release/scripts/check-debian13-images.py +``` + +Changing the builder OS intentionally changes the release build input and may +change linked binary bytes even when Rust sources and the Rust toolchain version +do not change. Repeatability is established by two clean builds with the same +new builder digest and lockfiles, comparing `dist/image-bin/SHA256SUMS`; hashes +from the retired builder are not the expected comparison. + +PKCS#11 support remains compiled in but config-gated. A vendor module and any +vendor-owned shared-library dependencies remain deployment inputs, not image +layers. Mount them read-only at operator-owned absolute paths and set the +provider's `module_path` to the mounted module. Do not copy an HSM module, PIN, +token database, or vendor configuration into the image. Because Distroless has +no package manager, the exact candidate must prove that the intended external +module and its dependency closure load on this base. + +For each candidate digest, run the Notary container as UID/GID `65532:65532` +with a read-only root filesystem and only explicitly documented state and audit +mounts writable. Verify CA-root TLS, binary and CEL-worker execution, PKCS#11 +module loading and signing, filesystem permissions, `registry-notary +healthcheck`, and readiness. The release workflow must then produce digest-bound +SBOM, Grype, and capsule evidence. These candidate checks cannot be inferred +from source-only image-contract tests. + Route exposure waivers, when needed, live on the affected entry in `security/exposure-manifest.json` so the review context stays with the route. There is no separate `security/waivers.yml` in this repository; diff --git a/products/notary/justfile b/products/notary/justfile index 5bbc999bc..3d2b86272 100644 --- a/products/notary/justfile +++ b/products/notary/justfile @@ -68,9 +68,9 @@ openapi-contract base_ref="": exposure-check: python3 scripts/check_security_assurance.py manifest -# Validate Dockerfiles for obvious secret-copy hazards. +# Validate Dockerfiles for secret-copy hazards and the non-root runtime contract. container-security: - python3 scripts/check_security_assurance.py dockerfile-secrets + python3 scripts/check_security_assurance.py dockerfile-secrets container-runtime # Run security assurance checks that do not require external services. security: diff --git a/products/notary/scripts/check_security_assurance.py b/products/notary/scripts/check_security_assurance.py index 1815b30d7..c7acda66d 100755 --- a/products/notary/scripts/check_security_assurance.py +++ b/products/notary/scripts/check_security_assurance.py @@ -837,6 +837,45 @@ def check_dockerfile_secret_patterns() -> None: fail(f"{path.name}:{lineno} may copy secret or private JWK material") +def check_container_runtime_contract() -> None: + path = ROOT / "Dockerfile" + if not path.is_file(): + fail(f"missing required Dockerfile: {path.relative_to(ROOT)}") + text = path.read_text(encoding="utf-8") + runtime_base = "gcr.io/distroless/cc-debian13:nonroot@sha256:" + marker = f"FROM {runtime_base}" + marker_at = text.find(marker) + if marker_at < 0: + fail("Dockerfile must use the pinned Distroless Debian 13 nonroot runtime") + runtime = text[marker_at:] + first_line = runtime.splitlines()[0] + if not re.fullmatch( + r"FROM gcr\.io/distroless/cc-debian13:nonroot@sha256:[0-9a-f]{64} AS runtime", + first_line, + ): + fail("Dockerfile runtime base must use an immutable Distroless image digest") + required = ( + 'ARG REGISTRY_NOTARY_FEATURES="registry-notary-cel,pkcs11"', + 'HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/usr/local/bin/registry-notary", "healthcheck"]', + 'ENTRYPOINT ["/usr/local/bin/registry-notary"]', + "COPY --from=builder --chown=65532:65532 /workspace/runtime-root/ /", + "WORKDIR /var/lib/registry-notary", + "registry-notary-cel-worker", + ) + for needle in required: + if needle not in text: + fail(f"Dockerfile is missing container runtime contract: {needle}") + for forbidden in ("\nRUN ", "apt-get", "/bin/sh", "curl ", "wget "): + if forbidden in runtime: + fail(f"Dockerfile final runtime contains forbidden dependency: {forbidden.strip()}") + if re.search( + r"^\s*(?:COPY|ADD)\b[^\n]*(?:\.so\b|pkcs11[^/\s]*module)", + text, + re.IGNORECASE | re.MULTILINE, + ): + fail("vendor PKCS#11 modules must remain external read-only mounts") + + def check_openapi_baseline() -> None: baseline = ROOT / "openapi" / "registry-notary.openapi.json" if not baseline.exists(): @@ -935,6 +974,7 @@ def main() -> None: "manifest", "route-sources", "dockerfile-secrets", + "container-runtime", "openapi-baseline", "openapi-coverage", ], @@ -944,6 +984,7 @@ def main() -> None: checks = args.checks or [ "manifest", "dockerfile-secrets", + "container-runtime", "openapi-baseline", ] if "manifest" in checks: @@ -952,6 +993,8 @@ def main() -> None: validate_route_sources() if "dockerfile-secrets" in checks: check_dockerfile_secret_patterns() + if "container-runtime" in checks: + check_container_runtime_contract() if "openapi-baseline" in checks: check_openapi_baseline() if "openapi-coverage" in checks: diff --git a/products/notary/tests/security_assurance_check_test.py b/products/notary/tests/security_assurance_check_test.py index e64d1fbf1..233f7adc8 100644 --- a/products/notary/tests/security_assurance_check_test.py +++ b/products/notary/tests/security_assurance_check_test.py @@ -241,6 +241,39 @@ def test_dockerfile_secret_check_covers_add_and_missing_file(self): with self.assertRaises(SystemExit): self.module.check_dockerfile_secret_patterns() + def test_container_runtime_contract_accepts_debian13_and_rejects_vendor_module(self): + digest = "a" * 64 + (self.root / "Dockerfile").write_text( + f"FROM rust:1.95-trixie@sha256:{digest} AS builder\n" + 'ARG REGISTRY_NOTARY_FEATURES="registry-notary-cel,pkcs11"\n' + "RUN touch /registry-notary-cel-worker\n" + f"FROM gcr.io/distroless/cc-debian13:nonroot@sha256:{digest} AS runtime\n" + "COPY --from=builder --chown=65532:65532 /workspace/runtime-root/ /\n" + "COPY --from=builder /registry-notary-cel-worker /usr/local/bin/registry-notary-cel-worker\n" + "WORKDIR /var/lib/registry-notary\n" + 'HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/usr/local/bin/registry-notary", "healthcheck"]\n' + 'ENTRYPOINT ["/usr/local/bin/registry-notary"]\n' + ) + self.module.check_container_runtime_contract() + + with (self.root / "Dockerfile").open("a") as dockerfile: + dockerfile.write("COPY vendor-pkcs11-module.so /usr/lib/module.so\n") + with self.assertRaises(SystemExit): + self.module.check_container_runtime_contract() + + def test_container_runtime_contract_rejects_unpinned_runtime(self): + (self.root / "Dockerfile").write_text( + 'ARG REGISTRY_NOTARY_FEATURES="registry-notary-cel,pkcs11"\n' + "RUN touch /registry-notary-cel-worker\n" + "FROM gcr.io/distroless/cc-debian13:nonroot AS runtime\n" + "COPY --from=builder --chown=65532:65532 /workspace/runtime-root/ /\n" + "WORKDIR /var/lib/registry-notary\n" + 'HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/usr/local/bin/registry-notary", "healthcheck"]\n' + 'ENTRYPOINT ["/usr/local/bin/registry-notary"]\n' + ) + with self.assertRaises(SystemExit): + self.module.check_container_runtime_contract() + def write_route_inventory(self): (self.root / "security" / "route-inventory.json").write_text(json.dumps({ "version": 1, From 125410755e32eb85f4d4a2abb0e2b1441a700ce9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:34:21 +0700 Subject: [PATCH 09/65] fix(release): enforce Debian 13 image bases Signed-off-by: Jeremi Joslin --- release/docker/Dockerfile.registry-notary | 22 +- release/docker/Dockerfile.registry-relay | 4 +- release/scripts/check-debian13-images.py | 260 ++++++++++++++++++++++ release/scripts/test_registry_release.py | 45 +++- 4 files changed, 322 insertions(+), 9 deletions(-) create mode 100755 release/scripts/check-debian13-images.py diff --git a/release/docker/Dockerfile.registry-notary b/release/docker/Dockerfile.registry-notary index 31c9f04ce..adafe9cf5 100644 --- a/release/docker/Dockerfile.registry-notary +++ b/release/docker/Dockerfile.registry-notary @@ -2,16 +2,26 @@ # syntax=docker/dockerfile:1.7 -FROM debian:bookworm-slim@sha256:60eac759739651111db372c07be67863818726f754804b8707c90979bda511df AS runtime +FROM debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd AS runtime-root -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates \ - && rm -rf /var/lib/apt/lists/* +RUN mkdir -p \ + /workspace/runtime-root/etc/registry-notary \ + /workspace/runtime-root/var/lib/registry-notary \ + /workspace/runtime-root/var/log/registry-notary \ + && chown -R 65532:65532 /workspace/runtime-root +FROM gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 AS runtime + +COPY --from=runtime-root --chown=65532:65532 /workspace/runtime-root/ / COPY --chmod=0755 dist/image-bin/registry-notary /usr/local/bin/registry-notary COPY --chmod=0755 dist/image-bin/registry-notary-cel-worker /usr/local/bin/registry-notary-cel-worker -USER nobody:nogroup +WORKDIR /var/lib/registry-notary + +ENV REGISTRY_NOTARY_BIND=0.0.0.0:8080 EXPOSE 8080 -ENTRYPOINT ["registry-notary"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/usr/local/bin/registry-notary", "healthcheck"] + +ENTRYPOINT ["/usr/local/bin/registry-notary"] +CMD ["--config", "/etc/registry-notary/config.yaml"] diff --git a/release/docker/Dockerfile.registry-relay b/release/docker/Dockerfile.registry-relay index aba6caa85..f95566034 100644 --- a/release/docker/Dockerfile.registry-relay +++ b/release/docker/Dockerfile.registry-relay @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -FROM debian:bookworm-slim@sha256:60eac759739651111db372c07be67863818726f754804b8707c90979bda511df AS runtime-root +FROM debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd AS runtime-root RUN mkdir -p \ /workspace/runtime-root/etc/registry-relay \ @@ -9,7 +9,7 @@ RUN mkdir -p \ /workspace/runtime-root/var/log/registry-relay && \ chown -R 65532:65532 /workspace/runtime-root -FROM gcr.io/distroless/cc-debian12:nonroot@sha256:ce0d66bc0f64aae46e6a03add867b07f42cc7b8799c949c2e898057b7f75a151 AS runtime +FROM gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 AS runtime COPY --from=runtime-root --chown=65532:65532 /workspace/runtime-root/ / COPY --chmod=0755 dist/image-bin/registry-relay /usr/local/bin/registry-relay diff --git a/release/scripts/check-debian13-images.py b/release/scripts/check-debian13-images.py new file mode 100755 index 000000000..1fbd3c617 --- /dev/null +++ b/release/scripts/check-debian13-images.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Enforce the Debian 13 boundary for maintained Registry Stack images.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + +RUST_BUILDER = ( + "rust:1.95-trixie@sha256:" + "f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3" +) +DEBIAN_PREPARATION = ( + "debian:trixie-slim@sha256:" + "020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd" +) +DISTROLESS_RUNTIME = ( + "gcr.io/distroless/cc-debian13:nonroot@sha256:" + "d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775" +) + +DOCKERFILES = ( + Path("crates/registry-relay/Dockerfile"), + Path("crates/registry-relay/Dockerfile.demo"), + Path("products/notary/Dockerfile"), + Path("release/docker/Dockerfile.registry-notary"), + Path("release/docker/Dockerfile.registry-relay"), +) + +# These are the maintained image and image-policy surfaces. Historical release +# notes are immutable evidence and intentionally are not rewritten by this gate. +MAINTAINED_TEXT_PATHS = DOCKERFILES + ( + Path(".github/workflows/release.yml"), + Path("crates/registry-relay/docs/ops.md"), + Path("crates/registry-relay/docs/security-assurance.md"), + Path("crates/registry-relay/scripts/check_docker_build_contract.py"), + Path("crates/registry-relay/scripts/run-live-consultation-journey.sh"), + Path("products/notary/docs/security-assurance.md"), +) + +RUST_BUILDER_DOCKERFILES = DOCKERFILES[:3] +PREPARATION_DOCKERFILES = DOCKERFILES[3:] +RELAY_DOCKERFILES = ( + Path("crates/registry-relay/Dockerfile"), + Path("crates/registry-relay/Dockerfile.demo"), + Path("release/docker/Dockerfile.registry-relay"), +) +NOTARY_DOCKERFILES = ( + Path("products/notary/Dockerfile"), + Path("release/docker/Dockerfile.registry-notary"), +) + +FROM_RE = re.compile(r"^FROM\s+(?:--platform=\S+\s+)?(\S+)", re.MULTILINE) +DIGEST_PIN_RE = re.compile(r"@sha256:[0-9a-f]{64}$") + + +def read(root: Path, relative: Path, failures: list[str]) -> str: + path = root / relative + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + failures.append(f"missing maintained image surface: {relative}") + return "" + + +def require( + text: str, + needle: str, + relative: Path, + detail: str, + failures: list[str], +) -> None: + if needle not in text: + failures.append(f"{relative}: missing {detail}: {needle!r}") + + +def runtime_stage(text: str) -> str: + marker = f"FROM {DISTROLESS_RUNTIME} AS runtime" + offset = text.find(marker) + return text[offset:] if offset >= 0 else "" + + +def check_repository(root: Path = ROOT) -> list[str]: + failures: list[str] = [] + texts = { + relative: read(root, relative, failures) + for relative in MAINTAINED_TEXT_PATHS + } + + retired_markers = ("book" + "worm", "debian" + "12") + for relative, text in texts.items(): + lowered = text.casefold() + for marker in retired_markers: + if marker in lowered: + failures.append( + f"{relative}: retired Debian image generation marker remains: {marker}" + ) + + for relative in DOCKERFILES: + text = texts[relative] + bases = FROM_RE.findall(text) + if not bases: + failures.append(f"{relative}: no FROM instruction found") + continue + for base in bases: + if not DIGEST_PIN_RE.search(base): + failures.append( + f"{relative}: upstream base is not pinned by immutable digest: {base}" + ) + + require( + text, + f"FROM {DISTROLESS_RUNTIME} AS runtime", + relative, + "Distroless Debian 13 non-root final runtime", + failures, + ) + runtime = runtime_stage(text) + for forbidden in ("\nRUN ", "apt-get", "/bin/sh", "curl ", "wget "): + if forbidden in runtime: + failures.append( + f"{relative}: final Distroless runtime contains {forbidden.strip()!r}" + ) + require( + runtime, + "HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3", + relative, + "binary healthcheck", + failures, + ) + + for relative in RUST_BUILDER_DOCKERFILES: + require( + texts[relative], + f"FROM {RUST_BUILDER} AS builder", + relative, + "pinned Debian 13 Rust builder", + failures, + ) + + for relative in PREPARATION_DOCKERFILES: + require( + texts[relative], + f"FROM {DEBIAN_PREPARATION} AS runtime-root", + relative, + "pinned Debian 13 runtime preparation base", + failures, + ) + + for relative in RELAY_DOCKERFILES: + text = texts[relative] + require( + text, + "/usr/local/bin/registry-relay-rhai-worker", + relative, + "Relay worker binary", + failures, + ) + require( + runtime_stage(text), + 'ENTRYPOINT ["/usr/local/bin/registry-relay"]', + relative, + "absolute Relay entrypoint", + failures, + ) + + product_notary = texts[Path("products/notary/Dockerfile")] + require( + product_notary, + 'ARG REGISTRY_NOTARY_FEATURES="registry-notary-cel,pkcs11"', + Path("products/notary/Dockerfile"), + "PKCS#11-enabled product build", + failures, + ) + for relative in NOTARY_DOCKERFILES: + text = texts[relative] + require( + text, + "registry-notary-cel-worker", + relative, + "Notary CEL worker binary", + failures, + ) + require( + runtime_stage(text), + 'ENTRYPOINT ["/usr/local/bin/registry-notary"]', + relative, + "absolute Notary entrypoint", + failures, + ) + require( + runtime_stage(text), + "--chown=65532:65532 /workspace/runtime-root/ /", + relative, + "numeric nonroot-owned Notary runtime directories", + failures, + ) + require( + runtime_stage(text), + "WORKDIR /var/lib/registry-notary", + relative, + "Notary working directory", + failures, + ) + if re.search( + r"^\s*(?:COPY|ADD)\b[^\n]*(?:\.so\b|pkcs11[^/\s]*module)", + text, + re.IGNORECASE | re.MULTILINE, + ): + failures.append( + f"{relative}: vendor PKCS#11 modules must remain external read-only mounts" + ) + + workflow = texts[Path(".github/workflows/release.yml")] + require( + workflow, + f"RELEASE_BUILDER_IMAGE: {RUST_BUILDER}", + Path(".github/workflows/release.yml"), + "pinned Debian 13 release builder", + failures, + ) + require( + workflow, + "--features registry-notary/registry-notary-cel,registry-notary/pkcs11", + Path(".github/workflows/release.yml"), + "PKCS#11-enabled release build", + failures, + ) + + live_journey = texts[ + Path("crates/registry-relay/scripts/run-live-consultation-journey.sh") + ] + require( + live_journey, + RUST_BUILDER, + Path("crates/registry-relay/scripts/run-live-consultation-journey.sh"), + "pinned Debian 13 live-journey builder", + failures, + ) + + return failures + + +def main() -> int: + failures = check_repository() + if failures: + print("Debian 13 image contract check failed:", file=sys.stderr) + for failure in failures: + print(f"- {failure}", file=sys.stderr) + return 1 + print("Debian 13 image contract check passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index a3eea1e76..b8caa7858 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 from __future__ import annotations +import importlib.util +import json import subprocess import sys import tempfile import unittest -import json from pathlib import Path import yaml @@ -17,7 +18,49 @@ IMAGE_DIGEST_REF = f"ghcr.io/registrystack/registry-notary@{IMAGE_DIGEST}" +def load_debian13_image_check(): + path = ROOT / "release/scripts/check-debian13-images.py" + spec = importlib.util.spec_from_file_location("check_debian13_images", path) + if spec is None or spec.loader is None: + raise ImportError(f"could not load module spec from {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + class RegistryReleaseTest(unittest.TestCase): + def test_maintained_images_follow_debian13_contract(self) -> None: + module = load_debian13_image_check() + self.assertEqual([], module.check_repository(ROOT)) + + def test_debian13_contract_rejects_retired_base_and_unpinned_base(self) -> None: + module = load_debian13_image_check() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for relative in module.MAINTAINED_TEXT_PATHS: + destination = root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text( + (ROOT / relative).read_text(encoding="utf-8"), + encoding="utf-8", + ) + + relay_dockerfile = root / "crates/registry-relay/Dockerfile" + text = relay_dockerfile.read_text(encoding="utf-8") + text = text.replace( + module.RUST_BUILDER, + "rust:1.95-" + "book" + "worm", + 1, + ) + relay_dockerfile.write_text(text, encoding="utf-8") + + failures = module.check_repository(root) + self.assertTrue( + any("retired Debian image generation marker" in failure for failure in failures) + ) + self.assertTrue( + any("not pinned by immutable digest" in failure for failure in failures) + ) def test_contributing_documents_major_functionality_test_policy(self) -> None: text = (ROOT / "CONTRIBUTING.md").read_text(encoding="utf-8") From b44f0c9bdf4d49176b8340f8d7ed4de4d2dfc68d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:34:24 +0700 Subject: [PATCH 10/65] ci(release): build with pinned Debian 13 image Signed-off-by: Jeremi Joslin --- .github/workflows/release.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fbb02cdb..f7a1b0359 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ env: CARGO_TERM_COLOR: always REGISTRY: ghcr.io IMAGE_NAMESPACE: registrystack - RELEASE_BUILDER_IMAGE: rust:1.95-bookworm@sha256:4c2fd73ef19c5ef9d54bee03b06b2839a392604fbfcd578ed948b71b37c1d7fb + RELEASE_BUILDER_IMAGE: rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 SYFT_VERSION: v1.45.1 SYFT_LINUX_AMD64_SHA256: 20c84195e24927f50a3b2269946be51f4c4abc9d2f145fee7388b4199149f716 GRYPE_VERSION: v0.114.0 @@ -122,6 +122,9 @@ jobs: - name: Release source model run: REGISTRY_RELEASE_SOURCE_MODE=monorepo release/scripts/check-release-source-model.sh + - name: Verify Debian 13 image contract + run: release/scripts/check-debian13-images.py + binaries: name: Build release binaries needs: verify From 40409b81e93bf0c4a598c4d9a57f0d3879b8712f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:48:51 +0700 Subject: [PATCH 11/65] fix(relay): enforce Debian 13 scan policy Signed-off-by: Jeremi Joslin --- .../registry-relay/docs/security-assurance.md | 21 ++- .../scripts/check_advisory_baselines.py | 124 +++++++++++++++-- .../security/advisory-baseline.json | 19 +-- .../tests/advisory_baseline_check_test.py | 131 +++++++++++++++++- 4 files changed, 257 insertions(+), 38 deletions(-) diff --git a/crates/registry-relay/docs/security-assurance.md b/crates/registry-relay/docs/security-assurance.md index 4a0a49602..8a9a6b737 100644 --- a/crates/registry-relay/docs/security-assurance.md +++ b/crates/registry-relay/docs/security-assurance.md @@ -9,8 +9,9 @@ signatures for the container image itself. Final deployments should pin the selected image by digest. A release is gated on zero unreviewed `zizmor` findings at severity `high` or -above, zero unreviewed Grype image findings at severity `critical` or above, -and no expired reviewed advisory-baseline entry. Route exposure waivers, when +above, zero fixable Grype image findings at any severity, zero unreviewed Grype +image findings at severity `high` or above, and no expired reviewed +advisory-baseline entry. Route exposure waivers, when present, live on the affected `security/exposure-manifest.json` entry so the review context stays with the route. GitHub Actions use major-version pins for well-known maintained actions, with `zizmor`, the reviewed advisory baseline, @@ -45,6 +46,15 @@ the same new builder digest and lockfiles, comparing the generated with the retired builder. The exact pushed candidate still needs its normal digest-bound SBOM, Grype, release-capsule, and standalone implementer evidence. +The Debian 13 migration check on July 19, 2026 scanned a structural Relay image +with the pinned final base and placeholder binaries. It found the non-fixable +Debian 13 `libc6` findings CVE-2026-5450 (Critical), CVE-2026-5928 (High), and +CVE-2026-5435 (High). No risk dispositions are recorded for these findings, so +a candidate that still reports them remains blocked. This structural scan only +supports removal of the retired Debian 12 exception. The scan of the exact +pushed image, including the real Relay and worker binaries, supersedes it for +release decisions. + For each candidate, execute the image with a read-only root filesystem and only the documented cache, data, and audit mounts writable. Confirm that the Relay binary and Rhai worker run as `65532:65532`, CA roots support an HTTPS discovery @@ -58,9 +68,10 @@ to the exact candidate digest; the source checks do not substitute for them. no separate `security/waivers.yml` in this repository; deployment-gate waivers are runtime configuration and surface through the admin posture document. - Reviewed advisory ratchets: [`security/advisory-baseline.json`](../security/advisory-baseline.json). - Each reviewed entry names a fingerprint, owner, reason, review date, and - expiration date. Stale reviewed entries are reported so the baseline can - shrink after the underlying issue is fixed. + Fixable Grype findings block at every severity and cannot be dispositioned. + Each reviewed High or Critical entry names a matching rule and severity, + owner, reason, review date, and expiration date. Stale reviewed entries are + reported so the baseline can shrink after the underlying issue is fixed. - Unauthenticated endpoint allowlist: [`security/auth-none-allowlist.yml`](../security/auth-none-allowlist.yml). Additions require maintainer review through [CODEOWNERS](../CODEOWNERS). - GitHub Actions pinning: most workflows pin well-known maintained actions to diff --git a/crates/registry-relay/scripts/check_advisory_baselines.py b/crates/registry-relay/scripts/check_advisory_baselines.py index 725711265..63f815511 100644 --- a/crates/registry-relay/scripts/check_advisory_baselines.py +++ b/crates/registry-relay/scripts/check_advisory_baselines.py @@ -31,6 +31,7 @@ REQUIRED_REVIEW_FIELDS = { "tool", "fingerprint", + "rule_id", "severity", "status", "owner", @@ -48,8 +49,14 @@ class Finding: severity: str location: str summary: str + fix_versions: tuple[str, ...] = () + fix_state: str = "" - def to_json(self) -> dict[str, str]: + @property + def fixable(self) -> bool: + return bool(self.fix_versions) or self.fix_state.casefold() == "fixed" + + def to_json(self) -> dict[str, Any]: return { "tool": self.tool, "fingerprint": self.fingerprint, @@ -57,6 +64,9 @@ def to_json(self) -> dict[str, str]: "severity": self.severity, "location": self.location, "summary": self.summary, + "fixable": self.fixable, + "fix_versions": list(self.fix_versions), + "fix_state": self.fix_state, } @@ -179,6 +189,26 @@ def normalize_grype(report: Any, subject: str) -> list[Finding]: package_name = str(artifact.get("name", "")) package_version = str(artifact.get("version", "")) package_type = str(artifact.get("type", "")) + fix = vulnerability.get("fix") + if fix is None: + fix = {} + if not isinstance(fix, dict): + fail(f"grype finding {vuln_id} has a malformed fix object") + fix_versions_value = fix.get("versions", []) + if fix_versions_value is None: + fix_versions_value = [] + if not isinstance(fix_versions_value, list) or any( + not isinstance(version, str) for version in fix_versions_value + ): + fail(f"grype finding {vuln_id} has malformed fix versions") + fix_versions = tuple( + version.strip() for version in fix_versions_value if version.strip() + ) + fix_state_value = fix.get("state", "") + if fix_state_value is None: + fix_state_value = "" + if not isinstance(fix_state_value, str): + fail(f"grype finding {vuln_id} has a malformed fix state") fingerprint = "|".join( [ "grype", @@ -197,6 +227,8 @@ def normalize_grype(report: Any, subject: str) -> list[Finding]: severity=severity, location=subject, summary=f"{vuln_id} in {package_name} {package_version}", + fix_versions=fix_versions, + fix_state=fix_state_value.strip(), ) ) return findings @@ -223,6 +255,8 @@ def load_baseline(path: Path) -> dict[str, Any]: severity_rank(str(policy["minimum_severity"])) if policy["action"] != "block_unreviewed": fail(f"unsupported policy action: {policy['action']}") + if policy["tool"] == "grype" and policy.get("block_fixable") is not True: + fail("grype policy must set block_fixable to true") seen_reviews: set[str] = set() for review in reviewed: validate_review_entry(review) @@ -244,10 +278,14 @@ def validate_review_entry(review: Any) -> None: severity_rank(str(review["severity"])) for field in ("reviewed_at", "expires_at"): parse_date(str(review[field]), field) - for field in ("fingerprint", "owner", "reason"): + for field in ("tool", "fingerprint", "rule_id", "owner", "reason"): value = review.get(field) if not isinstance(value, str) or not value.strip(): fail(f"reviewed finding {field} must be a non-blank string") + reviewed_at = parse_date(str(review["reviewed_at"]), "reviewed_at") + expires_at = parse_date(str(review["expires_at"]), "expires_at") + if expires_at < reviewed_at: + fail("reviewed finding expires_at must not precede reviewed_at") def parse_date(value: str, field: str) -> dt.date: @@ -264,6 +302,14 @@ def policy_threshold(baseline: dict[str, Any], tool: str) -> str: return str(matches[0]["minimum_severity"]).lower() +def finding_is_blocking( + finding: Finding, tool: str, threshold_rank: int +) -> bool: + return ( + tool == "grype" and finding.fixable + ) or severity_rank(finding.severity) >= threshold_rank + + def check_findings( tool: str, findings: list[Finding], @@ -273,8 +319,14 @@ def check_findings( ) -> int: threshold = policy_threshold(baseline, tool) threshold_rank = severity_rank(threshold) - blocking = [f for f in findings if severity_rank(f.severity) >= threshold_rank] - active_fingerprints = {finding.fingerprint for finding in blocking} + blocking = [ + finding + for finding in findings + if finding_is_blocking(finding, tool, threshold_rank) + ] + fixable = [finding for finding in blocking if tool == "grype" and finding.fixable] + reviewable = [finding for finding in blocking if finding not in fixable] + active_fingerprints = {finding.fingerprint for finding in reviewable} reviews = { str(review["fingerprint"]): review for review in baseline["reviewed_findings"] @@ -284,11 +336,15 @@ def check_findings( or str(review["fingerprint"]).startswith(f"{tool}|{review_scope}|") ) } + active_reviews = { + finding.fingerprint: reviews[finding.fingerprint] + for finding in reviewable + if finding.fingerprint in reviews + } expired = [ review - for review in reviews.values() - if review["fingerprint"] in active_fingerprints - and parse_date(str(review["expires_at"]), "expires_at") < today + for review in active_reviews.values() + if parse_date(str(review["expires_at"]), "expires_at") < today ] if expired: for review in expired: @@ -297,9 +353,48 @@ def check_findings( f"expired_at={review['expires_at']}", file=sys.stderr, ) - return 1 + future_reviewed = [ + review + for review in active_reviews.values() + if parse_date(str(review["reviewed_at"]), "reviewed_at") > today + ] + mismatched = [ + finding + for finding in reviewable + if finding.fingerprint in active_reviews + and ( + str(active_reviews[finding.fingerprint]["rule_id"]) != finding.rule_id + or str(active_reviews[finding.fingerprint]["severity"]).casefold() + != finding.severity.casefold() + ) + ] + for finding in fixable: + print( + "fixable finding cannot be dispositioned: " + f"{finding.tool} {finding.rule_id} {finding.severity} " + f"{finding.location} fixes={list(finding.fix_versions)} " + f"fingerprint={finding.fingerprint}", + file=sys.stderr, + ) + for review in future_reviewed: + print( + f"future-dated reviewed finding: {review['tool']} {review['fingerprint']} " + f"reviewed_at={review['reviewed_at']}", + file=sys.stderr, + ) + for finding in mismatched: + review = active_reviews[finding.fingerprint] + print( + "reviewed finding metadata mismatch: " + f"{finding.fingerprint} report_rule={finding.rule_id} " + f"review_rule={review['rule_id']} report_severity={finding.severity} " + f"review_severity={review['severity']}", + file=sys.stderr, + ) - unreviewed = [finding for finding in blocking if finding.fingerprint not in reviews] + unreviewed = [ + finding for finding in reviewable if finding.fingerprint not in reviews + ] if unreviewed: for finding in unreviewed: print( @@ -308,18 +403,17 @@ def check_findings( f"{finding.location} fingerprint={finding.fingerprint}", file=sys.stderr, ) - return 1 - stale = sorted(set(reviews) - active_fingerprints) print( "advisory baseline: " f"{tool} threshold={threshold} blocking={len(blocking)} " - f"reviewed={len(blocking) - len(unreviewed)} " - f"unreviewed={len(unreviewed)} expired={len(expired)} stale={len(stale)}" + f"fixable={len(fixable)} reviewed={len(active_reviews)} " + f"unreviewed={len(unreviewed)} expired={len(expired)} " + f"invalid={len(future_reviewed) + len(mismatched)} stale={len(stale)}" ) if stale: print(f"advisory baseline: {tool} has stale reviewed entries: {len(stale)}") - return 0 + return int(bool(fixable or expired or future_reviewed or mismatched or unreviewed)) def main() -> None: @@ -350,7 +444,7 @@ def main() -> None: blocking = [ finding.to_json() for finding in findings - if severity_rank(finding.severity) >= threshold_rank + if finding_is_blocking(finding, args.tool, threshold_rank) ] print(json.dumps(blocking, indent=2, sort_keys=True)) return diff --git a/crates/registry-relay/security/advisory-baseline.json b/crates/registry-relay/security/advisory-baseline.json index 2aba7d870..5047eecf1 100644 --- a/crates/registry-relay/security/advisory-baseline.json +++ b/crates/registry-relay/security/advisory-baseline.json @@ -9,21 +9,10 @@ }, { "tool": "grype", - "minimum_severity": "critical", - "action": "block_unreviewed" + "minimum_severity": "high", + "action": "block_unreviewed", + "block_fixable": true } ], - "reviewed_findings": [ - { - "tool": "grype", - "fingerprint": "grype|registry-relay-image|CVE-2023-45853|zlib1g|1:1.2.13.dfsg-1|deb", - "rule_id": "CVE-2023-45853", - "severity": "critical", - "status": "false_positive", - "owner": "@PublicSchema/maintainers", - "reason": "Not affected. Debian marks CVE-2023-45853 for bookworm: the vulnerable contrib/minizip code is not built into the shipped zlib1g binary (no fixed package is coming, so the prior 'until the base image moves to a fixed distro package' exit condition was unreachable). The Relay binary's deflate path is pure-Rust (flate2/miniz_oxide/zlib-rs, no libz-sys C linkage) and the only `zip` crate is a dev-only XLSX test fixture, so MiniZip is never reached. zlib1g is a transitive base-image package only. Suppression retained to keep grype quiet; re-review when the base image leaves Debian bookworm.", - "reviewed_at": "2026-06-04", - "expires_at": "2027-06-01" - } - ] + "reviewed_findings": [] } diff --git a/crates/registry-relay/tests/advisory_baseline_check_test.py b/crates/registry-relay/tests/advisory_baseline_check_test.py index 267f3c7d8..de9fd633a 100644 --- a/crates/registry-relay/tests/advisory_baseline_check_test.py +++ b/crates/registry-relay/tests/advisory_baseline_check_test.py @@ -41,8 +41,9 @@ def write_baseline(self, reviewed=None): }, { "tool": "grype", - "minimum_severity": "critical", + "minimum_severity": "high", "action": "block_unreviewed", + "block_fixable": True, }, ], "reviewed_findings": reviewed or [], @@ -87,6 +88,21 @@ def review(self, finding, **overrides): base.update(overrides) return base + def grype_report(self, severity="High", fix=None): + vulnerability = {"id": "CVE-2026-0001", "severity": severity} + if fix is not None: + vulnerability["fix"] = fix + return { + "matches": [{ + "vulnerability": vulnerability, + "artifact": { + "name": "openssl", + "version": "3.0.0", + "type": "deb", + }, + }] + } + def test_unreviewed_zizmor_high_fails(self): self.write_baseline() baseline = self.module.load_baseline(self.baseline_path) @@ -117,7 +133,13 @@ def test_reviewed_zizmor_high_passes_until_expiration(self): def test_expired_review_fails_even_when_fingerprint_matches(self): finding = self.module.normalize_zizmor(self.zizmor_report())[0] - self.write_baseline([self.review(finding, expires_at="2026-06-01")]) + self.write_baseline([ + self.review( + finding, + reviewed_at="2026-05-01", + expires_at="2026-06-01", + ) + ]) baseline = self.module.load_baseline(self.baseline_path) self.assertEqual( self.module.check_findings( @@ -132,7 +154,13 @@ def test_expired_review_fails_even_when_fingerprint_matches(self): def test_expired_stale_review_does_not_block(self): stale_finding = self.module.normalize_zizmor(self.zizmor_report())[0] active_finding = self.module.normalize_zizmor(self.zizmor_report(severity="Medium"))[0] - self.write_baseline([self.review(stale_finding, expires_at="2026-06-01")]) + self.write_baseline([ + self.review( + stale_finding, + reviewed_at="2026-05-01", + expires_at="2026-06-01", + ) + ]) baseline = self.module.load_baseline(self.baseline_path) self.assertEqual( self.module.check_findings( @@ -247,6 +275,103 @@ def test_grype_critical_requires_review(self): 1, ) + def test_grype_high_non_fixable_passes_with_current_review(self): + finding = self.module.normalize_grype( + self.grype_report(), "registry-relay-image" + )[0] + self.write_baseline([self.review(finding)]) + baseline = self.module.load_baseline(self.baseline_path) + self.assertEqual( + self.module.check_findings( + "grype", + [finding], + baseline, + self.module.parse_date("2026-06-02", "today"), + "registry-relay-image", + ), + 0, + ) + + def test_grype_fixable_low_fails_regardless_of_severity(self): + self.write_baseline() + baseline = self.module.load_baseline(self.baseline_path) + finding = self.module.normalize_grype( + self.grype_report("Low", {"versions": ["3.0.1"], "state": "fixed"}), + "registry-relay-image", + )[0] + self.assertTrue(finding.fixable) + self.assertEqual( + self.module.check_findings( + "grype", + [finding], + baseline, + self.module.parse_date("2026-06-02", "today"), + "registry-relay-image", + ), + 1, + ) + + def test_grype_fixable_finding_cannot_be_dispositioned(self): + finding = self.module.normalize_grype( + self.grype_report("Medium", {"versions": ["3.0.1"]}), + "registry-relay-image", + )[0] + self.write_baseline([self.review(finding)]) + baseline = self.module.load_baseline(self.baseline_path) + self.assertEqual( + self.module.check_findings( + "grype", + [finding], + baseline, + self.module.parse_date("2026-06-02", "today"), + "registry-relay-image", + ), + 1, + ) + + def test_grype_rejects_malformed_fix_metadata(self): + with self.assertRaises(SystemExit): + self.module.normalize_grype( + self.grype_report("Low", {"versions": "3.0.1"}), + "registry-relay-image", + ) + + def test_future_dated_review_fails(self): + finding = self.module.normalize_grype( + self.grype_report(), "registry-relay-image" + )[0] + self.write_baseline([self.review(finding, reviewed_at="2026-06-03")]) + baseline = self.module.load_baseline(self.baseline_path) + self.assertEqual( + self.module.check_findings( + "grype", + [finding], + baseline, + self.module.parse_date("2026-06-02", "today"), + "registry-relay-image", + ), + 1, + ) + + def test_review_metadata_must_match_current_finding(self): + finding = self.module.normalize_grype( + self.grype_report(), "registry-relay-image" + )[0] + for field, value in (("rule_id", "CVE-OLD"), ("severity", "critical")): + with self.subTest(field=field): + self.write_baseline([self.review(finding, **{field: value})]) + baseline = self.module.load_baseline(self.baseline_path) + self.assertEqual( + self.module.check_findings( + "grype", + [finding], + baseline, + self.module.parse_date("2026-06-02", "today"), + "registry-relay-image", + ), + 1, + ) + def test_grype_unknown_severity_is_below_initial_threshold(self): self.write_baseline() baseline = self.module.load_baseline(self.baseline_path) From 85f338a8ee9e04443dd1dedf61c94746bff9df20 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:49:00 +0700 Subject: [PATCH 12/65] fix(notary): enforce Debian 13 scan policy Signed-off-by: Jeremi Joslin --- products/notary/docs/security-assurance.md | 28 ++-- .../scripts/check_advisory_baselines.py | 124 +++++++++++++++-- .../notary/security/advisory-baseline.json | 5 +- .../tests/advisory_baseline_check_test.py | 131 +++++++++++++++++- 4 files changed, 260 insertions(+), 28 deletions(-) diff --git a/products/notary/docs/security-assurance.md b/products/notary/docs/security-assurance.md index 1fdc88330..46465a09e 100644 --- a/products/notary/docs/security-assurance.md +++ b/products/notary/docs/security-assurance.md @@ -10,7 +10,8 @@ the selected images by digest. The Registry Notary image is built with CEL and PKCS#11 compiled in. Runtime use remains config-gated, and the image is covered by the CEL worker-protocol -smoke, SBOM, and Grype critical-vulnerability gate. +smoke, SBOM, and Grype gate for every fixable finding plus every unreviewed High +or Critical finding. ## Container runtime policy @@ -40,6 +41,15 @@ do not change. Repeatability is established by two clean builds with the same new builder digest and lockfiles, comparing `dist/image-bin/SHA256SUMS`; hashes from the retired builder are not the expected comparison. +The Debian 13 migration check on July 19, 2026 scanned a structural Notary +image with the pinned final base and placeholder binaries. It found the +non-fixable Debian 13 `libc6` findings CVE-2026-5450 (Critical), CVE-2026-5928 +(High), and CVE-2026-5435 (High). No risk dispositions are recorded for these +findings, so a candidate that still reports them remains blocked. This +structural scan only supports removal of the retired Debian 12 exception. The +scan of the exact pushed image, including the real Notary and CEL worker +binaries, supersedes it for release decisions. + PKCS#11 support remains compiled in but config-gated. A vendor module and any vendor-owned shared-library dependencies remain deployment inputs, not image layers. Mount them read-only at operator-owned absolute paths and set the @@ -66,13 +76,15 @@ Reviewed advisory ratchets live in `security/advisory-baseline.json`. The initial blocking gates are: - `zizmor` findings with severity `high` or above. -- Grype image findings with severity `critical` or above. - -Every reviewed entry must include a fingerprint, owner, reason, review date, -and expiration date. New unreviewed findings at or above the threshold fail CI. -Expired reviewed entries fail CI while the finding is still active. Stale -reviewed entries are reported so the baseline can shrink after the underlying -issue is fixed. +- Grype image findings with a known fix, regardless of severity. +- Grype image findings with severity `high` or above when they do not have a + current reviewed disposition. + +Fixable findings cannot be dispositioned. Every reviewed non-fixable High or +Critical finding must include a fingerprint, matching rule and severity, owner, +reason, review date, and expiration date. Future-dated or expired entries fail +CI while the finding is still active. Stale reviewed entries are reported so +the baseline can shrink after the underlying issue is fixed. The unauthenticated endpoint allowlist lives in `security/auth-none-allowlist.yml`. Additions require maintainer review through diff --git a/products/notary/scripts/check_advisory_baselines.py b/products/notary/scripts/check_advisory_baselines.py index 725711265..63f815511 100644 --- a/products/notary/scripts/check_advisory_baselines.py +++ b/products/notary/scripts/check_advisory_baselines.py @@ -31,6 +31,7 @@ REQUIRED_REVIEW_FIELDS = { "tool", "fingerprint", + "rule_id", "severity", "status", "owner", @@ -48,8 +49,14 @@ class Finding: severity: str location: str summary: str + fix_versions: tuple[str, ...] = () + fix_state: str = "" - def to_json(self) -> dict[str, str]: + @property + def fixable(self) -> bool: + return bool(self.fix_versions) or self.fix_state.casefold() == "fixed" + + def to_json(self) -> dict[str, Any]: return { "tool": self.tool, "fingerprint": self.fingerprint, @@ -57,6 +64,9 @@ def to_json(self) -> dict[str, str]: "severity": self.severity, "location": self.location, "summary": self.summary, + "fixable": self.fixable, + "fix_versions": list(self.fix_versions), + "fix_state": self.fix_state, } @@ -179,6 +189,26 @@ def normalize_grype(report: Any, subject: str) -> list[Finding]: package_name = str(artifact.get("name", "")) package_version = str(artifact.get("version", "")) package_type = str(artifact.get("type", "")) + fix = vulnerability.get("fix") + if fix is None: + fix = {} + if not isinstance(fix, dict): + fail(f"grype finding {vuln_id} has a malformed fix object") + fix_versions_value = fix.get("versions", []) + if fix_versions_value is None: + fix_versions_value = [] + if not isinstance(fix_versions_value, list) or any( + not isinstance(version, str) for version in fix_versions_value + ): + fail(f"grype finding {vuln_id} has malformed fix versions") + fix_versions = tuple( + version.strip() for version in fix_versions_value if version.strip() + ) + fix_state_value = fix.get("state", "") + if fix_state_value is None: + fix_state_value = "" + if not isinstance(fix_state_value, str): + fail(f"grype finding {vuln_id} has a malformed fix state") fingerprint = "|".join( [ "grype", @@ -197,6 +227,8 @@ def normalize_grype(report: Any, subject: str) -> list[Finding]: severity=severity, location=subject, summary=f"{vuln_id} in {package_name} {package_version}", + fix_versions=fix_versions, + fix_state=fix_state_value.strip(), ) ) return findings @@ -223,6 +255,8 @@ def load_baseline(path: Path) -> dict[str, Any]: severity_rank(str(policy["minimum_severity"])) if policy["action"] != "block_unreviewed": fail(f"unsupported policy action: {policy['action']}") + if policy["tool"] == "grype" and policy.get("block_fixable") is not True: + fail("grype policy must set block_fixable to true") seen_reviews: set[str] = set() for review in reviewed: validate_review_entry(review) @@ -244,10 +278,14 @@ def validate_review_entry(review: Any) -> None: severity_rank(str(review["severity"])) for field in ("reviewed_at", "expires_at"): parse_date(str(review[field]), field) - for field in ("fingerprint", "owner", "reason"): + for field in ("tool", "fingerprint", "rule_id", "owner", "reason"): value = review.get(field) if not isinstance(value, str) or not value.strip(): fail(f"reviewed finding {field} must be a non-blank string") + reviewed_at = parse_date(str(review["reviewed_at"]), "reviewed_at") + expires_at = parse_date(str(review["expires_at"]), "expires_at") + if expires_at < reviewed_at: + fail("reviewed finding expires_at must not precede reviewed_at") def parse_date(value: str, field: str) -> dt.date: @@ -264,6 +302,14 @@ def policy_threshold(baseline: dict[str, Any], tool: str) -> str: return str(matches[0]["minimum_severity"]).lower() +def finding_is_blocking( + finding: Finding, tool: str, threshold_rank: int +) -> bool: + return ( + tool == "grype" and finding.fixable + ) or severity_rank(finding.severity) >= threshold_rank + + def check_findings( tool: str, findings: list[Finding], @@ -273,8 +319,14 @@ def check_findings( ) -> int: threshold = policy_threshold(baseline, tool) threshold_rank = severity_rank(threshold) - blocking = [f for f in findings if severity_rank(f.severity) >= threshold_rank] - active_fingerprints = {finding.fingerprint for finding in blocking} + blocking = [ + finding + for finding in findings + if finding_is_blocking(finding, tool, threshold_rank) + ] + fixable = [finding for finding in blocking if tool == "grype" and finding.fixable] + reviewable = [finding for finding in blocking if finding not in fixable] + active_fingerprints = {finding.fingerprint for finding in reviewable} reviews = { str(review["fingerprint"]): review for review in baseline["reviewed_findings"] @@ -284,11 +336,15 @@ def check_findings( or str(review["fingerprint"]).startswith(f"{tool}|{review_scope}|") ) } + active_reviews = { + finding.fingerprint: reviews[finding.fingerprint] + for finding in reviewable + if finding.fingerprint in reviews + } expired = [ review - for review in reviews.values() - if review["fingerprint"] in active_fingerprints - and parse_date(str(review["expires_at"]), "expires_at") < today + for review in active_reviews.values() + if parse_date(str(review["expires_at"]), "expires_at") < today ] if expired: for review in expired: @@ -297,9 +353,48 @@ def check_findings( f"expired_at={review['expires_at']}", file=sys.stderr, ) - return 1 + future_reviewed = [ + review + for review in active_reviews.values() + if parse_date(str(review["reviewed_at"]), "reviewed_at") > today + ] + mismatched = [ + finding + for finding in reviewable + if finding.fingerprint in active_reviews + and ( + str(active_reviews[finding.fingerprint]["rule_id"]) != finding.rule_id + or str(active_reviews[finding.fingerprint]["severity"]).casefold() + != finding.severity.casefold() + ) + ] + for finding in fixable: + print( + "fixable finding cannot be dispositioned: " + f"{finding.tool} {finding.rule_id} {finding.severity} " + f"{finding.location} fixes={list(finding.fix_versions)} " + f"fingerprint={finding.fingerprint}", + file=sys.stderr, + ) + for review in future_reviewed: + print( + f"future-dated reviewed finding: {review['tool']} {review['fingerprint']} " + f"reviewed_at={review['reviewed_at']}", + file=sys.stderr, + ) + for finding in mismatched: + review = active_reviews[finding.fingerprint] + print( + "reviewed finding metadata mismatch: " + f"{finding.fingerprint} report_rule={finding.rule_id} " + f"review_rule={review['rule_id']} report_severity={finding.severity} " + f"review_severity={review['severity']}", + file=sys.stderr, + ) - unreviewed = [finding for finding in blocking if finding.fingerprint not in reviews] + unreviewed = [ + finding for finding in reviewable if finding.fingerprint not in reviews + ] if unreviewed: for finding in unreviewed: print( @@ -308,18 +403,17 @@ def check_findings( f"{finding.location} fingerprint={finding.fingerprint}", file=sys.stderr, ) - return 1 - stale = sorted(set(reviews) - active_fingerprints) print( "advisory baseline: " f"{tool} threshold={threshold} blocking={len(blocking)} " - f"reviewed={len(blocking) - len(unreviewed)} " - f"unreviewed={len(unreviewed)} expired={len(expired)} stale={len(stale)}" + f"fixable={len(fixable)} reviewed={len(active_reviews)} " + f"unreviewed={len(unreviewed)} expired={len(expired)} " + f"invalid={len(future_reviewed) + len(mismatched)} stale={len(stale)}" ) if stale: print(f"advisory baseline: {tool} has stale reviewed entries: {len(stale)}") - return 0 + return int(bool(fixable or expired or future_reviewed or mismatched or unreviewed)) def main() -> None: @@ -350,7 +444,7 @@ def main() -> None: blocking = [ finding.to_json() for finding in findings - if severity_rank(finding.severity) >= threshold_rank + if finding_is_blocking(finding, args.tool, threshold_rank) ] print(json.dumps(blocking, indent=2, sort_keys=True)) return diff --git a/products/notary/security/advisory-baseline.json b/products/notary/security/advisory-baseline.json index 2d79c30f1..920be7d22 100644 --- a/products/notary/security/advisory-baseline.json +++ b/products/notary/security/advisory-baseline.json @@ -9,8 +9,9 @@ }, { "tool": "grype", - "minimum_severity": "critical", - "action": "block_unreviewed" + "minimum_severity": "high", + "action": "block_unreviewed", + "block_fixable": true } ], "reviewed_findings": [] diff --git a/products/notary/tests/advisory_baseline_check_test.py b/products/notary/tests/advisory_baseline_check_test.py index 0d230070f..fd80ab567 100644 --- a/products/notary/tests/advisory_baseline_check_test.py +++ b/products/notary/tests/advisory_baseline_check_test.py @@ -41,8 +41,9 @@ def write_baseline(self, reviewed=None): }, { "tool": "grype", - "minimum_severity": "critical", + "minimum_severity": "high", "action": "block_unreviewed", + "block_fixable": True, }, ], "reviewed_findings": reviewed or [], @@ -87,6 +88,21 @@ def review(self, finding, **overrides): base.update(overrides) return base + def grype_report(self, severity="High", fix=None): + vulnerability = {"id": "CVE-2026-0001", "severity": severity} + if fix is not None: + vulnerability["fix"] = fix + return { + "matches": [{ + "vulnerability": vulnerability, + "artifact": { + "name": "openssl", + "version": "3.0.0", + "type": "deb", + }, + }] + } + def test_unreviewed_zizmor_high_fails(self): self.write_baseline() baseline = self.module.load_baseline(self.baseline_path) @@ -117,7 +133,13 @@ def test_reviewed_zizmor_high_passes_until_expiration(self): def test_expired_review_fails_even_when_fingerprint_matches(self): finding = self.module.normalize_zizmor(self.zizmor_report())[0] - self.write_baseline([self.review(finding, expires_at="2026-06-01")]) + self.write_baseline([ + self.review( + finding, + reviewed_at="2026-05-01", + expires_at="2026-06-01", + ) + ]) baseline = self.module.load_baseline(self.baseline_path) self.assertEqual( self.module.check_findings( @@ -132,7 +154,13 @@ def test_expired_review_fails_even_when_fingerprint_matches(self): def test_expired_stale_review_does_not_block(self): stale_finding = self.module.normalize_zizmor(self.zizmor_report())[0] active_finding = self.module.normalize_zizmor(self.zizmor_report(severity="Medium"))[0] - self.write_baseline([self.review(stale_finding, expires_at="2026-06-01")]) + self.write_baseline([ + self.review( + stale_finding, + reviewed_at="2026-05-01", + expires_at="2026-06-01", + ) + ]) baseline = self.module.load_baseline(self.baseline_path) self.assertEqual( self.module.check_findings( @@ -247,6 +275,103 @@ def test_grype_critical_requires_review(self): 1, ) + def test_grype_high_non_fixable_passes_with_current_review(self): + finding = self.module.normalize_grype( + self.grype_report(), "registry-relay-image" + )[0] + self.write_baseline([self.review(finding)]) + baseline = self.module.load_baseline(self.baseline_path) + self.assertEqual( + self.module.check_findings( + "grype", + [finding], + baseline, + self.module.parse_date("2026-06-02", "today"), + "registry-relay-image", + ), + 0, + ) + + def test_grype_fixable_low_fails_regardless_of_severity(self): + self.write_baseline() + baseline = self.module.load_baseline(self.baseline_path) + finding = self.module.normalize_grype( + self.grype_report("Low", {"versions": ["3.0.1"], "state": "fixed"}), + "registry-relay-image", + )[0] + self.assertTrue(finding.fixable) + self.assertEqual( + self.module.check_findings( + "grype", + [finding], + baseline, + self.module.parse_date("2026-06-02", "today"), + "registry-relay-image", + ), + 1, + ) + + def test_grype_fixable_finding_cannot_be_dispositioned(self): + finding = self.module.normalize_grype( + self.grype_report("Medium", {"versions": ["3.0.1"]}), + "registry-relay-image", + )[0] + self.write_baseline([self.review(finding)]) + baseline = self.module.load_baseline(self.baseline_path) + self.assertEqual( + self.module.check_findings( + "grype", + [finding], + baseline, + self.module.parse_date("2026-06-02", "today"), + "registry-relay-image", + ), + 1, + ) + + def test_grype_rejects_malformed_fix_metadata(self): + with self.assertRaises(SystemExit): + self.module.normalize_grype( + self.grype_report("Low", {"versions": "3.0.1"}), + "registry-relay-image", + ) + + def test_future_dated_review_fails(self): + finding = self.module.normalize_grype( + self.grype_report(), "registry-relay-image" + )[0] + self.write_baseline([self.review(finding, reviewed_at="2026-06-03")]) + baseline = self.module.load_baseline(self.baseline_path) + self.assertEqual( + self.module.check_findings( + "grype", + [finding], + baseline, + self.module.parse_date("2026-06-02", "today"), + "registry-relay-image", + ), + 1, + ) + + def test_review_metadata_must_match_current_finding(self): + finding = self.module.normalize_grype( + self.grype_report(), "registry-relay-image" + )[0] + for field, value in (("rule_id", "CVE-OLD"), ("severity", "critical")): + with self.subTest(field=field): + self.write_baseline([self.review(finding, **{field: value})]) + baseline = self.module.load_baseline(self.baseline_path) + self.assertEqual( + self.module.check_findings( + "grype", + [finding], + baseline, + self.module.parse_date("2026-06-02", "today"), + "registry-relay-image", + ), + 1, + ) + def test_grype_unknown_severity_is_below_initial_threshold(self): self.write_baseline() baseline = self.module.load_baseline(self.baseline_path) From f3552bdc39f6602a2663c2522c579973f8cd62ad Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:49:03 +0700 Subject: [PATCH 13/65] ci(release): enforce builder and image scan policy Signed-off-by: Jeremi Joslin --- .github/workflows/release.yml | 26 ++++++++++++-- release/scripts/test_registry_release.py | 45 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7a1b0359..ca19312f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -148,6 +148,14 @@ jobs: exit 1 fi + - name: Fingerprint release builder + id: release-builder + shell: bash + run: | + set -euo pipefail + fingerprint="$(printf '%s' "${RELEASE_BUILDER_IMAGE}" | sha256sum | awk '{print $1}')" + printf 'fingerprint=%s\n' "${fingerprint}" >> "${GITHUB_OUTPUT}" + - name: Restore Cargo release cache uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 with: @@ -155,9 +163,9 @@ jobs: .cargo-home/registry .cargo-home/git target - key: registry-stack-release-cargo-${{ runner.os }}-rust-1.95.0-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }} + key: registry-stack-release-cargo-${{ runner.os }}-${{ steps.release-builder.outputs.fingerprint }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }} restore-keys: | - registry-stack-release-cargo-${{ runner.os }}-rust-1.95.0- + registry-stack-release-cargo-${{ runner.os }}-${{ steps.release-builder.outputs.fingerprint }}- - name: Build binaries shell: bash @@ -404,7 +412,21 @@ jobs: --sha256 "${digest}" done < <(find dist/image-bin -maxdepth 1 -type f -print0 | sort -z) + - name: Enforce release image scan policy + shell: bash + run: | + set -euo pipefail + status=0 + python3 products/notary/scripts/check_advisory_baselines.py \ + grype dist/grype/registry-notary.grype.json \ + --subject registry-notary-image || status=1 + python3 crates/registry-relay/scripts/check_advisory_baselines.py \ + grype dist/grype/registry-relay.grype.json \ + --subject registry-relay-image || status=1 + exit "${status}" + - name: Upload image evidence + if: ${{ always() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: registry-stack-image-evidence diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index b8caa7858..e352bfe84 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -96,6 +96,51 @@ def test_release_image_packaging_uses_release_dockerfiles(self) -> None: text = (ROOT / dockerfile).read_text(encoding="utf-8") self.assertIn("dist/image-bin", text) + def test_release_cargo_cache_is_scoped_to_builder_image(self) -> None: + workflow = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + binaries_job = workflow[ + workflow.index("\n binaries:") : workflow.index("\n registryctl-extra-binaries:") + ] + + fingerprint_step = binaries_job.index("Fingerprint release builder") + cache_step = binaries_job.index("Restore Cargo release cache") + self.assertLess(fingerprint_step, cache_step) + self.assertIn( + "printf '%s' \"${RELEASE_BUILDER_IMAGE}\" | sha256sum", + binaries_job, + ) + builder_fingerprint = "${{ steps.release-builder.outputs.fingerprint }}" + self.assertEqual(2, binaries_job.count(builder_fingerprint)) + self.assertNotIn( + "registry-stack-release-cargo-${{ runner.os }}-rust-1.95.0-", + binaries_job, + ) + + def test_release_image_scans_are_policy_enforced_and_preserved(self) -> None: + workflow = (ROOT / ".github/workflows/release.yml").read_text(encoding="utf-8") + images_job = workflow[ + workflow.index("\n images:") : workflow.index("\n github-release:") + ] + + scan_step = images_job.index("Build, push, and scan images") + enforcement_step = images_job.index("Enforce release image scan policy") + upload_step = images_job.index("Upload image evidence") + self.assertLess(scan_step, enforcement_step) + self.assertLess(enforcement_step, upload_step) + self.assertIn( + "grype dist/grype/registry-notary.grype.json \\\n" + " --subject registry-notary-image", + images_job, + ) + self.assertIn( + "grype dist/grype/registry-relay.grype.json \\\n" + " --subject registry-relay-image", + images_job, + ) + self.assertIn("exit \"${status}\"", images_job) + self.assertIn("if: ${{ always() }}", images_job[upload_step:]) + self.assertIn("dist/grype/*", images_job[upload_step:]) + def test_release_packaging_excludes_retired_notary_source_sidecar(self) -> None: retired_names = ( "registry-notary-source-adapter-sidecar", From d4bbf850d3004966e51f4c70f9ae89de1006983a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:31:53 +0700 Subject: [PATCH 14/65] fix(relay): align the 1.0 support roster Signed-off-by: Jeremi Joslin --- crates/registry-relay/CHANGELOG.md | 6 ++ crates/registry-relay/README.md | 20 +++++++ .../registry-relay/STANDARDS_ASSUMPTIONS.md | 22 +++++++ crates/registry-relay/docs/api.md | 5 ++ crates/registry-relay/docs/release-notes.md | 9 +++ .../scripts/check_security_assurance.py | 5 ++ .../security/exposure-manifest.json | 60 +++++++++---------- .../tests/security_assurance_check_test.py | 10 ++++ 8 files changed, 107 insertions(+), 30 deletions(-) diff --git a/crates/registry-relay/CHANGELOG.md b/crates/registry-relay/CHANGELOG.md index 51e39a7d8..1c4180071 100644 --- a/crates/registry-relay/CHANGELOG.md +++ b/crates/registry-relay/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Align the 1.0 standards roster with the canonical no-optional-feature release + build. OpenAPI and problem contracts, portable metadata, CSV and XLSX input, + and JSON aggregate output are stable. Optional standards adapters and the + shipped non-gated Parquet, CSV aggregate, and SDMX-JSON surfaces are + experimental and feature-frozen, outside the 1.0 compatibility promise. + ## 0.11.0 - 2026-07-18 - Relay now publishes a reproducible, product-owned Draft 2020-12 schema for diff --git a/crates/registry-relay/README.md b/crates/registry-relay/README.md index 627f95232..df12ba869 100644 --- a/crates/registry-relay/README.md +++ b/crates/registry-relay/README.md @@ -21,6 +21,26 @@ Registry Relay is an experiment toward a redesigned [GovStack](https://govstack. Standards integrations such as DCAT-AP, OGC API Records, OGC API Features, Registry Notary evidence-offering discovery, and the optional [Social Protection Digital Convergence Initiative (SP DCI)](https://spdci.org/) sync adapter are layered on top of the core gateway. [STANDARDS_ASSUMPTIONS.md](STANDARDS_ASSUMPTIONS.md) states precisely what Relay publishes versus what downstream tools may infer. +## 1.0 Support Roster + +The canonical 1.0 release has no optional Relay Cargo features enabled. Stable +support covers OpenAPI 3.x and RFC 9457 errors; RFC 9727 and portable DCAT, +DCAT-AP, BRegDCAT-AP, JSON-LD, SHACL, JSON Schema, ODRL, and link-free OGC +Records metadata; CSV and XLSX source input; and JSON aggregate output. + +CSV, XLSX, and Parquet are source decoders. Aggregate output supports JSON, +CSV, and SDMX-JSON. The live OGC API Records adapter, OGC API Features, OGC API +EDR, SP DCI routes, standards-CEL mapping, CSV and SDMX-JSON aggregate output, +attribute release, and Parquet source input are experimental and feature-frozen. +Experimental surfaces are outside the 1.0 compatibility promise. Feature-gated +source and all-feature tests remain available. Non-feature-gated experimental +formats remain shipped but unstable to avoid breaking existing configurations. + +The stable link-free OGC Records metadata artifacts do not enable or stabilize +the experimental live OGC API Records adapter. The public +[support roster](https://docs.registrystack.org/reference/standards/#registry-relay-10-support-roster) +records the owner, evidence, decision date, and delivery rule for every surface. + ## Get Started Without cloning this repository, use the Registry Docs tutorials. They create a Relay project from a sample workbook with `registryctl`, start the protected API, and run smoke checks: diff --git a/crates/registry-relay/STANDARDS_ASSUMPTIONS.md b/crates/registry-relay/STANDARDS_ASSUMPTIONS.md index b5730abbf..ed9ce4e71 100644 --- a/crates/registry-relay/STANDARDS_ASSUMPTIONS.md +++ b/crates/registry-relay/STANDARDS_ASSUMPTIONS.md @@ -17,6 +17,28 @@ Registry Relay may publish: Registry Relay does not publish a proprietary source-of-truth flag. +## 1.0 support boundary + +The stable 1.0 standards surface is OpenAPI 3.x publication, RFC 9457 problem +responses, RFC 9727 discovery, and the portable metadata family: DCAT and +DCAT-AP, BRegDCAT-AP, JSON-LD, SHACL, JSON Schema, ODRL, and link-free OGC +Records artifacts. + +The live OGC API Records adapter, OGC API Features, OGC API EDR, SP DCI sync +routes, and standards-CEL mapping are experimental and feature-frozen. They are +outside the 1.0 compatibility promise and excluded from the canonical release +feature list. Their source and all-feature security tests remain in the +repository. + +Link-free OGC Records bodies under `/metadata/ogc/records` are portable metadata. +They are not the live adapter under `/ogc/v1/records` and do not depend on its +`ogcapi-records` Cargo feature. + +CSV, XLSX, and Parquet are source decoders, not one output family. CSV and XLSX +input are stable for 1.0. Parquet input remains shipped, experimental, and +feature-frozen. Aggregate output supports JSON, CSV, and SDMX-JSON. JSON is +stable; CSV and SDMX-JSON remain shipped, experimental, and feature-frozen. + ## Facts, publication choices, and downstream hypotheses Registry Relay publishes machine-readable metadata facts and descriptors. It diff --git a/crates/registry-relay/docs/api.md b/crates/registry-relay/docs/api.md index 996f0d253..faa95c509 100644 --- a/crates/registry-relay/docs/api.md +++ b/crates/registry-relay/docs/api.md @@ -280,6 +280,11 @@ connects them back to the native dataset model. `GET /metadata/*` is the canonical standards-facing metadata surface. When the runtime config points at a split metadata manifest, these routes render from the compiled portable manifest and filter the compiled view to the caller's metadata scopes. They expose catalog JSON, base DCAT, application-profile DCAT, SHACL, dataset/entity metadata, evidence-offering metadata, Draft 2020-12 JSON Schemas, and link-free OGC Records bodies. Visible aggregate distributions advertise native JSON, SDMX JSON 2.1, CSV, and, for configured spatial aggregates when built with `ogcapi-edr`, the OGC EDR `/area` endpoint. They do not grant row, evidence verification, aggregate, or admin access. +The link-free OGC Records bodies are stable portable metadata. They do not +enable or stabilize the experimental live OGC API Records adapter. JSON is the +stable aggregate representation for 1.0; CSV and SDMX-JSON aggregate output +remain shipped, experimental, and feature-frozen. + Relay-native discovery remains under `/v1/datasets` and runtime entity routes. Portable metadata consumers should use `/metadata/*` or static publication. Metadata responses include private validators for the authenticated view. Clients can send `If-None-Match`; unchanged metadata returns `304 Not Modified`. The gateway also sets `Cache-Control: private, no-store` and `Vary: Authorization` so shared caches do not reuse one principal's scoped catalog for another caller. diff --git a/crates/registry-relay/docs/release-notes.md b/crates/registry-relay/docs/release-notes.md index 5ac088e5a..0f7a7d25d 100644 --- a/crates/registry-relay/docs/release-notes.md +++ b/crates/registry-relay/docs/release-notes.md @@ -2,6 +2,15 @@ ## Unreleased +- The 1.0 support roster now treats OpenAPI 3.x and RFC 9457, RFC 9727 and the + portable metadata family, CSV and XLSX source input, and JSON aggregate output + as stable. Live OGC adapters, SP DCI routes, standards-CEL mapping, CSV and + SDMX-JSON aggregate output, attribute release, and Parquet source input are + experimental and feature-frozen. Feature-gated experimental adapters are + excluded from the canonical release binary while their source and all-feature + tests remain available. Non-feature-gated experimental formats remain shipped + but outside the 1.0 compatibility promise. + ## 0.11.0 - BREAKING: Configuration `${VAR}` expansion now rejects environment variables diff --git a/crates/registry-relay/scripts/check_security_assurance.py b/crates/registry-relay/scripts/check_security_assurance.py index b64b848fc..af99a85af 100755 --- a/crates/registry-relay/scripts/check_security_assurance.py +++ b/crates/registry-relay/scripts/check_security_assurance.py @@ -130,6 +130,11 @@ def validate_manifest() -> None: check_value(entry, "stability", STABILITY) check_value(entry, "data_classification", DATA) check_value(entry, "source", SOURCES) + if entry["feature"] is not None and entry["stability"] != "experimental": + fail( + f"{entry['path']} is feature-gated but has stability " + f"{entry['stability']}; the 1.0 optional surfaces are experimental" + ) if entry["method"] not in {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}: fail(f"{entry['path']} has invalid method {entry['method']}") if not isinstance(entry["scopes"], list): diff --git a/crates/registry-relay/security/exposure-manifest.json b/crates/registry-relay/security/exposure-manifest.json index 973b3575b..f6ae50a61 100644 --- a/crates/registry-relay/security/exposure-manifest.json +++ b/crates/registry-relay/security/exposure-manifest.json @@ -424,7 +424,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -449,7 +449,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -474,7 +474,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1118,7 +1118,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1142,7 +1142,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1166,7 +1166,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1190,7 +1190,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1214,9 +1214,9 @@ "rate_limit": null, "audit": "required", "openapi": false, - "stability": "beta", + "stability": "experimental", "data_classification": "personal", - "notes": "Off-by-default beta feature. Governed identity attribute release: purpose-bound, projection-limited, exactly-one-subject resolve returning only configured claims.", + "notes": "Off-by-default experimental, feature-frozen surface outside the 1.0 compatibility promise. Governed identity attribute release is purpose-bound, projection-limited, and exactly-one-subject.", "source": "manual", "enforcement_tests": [ "tests/attribute_release_api.rs::resolve_without_release_scope_is_denied_before_read", @@ -1238,9 +1238,9 @@ "rate_limit": null, "audit": "required", "openapi": false, - "stability": "beta", + "stability": "experimental", "data_classification": "metadata", - "notes": "Off-by-default beta feature. Attribute release profile discovery (authenticated); lists visible profiles without exposing source internals.", + "notes": "Off-by-default experimental, feature-frozen surface outside the 1.0 compatibility promise. Authenticated discovery lists visible profiles without exposing source internals.", "source": "manual", "enforcement_tests": [ "tests/attribute_release_api.rs::discovery_hides_profiles_without_release_scope", @@ -1262,7 +1262,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "metadata", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1287,7 +1287,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "metadata", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1312,7 +1312,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "metadata", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1337,7 +1337,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "metadata", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1362,7 +1362,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "metadata", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1387,7 +1387,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "metadata", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1412,7 +1412,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "metadata", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1437,7 +1437,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1462,7 +1462,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1487,7 +1487,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1512,7 +1512,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1537,7 +1537,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1562,7 +1562,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1587,7 +1587,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "metadata", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1612,7 +1612,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "metadata", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1637,7 +1637,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "metadata", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1662,7 +1662,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "metadata", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1687,7 +1687,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "metadata", "notes": "Protected Registry Relay route.", "source": "manual", @@ -1712,7 +1712,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "stable", + "stability": "experimental", "data_classification": "metadata", "notes": "Protected Registry Relay route.", "source": "manual", diff --git a/crates/registry-relay/tests/security_assurance_check_test.py b/crates/registry-relay/tests/security_assurance_check_test.py index 2af8b5ccb..8c1869721 100644 --- a/crates/registry-relay/tests/security_assurance_check_test.py +++ b/crates/registry-relay/tests/security_assurance_check_test.py @@ -94,6 +94,16 @@ def test_valid_contract_passes(self): self.write_contracts(self.entry()) self.module.validate_manifest() + def test_feature_gated_endpoint_must_remain_experimental(self): + self.write_contracts(self.entry(feature="ogcapi-features", stability="stable")) + with self.assertRaises(SystemExit): + self.module.validate_manifest() + + self.write_contracts( + self.entry(feature="ogcapi-features", stability="experimental") + ) + self.module.validate_manifest() + def test_missing_route_manifest_entry_fails(self): self.write_contracts(self.entry(path="/other")) with self.assertRaises(SystemExit): From 0f2ce9b03c57cca5a0b8988c754bbfde9ff8f598 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:36:21 +0700 Subject: [PATCH 15/65] docs(relay): publish the 1.0 support roster Signed-off-by: Jeremi Joslin --- docs/site/.markdownlint-cli2.yaml | 1 + docs/site/scripts/generate-data.mjs | 30 ++ .../scripts/relay-release-contract.test.mjs | 162 +++++++++ .../src/components/RelaySupportRoster.astro | 76 ++++ .../docs/reference/apis/registry-relay.mdx | 18 +- .../src/content/docs/reference/standards.mdx | 18 +- .../src/data/generated/relay-support.json | 344 ++++++++++++++++++ docs/site/src/data/generated/standards.json | 22 +- docs/site/src/data/relay-support.yaml | 286 +++++++++++++++ docs/site/src/data/standards.yaml | 23 +- 10 files changed, 953 insertions(+), 27 deletions(-) create mode 100644 docs/site/scripts/relay-release-contract.test.mjs create mode 100644 docs/site/src/components/RelaySupportRoster.astro create mode 100644 docs/site/src/data/generated/relay-support.json create mode 100644 docs/site/src/data/relay-support.yaml diff --git a/docs/site/.markdownlint-cli2.yaml b/docs/site/.markdownlint-cli2.yaml index e97ca5b13..afc98087b 100644 --- a/docs/site/.markdownlint-cli2.yaml +++ b/docs/site/.markdownlint-cli2.yaml @@ -24,6 +24,7 @@ config: - OpenApiSourcesTable - ContractsTable - StandardsTable + - RelaySupportRoster - SpecRegister - LegendList - HomeLanding diff --git a/docs/site/scripts/generate-data.mjs b/docs/site/scripts/generate-data.mjs index 322d5c78b..4e85d8cc7 100644 --- a/docs/site/scripts/generate-data.mjs +++ b/docs/site/scripts/generate-data.mjs @@ -24,6 +24,21 @@ const required = { 'last_checked', 'notes', ], + 'relay-support': [ + 'id', + 'name', + 'category', + 'stability_tier', + 'support_owner', + 'feature_frozen', + 'canonical_release', + 'cargo_features', + 'openapi_policy', + 'decision_date', + 'decision_reference', + 'evidence', + 'notes', + ], 'openapi-sources': ['id', 'name', 'owner', 'source', 'artifact', 'status', 'reference_path'], }; @@ -45,6 +60,21 @@ async function loadYaml(name) { if (name === 'standards' && 'evidence_gap' in item && typeof item.evidence_gap !== 'boolean') { throw new Error(`standards.yaml entry ${index + 1} evidence_gap must be true or false`); } + if (name === 'relay-support') { + if (!['stable', 'experimental'].includes(item.stability_tier)) { + throw new Error( + `relay-support.yaml entry ${index + 1} has invalid stability_tier ${item.stability_tier}`, + ); + } + if (typeof item.feature_frozen !== 'boolean' || typeof item.canonical_release !== 'boolean') { + throw new Error( + `relay-support.yaml entry ${index + 1} feature_frozen and canonical_release must be booleans`, + ); + } + if (!Array.isArray(item.cargo_features)) { + throw new Error(`relay-support.yaml entry ${index + 1} cargo_features must be a list`); + } + } } return parsed; } diff --git a/docs/site/scripts/relay-release-contract.test.mjs b/docs/site/scripts/relay-release-contract.test.mjs new file mode 100644 index 000000000..daf390c0a --- /dev/null +++ b/docs/site/scripts/relay-release-contract.test.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { isAbsolute, resolve } from 'node:path'; +import test from 'node:test'; +import YAML from 'yaml'; + +const docsRoot = process.cwd(); +const repoRoot = resolve(docsRoot, '../..'); + +const stableIds = new Set([ + 'openapi-publication', + 'rfc9457-problem-contract', + 'rfc9727-api-catalog', + 'dcat-metadata', + 'bregdcat-ap-metadata', + 'json-ld-metadata', + 'shacl-metadata', + 'json-schema-metadata', + 'odrl-metadata', + 'link-free-ogc-records-metadata', + 'csv-source-input', + 'xlsx-source-input', + 'json-aggregate-output', +]); + +const experimentalIds = new Set([ + 'live-ogc-api-records', + 'ogc-api-features', + 'ogc-api-edr', + 'sp-dci-sync', + 'standards-cel-mapping', + 'sdmx-json-aggregate-output', + 'csv-aggregate-output', + 'attribute-release', + 'parquet-source-input', +]); + +async function readRepo(path) { + return readFile(resolve(repoRoot, path), 'utf8'); +} + +async function loadRoster() { + return YAML.parse(await readFile(resolve(docsRoot, 'src/data/relay-support.yaml'), 'utf8')); +} + +test('Relay 1.0 roster pins the approved stable and experimental surfaces', async () => { + const roster = await loadRoster(); + const ids = roster.map((entry) => entry.id); + assert.equal(new Set(ids).size, ids.length, 'roster ids must be unique'); + assert.deepEqual( + new Set(roster.filter((entry) => entry.stability_tier === 'stable').map((entry) => entry.id)), + stableIds, + ); + assert.deepEqual( + new Set( + roster.filter((entry) => entry.stability_tier === 'experimental').map((entry) => entry.id), + ), + experimentalIds, + ); + + for (const entry of roster) { + assert.equal(entry.decision_date, '2026-07-19', `${entry.id} decision date`); + assert.equal( + entry.decision_reference, + 'https://github.com/registrystack/registry-stack/issues/305', + `${entry.id} decision reference`, + ); + assert.ok(entry.evidence, `${entry.id} evidence reference`); + if (entry.stability_tier === 'stable') { + assert.notEqual(entry.support_owner, 'none', `${entry.id} needs a support owner`); + assert.equal(entry.feature_frozen, false, `${entry.id} must not be frozen`); + assert.equal(entry.canonical_release, true, `${entry.id} must be in the release contract`); + } else { + assert.equal(entry.support_owner, 'none', `${entry.id} has no approved support owner`); + assert.equal(entry.feature_frozen, true, `${entry.id} must be feature-frozen`); + assert.equal(entry.canonical_release, false, `${entry.id} must remain outside 1.0`); + } + } +}); + +test('generated Relay roster is byte-for-byte current', async () => { + const source = await loadRoster(); + const generated = await readFile( + resolve(docsRoot, 'src/data/generated/relay-support.json'), + 'utf8', + ); + assert.equal(generated, `${JSON.stringify(source, null, 2)}\n`); +}); + +test('canonical Relay release, local image, and OpenAPI use the same feature set', async () => { + const roster = await loadRoster(); + const canonicalFeatures = new Set( + roster + .filter((entry) => entry.canonical_release) + .flatMap((entry) => entry.cargo_features), + ); + assert.deepEqual(canonicalFeatures, new Set(), 'the approved 1.0 Relay feature list is empty'); + + const cargoToml = await readRepo('crates/registry-relay/Cargo.toml'); + const declaredFeatures = new Set( + [...cargoToml.matchAll(/^([a-z][a-z0-9-]*)\s*=\s*\[/gm)].map((match) => match[1]), + ); + for (const feature of roster.flatMap((entry) => entry.cargo_features)) { + assert.ok(declaredFeatures.has(feature), `experimental source feature ${feature} must remain`); + } + + const dockerfile = await readRepo('crates/registry-relay/Dockerfile'); + assert.match( + dockerfile, + /^ARG REGISTRY_RELAY_FEATURES=""$/m, + 'the local production image must default to the canonical empty feature set', + ); + + const workflowPath = process.env.RELAY_RELEASE_WORKFLOW_PATH ?? '.github/workflows/release.yml'; + const workflow = isAbsolute(workflowPath) + ? await readFile(workflowPath, 'utf8') + : await readRepo(workflowPath); + const workflowRelayFeatures = new Set( + [...workflow.matchAll(/registry-relay\/([a-z][a-z0-9-]*)/g)].map((match) => match[1]), + ); + assert.deepEqual( + workflowRelayFeatures, + canonicalFeatures, + 'the release workflow Relay features must match the canonical 1.0 roster', + ); + + const openapi = JSON.parse( + await readRepo('crates/registry-relay/openapi/registry-relay.openapi.json'), + ); + const exposure = JSON.parse( + await readRepo('crates/registry-relay/security/exposure-manifest.json'), + ); + const experimentalFeatures = new Set( + roster + .filter((entry) => entry.stability_tier === 'experimental') + .flatMap((entry) => entry.cargo_features), + ); + for (const endpoint of exposure.endpoints.filter( + (entry) => entry.feature && experimentalFeatures.has(entry.feature), + )) { + assert.equal(endpoint.stability, 'experimental', `${endpoint.method} ${endpoint.path} tier`); + assert.equal( + openapi.paths[endpoint.path], + undefined, + `${endpoint.path} must not appear in the pinned canonical OpenAPI`, + ); + } + assert.ok( + openapi.paths['/metadata/ogc/records'], + 'stable link-free OGC Records metadata must remain in the pinned OpenAPI', + ); + + const justfile = await readRepo('crates/registry-relay/Justfile'); + assert.match(justfile, /^\s*cargo test --all-features$/m, 'all-feature tests must remain enabled'); +}); + +test('Relay documentation distinguishes source decoders from aggregate output', async () => { + const readme = await readRepo('crates/registry-relay/README.md'); + assert.match(readme, /CSV, XLSX, and Parquet are source decoders/); + assert.match(readme, /Aggregate output supports JSON,\s+CSV, and SDMX-JSON/); + assert.match(readme, /Experimental surfaces are outside the 1\.0 compatibility promise/); +}); diff --git a/docs/site/src/components/RelaySupportRoster.astro b/docs/site/src/components/RelaySupportRoster.astro new file mode 100644 index 000000000..56b77b78d --- /dev/null +++ b/docs/site/src/components/RelaySupportRoster.astro @@ -0,0 +1,76 @@ +--- +import roster from '../data/generated/relay-support.json'; + +type RosterEntry = (typeof roster)[number]; + +const stable = roster.filter((entry: RosterEntry) => entry.stability_tier === 'stable'); +const experimental = roster.filter( + (entry: RosterEntry) => entry.stability_tier === 'experimental', +); +--- + +
+ + + + + + + + + + + + {stable.map((entry: RosterEntry) => ( + + + + + + + + ))} + +
SurfaceCategorySupport ownerDeliveryEvidence
{entry.name}{entry.category}{entry.support_owner}Canonical 1.0 release{entry.evidence}
+
+ +

Experimental and feature-frozen

+ +
+ + + + + + + + + + + + {experimental.map((entry: RosterEntry) => ( + + + + + + + + ))} + +
SurfaceCategoryCargo featureDeliveryEvidence
{entry.name}{entry.category} + {entry.cargo_features.length > 0 + ? entry.cargo_features.map((feature: string) => {feature}) + : 'Compiled in; not feature-gated'} + + {entry.cargo_features.length > 0 + ? 'Source available; excluded from the canonical release' + : 'Shipped, unstable, and frozen'} + {entry.evidence}
+
+ +

+ Generated from src/data/relay-support.yaml. Every decision is dated + {roster[0].decision_date} and references + Registry Stack issue #305. +

diff --git a/docs/site/src/content/docs/reference/apis/registry-relay.mdx b/docs/site/src/content/docs/reference/apis/registry-relay.mdx index ad54fc649..6a183212a 100644 --- a/docs/site/src/content/docs/reference/apis/registry-relay.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-relay.mdx @@ -6,7 +6,7 @@ owner: registry-relay source_repos: - registry-relay - registry-platform -last_reviewed: "2026-06-20" +last_reviewed: "2026-07-19" doc_type: reference locale: en standards_referenced: @@ -41,6 +41,11 @@ consultation APIs. Callers read configured entities, schemas, declared aggregate policy metadata, and evidence-offering descriptors. Relay never widens a caller's reach at request time: the routes, datasets, and scopes a gateway exposes are fixed by its configuration. +CSV, XLSX, and Parquet are source decoders. Aggregate output supports JSON, CSV, and +SDMX-JSON. CSV and XLSX input plus JSON aggregate output are stable for 1.0. Parquet input, +CSV aggregate output, and SDMX-JSON aggregate output remain shipped but experimental and +feature-frozen. + ## Authentication A Relay instance runs exactly one auth mode (API key or OIDC/OAuth2), configured under `auth`. @@ -58,9 +63,14 @@ For setup steps, scope strings, and credential configuration, see baseline OpenAPI component because not every entity requires purpose capture. At runtime, `api.require_purpose_header: true` on an entity or aggregate source makes the header mandatory for that operation. Calls that omit it are rejected with `400 auth.purpose_required`. -- **Feature-gated surfaces.** OGC API Features, Records, and EDR adapters, and SP-DCI sync routes - only mount when the gateway is built and configured with the matching Cargo feature. - Relay does not issue response credentials or host issuer DID documents. +- **Feature-gated surfaces.** The live OGC API Features, Records, and EDR adapters, SP DCI sync + routes, standards-CEL mapping, and attribute release are experimental and feature-frozen. + They are outside the 1.0 compatibility promise and excluded from the canonical release binary. + Their source and all-feature tests remain available. Relay does not issue response credentials + or host issuer DID documents. +- **Link-free Records metadata.** Stable link-free OGC Records bodies under + `/metadata/ogc/records` are portable metadata. They are separate from the experimental live + adapter under `/ogc/v1/records`. - **Admin routes.** Reload and metrics routes are mounted on a separate, optional admin listener, require `registry_relay:admin` for reload/config mutation, `registry_relay:metrics_read` for metrics, and `registry_relay:ops_read` for operational posture reads. They do not appear in the diff --git a/docs/site/src/content/docs/reference/standards.mdx b/docs/site/src/content/docs/reference/standards.mdx index 8067968ed..43cbb584d 100644 --- a/docs/site/src/content/docs/reference/standards.mdx +++ b/docs/site/src/content/docs/reference/standards.mdx @@ -9,7 +9,7 @@ source_repos: - registry-platform - registry-manifest - registry-notary -last_reviewed: "2026-07-07" +last_reviewed: "2026-07-19" doc_type: reference locale: en standards_referenced: @@ -39,12 +39,28 @@ standards_referenced: --- import StandardsTable from '../../../components/StandardsTable.astro'; +import RelaySupportRoster from '../../../components/RelaySupportRoster.astro'; This register lists every standard the registry stack touches, the projects that touch it, and the claim level that names how strong the relationship is. The registry stack does not introduce a new standard; each project emits, maps to, or aligns with existing ones, and says so explicitly. The table is generated from `src/data/standards.yaml`. For selected external validator results, see [ITB and SEMIC evidence](../itb-semic-evidence/). +## Registry Relay 1.0 support roster + +Registry Relay's 1.0 compatibility promise covers the stable surfaces in this roster. +Experimental surfaces are outside that promise and feature-frozen: maintainers keep their source +and tests, but do not expand them without a separately reviewed support decision. +Non-feature-gated experimental formats remain shipped and unstable because adding a gate for 1.0 +would break existing configurations. + +The stable link-free OGC Records metadata artifacts are separate from the experimental live OGC +API Records adapter. +CSV, XLSX, and Parquet are source decoders. +Aggregate output supports JSON, CSV, and SDMX-JSON, with only JSON stable for 1.0. + + + ## Register diff --git a/docs/site/src/data/generated/relay-support.json b/docs/site/src/data/generated/relay-support.json new file mode 100644 index 000000000..6f9c992bd --- /dev/null +++ b/docs/site/src/data/generated/relay-support.json @@ -0,0 +1,344 @@ +[ + { + "id": "openapi-publication", + "name": "OpenAPI 3.x publication", + "category": "standard", + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, + "cargo_features": [], + "openapi_policy": "included", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/openapi/registry-relay.openapi.json", + "notes": "The pinned default document is the canonical public API contract." + }, + { + "id": "rfc9457-problem-contract", + "name": "RFC 9457 problem contract", + "category": "standard", + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, + "cargo_features": [], + "openapi_policy": "included", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/error_taxonomy.rs", + "notes": "Stable error codes accompany the RFC 9457 response shape." + }, + { + "id": "rfc9727-api-catalog", + "name": "RFC 9727 API catalog", + "category": "standard", + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, + "cargo_features": [], + "openapi_policy": "included", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/e2e_health.rs", + "notes": "The well-known API catalog is compiled into the canonical release." + }, + { + "id": "dcat-metadata", + "name": "DCAT and DCAT-AP metadata", + "category": "portable_metadata", + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, + "cargo_features": [], + "openapi_policy": "included", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/catalog_entity.rs", + "notes": "Link-free metadata artifacts are part of the portable metadata family." + }, + { + "id": "bregdcat-ap-metadata", + "name": "BRegDCAT-AP metadata", + "category": "portable_metadata", + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, + "cargo_features": [], + "openapi_policy": "included", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/catalog_entity.rs", + "notes": "The support promise is limited to the profiled artifact described by the standards register." + }, + { + "id": "json-ld-metadata", + "name": "JSON-LD metadata", + "category": "portable_metadata", + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, + "cargo_features": [], + "openapi_policy": "included", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/catalog_entity.rs", + "notes": "The promise covers the emitted metadata artifacts, not broad RDF conformance." + }, + { + "id": "shacl-metadata", + "name": "SHACL metadata", + "category": "portable_metadata", + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, + "cargo_features": [], + "openapi_policy": "included", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/catalog_entity.rs", + "notes": "The promise covers the generated shapes and routes asserted by Relay tests." + }, + { + "id": "json-schema-metadata", + "name": "JSON Schema metadata", + "category": "portable_metadata", + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, + "cargo_features": [], + "openapi_policy": "included", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/catalog_entity.rs", + "notes": "Draft 2020-12 entity schema publication is part of the canonical release." + }, + { + "id": "odrl-metadata", + "name": "ODRL policy metadata", + "category": "portable_metadata", + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, + "cargo_features": [], + "openapi_policy": "included", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/catalog_entity.rs", + "notes": "Published ODRL metadata remains bounded by Relay's documented enforcement subset." + }, + { + "id": "link-free-ogc-records-metadata", + "name": "Link-free OGC Records metadata artifacts", + "category": "portable_metadata", + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, + "cargo_features": [], + "openapi_policy": "included", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/catalog_entity.rs", + "notes": "These metadata artifacts do not enable the live OGC API Records adapter." + }, + { + "id": "csv-source-input", + "name": "CSV source input", + "category": "source_decoder", + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, + "cargo_features": [], + "openapi_policy": "not_applicable", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/format_csv.rs", + "notes": "CSV is a source decoder, not an aggregate output commitment." + }, + { + "id": "xlsx-source-input", + "name": "XLSX source input", + "category": "source_decoder", + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, + "cargo_features": [], + "openapi_policy": "not_applicable", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/format_xlsx.rs", + "notes": "First-party maintained onboarding is the accepted 1.0 support evidence." + }, + { + "id": "json-aggregate-output", + "name": "JSON aggregate output", + "category": "aggregate_output", + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, + "cargo_features": [], + "openapi_policy": "included", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/aggregates_entity.rs", + "notes": "JSON is the stable aggregate representation in the 1.0 compatibility promise." + }, + { + "id": "live-ogc-api-records", + "name": "Live OGC API Records adapter", + "category": "adapter", + "stability_tier": "experimental", + "support_owner": "none", + "feature_frozen": true, + "canonical_release": false, + "cargo_features": [ + "ogcapi-records" + ], + "openapi_policy": "excluded", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/ogc_records_api.rs", + "notes": "Source and all-feature tests remain, but the adapter is outside the 1.0 compatibility promise." + }, + { + "id": "ogc-api-features", + "name": "OGC API Features", + "category": "adapter", + "stability_tier": "experimental", + "support_owner": "none", + "feature_frozen": true, + "canonical_release": false, + "cargo_features": [ + "ogcapi-features" + ], + "openapi_policy": "excluded", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/ogc_api.rs", + "notes": "Source and all-feature tests remain, but the adapter is outside the 1.0 compatibility promise." + }, + { + "id": "ogc-api-edr", + "name": "OGC API Environmental Data Retrieval", + "category": "adapter", + "stability_tier": "experimental", + "support_owner": "none", + "feature_frozen": true, + "canonical_release": false, + "cargo_features": [ + "ogcapi-edr" + ], + "openapi_policy": "excluded", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/ogc_edr_api.rs", + "notes": "Source and all-feature tests remain, but the adapter is outside the 1.0 compatibility promise." + }, + { + "id": "sp-dci-sync", + "name": "SP DCI sync routes", + "category": "adapter", + "stability_tier": "experimental", + "support_owner": "none", + "feature_frozen": true, + "canonical_release": false, + "cargo_features": [ + "spdci-api-standards" + ], + "openapi_policy": "excluded", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/spdci_api_standards.rs", + "notes": "Source and all-feature security tests remain, but the adapter is outside the 1.0 compatibility promise." + }, + { + "id": "standards-cel-mapping", + "name": "Standards-CEL mapping", + "category": "adapter", + "stability_tier": "experimental", + "support_owner": "none", + "feature_frozen": true, + "canonical_release": false, + "cargo_features": [ + "standards-cel-mapping" + ], + "openapi_policy": "not_applicable", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/spdci_config_validation.rs", + "notes": "Source and all-feature tests remain, but the mapping is outside the 1.0 compatibility promise." + }, + { + "id": "sdmx-json-aggregate-output", + "name": "SDMX-JSON aggregate output", + "category": "aggregate_output", + "stability_tier": "experimental", + "support_owner": "none", + "feature_frozen": true, + "canonical_release": false, + "cargo_features": [], + "openapi_policy": "included_unstable", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/aggregates_entity.rs", + "notes": "This non-gated representation remains shipped but unstable and feature-frozen." + }, + { + "id": "csv-aggregate-output", + "name": "CSV aggregate output", + "category": "aggregate_output", + "stability_tier": "experimental", + "support_owner": "none", + "feature_frozen": true, + "canonical_release": false, + "cargo_features": [], + "openapi_policy": "included_unstable", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/aggregates_entity.rs", + "notes": "This non-gated representation remains shipped but unstable and feature-frozen." + }, + { + "id": "attribute-release", + "name": "Attribute release", + "category": "adapter", + "stability_tier": "experimental", + "support_owner": "none", + "feature_frozen": true, + "canonical_release": false, + "cargo_features": [ + "attribute-release" + ], + "openapi_policy": "excluded", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/attribute_release_api.rs", + "notes": "Source and all-feature tests remain, but the adapter is outside the 1.0 compatibility promise." + }, + { + "id": "parquet-source-input", + "name": "Parquet source input", + "category": "source_decoder", + "stability_tier": "experimental", + "support_owner": "none", + "feature_frozen": true, + "canonical_release": false, + "cargo_features": [], + "openapi_policy": "not_applicable", + "decision_date": "2026-07-19", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "evidence": "crates/registry-relay/tests/format_parquet.rs", + "notes": "This non-gated decoder remains shipped but unstable and feature-frozen." + } +] diff --git a/docs/site/src/data/generated/standards.json b/docs/site/src/data/generated/standards.json index 585bea56e..e64ced3ff 100644 --- a/docs/site/src/data/generated/standards.json +++ b/docs/site/src/data/generated/standards.json @@ -125,8 +125,8 @@ "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/ogc_records_api.rs" } ], - "last_checked": "2026-06-13", - "notes": "Relay exposes records routes behind the ogcapi-records feature (route output asserted by the OGC records API tests), and Manifest renders and publishes static OGC Records item collections." + "last_checked": "2026-07-19", + "notes": "The link-free Records metadata artifacts are stable for 1.0. The live Relay adapter behind ogcapi-records is a separate experimental, feature-frozen surface outside the 1.0 compatibility promise. Manifest renders and publishes static OGC Records item collections." }, { "id": "ogc-api-features", @@ -154,8 +154,8 @@ "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/ogc_api.rs" } ], - "last_checked": "2026-06-20", - "notes": "Relay exposes OGC API Features routes behind the ogcapi-features feature. The claim is scoped to the profiled route output tested in Registry Relay, not full OGC conformance." + "last_checked": "2026-07-19", + "notes": "Relay exposes OGC API Features routes behind the ogcapi-features feature. The adapter is experimental, feature-frozen, and outside the 1.0 compatibility promise. The claim is scoped to the profiled route output tested in Registry Relay, not full OGC conformance." }, { "id": "ogc-api-edr", @@ -179,8 +179,8 @@ "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/ogc_edr_api.rs" } ], - "last_checked": "2026-06-20", - "notes": "Relay exposes OGC API EDR area routes behind the ogcapi-edr feature for configured spatial aggregates. The claim is scoped to the tested adapter surface, not full OGC conformance." + "last_checked": "2026-07-19", + "notes": "Relay exposes OGC API EDR area routes behind the ogcapi-edr feature for configured spatial aggregates. The adapter is experimental, feature-frozen, and outside the 1.0 compatibility promise. The claim is scoped to the tested adapter surface, not full OGC conformance." }, { "id": "openapi", @@ -217,7 +217,7 @@ "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/notary/openapi/registry-notary.openapi.json" } ], - "last_checked": "2026-06-13", + "last_checked": "2026-07-19", "notes": "Relay and Notary generate OpenAPI; the pinned generated documents are cited directly. These docs publish the same pinned artifacts as native API reference pages." }, { @@ -522,8 +522,8 @@ "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/aggregates_entity.rs" } ], - "last_checked": "2026-06-13", - "notes": "Relay serves configured aggregates as SDMX-JSON 2.1 data messages via content negotiation (application/vnd.sdmx.data+json;version=2.1) or ?f=sdmx-json. Scope is data messages only: structure messages and the SDMX REST API are not implemented. Relay-specific x-extensions carry unit, multiplier, decimals, and completeness metadata; measure definition URIs are not mapped; the message sender is fixed. Conformance evidence is the Relay test suite validating output against the published SDMX-JSON data-message schema." + "last_checked": "2026-07-19", + "notes": "Relay serves configured aggregates as SDMX-JSON 2.1 data messages via content negotiation (application/vnd.sdmx.data+json;version=2.1) or ?f=sdmx-json. Scope is data messages only: structure messages and the SDMX REST API are not implemented. Relay-specific x-extensions carry unit, multiplier, decimals, and completeness metadata; measure definition URIs are not mapped; the message sender is fixed. Conformance evidence is the Relay test suite validating output against the published SDMX-JSON data-message schema. This aggregate representation remains shipped but is experimental, feature-frozen, and outside the 1.0 compatibility promise." }, { "id": "prov-o", @@ -657,8 +657,8 @@ "url": "https://github.com/registrystack/registry-stack/tree/2dfcf2dd/crates/registryctl/tests/fixtures/project-authoring/opencrvs" } ], - "last_checked": "2026-05-23", - "notes": "Relay exposes an SP-DCI sync adapter behind `spdci-api-standards`. A Registry Stack project can also compile a reviewed DCI source journey into a Relay consultation. Registry Notary consumes the consultation result and does not connect to DCI sources directly." + "last_checked": "2026-07-19", + "notes": "Relay exposes an SP-DCI sync adapter behind `spdci-api-standards`. The adapter and standards-CEL mapping are experimental, feature-frozen, and outside the 1.0 compatibility promise. A Registry Stack project can also compile a reviewed DCI source journey into a Relay consultation. Registry Notary consumes the consultation result and does not connect to DCI sources directly." }, { "id": "oid4vci", diff --git a/docs/site/src/data/relay-support.yaml b/docs/site/src/data/relay-support.yaml new file mode 100644 index 000000000..3271fcf32 --- /dev/null +++ b/docs/site/src/data/relay-support.yaml @@ -0,0 +1,286 @@ +- id: openapi-publication + name: OpenAPI 3.x publication + category: standard + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true + cargo_features: [] + openapi_policy: included + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/openapi/registry-relay.openapi.json + notes: The pinned default document is the canonical public API contract. +- id: rfc9457-problem-contract + name: RFC 9457 problem contract + category: standard + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true + cargo_features: [] + openapi_policy: included + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/error_taxonomy.rs + notes: Stable error codes accompany the RFC 9457 response shape. +- id: rfc9727-api-catalog + name: RFC 9727 API catalog + category: standard + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true + cargo_features: [] + openapi_policy: included + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/e2e_health.rs + notes: The well-known API catalog is compiled into the canonical release. +- id: dcat-metadata + name: DCAT and DCAT-AP metadata + category: portable_metadata + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true + cargo_features: [] + openapi_policy: included + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/catalog_entity.rs + notes: Link-free metadata artifacts are part of the portable metadata family. +- id: bregdcat-ap-metadata + name: BRegDCAT-AP metadata + category: portable_metadata + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true + cargo_features: [] + openapi_policy: included + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/catalog_entity.rs + notes: The support promise is limited to the profiled artifact described by the standards register. +- id: json-ld-metadata + name: JSON-LD metadata + category: portable_metadata + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true + cargo_features: [] + openapi_policy: included + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/catalog_entity.rs + notes: The promise covers the emitted metadata artifacts, not broad RDF conformance. +- id: shacl-metadata + name: SHACL metadata + category: portable_metadata + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true + cargo_features: [] + openapi_policy: included + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/catalog_entity.rs + notes: The promise covers the generated shapes and routes asserted by Relay tests. +- id: json-schema-metadata + name: JSON Schema metadata + category: portable_metadata + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true + cargo_features: [] + openapi_policy: included + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/catalog_entity.rs + notes: Draft 2020-12 entity schema publication is part of the canonical release. +- id: odrl-metadata + name: ODRL policy metadata + category: portable_metadata + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true + cargo_features: [] + openapi_policy: included + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/catalog_entity.rs + notes: Published ODRL metadata remains bounded by Relay's documented enforcement subset. +- id: link-free-ogc-records-metadata + name: Link-free OGC Records metadata artifacts + category: portable_metadata + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true + cargo_features: [] + openapi_policy: included + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/catalog_entity.rs + notes: These metadata artifacts do not enable the live OGC API Records adapter. +- id: csv-source-input + name: CSV source input + category: source_decoder + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true + cargo_features: [] + openapi_policy: not_applicable + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/format_csv.rs + notes: CSV is a source decoder, not an aggregate output commitment. +- id: xlsx-source-input + name: XLSX source input + category: source_decoder + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true + cargo_features: [] + openapi_policy: not_applicable + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/format_xlsx.rs + notes: First-party maintained onboarding is the accepted 1.0 support evidence. +- id: json-aggregate-output + name: JSON aggregate output + category: aggregate_output + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true + cargo_features: [] + openapi_policy: included + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/aggregates_entity.rs + notes: JSON is the stable aggregate representation in the 1.0 compatibility promise. +- id: live-ogc-api-records + name: Live OGC API Records adapter + category: adapter + stability_tier: experimental + support_owner: none + feature_frozen: true + canonical_release: false + cargo_features: [ogcapi-records] + openapi_policy: excluded + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/ogc_records_api.rs + notes: Source and all-feature tests remain, but the adapter is outside the 1.0 compatibility promise. +- id: ogc-api-features + name: OGC API Features + category: adapter + stability_tier: experimental + support_owner: none + feature_frozen: true + canonical_release: false + cargo_features: [ogcapi-features] + openapi_policy: excluded + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/ogc_api.rs + notes: Source and all-feature tests remain, but the adapter is outside the 1.0 compatibility promise. +- id: ogc-api-edr + name: OGC API Environmental Data Retrieval + category: adapter + stability_tier: experimental + support_owner: none + feature_frozen: true + canonical_release: false + cargo_features: [ogcapi-edr] + openapi_policy: excluded + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/ogc_edr_api.rs + notes: Source and all-feature tests remain, but the adapter is outside the 1.0 compatibility promise. +- id: sp-dci-sync + name: SP DCI sync routes + category: adapter + stability_tier: experimental + support_owner: none + feature_frozen: true + canonical_release: false + cargo_features: [spdci-api-standards] + openapi_policy: excluded + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/spdci_api_standards.rs + notes: Source and all-feature security tests remain, but the adapter is outside the 1.0 compatibility promise. +- id: standards-cel-mapping + name: Standards-CEL mapping + category: adapter + stability_tier: experimental + support_owner: none + feature_frozen: true + canonical_release: false + cargo_features: [standards-cel-mapping] + openapi_policy: not_applicable + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/spdci_config_validation.rs + notes: Source and all-feature tests remain, but the mapping is outside the 1.0 compatibility promise. +- id: sdmx-json-aggregate-output + name: SDMX-JSON aggregate output + category: aggregate_output + stability_tier: experimental + support_owner: none + feature_frozen: true + canonical_release: false + cargo_features: [] + openapi_policy: included_unstable + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/aggregates_entity.rs + notes: This non-gated representation remains shipped but unstable and feature-frozen. +- id: csv-aggregate-output + name: CSV aggregate output + category: aggregate_output + stability_tier: experimental + support_owner: none + feature_frozen: true + canonical_release: false + cargo_features: [] + openapi_policy: included_unstable + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/aggregates_entity.rs + notes: This non-gated representation remains shipped but unstable and feature-frozen. +- id: attribute-release + name: Attribute release + category: adapter + stability_tier: experimental + support_owner: none + feature_frozen: true + canonical_release: false + cargo_features: [attribute-release] + openapi_policy: excluded + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/attribute_release_api.rs + notes: Source and all-feature tests remain, but the adapter is outside the 1.0 compatibility promise. +- id: parquet-source-input + name: Parquet source input + category: source_decoder + stability_tier: experimental + support_owner: none + feature_frozen: true + canonical_release: false + cargo_features: [] + openapi_policy: not_applicable + decision_date: "2026-07-19" + decision_reference: https://github.com/registrystack/registry-stack/issues/305 + evidence: crates/registry-relay/tests/format_parquet.rs + notes: This non-gated decoder remains shipped but unstable and feature-frozen. diff --git a/docs/site/src/data/standards.yaml b/docs/site/src/data/standards.yaml index c3765482f..3c84c315a 100644 --- a/docs/site/src/data/standards.yaml +++ b/docs/site/src/data/standards.yaml @@ -85,8 +85,8 @@ url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md - label: Registry Relay OGC records API tests url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/ogc_records_api.rs - last_checked: 2026-06-13 - notes: Relay exposes records routes behind the ogcapi-records feature (route output asserted by the OGC records API tests), and Manifest renders and publishes static OGC Records item collections. + last_checked: 2026-07-19 + notes: The link-free Records metadata artifacts are stable for 1.0. The live Relay adapter behind ogcapi-records is a separate experimental, feature-frozen surface outside the 1.0 compatibility promise. Manifest renders and publishes static OGC Records item collections. - id: ogc-api-features name: OGC API Features standards_body: Open Geospatial Consortium @@ -105,8 +105,8 @@ url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md - label: Registry Relay OGC Features API tests url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/ogc_api.rs - last_checked: 2026-06-20 - notes: Relay exposes OGC API Features routes behind the ogcapi-features feature. The claim is scoped to the profiled route output tested in Registry Relay, not full OGC conformance. + last_checked: 2026-07-19 + notes: Relay exposes OGC API Features routes behind the ogcapi-features feature. The adapter is experimental, feature-frozen, and outside the 1.0 compatibility promise. The claim is scoped to the profiled route output tested in Registry Relay, not full OGC conformance. - id: ogc-api-edr name: OGC API Environmental Data Retrieval standards_body: Open Geospatial Consortium @@ -123,8 +123,8 @@ evidence_docs: - label: Registry Relay OGC EDR API tests url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/ogc_edr_api.rs - last_checked: 2026-06-20 - notes: Relay exposes OGC API EDR area routes behind the ogcapi-edr feature for configured spatial aggregates. The claim is scoped to the tested adapter surface, not full OGC conformance. + last_checked: 2026-07-19 + notes: Relay exposes OGC API EDR area routes behind the ogcapi-edr feature for configured spatial aggregates. The adapter is experimental, feature-frozen, and outside the 1.0 compatibility promise. The claim is scoped to the tested adapter surface, not full OGC conformance. - id: openapi name: OpenAPI standards_body: OpenAPI Initiative @@ -148,7 +148,7 @@ url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/openapi/registry-relay.openapi.json - label: Registry Notary generated OpenAPI document (pinned) url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/notary/openapi/registry-notary.openapi.json - last_checked: 2026-06-13 + last_checked: 2026-07-19 notes: Relay and Notary generate OpenAPI; the pinned generated documents are cited directly. These docs publish the same pinned artifacts as native API reference pages. - id: shacl name: SHACL @@ -364,7 +364,7 @@ url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/docs/client-integration.md - label: Registry Relay aggregates tests (schema-validated SDMX-JSON output) url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/aggregates_entity.rs - last_checked: 2026-06-13 + last_checked: 2026-07-19 notes: >- Relay serves configured aggregates as SDMX-JSON 2.1 data messages via content negotiation (application/vnd.sdmx.data+json;version=2.1) or @@ -373,7 +373,8 @@ unit, multiplier, decimals, and completeness metadata; measure definition URIs are not mapped; the message sender is fixed. Conformance evidence is the Relay test suite validating output against the published SDMX-JSON - data-message schema. + data-message schema. This aggregate representation remains shipped but is + experimental, feature-frozen, and outside the 1.0 compatibility promise. - id: prov-o name: PROV-O standards_body: W3C @@ -473,8 +474,8 @@ url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md - label: Registry Stack OpenCRVS project-authoring fixture url: https://github.com/registrystack/registry-stack/tree/2dfcf2dd/crates/registryctl/tests/fixtures/project-authoring/opencrvs - last_checked: 2026-05-23 - notes: Relay exposes an SP-DCI sync adapter behind `spdci-api-standards`. A Registry Stack project can also compile a reviewed DCI source journey into a Relay consultation. Registry Notary consumes the consultation result and does not connect to DCI sources directly. + last_checked: 2026-07-19 + notes: Relay exposes an SP-DCI sync adapter behind `spdci-api-standards`. The adapter and standards-CEL mapping are experimental, feature-frozen, and outside the 1.0 compatibility promise. A Registry Stack project can also compile a reviewed DCI source journey into a Relay consultation. Registry Notary consumes the consultation result and does not connect to DCI sources directly. - id: oid4vci name: OpenID for Verifiable Credential Issuance standards_body: OpenID Foundation From bfc1b88ecca08d53c36689277c548f4810d39628 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:45:13 +0700 Subject: [PATCH 16/65] fix(relay): preserve stable core record routes Signed-off-by: Jeremi Joslin --- .../scripts/check_security_assurance.py | 38 ++++++++++++++++--- .../security/exposure-manifest.json | 6 +-- .../tests/security_assurance_check_test.py | 32 ++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/crates/registry-relay/scripts/check_security_assurance.py b/crates/registry-relay/scripts/check_security_assurance.py index af99a85af..b1402df23 100755 --- a/crates/registry-relay/scripts/check_security_assurance.py +++ b/crates/registry-relay/scripts/check_security_assurance.py @@ -54,6 +54,26 @@ # acceptable and is reviewed as a narrow exception. STATIC_OPENAPI_OPERATION_ALLOWLIST: dict[tuple[str, str], str] = {} +# These protected record-read routes are part of Relay's stable core data plane. +# Standards feature-roster changes must not reclassify them with optional adapters. +CORE_STABLE_ROUTE_KEYS = { + ( + "public", + "GET", + "/v1/datasets/{dataset_id}/entities/{entity}/records", + ), + ( + "public", + "GET", + "/v1/datasets/{dataset_id}/entities/{entity}/records/{id}", + ), + ( + "public", + "GET", + "/v1/datasets/{dataset_id}/entities/{entity}/records/{id}/relationships/{relationship}", + ), +} + def load_json(path: Path) -> object: try: @@ -130,11 +150,7 @@ def validate_manifest() -> None: check_value(entry, "stability", STABILITY) check_value(entry, "data_classification", DATA) check_value(entry, "source", SOURCES) - if entry["feature"] is not None and entry["stability"] != "experimental": - fail( - f"{entry['path']} is feature-gated but has stability " - f"{entry['stability']}; the 1.0 optional surfaces are experimental" - ) + validate_stability(entry) if entry["method"] not in {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}: fail(f"{entry['path']} has invalid method {entry['method']}") if not isinstance(entry["scopes"], list): @@ -183,6 +199,18 @@ def validate_manifest() -> None: validate_route_sources(inventory) +def validate_stability(entry: dict) -> None: + if entry["feature"] is not None and entry["stability"] != "experimental": + fail( + f"{entry['path']} is feature-gated but has stability " + f"{entry['stability']}; the 1.0 optional surfaces are experimental" + ) + if key(entry) in CORE_STABLE_ROUTE_KEYS and entry["stability"] != "stable": + fail( + f"{entry['path']} is a core protected record-read route and must remain stable" + ) + + def check_value(entry: dict, field: str, allowed: set[str]) -> None: if entry[field] not in allowed: fail(f"{entry['path']} has invalid {field}: {entry[field]}") diff --git a/crates/registry-relay/security/exposure-manifest.json b/crates/registry-relay/security/exposure-manifest.json index f6ae50a61..14c71de58 100644 --- a/crates/registry-relay/security/exposure-manifest.json +++ b/crates/registry-relay/security/exposure-manifest.json @@ -424,7 +424,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "experimental", + "stability": "stable", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -449,7 +449,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "experimental", + "stability": "stable", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", @@ -474,7 +474,7 @@ "rate_limit": null, "audit": "required", "openapi": true, - "stability": "experimental", + "stability": "stable", "data_classification": "personal", "notes": "Protected Registry Relay route.", "source": "manual", diff --git a/crates/registry-relay/tests/security_assurance_check_test.py b/crates/registry-relay/tests/security_assurance_check_test.py index 8c1869721..80ca49ec3 100644 --- a/crates/registry-relay/tests/security_assurance_check_test.py +++ b/crates/registry-relay/tests/security_assurance_check_test.py @@ -104,6 +104,38 @@ def test_feature_gated_endpoint_must_remain_experimental(self): ) self.module.validate_manifest() + def test_core_record_read_routes_must_remain_stable(self): + expected = { + ( + "public", + "GET", + "/v1/datasets/{dataset_id}/entities/{entity}/records", + ), + ( + "public", + "GET", + "/v1/datasets/{dataset_id}/entities/{entity}/records/{id}", + ), + ( + "public", + "GET", + "/v1/datasets/{dataset_id}/entities/{entity}/records/{id}/relationships/{relationship}", + ), + } + self.assertEqual(self.module.CORE_STABLE_ROUTE_KEYS, expected) + + for listener, method, path in expected: + with self.subTest(path=path): + entry = self.entry( + listener=listener, + method=method, + path=path, + stability="experimental", + ) + with self.assertRaises(SystemExit): + self.module.validate_stability(entry) + self.module.validate_stability({**entry, "stability": "stable"}) + def test_missing_route_manifest_entry_fails(self): self.write_contracts(self.entry(path="/other")) with self.assertRaises(SystemExit): From 6bc29d3f777ef60229a704037841fb65c480ca87 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:45:17 +0700 Subject: [PATCH 17/65] docs(relay): expose unstable OpenAPI selectors Signed-off-by: Jeremi Joslin --- docs/site/scripts/generate-data.mjs | 16 +++++++++++++ .../scripts/relay-release-contract.test.mjs | 24 +++++++++++++++++++ .../src/data/generated/relay-support.json | 16 +++++++++++++ docs/site/src/data/relay-support.yaml | 6 +++++ 4 files changed, 62 insertions(+) diff --git a/docs/site/scripts/generate-data.mjs b/docs/site/scripts/generate-data.mjs index 4e85d8cc7..b433e7270 100644 --- a/docs/site/scripts/generate-data.mjs +++ b/docs/site/scripts/generate-data.mjs @@ -74,6 +74,22 @@ async function loadYaml(name) { if (!Array.isArray(item.cargo_features)) { throw new Error(`relay-support.yaml entry ${index + 1} cargo_features must be a list`); } + if (item.openapi_policy === 'included_unstable') { + const selectors = item.openapi_selectors; + if ( + selectors === null || + typeof selectors !== 'object' || + !Array.isArray(selectors.format_tokens) || + selectors.format_tokens.length === 0 || + !Array.isArray(selectors.media_types) || + selectors.media_types.length === 0 + ) { + throw new Error( + `relay-support.yaml entry ${index + 1} included_unstable surfaces require non-empty ` + + 'openapi_selectors.format_tokens and openapi_selectors.media_types lists', + ); + } + } } } return parsed; diff --git a/docs/site/scripts/relay-release-contract.test.mjs b/docs/site/scripts/relay-release-contract.test.mjs index daf390c0a..358e4359b 100644 --- a/docs/site/scripts/relay-release-contract.test.mjs +++ b/docs/site/scripts/relay-release-contract.test.mjs @@ -87,6 +87,30 @@ test('generated Relay roster is byte-for-byte current', async () => { assert.equal(generated, `${JSON.stringify(source, null, 2)}\n`); }); +test('included unstable OpenAPI formats publish machine-readable selectors', async () => { + const roster = await loadRoster(); + const includedUnstable = roster.filter((entry) => entry.openapi_policy === 'included_unstable'); + assert.deepEqual( + new Map(includedUnstable.map((entry) => [entry.id, entry.openapi_selectors])), + new Map([ + [ + 'sdmx-json-aggregate-output', + { + format_tokens: ['sdmx-json'], + media_types: ['application/vnd.sdmx.data+json;version=2.1'], + }, + ], + [ + 'csv-aggregate-output', + { format_tokens: ['csv'], media_types: ['text/csv'] }, + ], + ]), + ); + for (const entry of includedUnstable) { + assert.equal(entry.category, 'aggregate_output', `${entry.id} selector category`); + } +}); + test('canonical Relay release, local image, and OpenAPI use the same feature set', async () => { const roster = await loadRoster(); const canonicalFeatures = new Set( diff --git a/docs/site/src/data/generated/relay-support.json b/docs/site/src/data/generated/relay-support.json index 6f9c992bd..94e721f59 100644 --- a/docs/site/src/data/generated/relay-support.json +++ b/docs/site/src/data/generated/relay-support.json @@ -289,6 +289,14 @@ "canonical_release": false, "cargo_features": [], "openapi_policy": "included_unstable", + "openapi_selectors": { + "format_tokens": [ + "sdmx-json" + ], + "media_types": [ + "application/vnd.sdmx.data+json;version=2.1" + ] + }, "decision_date": "2026-07-19", "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", "evidence": "crates/registry-relay/tests/aggregates_entity.rs", @@ -304,6 +312,14 @@ "canonical_release": false, "cargo_features": [], "openapi_policy": "included_unstable", + "openapi_selectors": { + "format_tokens": [ + "csv" + ], + "media_types": [ + "text/csv" + ] + }, "decision_date": "2026-07-19", "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", "evidence": "crates/registry-relay/tests/aggregates_entity.rs", diff --git a/docs/site/src/data/relay-support.yaml b/docs/site/src/data/relay-support.yaml index 3271fcf32..95bd95d05 100644 --- a/docs/site/src/data/relay-support.yaml +++ b/docs/site/src/data/relay-support.yaml @@ -241,6 +241,9 @@ canonical_release: false cargo_features: [] openapi_policy: included_unstable + openapi_selectors: + format_tokens: [sdmx-json] + media_types: [application/vnd.sdmx.data+json;version=2.1] decision_date: "2026-07-19" decision_reference: https://github.com/registrystack/registry-stack/issues/305 evidence: crates/registry-relay/tests/aggregates_entity.rs @@ -254,6 +257,9 @@ canonical_release: false cargo_features: [] openapi_policy: included_unstable + openapi_selectors: + format_tokens: [csv] + media_types: [text/csv] decision_date: "2026-07-19" decision_reference: https://github.com/registrystack/registry-stack/issues/305 evidence: crates/registry-relay/tests/aggregates_entity.rs From 273391a3e996830e9b0e0740a1081148704623de Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:54:32 +0700 Subject: [PATCH 18/65] ci(release): use canonical Relay feature set Signed-off-by: Jeremi Joslin --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca19312f5..fb28ac6c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -188,7 +188,7 @@ jobs: -p registry-manifest-cli \ -p registry-relay \ -p registry-notary \ - --features registry-relay/spdci-api-standards,registry-relay/standards-cel-mapping,registry-relay/ogcapi-edr,registry-notary/registry-notary-cel + --features registry-notary/registry-notary-cel cp target/release/registry-notary dist/image-bin/registry-notary cargo build --release --locked \ -p registry-notary \ From e1f920ece71df479bcff162e6da225b624c4fc08 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:55:18 +0700 Subject: [PATCH 19/65] test(relay): parse release features precisely Signed-off-by: Jeremi Joslin --- docs/site/scripts/relay-release-contract.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/site/scripts/relay-release-contract.test.mjs b/docs/site/scripts/relay-release-contract.test.mjs index 358e4359b..01421e197 100644 --- a/docs/site/scripts/relay-release-contract.test.mjs +++ b/docs/site/scripts/relay-release-contract.test.mjs @@ -140,7 +140,10 @@ test('canonical Relay release, local image, and OpenAPI use the same feature set ? await readFile(workflowPath, 'utf8') : await readRepo(workflowPath); const workflowRelayFeatures = new Set( - [...workflow.matchAll(/registry-relay\/([a-z][a-z0-9-]*)/g)].map((match) => match[1]), + [...workflow.matchAll(/--features\s+([^\s'"]+)/g)] + .flatMap((match) => match[1].split(',')) + .filter((feature) => feature.startsWith('registry-relay/')) + .map((feature) => feature.slice('registry-relay/'.length)), ); assert.deepEqual( workflowRelayFeatures, From 9fa26316588c028262d47bb167bf8afd9bfcadbd Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:47:51 +0700 Subject: [PATCH 20/65] feat(release): guard stable lifecycle contracts Signed-off-by: Jeremi Joslin --- release/contracts/selected-metrics.json | 164 ++++++++ release/exercises/README.md | 63 +++ .../upgrade-exercise-v1.template.json | 185 ++++++++ release/scripts/check-gates-inventory.py | 33 ++ .../check-stable-surface-compatibility.py | 397 ++++++++++++++++++ .../scripts/filter-relay-openapi-stability.py | 169 ++++++++ release/scripts/test_check_gates_inventory.py | 37 ++ ...test_check_stable_surface_compatibility.py | 141 +++++++ .../test_filter_relay_openapi_stability.py | 184 ++++++++ .../scripts/test_validate_upgrade_exercise.py | 114 +++++ release/scripts/validate-upgrade-exercise.py | 341 +++++++++++++++ 11 files changed, 1828 insertions(+) create mode 100644 release/contracts/selected-metrics.json create mode 100644 release/exercises/README.md create mode 100644 release/exercises/upgrade-exercise-v1.template.json create mode 100644 release/scripts/check-stable-surface-compatibility.py create mode 100644 release/scripts/filter-relay-openapi-stability.py create mode 100644 release/scripts/test_check_stable_surface_compatibility.py create mode 100644 release/scripts/test_filter_relay_openapi_stability.py create mode 100644 release/scripts/test_validate_upgrade_exercise.py create mode 100644 release/scripts/validate-upgrade-exercise.py diff --git a/release/contracts/selected-metrics.json b/release/contracts/selected-metrics.json new file mode 100644 index 000000000..82fb615e0 --- /dev/null +++ b/release/contracts/selected-metrics.json @@ -0,0 +1,164 @@ +{ + "schema": "registry-stack.selected-metrics/v1", + "release_line": 1, + "metrics": [ + { + "product": "registry-relay", + "name": "registry_relay_http_requests_total", + "type": "counter", + "meaning": "Completed HTTP requests grouped by bounded request attributes.", + "labels": { + "endpoint_kind": "Bounded route family, never a raw request path.", + "error_code": "Released machine error code, or none for a successful response.", + "method": "Normalized HTTP method.", + "status_class": "HTTP status class.", + "status_code": "HTTP response status code." + }, + "source": "crates/registry-relay/src/observability.rs" + }, + { + "product": "registry-relay", + "name": "registry_relay_http_request_duration_seconds", + "type": "histogram", + "meaning": "HTTP request duration in seconds grouped by bounded request attributes.", + "labels": { + "endpoint_kind": "Bounded route family, never a raw request path.", + "error_code": "Released machine error code, or none for a successful response.", + "method": "Normalized HTTP method.", + "status_class": "HTTP status class.", + "status_code": "HTTP response status code." + }, + "source": "crates/registry-relay/src/observability.rs" + }, + { + "product": "registry-relay", + "name": "registry_relay_readiness_ready_resources", + "type": "gauge", + "meaning": "Number of configured resources that are ready.", + "labels": {}, + "source": "crates/registry-relay/src/observability.rs" + }, + { + "product": "registry-relay", + "name": "registry_relay_readiness_not_ready_resources", + "type": "gauge", + "meaning": "Number of configured resources that are not ready.", + "labels": {}, + "source": "crates/registry-relay/src/observability.rs" + }, + { + "product": "registry-relay", + "name": "registry_relay_readiness_failed_resources", + "type": "gauge", + "meaning": "Number of configured resources whose latest ingest failed.", + "labels": {}, + "source": "crates/registry-relay/src/observability.rs" + }, + { + "product": "registry-relay", + "name": "registry_relay_readiness_unresolved_entities", + "type": "gauge", + "meaning": "Number of configured entities without a resolved backing resource.", + "labels": {}, + "source": "crates/registry-relay/src/observability.rs" + }, + { + "product": "registry-relay", + "name": "registry_relay_readiness_fully_ready", + "type": "gauge", + "meaning": "Whether every resource is ready and every entity is resolved, represented as zero or one.", + "labels": {}, + "source": "crates/registry-relay/src/observability.rs" + }, + { + "product": "registry-notary", + "name": "registry_notary_http_requests_total", + "type": "counter", + "meaning": "Completed HTTP requests grouped by bounded request attributes.", + "labels": { + "endpoint_kind": "Bounded route family, never a raw request path.", + "error_code": "Released machine error code, or none for a successful response.", + "method": "Normalized HTTP method.", + "status_class": "HTTP status class.", + "status_code": "HTTP response status code." + }, + "source": "crates/registry-notary-server/src/metrics.rs" + }, + { + "product": "registry-notary", + "name": "registry_notary_http_request_duration_seconds", + "type": "histogram", + "meaning": "HTTP request duration in seconds grouped by bounded request attributes.", + "labels": { + "endpoint_kind": "Bounded route family, never a raw request path.", + "error_code": "Released machine error code, or none for a successful response.", + "method": "Normalized HTTP method.", + "status_class": "HTTP status class.", + "status_code": "HTTP response status code." + }, + "source": "crates/registry-notary-server/src/metrics.rs" + }, + { + "product": "registry-notary", + "name": "registry_notary_audit_events_total", + "type": "counter", + "meaning": "Audit write attempts grouped by bounded outcome.", + "labels": { + "outcome": "Bounded audit write outcome." + }, + "source": "crates/registry-notary-server/src/metrics.rs" + }, + { + "product": "registry-notary", + "name": "registry_notary_replay_events_total", + "type": "counter", + "meaning": "Replay-store decisions grouped by protocol flow and bounded outcome.", + "labels": { + "flow": "Bounded replay-protected protocol flow.", + "outcome": "Bounded replay decision outcome." + }, + "source": "crates/registry-notary-server/src/metrics.rs" + }, + { + "product": "registry-notary", + "name": "registry_notary_credential_issuance_total", + "type": "counter", + "meaning": "Credential issuance attempts grouped by protocol and bounded outcome.", + "labels": { + "outcome": "Bounded credential issuance outcome.", + "protocol": "Credential issuance protocol." + }, + "source": "crates/registry-notary-server/src/metrics.rs" + }, + { + "product": "registry-notary", + "name": "registry_notary_cel_evaluations_total", + "type": "counter", + "meaning": "CEL worker evaluations grouped by bounded outcome.", + "labels": { + "outcome": "Bounded CEL evaluation outcome." + }, + "source": "crates/registry-notary-server/src/metrics.rs" + }, + { + "product": "registry-notary", + "name": "registry_notary_cel_evaluation_duration_ms_total", + "type": "counter", + "meaning": "Accumulated CEL evaluation duration in milliseconds grouped by bounded outcome.", + "labels": { + "outcome": "Bounded CEL evaluation outcome." + }, + "source": "crates/registry-notary-server/src/metrics.rs" + }, + { + "product": "registry-notary", + "name": "registry_notary_cel_worker_pool", + "type": "gauge", + "meaning": "Current CEL worker-pool state value.", + "labels": { + "state": "Bounded worker-pool state name." + }, + "source": "crates/registry-notary-server/src/metrics.rs" + } + ] +} diff --git a/release/exercises/README.md b/release/exercises/README.md new file mode 100644 index 000000000..34fb56a2b --- /dev/null +++ b/release/exercises/README.md @@ -0,0 +1,63 @@ +# Upgrade exercise records + +The files in this directory separate reusable release preparation from evidence +captured against an exact release candidate. + +## Candidate-neutral preparation + +`upgrade-exercise-v1.template.json` defines the machine-validated evidence +record for a Registry Stack stable upgrade. The template is preparation only. +Its `record_kind` is `template`, every result is `not_run`, and both candidate +attestations are `false`. A validated template does not satisfy a release gate. + +Validate the template with: + +```sh +python3 release/scripts/validate-upgrade-exercise.py --template \ + release/exercises/upgrade-exercise-v1.template.json +``` + +The template consumes the committed Relay and Notary configuration schemas in +`schemas/`. It does not define another configuration model. + +## Frozen-candidate evidence + +After the candidate source, release manifest, images, and standalone Solmara +release are frozen and independently verified: + +1. Copy the template to a candidate-specific JSON file. +2. Change `record_kind` to `candidate_evidence`. +3. Replace every placeholder with an exact version, commit, digest, timestamp, + bounded authority identifier, or evidence label. +4. Hash each committed configuration schema and every complete recovery-set + artifact. Do not copy secret values into the record. +5. Exercise every required check against the pinned standalone Solmara + topology. Record `passed` only when the retained evidence proves the check. +6. Set `candidate_frozen` and `candidate_independently_verified` to `true`. +7. Validate the candidate record without `--template`: + + ```sh + python3 release/scripts/validate-upgrade-exercise.py \ + release/exercises/ + ``` + +The validator accepts only a bounded schema. It records hashes and labels, not +raw commands, logs, database URLs, credentials, tokens, subject identifiers, +source rows, audit contents, or key material. Keep the underlying evidence in +the access-controlled release-evidence system and use its SHA-256 digest in the +public record. + +The candidate run must prove all of the following before the record validates: + +- Independently verified candidate artifacts and a ready source deployment +- Complete version-specific backup and restore sets +- Forward Notary schema upgrade and rejection by the older Notary binary +- Readiness before traffic admission and retained correctness state after restart +- Exactly one Notary authority paired with each Relay authority +- Registry-backed direct and OpenID for Verifiable Credential Issuance issuance +- General rollback before target traffic +- Fix-forward behavior after target writes or credential issuance +- Complete restore, restored readiness, and config anti-rollback rejection + +If any frozen candidate artifact changes, discard the result and repeat the +exercise against the new exact digests. diff --git a/release/exercises/upgrade-exercise-v1.template.json b/release/exercises/upgrade-exercise-v1.template.json new file mode 100644 index 000000000..2236b93bb --- /dev/null +++ b/release/exercises/upgrade-exercise-v1.template.json @@ -0,0 +1,185 @@ +{ + "schema": "registry-stack.upgrade-exercise/v1", + "record_kind": "template", + "exercise_id": "", + "recorded_at": "", + "source_release": { + "version": "", + "source_commit": "", + "relay_image_digest": "", + "notary_image_digest": "" + }, + "target_release": { + "version": "", + "source_commit": "", + "relay_image_digest": "", + "notary_image_digest": "" + }, + "target_release_manifest_sha256": "", + "candidate_frozen": false, + "candidate_independently_verified": false, + "config_schemas": { + "registry-relay": { + "path": "schemas/registry-relay.config.schema.json", + "sha256": "" + }, + "registry-notary": { + "path": "schemas/registry-notary.config.schema.json", + "sha256": "" + } + }, + "topology": { + "repository": "registrystack/solmara-lab", + "release_tag": "", + "source_commit": "", + "relay_authorities": [ + "" + ], + "notary_authorities": [ + "" + ], + "authority_pairs": [ + { + "relay": "", + "notary": "" + } + ] + }, + "recovery_set": [ + { + "item": "relay_database", + "artifact_sha256": "" + }, + { + "item": "notary_database", + "artifact_sha256": "" + }, + { + "item": "config_and_bundle", + "artifact_sha256": "" + }, + { + "item": "anti_rollback_state", + "artifact_sha256": "" + }, + { + "item": "audit_state", + "artifact_sha256": "" + }, + { + "item": "notary_sensitive_key_reference", + "artifact_sha256": "" + }, + { + "item": "relay_key_lifecycle_reference", + "artifact_sha256": "" + } + ], + "results": [ + { + "check_id": "candidate_artifacts_independently_verified", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + }, + { + "check_id": "source_release_ready", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + }, + { + "check_id": "pre_upgrade_complete_backup", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + }, + { + "check_id": "notary_forward_schema_upgrade", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + }, + { + "check_id": "older_notary_rejects_newer_schema", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + }, + { + "check_id": "target_products_ready_before_traffic", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + }, + { + "check_id": "one_notary_authority_per_relay_authority", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + }, + { + "check_id": "registry_backed_direct_issuance", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + }, + { + "check_id": "registry_backed_oid4vci_issuance", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + }, + { + "check_id": "target_restart_retains_correctness_state", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + }, + { + "check_id": "rollback_before_target_traffic", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + }, + { + "check_id": "post_write_fix_forward_boundary", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + }, + { + "check_id": "complete_restore", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + }, + { + "check_id": "restored_products_ready", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + }, + { + "check_id": "anti_rollback_rejects_older_bundle", + "outcome": "not_run", + "observed_at": "", + "evidence_label": "", + "evidence_sha256": "" + } + ] +} diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index df4ea6a63..a0bb8a15b 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -22,6 +22,7 @@ ), ("Cargo deny", "run: cargo deny check"), ("Notary OpenAPI baseline", "run: just openapi-check"), + ("Notary OpenAPI contract", "name: Notary OpenAPI contract"), ("Notary exposure check", "name: Notary exposure check"), ("Notary exposure command", "run: just exposure-check"), ("Relay OpenAPI contract", "name: Relay OpenAPI contract"), @@ -45,6 +46,38 @@ ), ("Gate inventory self-check", "run: python3 release/scripts/check-gates-inventory.py"), ("Gate inventory tests", "run: python3 -m unittest release/scripts/test_check_gates_inventory.py"), + ( + "Stable surface compatibility", + "run: python3 release/scripts/check-stable-surface-compatibility.py", + ), + ( + "Stable surface compatibility tests", + "run: python3 -m unittest release/scripts/test_check_stable_surface_compatibility.py", + ), + ( + "Relay OpenAPI stability filter tests", + "run: python3 -m unittest release/scripts/test_filter_relay_openapi_stability.py", + ), + ( + "Upgrade exercise validator tests", + "run: python3 -m unittest release/scripts/test_validate_upgrade_exercise.py", + ), + ( + "Upgrade exercise template validation", + "python3 release/scripts/validate-upgrade-exercise.py --template", + ), + ( + "Base-reference compatibility input", + "STABLE_SURFACE_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before }}", + ), + ( + "OpenAPI base-reference input", + "OPENAPI_CONTRACT_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before }}", + ), + ( + "Stable error registry path filter", + "docs/site/src/content/docs/reference/errors.mdx)", + ), ("Docs dependency install", "run: npm ci"), ("Docs tests", "run: npm test"), ("Docs build check", "run: npm run check"), diff --git a/release/scripts/check-stable-surface-compatibility.py b/release/scripts/check-stable-surface-compatibility.py new file mode 100644 index 000000000..338878c6c --- /dev/null +++ b/release/scripts/check-stable-surface-compatibility.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +"""Validate additive compatibility for released errors and selected metrics.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +METRICS_CONTRACT = Path("release/contracts/selected-metrics.json") +ERROR_REFERENCE = Path("docs/site/src/content/docs/reference/errors.mdx") +OPENAPI_SPECS = { + "registry-relay": Path("crates/registry-relay/openapi/registry-relay.openapi.json"), + "registry-notary": Path("products/notary/openapi/registry-notary.openapi.json"), +} +MACHINE_CODE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+$") +ERROR_ROW = re.compile(r"^\| `([^`]+)` \| ([^|]+) \|") +HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put", "trace"} + + +class ContractError(ValueError): + """A stable-surface contract is invalid or incompatible.""" + + +@dataclass(frozen=True) +class ErrorContract: + meaning: str + products: frozenset[str] + + +def parse_error_registry(text: str) -> dict[str, ErrorContract]: + product: str | None = None + entries: dict[str, tuple[str, set[str]]] = {} + for line_number, line in enumerate(text.splitlines(), 1): + if line == "## Registry Notary": + product = "registry-notary" + continue + if line == "## Registry Relay": + product = "registry-relay" + continue + if line.startswith("## "): + product = None + continue + match = ERROR_ROW.match(line) + if match is None or product is None: + continue + code, meaning = match.groups() + if MACHINE_CODE.fullmatch(code) is None: + continue + meaning = meaning.strip() + if not meaning: + raise ContractError(f"empty meaning for {code} at error reference line {line_number}") + if code in entries and entries[code][0] != meaning: + raise ContractError( + f"{code} has more than one stack-wide meaning: " + f"{entries[code][0]!r} and {meaning!r}" + ) + entries.setdefault(code, (meaning, set()))[1].add(product) + + if not entries: + raise ContractError("error reference contains no Registry Relay or Registry Notary codes") + return { + code: ErrorContract(meaning, frozenset(products)) + for code, (meaning, products) in entries.items() + } + + +def compare_error_contracts( + base: dict[str, ErrorContract], current: dict[str, ErrorContract] +) -> list[str]: + errors: list[str] = [] + for code, old in sorted(base.items()): + new = current.get(code) + if new is None: + errors.append(f"released error code removed: {code}") + continue + if new.meaning != old.meaning: + errors.append( + f"released error meaning changed for {code}: {old.meaning!r} -> {new.meaning!r}" + ) + removed_products = old.products - new.products + if removed_products: + errors.append( + f"released error code {code} removed from: {', '.join(sorted(removed_products))}" + ) + return errors + + +def load_json(text: str, label: str) -> Any: + try: + return json.loads(text) + except json.JSONDecodeError as error: + raise ContractError(f"{label} is not valid JSON: {error}") from error + + +def validate_metrics_contract(data: Any, root: Path = ROOT) -> dict[tuple[str, str], dict[str, Any]]: + if not isinstance(data, dict): + raise ContractError("selected metrics contract must be an object") + if data.get("schema") != "registry-stack.selected-metrics/v1": + raise ContractError("selected metrics contract has an unsupported schema") + if data.get("release_line") != 1: + raise ContractError("selected metrics contract must target release line 1") + metrics = data.get("metrics") + if not isinstance(metrics, list) or not metrics: + raise ContractError("selected metrics contract must contain a non-empty metrics list") + + result: dict[tuple[str, str], dict[str, Any]] = {} + allowed = {"product", "name", "type", "meaning", "labels", "source"} + for index, metric in enumerate(metrics): + label = f"metrics[{index}]" + if not isinstance(metric, dict) or set(metric) != allowed: + raise ContractError(f"{label} must contain exactly {', '.join(sorted(allowed))}") + product = metric["product"] + name = metric["name"] + metric_type = metric["type"] + meaning = metric["meaning"] + labels = metric["labels"] + source = metric["source"] + if product not in {"registry-relay", "registry-notary"}: + raise ContractError(f"{label}.product is not a released product") + if not isinstance(name, str) or re.fullmatch(r"[a-z_:][a-z0-9_:]*", name) is None: + raise ContractError(f"{label}.name is not a Prometheus metric name") + if metric_type not in {"counter", "gauge", "histogram", "summary", "untyped"}: + raise ContractError(f"{label}.type is not a Prometheus metric type") + if not isinstance(meaning, str) or not meaning.strip(): + raise ContractError(f"{label}.meaning must be non-empty") + if not isinstance(labels, dict) or any( + not isinstance(key, str) + or re.fullmatch(r"[a-z_][a-z0-9_]*", key) is None + or not isinstance(value, str) + or not value.strip() + for key, value in labels.items() + ): + raise ContractError(f"{label}.labels must map label names to non-empty meanings") + if not isinstance(source, str) or Path(source).is_absolute() or ".." in Path(source).parts: + raise ContractError(f"{label}.source must be a repository-relative path") + + source_path = root / source + if not source_path.is_file(): + raise ContractError(f"{label}.source does not exist: {source}") + source_text = source_path.read_text(encoding="utf-8") + type_declaration = f"# TYPE {name} {metric_type}" + if type_declaration not in source_text: + raise ContractError(f"{source} does not declare {type_declaration}") + for label_name in labels: + if f'{label_name}=\\"' not in source_text: + raise ContractError( + f"{source} does not emit selected label {label_name!r} for {name}" + ) + + key = (product, name) + if key in result: + raise ContractError(f"duplicate selected metric: {product} {name}") + result[key] = metric + return result + + +def compare_metrics_contracts( + base: dict[tuple[str, str], dict[str, Any]], + current: dict[tuple[str, str], dict[str, Any]], +) -> list[str]: + errors: list[str] = [] + protected = ("type", "meaning", "labels") + for key, old in sorted(base.items()): + new = current.get(key) + product, name = key + if new is None: + errors.append(f"selected metric removed: {product} {name}") + continue + for field in protected: + if new[field] != old[field]: + errors.append( + f"selected metric {product} {name} changed {field}: " + f"{old[field]!r} -> {new[field]!r}" + ) + return errors + + +def verify_error_codes_have_source( + errors: dict[str, ErrorContract], root: Path = ROOT +) -> list[str]: + source_parts: list[str] = [] + for crate_root in (root / "crates").glob("registry-*"): + if not crate_root.is_dir(): + continue + for path in crate_root.rglob("*.rs"): + source_parts.append(path.read_text(encoding="utf-8")) + for path in OPENAPI_SPECS.values(): + source_parts.append((root / path).read_text(encoding="utf-8")) + source = "\n".join(source_parts) + return [ + f"documented error code has no Rust or OpenAPI source literal: {code}" + for code in sorted(errors) + if f'"{code}"' not in source + ] + + +def _resolve_local_ref(document: Any, ref: str) -> Any: + value = document + for raw_segment in ref[2:].split("/"): + segment = raw_segment.replace("~1", "/").replace("~0", "~") + value = value[segment] + return value + + +def _codes_in_openapi(value: Any, document: Any, seen: frozenset[str] = frozenset()) -> set[str]: + if isinstance(value, dict): + ref = value.get("$ref") + if isinstance(ref, str) and ref.startswith("#/"): + if ref in seen: + return set() + return _codes_in_openapi(_resolve_local_ref(document, ref), document, seen | {ref}) + found: set[str] = set() + for key, nested in value.items(): + if key == "code" and isinstance(nested, str) and MACHINE_CODE.fullmatch(nested): + found.add(nested) + found.update(_codes_in_openapi(nested, document, seen)) + return found + if isinstance(value, list): + found: set[str] = set() + for nested in value: + found.update(_codes_in_openapi(nested, document, seen)) + return found + return set() + + +def openapi_error_mappings(document: Any, product: str) -> set[tuple[str, str, str, str, str]]: + mappings: set[tuple[str, str, str, str, str]] = set() + if not isinstance(document, dict) or not isinstance(document.get("paths"), dict): + raise ContractError(f"{product} OpenAPI document does not contain paths") + for path, path_item in document["paths"].items(): + if not isinstance(path_item, dict): + continue + for method, operation in path_item.items(): + if method not in HTTP_METHODS or not isinstance(operation, dict): + continue + responses = operation.get("responses", {}) + if not isinstance(responses, dict): + continue + for status, response in responses.items(): + for code in _codes_in_openapi(response, document): + mappings.add((product, method.upper(), path, str(status), code)) + return mappings + + +def compare_openapi_mappings( + base: set[tuple[str, str, str, str, str]], + current: set[tuple[str, str, str, str, str]], +) -> list[str]: + return [ + "documented error mapping removed or changed: " + " ".join(mapping) + for mapping in sorted(base - current) + ] + + +def git_show(ref: str, path: Path, root: Path = ROOT) -> str | None: + completed = subprocess.run( + ["git", "show", f"{ref}:{path.as_posix()}"], + cwd=root, + check=False, + capture_output=True, + text=True, + ) + if completed.returncode == 0: + return completed.stdout + return None + + +def valid_git_ref(ref: str, root: Path = ROOT) -> bool: + if not ref or set(ref) == {"0"}: + return False + completed = subprocess.run( + ["git", "rev-parse", "--verify", f"{ref}^{{commit}}"], + cwd=root, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return completed.returncode == 0 + + +def check(base_ref: str | None, root: Path = ROOT) -> list[str]: + current_errors = parse_error_registry((root / ERROR_REFERENCE).read_text(encoding="utf-8")) + errors = verify_error_codes_have_source(current_errors, root) + current_metrics_data = load_json( + (root / METRICS_CONTRACT).read_text(encoding="utf-8"), str(METRICS_CONTRACT) + ) + current_metrics = validate_metrics_contract(current_metrics_data, root) + current_mappings: set[tuple[str, str, str, str, str]] = set() + for product, path in OPENAPI_SPECS.items(): + document = load_json((root / path).read_text(encoding="utf-8"), str(path)) + current_mappings.update(openapi_error_mappings(document, product)) + + if not base_ref: + return errors + if not valid_git_ref(base_ref, root): + errors.append(f"stable-surface base ref is not available: {base_ref}") + return errors + + base_metrics_text = git_show(base_ref, METRICS_CONTRACT, root) + if base_metrics_text is None: + print( + f"stable-surface contract did not exist at {base_ref}; validated bootstrap contract", + file=sys.stderr, + ) + return errors + + base_metrics_data = load_json(base_metrics_text, f"{base_ref}:{METRICS_CONTRACT}") + # Validate shape without requiring base sources to exist in the current tree. + base_metrics = _validate_metrics_shape_only(base_metrics_data) + errors.extend(compare_metrics_contracts(base_metrics, current_metrics)) + + base_errors_text = git_show(base_ref, ERROR_REFERENCE, root) + if base_errors_text is None: + errors.append(f"base ref lacks released error registry: {ERROR_REFERENCE}") + else: + errors.extend(compare_error_contracts(parse_error_registry(base_errors_text), current_errors)) + + base_mappings: set[tuple[str, str, str, str, str]] = set() + for product, path in OPENAPI_SPECS.items(): + base_text = git_show(base_ref, path, root) + if base_text is None: + continue + base_mappings.update(openapi_error_mappings(load_json(base_text, f"{base_ref}:{path}"), product)) + errors.extend(compare_openapi_mappings(base_mappings, current_mappings)) + return errors + + +def _validate_metrics_shape_only(data: Any) -> dict[tuple[str, str], dict[str, Any]]: + if not isinstance(data, dict) or data.get("schema") != "registry-stack.selected-metrics/v1": + raise ContractError("base selected metrics contract has an unsupported schema") + if data.get("release_line") != 1: + raise ContractError("base selected metrics contract must target release line 1") + metrics = data.get("metrics") + if not isinstance(metrics, list) or not metrics: + raise ContractError("base selected metrics contract has no non-empty metrics list") + result: dict[tuple[str, str], dict[str, Any]] = {} + for index, metric in enumerate(metrics): + label = f"base metrics[{index}]" + if not isinstance(metric, dict): + raise ContractError(f"{label} is not an object") + try: + product = metric["product"] + name = metric["name"] + for field in ("type", "meaning", "labels"): + metric[field] + except (KeyError, TypeError) as error: + raise ContractError(f"{label} is missing a protected field") from error + if product not in {"registry-relay", "registry-notary"}: + raise ContractError(f"{label}.product is not a released product") + if not isinstance(name, str) or re.fullmatch(r"[a-z_:][a-z0-9_:]*", name) is None: + raise ContractError(f"{label}.name is not a Prometheus metric name") + if metric["type"] not in {"counter", "gauge", "histogram", "summary", "untyped"}: + raise ContractError(f"{label}.type is not a Prometheus metric type") + if not isinstance(metric["meaning"], str) or not metric["meaning"].strip(): + raise ContractError(f"{label}.meaning must be non-empty") + if not isinstance(metric["labels"], dict): + raise ContractError(f"{label}.labels must be an object") + key = (product, name) + if key in result: + raise ContractError(f"duplicate base selected metric: {product} {name}") + result[key] = metric + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--base-ref", + default=os.environ.get("STABLE_SURFACE_BASE_REF"), + help="Git commit to compare against; omit for current-contract validation only", + ) + args = parser.parse_args() + try: + errors = check(args.base_ref) + except (ContractError, OSError, KeyError, TypeError) as error: + print(f"stable-surface compatibility check failed: {error}", file=sys.stderr) + return 1 + if errors: + print("stable-surface compatibility check failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + print("stable-surface compatibility check passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/release/scripts/filter-relay-openapi-stability.py b/release/scripts/filter-relay-openapi-stability.py new file mode 100644 index 000000000..128cda877 --- /dev/null +++ b/release/scripts/filter-relay-openapi-stability.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Remove roster-declared unstable formats from Relay OpenAPI compatibility inputs.""" + +from __future__ import annotations + +import argparse +import copy +import json +import sys +from pathlib import Path +from typing import Any + + +class RosterError(ValueError): + """The Relay support roster cannot safely drive compatibility filtering.""" + + +def load_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + raise RosterError(f"{path} is not valid JSON: {error}") from error + + +def index_roster(value: Any) -> dict[str, dict[str, Any]]: + if not isinstance(value, list) or not value: + raise RosterError("Relay support roster must be a non-empty list") + result: dict[str, dict[str, Any]] = {} + for index, entry in enumerate(value): + if not isinstance(entry, dict) or not isinstance(entry.get("id"), str): + raise RosterError(f"Relay support roster entry {index} has no string id") + entry_id = entry["id"] + if entry_id in result: + raise RosterError(f"duplicate Relay support roster id: {entry_id}") + result[entry_id] = entry + return result + + +def compare_rosters( + base_value: Any, current_value: Any +) -> list[str]: + """Reject a downgrade of a surface that the base roster promised as stable.""" + base = index_roster(base_value) + current = index_roster(current_value) + errors: list[str] = [] + for entry_id, old in sorted(base.items()): + if old.get("stability_tier") != "stable": + continue + new = current.get(entry_id) + if new is None: + errors.append(f"stable Relay roster surface removed: {entry_id}") + continue + if new.get("stability_tier") != "stable" or new.get("canonical_release") is not True: + errors.append(f"stable Relay roster surface downgraded: {entry_id}") + if old.get("openapi_policy") == "included" and new.get("openapi_policy") != "included": + errors.append(f"stable Relay OpenAPI surface excluded: {entry_id}") + return errors + + +def unstable_aggregate_selectors(roster_value: Any) -> tuple[set[str], set[str]]: + """Read selectors only from authoritative included-unstable roster entries.""" + tokens: set[str] = set() + media_types: set[str] = set() + for entry_id, entry in index_roster(roster_value).items(): + if entry.get("openapi_policy") != "included_unstable": + continue + if ( + entry.get("category") != "aggregate_output" + or entry.get("stability_tier") != "experimental" + or entry.get("feature_frozen") is not True + or entry.get("canonical_release") is not False + ): + raise RosterError( + f"included-unstable Relay surface {entry_id} must be a frozen, " + "non-canonical experimental aggregate output" + ) + selectors = entry.get("openapi_selectors") + if not isinstance(selectors, dict) or set(selectors) != { + "format_tokens", + "media_types", + }: + raise RosterError( + f"included-unstable Relay surface {entry_id} needs exact OpenAPI selectors" + ) + entry_tokens = selectors["format_tokens"] + entry_media_types = selectors["media_types"] + if ( + not isinstance(entry_tokens, list) + or not entry_tokens + or any(not isinstance(item, str) or not item for item in entry_tokens) + or not isinstance(entry_media_types, list) + or not entry_media_types + or any(not isinstance(item, str) or not item for item in entry_media_types) + ): + raise RosterError( + f"included-unstable Relay surface {entry_id} has empty or invalid selectors" + ) + tokens.update(entry_tokens) + media_types.update(entry_media_types) + return tokens, media_types + + +def _filter_operation(value: Any, tokens: set[str], media_types: set[str]) -> None: + if isinstance(value, dict): + enum = value.get("enum") + if isinstance(enum, list): + value["enum"] = [item for item in enum if item not in tokens] + content = value.get("content") + if isinstance(content, dict): + for media_type in media_types: + content.pop(media_type, None) + for nested in value.values(): + _filter_operation(nested, tokens, media_types) + elif isinstance(value, list): + for nested in value: + _filter_operation(nested, tokens, media_types) + + +def filter_openapi(document: Any, roster_value: Any) -> dict[str, Any]: + if not isinstance(document, dict) or not isinstance(document.get("paths"), dict): + raise RosterError("Relay OpenAPI document has no paths object") + tokens, media_types = unstable_aggregate_selectors(roster_value) + filtered = copy.deepcopy(document) + for path, path_item in filtered["paths"].items(): + if "aggregates" not in path.split("/"): + continue + _filter_operation(path_item, tokens, media_types) + return filtered + + +def write_json(path: Path, value: Any) -> None: + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--current-roster", required=True, type=Path) + parser.add_argument("--base-roster", type=Path) + parser.add_argument("--base-openapi", required=True, type=Path) + parser.add_argument("--current-openapi", required=True, type=Path) + parser.add_argument("--filtered-base", required=True, type=Path) + parser.add_argument("--filtered-current", required=True, type=Path) + args = parser.parse_args() + try: + current_roster = load_json(args.current_roster) + if args.base_roster is None: + base_roster = current_roster + else: + base_roster = load_json(args.base_roster) + errors = compare_rosters(base_roster, current_roster) + if errors: + raise RosterError("; ".join(errors)) + write_json( + args.filtered_base, + filter_openapi(load_json(args.base_openapi), base_roster), + ) + write_json( + args.filtered_current, + filter_openapi(load_json(args.current_openapi), current_roster), + ) + except (OSError, RosterError) as error: + print(f"Relay OpenAPI stability filter failed: {error}", file=sys.stderr) + return 1 + print("Relay OpenAPI stability filter passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index 2fa523344..fd3cdf411 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -58,6 +58,43 @@ def test_missing_openid_conformance_runner_tests_are_reported(self) -> None: "OpenID conformance runner tests", self.module.missing_gates(text) ) + def test_missing_stable_surface_gate_is_reported(self) -> None: + text = self.workflow.replace( + "run: python3 release/scripts/check-stable-surface-compatibility.py", + "run: python3 release/scripts/skip-stable-surface-compatibility.py", + ) + self.assertIn("Stable surface compatibility", self.module.missing_gates(text)) + + def test_missing_relay_openapi_stability_filter_tests_are_reported(self) -> None: + text = self.workflow.replace( + "run: python3 -m unittest release/scripts/test_filter_relay_openapi_stability.py", + "run: python3 -m unittest release/scripts/skip_filter_relay_openapi_stability.py", + ) + self.assertIn("Relay OpenAPI stability filter tests", self.module.missing_gates(text)) + + def test_missing_openapi_base_reference_is_reported(self) -> None: + text = self.workflow.replace( + "OPENAPI_CONTRACT_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before }}", + "OPENAPI_CONTRACT_BASE_REF: disabled", + ) + self.assertIn("OpenAPI base-reference input", self.module.missing_gates(text)) + + def test_missing_upgrade_exercise_template_validation_is_reported(self) -> None: + text = self.workflow.replace( + "python3 release/scripts/validate-upgrade-exercise.py --template", + "python3 release/scripts/validate-upgrade-exercise.py --skip-template", + ) + self.assertIn( + "Upgrade exercise template validation", self.module.missing_gates(text) + ) + + def test_missing_stable_error_registry_path_filter_is_reported(self) -> None: + text = self.workflow.replace( + "docs/site/src/content/docs/reference/errors.mdx)", + "docs/site/src/content/docs/reference/removed-errors.mdx)", + ) + self.assertIn("Stable error registry path filter", self.module.missing_gates(text)) + def test_missing_registryctl_tutorial_path_filter_is_reported(self) -> None: text = self.workflow.replace( "registryctl_tutorial: ${{ steps.filter.outputs.registryctl_tutorial }}", diff --git a/release/scripts/test_check_stable_surface_compatibility.py b/release/scripts/test_check_stable_surface_compatibility.py new file mode 100644 index 000000000..88442def5 --- /dev/null +++ b/release/scripts/test_check_stable_surface_compatibility.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "release" / "scripts" / "check-stable-surface-compatibility.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("stable_surface", SCRIPT) + if spec is None or spec.loader is None: + raise ImportError(f"could not load module spec from {SCRIPT}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class StableSurfaceCompatibilityTest(unittest.TestCase): + def setUp(self) -> None: + self.module = load_module() + + def test_error_registry_requires_one_stack_wide_meaning(self) -> None: + text = """\ +## Registry Notary +| Code | Meaning | Cause | +| --- | --- | --- | +| `auth.scope_denied` | required scope is missing | x | +## Registry Relay +| Code | Meaning | Cause | +| --- | --- | --- | +| `auth.scope_denied` | scope denied | x | +""" + with self.assertRaisesRegex(self.module.ContractError, "stack-wide meaning"): + self.module.parse_error_registry(text) + + def test_error_additions_are_allowed_but_removal_and_change_are_not(self) -> None: + old = { + "request.invalid": self.module.ErrorContract( + "request is invalid", frozenset({"registry-notary", "registry-relay"}) + ) + } + additive = { + **old, + "request.conflict": self.module.ErrorContract( + "request conflicts", frozenset({"registry-notary"}) + ), + } + self.assertEqual([], self.module.compare_error_contracts(old, additive)) + self.assertIn( + "released error code removed: request.invalid", + self.module.compare_error_contracts(old, {}), + ) + changed = { + "request.invalid": self.module.ErrorContract( + "different meaning", frozenset({"registry-notary"}) + ) + } + errors = self.module.compare_error_contracts(old, changed) + self.assertTrue(any("meaning changed" in error for error in errors)) + self.assertTrue(any("removed from: registry-relay" in error for error in errors)) + + def test_metric_contract_is_anchored_in_source(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "metrics.rs" + source.write_text( + '# TYPE product_requests_total counter\\nmetric{{outcome=\\"{}\\"}}', + encoding="utf-8", + ) + contract = { + "schema": "registry-stack.selected-metrics/v1", + "release_line": 1, + "metrics": [ + { + "product": "registry-notary", + "name": "product_requests_total", + "type": "counter", + "meaning": "Completed requests.", + "labels": {"outcome": "Bounded outcome."}, + "source": "metrics.rs", + } + ], + } + validated = self.module.validate_metrics_contract(contract, root) + self.assertIn(("registry-notary", "product_requests_total"), validated) + contract["metrics"][0]["labels"] = {"route": "Raw route."} + with self.assertRaisesRegex(self.module.ContractError, "selected label"): + self.module.validate_metrics_contract(contract, root) + + def test_metric_additions_are_allowed_but_protected_fields_do_not_change(self) -> None: + metric = { + "product": "registry-relay", + "name": "requests_total", + "type": "counter", + "meaning": "Completed requests.", + "labels": {"outcome": "Bounded outcome."}, + "source": "metrics.rs", + } + key = (metric["product"], metric["name"]) + self.assertEqual([], self.module.compare_metrics_contracts({key: metric}, {key: metric})) + changed = {**metric, "type": "gauge"} + errors = self.module.compare_metrics_contracts({key: metric}, {key: changed}) + self.assertTrue(any("changed type" in error for error in errors)) + self.assertTrue(self.module.compare_metrics_contracts({key: metric}, {})) + + def test_openapi_error_mapping_removal_is_breaking(self) -> None: + document = { + "paths": { + "/v1/items": { + "get": { + "responses": { + "404": { + "content": { + "application/problem+json": { + "example": {"code": "item.not_found"} + } + } + } + } + } + } + } + } + mapping = self.module.openapi_error_mappings(document, "registry-relay") + self.assertEqual(1, len(mapping)) + errors = self.module.compare_openapi_mappings(mapping, set()) + self.assertEqual(1, len(errors)) + self.assertIn("item.not_found", errors[0]) + + def test_real_current_contract_validates_without_a_base(self) -> None: + self.assertEqual([], self.module.check(None, ROOT)) + + +if __name__ == "__main__": + unittest.main() diff --git a/release/scripts/test_filter_relay_openapi_stability.py b/release/scripts/test_filter_relay_openapi_stability.py new file mode 100644 index 000000000..511581977 --- /dev/null +++ b/release/scripts/test_filter_relay_openapi_stability.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "release" / "scripts" / "filter-relay-openapi-stability.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("filter_relay_openapi_stability", SCRIPT) + if spec is None or spec.loader is None: + raise ImportError(f"could not load module spec from {SCRIPT}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def roster_entry( + entry_id: str, + *, + stability: str, + policy: str, + token: str | None = None, + media_type: str | None = None, +): + entry = { + "id": entry_id, + "category": "aggregate_output", + "stability_tier": stability, + "feature_frozen": stability == "experimental", + "canonical_release": stability == "stable", + "openapi_policy": policy, + } + if token is not None and media_type is not None: + entry["openapi_selectors"] = { + "format_tokens": [token], + "media_types": [media_type], + } + return entry + + +class RelayOpenapiStabilityFilterTest(unittest.TestCase): + def setUp(self) -> None: + self.module = load_module() + self.roster = [ + roster_entry("json-output", stability="stable", policy="included"), + roster_entry( + "csv-output", + stability="experimental", + policy="included_unstable", + token="csv", + media_type="text/csv", + ), + roster_entry( + "sdmx-output", + stability="experimental", + policy="included_unstable", + token="sdmx-json", + media_type="application/vnd.sdmx.data+json;version=2.1", + ), + ] + aggregate_operation = { + "get": { + "parameters": [ + { + "name": "f", + "schema": {"enum": ["json", "csv", "sdmx-json"]}, + } + ], + "responses": { + "200": { + "content": { + "application/json": {"schema": {"type": "object"}}, + "text/csv": {"schema": {"type": "string"}}, + "application/vnd.sdmx.data+json;version=2.1": { + "schema": {"type": "object"} + }, + } + } + }, + } + } + self.document = { + "openapi": "3.1.0", + "paths": { + "/v1/datasets/{dataset_id}/aggregates/{aggregate_id}": aggregate_operation, + "/v1/datasets/{dataset_id}/entities/{entity}/records/{record_id}": { + "get": { + "responses": { + "200": { + "content": { + "application/json": {"schema": {"type": "object"}} + } + } + } + } + }, + }, + } + + def test_roster_selectors_remove_only_unstable_aggregate_representations(self) -> None: + filtered = self.module.filter_openapi(self.document, self.roster) + operation = filtered["paths"]["/v1/datasets/{dataset_id}/aggregates/{aggregate_id}"][ + "get" + ] + self.assertEqual(["json"], operation["parameters"][0]["schema"]["enum"]) + self.assertEqual( + {"application/json"}, set(operation["responses"]["200"]["content"]) + ) + self.assertIn( + "/v1/datasets/{dataset_id}/entities/{entity}/records/{record_id}", + filtered["paths"], + ) + self.assertEqual( + self.document["paths"][ + "/v1/datasets/{dataset_id}/entities/{entity}/records/{record_id}" + ], + filtered["paths"][ + "/v1/datasets/{dataset_id}/entities/{entity}/records/{record_id}" + ], + ) + + def test_filter_has_no_hard_coded_format_list(self) -> None: + roster = [ + roster_entry( + "future-output", + stability="experimental", + policy="included_unstable", + token="future-format", + media_type="application/vnd.example.future", + ) + ] + document = { + "paths": { + "/v1/aggregates/example": { + "get": { + "parameters": [{"schema": {"enum": ["json", "future-format"]}}], + "responses": { + "200": { + "content": { + "application/json": {}, + "application/vnd.example.future": {}, + } + } + }, + } + } + } + } + filtered = self.module.filter_openapi(document, roster) + operation = filtered["paths"]["/v1/aggregates/example"]["get"] + self.assertEqual(["json"], operation["parameters"][0]["schema"]["enum"]) + self.assertEqual({"application/json"}, set(operation["responses"]["200"]["content"])) + + def test_stable_roster_surface_cannot_be_downgraded(self) -> None: + base = [roster_entry("json-output", stability="stable", policy="included")] + current = [ + roster_entry( + "json-output", + stability="experimental", + policy="included_unstable", + token="json", + media_type="application/json", + ) + ] + errors = self.module.compare_rosters(base, current) + self.assertTrue(any("downgraded" in error for error in errors)) + self.assertTrue(any("excluded" in error for error in errors)) + + def test_included_unstable_entry_requires_authoritative_selectors(self) -> None: + invalid = [ + roster_entry("csv-output", stability="experimental", policy="included_unstable") + ] + with self.assertRaisesRegex(self.module.RosterError, "exact OpenAPI selectors"): + self.module.filter_openapi(self.document, invalid) + + +if __name__ == "__main__": + unittest.main() diff --git a/release/scripts/test_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py new file mode 100644 index 000000000..c7c889dd4 --- /dev/null +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import copy +import hashlib +import importlib.util +import json +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "release" / "scripts" / "validate-upgrade-exercise.py" +TEMPLATE = ROOT / "release" / "exercises" / "upgrade-exercise-v1.template.json" + + +def load_module(): + spec = importlib.util.spec_from_file_location("validate_upgrade_exercise", SCRIPT) + if spec is None or spec.loader is None: + raise ImportError(f"could not load module spec from {SCRIPT}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class UpgradeExerciseValidatorTest(unittest.TestCase): + def setUp(self) -> None: + self.module = load_module() + self.template = json.loads(TEMPLATE.read_text(encoding="utf-8")) + + def candidate(self): + def replace(value): + if isinstance(value, dict): + return {key: replace(item) for key, item in value.items()} + if isinstance(value, list): + return [replace(item) for item in value] + if not isinstance(value, str) or not value.startswith("<"): + return value + if value == "": + return "v1-upgrade-exercise" + if value == "": + return "relay-authority-a" + if value == "": + return "notary-authority-a" + if value == "": + return "redacted-evidence" + if "AT>" in value: + return "2026-07-19T12:00:00Z" + if "VERSION>" in value or "RELEASE_TAG>" in value: + return "v1.0.0" + if "COMMIT>" in value: + return "a" * 40 + if "SHA256>" in value or "DIGEST>" in value: + return "sha256:" + "b" * 64 + raise AssertionError(f"unhandled placeholder {value}") + + record = replace(copy.deepcopy(self.template)) + record["record_kind"] = "candidate_evidence" + record["candidate_frozen"] = True + record["candidate_independently_verified"] = True + for result in record["results"]: + result["outcome"] = "passed" + for product, path in self.module.CONFIG_SCHEMAS.items(): + record["config_schemas"][product]["sha256"] = "sha256:" + hashlib.sha256( + (ROOT / path).read_bytes() + ).hexdigest() + return record + + def test_template_is_valid_preparation_but_not_candidate_evidence(self) -> None: + self.module.validate_record(self.template, allow_template=True) + with self.assertRaisesRegex(self.module.ExerciseError, "not candidate evidence"): + self.module.validate_record(self.template, allow_template=False) + + def test_complete_candidate_record_is_valid(self) -> None: + self.module.validate_record(self.candidate(), allow_template=False) + + def test_unknown_field_is_rejected_to_prevent_raw_evidence(self) -> None: + record = self.candidate() + record["results"][0]["raw_output"] = "Authorization: Bearer secret" + with self.assertRaisesRegex(self.module.ExerciseError, "unknown raw_output"): + self.module.validate_record(record, allow_template=False) + + def test_authority_identifier_cannot_contain_a_url_or_subject_data(self) -> None: + record = self.candidate() + record["topology"]["relay_authorities"][0] = "https://registry.example.test/subject/1" + with self.assertRaisesRegex(self.module.ExerciseError, "invalid or unsafe"): + self.module.validate_record(record, allow_template=False) + + def test_every_required_check_must_pass(self) -> None: + record = self.candidate() + record["results"].pop() + with self.assertRaisesRegex(self.module.ExerciseError, "every required check"): + self.module.validate_record(record, allow_template=False) + record = self.candidate() + record["results"][0]["outcome"] = "failed" + with self.assertRaisesRegex(self.module.ExerciseError, "must be 'passed'"): + self.module.validate_record(record, allow_template=False) + + def test_complete_release_specific_recovery_set_is_required(self) -> None: + record = self.candidate() + record["recovery_set"].pop() + with self.assertRaisesRegex(self.module.ExerciseError, "complete release-specific"): + self.module.validate_record(record, allow_template=False) + + def test_candidate_must_use_committed_config_schemas(self) -> None: + record = self.candidate() + record["config_schemas"]["registry-notary"]["sha256"] = "sha256:" + "0" * 64 + with self.assertRaisesRegex(self.module.ExerciseError, "committed schema"): + self.module.validate_record(record, allow_template=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/release/scripts/validate-upgrade-exercise.py b/release/scripts/validate-upgrade-exercise.py new file mode 100644 index 000000000..f0653dc58 --- /dev/null +++ b/release/scripts/validate-upgrade-exercise.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Validate a redaction-safe Registry Stack upgrade exercise record.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +SCHEMA = "registry-stack.upgrade-exercise/v1" +SOLMARA_REPOSITORY = "registrystack/solmara-lab" +CONFIG_SCHEMAS = { + "registry-relay": Path("schemas/registry-relay.config.schema.json"), + "registry-notary": Path("schemas/registry-notary.config.schema.json"), +} +REQUIRED_CHECKS = ( + "candidate_artifacts_independently_verified", + "source_release_ready", + "pre_upgrade_complete_backup", + "notary_forward_schema_upgrade", + "older_notary_rejects_newer_schema", + "target_products_ready_before_traffic", + "one_notary_authority_per_relay_authority", + "registry_backed_direct_issuance", + "registry_backed_oid4vci_issuance", + "target_restart_retains_correctness_state", + "rollback_before_target_traffic", + "post_write_fix_forward_boundary", + "complete_restore", + "restored_products_ready", + "anti_rollback_rejects_older_bundle", +) +REQUIRED_RECOVERY_ITEMS = ( + "relay_database", + "notary_database", + "config_and_bundle", + "anti_rollback_state", + "audit_state", + "notary_sensitive_key_reference", + "relay_key_lifecycle_reference", +) +SLUG = re.compile(r"^[a-z0-9][a-z0-9._-]{0,127}$") +VERSION = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$") +COMMIT = re.compile(r"^[0-9a-f]{40}$") +SHA256 = re.compile(r"^(?:sha256:)?[0-9a-f]{64}$") +TIMESTAMP = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" +) +PLACEHOLDER = re.compile(r"^<[A-Z0-9_]+>$") + + +class ExerciseError(ValueError): + """An upgrade exercise record is invalid.""" + + +def require_object(value: Any, label: str, keys: set[str]) -> dict[str, Any]: + if not isinstance(value, dict): + raise ExerciseError(f"{label} must be an object") + unknown = set(value) - keys + missing = keys - set(value) + if unknown or missing: + details: list[str] = [] + if missing: + details.append("missing " + ", ".join(sorted(missing))) + if unknown: + details.append("unknown " + ", ".join(sorted(unknown))) + raise ExerciseError(f"{label} has invalid fields: {'; '.join(details)}") + return value + + +def bounded_string( + value: Any, + label: str, + pattern: re.Pattern[str], + *, + template: bool, +) -> str: + if not isinstance(value, str): + raise ExerciseError(f"{label} must be a string") + if template and PLACEHOLDER.fullmatch(value): + return value + if pattern.fullmatch(value) is None: + raise ExerciseError(f"{label} has an invalid or unsafe value") + return value + + +def validate_release(value: Any, label: str, *, template: bool) -> None: + release = require_object( + value, + label, + {"version", "source_commit", "relay_image_digest", "notary_image_digest"}, + ) + bounded_string(release["version"], f"{label}.version", VERSION, template=template) + bounded_string(release["source_commit"], f"{label}.source_commit", COMMIT, template=template) + bounded_string( + release["relay_image_digest"], + f"{label}.relay_image_digest", + SHA256, + template=template, + ) + bounded_string( + release["notary_image_digest"], + f"{label}.notary_image_digest", + SHA256, + template=template, + ) + + +def validate_config_schemas(value: Any, *, template: bool, root: Path) -> None: + schemas = require_object(value, "config_schemas", set(CONFIG_SCHEMAS)) + for product, expected_path in CONFIG_SCHEMAS.items(): + entry = require_object( + schemas[product], f"config_schemas.{product}", {"path", "sha256"} + ) + if entry["path"] != expected_path.as_posix(): + raise ExerciseError( + f"config_schemas.{product}.path must consume {expected_path.as_posix()}" + ) + digest = bounded_string( + entry["sha256"], f"config_schemas.{product}.sha256", SHA256, template=template + ) + if not template: + actual = hashlib.sha256((root / expected_path).read_bytes()).hexdigest() + if digest.removeprefix("sha256:") != actual: + raise ExerciseError( + f"config_schemas.{product}.sha256 does not match the committed schema" + ) + + +def validate_topology(value: Any, *, template: bool) -> None: + topology = require_object( + value, + "topology", + { + "repository", + "release_tag", + "source_commit", + "relay_authorities", + "notary_authorities", + "authority_pairs", + }, + ) + if topology["repository"] != SOLMARA_REPOSITORY: + raise ExerciseError(f"topology.repository must be {SOLMARA_REPOSITORY}") + bounded_string(topology["release_tag"], "topology.release_tag", VERSION, template=template) + bounded_string(topology["source_commit"], "topology.source_commit", COMMIT, template=template) + relay = validate_authorities( + topology["relay_authorities"], "topology.relay_authorities", template=template + ) + notary = validate_authorities( + topology["notary_authorities"], "topology.notary_authorities", template=template + ) + pairs = topology["authority_pairs"] + if not isinstance(pairs, list) or not pairs: + raise ExerciseError("topology.authority_pairs must be a non-empty list") + seen_relay: set[str] = set() + seen_notary: set[str] = set() + for index, pair_value in enumerate(pairs): + pair = require_object( + pair_value, f"topology.authority_pairs[{index}]", {"relay", "notary"} + ) + pair_relay = bounded_string( + pair["relay"], f"topology.authority_pairs[{index}].relay", SLUG, template=template + ) + pair_notary = bounded_string( + pair["notary"], f"topology.authority_pairs[{index}].notary", SLUG, template=template + ) + if not template and (pair_relay not in relay or pair_notary not in notary): + raise ExerciseError("topology.authority_pairs references an undeclared authority") + if pair_relay in seen_relay or pair_notary in seen_notary: + raise ExerciseError("topology.authority_pairs must be one-to-one") + seen_relay.add(pair_relay) + seen_notary.add(pair_notary) + if not template and (seen_relay != relay or seen_notary != notary): + raise ExerciseError("every Relay and Notary authority must appear in exactly one pair") + + +def validate_authorities(value: Any, label: str, *, template: bool) -> set[str]: + if not isinstance(value, list) or not value: + raise ExerciseError(f"{label} must be a non-empty list") + result = { + bounded_string(item, f"{label}[{index}]", SLUG, template=template) + for index, item in enumerate(value) + } + if len(result) != len(value): + raise ExerciseError(f"{label} must not contain duplicates") + return result + + +def validate_recovery_set(value: Any, *, template: bool) -> None: + if not isinstance(value, list): + raise ExerciseError("recovery_set must be a list") + seen: set[str] = set() + for index, item_value in enumerate(value): + item = require_object( + item_value, f"recovery_set[{index}]", {"item", "artifact_sha256"} + ) + item_name = bounded_string( + item["item"], f"recovery_set[{index}].item", SLUG, template=template + ) + bounded_string( + item["artifact_sha256"], + f"recovery_set[{index}].artifact_sha256", + SHA256, + template=template, + ) + if item_name in seen: + raise ExerciseError(f"duplicate recovery_set item: {item_name}") + seen.add(item_name) + if not template and seen != set(REQUIRED_RECOVERY_ITEMS): + missing = set(REQUIRED_RECOVERY_ITEMS) - seen + extra = seen - set(REQUIRED_RECOVERY_ITEMS) + raise ExerciseError( + "recovery_set must contain the complete release-specific restore set" + + (f"; missing {', '.join(sorted(missing))}" if missing else "") + + (f"; unknown {', '.join(sorted(extra))}" if extra else "") + ) + + +def validate_results(value: Any, *, template: bool) -> None: + if not isinstance(value, list): + raise ExerciseError("results must be a list") + seen: set[str] = set() + for index, result_value in enumerate(value): + result = require_object( + result_value, + f"results[{index}]", + {"check_id", "outcome", "observed_at", "evidence_label", "evidence_sha256"}, + ) + check_id = bounded_string( + result["check_id"], f"results[{index}].check_id", SLUG, template=template + ) + if check_id not in REQUIRED_CHECKS: + raise ExerciseError(f"results[{index}].check_id is not a required check") + expected_outcome = "not_run" if template else "passed" + if result["outcome"] != expected_outcome: + raise ExerciseError( + f"results[{index}].outcome must be {expected_outcome!r} for this record kind" + ) + bounded_string( + result["observed_at"], f"results[{index}].observed_at", TIMESTAMP, template=template + ) + bounded_string( + result["evidence_label"], + f"results[{index}].evidence_label", + SLUG, + template=template, + ) + bounded_string( + result["evidence_sha256"], + f"results[{index}].evidence_sha256", + SHA256, + template=template, + ) + if check_id in seen: + raise ExerciseError(f"duplicate result check_id: {check_id}") + seen.add(check_id) + if seen != set(REQUIRED_CHECKS): + raise ExerciseError("results must contain every required check exactly once") + + +def validate_record(data: Any, *, allow_template: bool, root: Path = ROOT) -> None: + record = require_object( + data, + "record", + { + "schema", + "record_kind", + "exercise_id", + "recorded_at", + "source_release", + "target_release", + "target_release_manifest_sha256", + "candidate_frozen", + "candidate_independently_verified", + "config_schemas", + "topology", + "recovery_set", + "results", + }, + ) + if record["schema"] != SCHEMA: + raise ExerciseError(f"schema must be {SCHEMA}") + kind = record["record_kind"] + if kind not in {"template", "candidate_evidence"}: + raise ExerciseError("record_kind must be template or candidate_evidence") + template = kind == "template" + if template and not allow_template: + raise ExerciseError("template is preparation, not candidate evidence; pass --template to validate it") + if not template and allow_template: + raise ExerciseError("--template accepts only a template record") + bounded_string(record["exercise_id"], "exercise_id", SLUG, template=template) + bounded_string(record["recorded_at"], "recorded_at", TIMESTAMP, template=template) + validate_release(record["source_release"], "source_release", template=template) + validate_release(record["target_release"], "target_release", template=template) + bounded_string( + record["target_release_manifest_sha256"], + "target_release_manifest_sha256", + SHA256, + template=template, + ) + expected_bool = not template + if record["candidate_frozen"] is not expected_bool: + raise ExerciseError(f"candidate_frozen must be {str(expected_bool).lower()}") + if record["candidate_independently_verified"] is not expected_bool: + raise ExerciseError( + f"candidate_independently_verified must be {str(expected_bool).lower()}" + ) + validate_config_schemas(record["config_schemas"], template=template, root=root) + validate_topology(record["topology"], template=template) + validate_recovery_set(record["recovery_set"], template=template) + validate_results(record["results"], template=template) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("record", type=Path) + parser.add_argument( + "--template", + action="store_true", + help="validate a preparation template; templates never count as candidate evidence", + ) + args = parser.parse_args() + try: + data = json.loads(args.record.read_text(encoding="utf-8")) + validate_record(data, allow_template=args.template) + except (ExerciseError, OSError, json.JSONDecodeError) as error: + print(f"upgrade exercise validation failed: {error}", file=sys.stderr) + return 1 + print(f"upgrade exercise validation passed: {args.record}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From eaf2727657c8c00e15a88058e4df789c3519ad23 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:47:55 +0700 Subject: [PATCH 21/65] fix(relay): scope OpenAPI stability to support roster Signed-off-by: Jeremi Joslin --- .../scripts/check-openapi-contract.sh | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/registry-relay/scripts/check-openapi-contract.sh b/crates/registry-relay/scripts/check-openapi-contract.sh index 602c3c0a6..ae157e48d 100755 --- a/crates/registry-relay/scripts/check-openapi-contract.sh +++ b/crates/registry-relay/scripts/check-openapi-contract.sh @@ -10,6 +10,11 @@ BASE_REF="${1:-${OPENAPI_CONTRACT_BASE_REF:-}}" WORK_DIR="target/openapi-contract" GENERATED="$WORK_DIR/generated.openapi.json" BASELINE="$WORK_DIR/base.openapi.json" +FILTERED_BASELINE="$WORK_DIR/base.stable.openapi.json" +FILTERED_CURRENT="$WORK_DIR/current.stable.openapi.json" +ROSTER_PATH="../../docs/site/src/data/generated/relay-support.json" +BASE_ROSTER="$WORK_DIR/base.relay-support.json" +FILTER_SCRIPT="../../release/scripts/filter-relay-openapi-stability.py" mkdir -p "$WORK_DIR" @@ -74,5 +79,30 @@ if ! git cat-file -e "$BASE_REF:$REFERENCE_CONFIG_FROM_ROOT" 2>/dev/null; then fi git show "$BASE_REF:$SPEC_PATH_FROM_ROOT" > "$BASELINE" + +if [[ -f "$ROSTER_PATH" ]]; then + roster_args=(--current-roster "$ROSTER_PATH") + ROSTER_PATH_FROM_ROOT="$(git ls-files --full-name -- "$ROSTER_PATH")" + if [[ -z "$ROSTER_PATH_FROM_ROOT" ]]; then + echo "Relay support roster is not tracked: '$ROSTER_PATH'" >&2 + exit 1 + fi + if git cat-file -e "$BASE_REF:$ROSTER_PATH_FROM_ROOT" 2>/dev/null; then + git show "$BASE_REF:$ROSTER_PATH_FROM_ROOT" > "$BASE_ROSTER" + roster_args+=(--base-roster "$BASE_ROSTER") + fi + python3 "$FILTER_SCRIPT" \ + "${roster_args[@]}" \ + --base-openapi "$BASELINE" \ + --current-openapi "$SPEC_PATH" \ + --filtered-base "$FILTERED_BASELINE" \ + --filtered-current "$FILTERED_CURRENT" +else + echo "Relay support roster not present; using bootstrap OpenAPI compatibility scope" + cp "$BASELINE" "$FILTERED_BASELINE" + cp "$SPEC_PATH" "$FILTERED_CURRENT" +fi + # Accepted one-time diffs live in the ignore file; see its header comment. -oasdiff breaking --fail-on ERR --err-ignore openapi/oasdiff-err-ignore.txt "$BASELINE" "$SPEC_PATH" +oasdiff breaking --fail-on ERR --err-ignore openapi/oasdiff-err-ignore.txt \ + "$FILTERED_BASELINE" "$FILTERED_CURRENT" From fb6195f67a0c547ac9fc85cdc6aedc02d5be25e9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:47:59 +0700 Subject: [PATCH 22/65] docs: finalize 1.0 lifecycle policy Signed-off-by: Jeremi Joslin --- .../docs/operate/backup-and-restore.mdx | 12 +-- .../docs/operate/upgrade-and-rollback.mdx | 33 ++++--- .../content/docs/reference/api-stability.mdx | 87 +++++++++++-------- .../docs/reference/deprecation-policy.mdx | 20 ++--- .../src/content/docs/reference/errors.mdx | 35 +++++--- .../content/docs/security/support-window.mdx | 34 ++++---- 6 files changed, 129 insertions(+), 92 deletions(-) diff --git a/docs/site/src/content/docs/operate/backup-and-restore.mdx b/docs/site/src/content/docs/operate/backup-and-restore.mdx index e0ae8659e..b23c078e9 100644 --- a/docs/site/src/content/docs/operate/backup-and-restore.mdx +++ b/docs/site/src/content/docs/operate/backup-and-restore.mdx @@ -19,6 +19,9 @@ because losing it rotates generated API credentials, audit HMAC secrets, and the Notary issuer identity; then preserve product state directories, every enabled product's complete PostgreSQL database, config-trust files, and source inputs. +This page remains draft until complete backup and restore pass against the exact independently +verified 1.0 candidate and its pinned standalone Solmara topology. + ## When to use this Use this for `registryctl` generated projects and for hand-written single-node Docker Compose @@ -34,7 +37,7 @@ preauthorization state survives in PostgreSQL. ## Before you start -- Know the project directory that contains `compose.yaml`, `registryctl.yaml`, `secrets/`, +- Know the project directory that contains `compose.yaml`, `registry-stack.yaml`, `secrets/`, and `state/`. - Know whether Relay consultations or Registry Notary are enabled, which PostgreSQL database each product uses, and which role-provisioning record belongs to each database. @@ -66,7 +69,7 @@ and host-mounted state: ```sh backup_items="" -for item in registryctl.yaml compose.yaml relay notary state data; do +for item in registry-stack.yaml compose.yaml relay notary state data; do [ -e "$item" ] && backup_items="$backup_items $item" done [ -f .env ] && backup_items="$backup_items .env" @@ -303,14 +306,11 @@ Start the deployment and run product checks: ```sh registryctl start registryctl doctor --profile local -if [ -f registryctl.yaml ] && grep -q '^relay:' registryctl.yaml; then - registryctl smoke -fi ``` The human-readable `doctor` report must contain no `startup_fail` findings. Add `--format json` when another program needs the versioned report. -Run `registryctl smoke` only when `registryctl.yaml` declares Relay. For a Notary deployment, +Run `registryctl smoke` when the generated deployment includes Relay. For a Notary deployment, run `registry-notary doctor --config --format json`, then exercise an authenticated claim or credential journey appropriate to that deployment. `registryctl` does not provide a product-scoped Notary smoke subcommand. When OID4VCI preauthorization is enabled, diff --git a/docs/site/src/content/docs/operate/upgrade-and-rollback.mdx b/docs/site/src/content/docs/operate/upgrade-and-rollback.mdx index 3bfb79678..6ac6daec4 100644 --- a/docs/site/src/content/docs/operate/upgrade-and-rollback.mdx +++ b/docs/site/src/content/docs/operate/upgrade-and-rollback.mdx @@ -5,7 +5,7 @@ status: draft owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-07-07" +last_reviewed: "2026-07-19" doc_type: how-to locale: en standards_referenced: [] @@ -24,12 +24,11 @@ version-specific config and state restore sets, and restore the previous release anti-rollback state before starting its binary. The [revert section](#roll-back-to-the-previous-release) covers this. -{/* TODO[review]: GH#203 requires this procedure to be exercised at least once against a - real topology. A partial exercise record now exists at - release/exercises/upgrade-v0.8.3-to-v0.8.4.md and is linked from the verification - step below. Keep status: draft until the remaining gaps are exercised: - release-artifact verification, credential issuance, metrics, the historical Redis - replay/nonce path used by that release, and anti-rollback monotonicity. */} +This page remains draft until the complete procedure passes against the exact independently +verified 1.0 candidate and a pinned standalone Solmara release. +The candidate-neutral record template is +`release/exercises/upgrade-exercise-v1.template.json`; a validated template is preparation, not +release evidence. ## Prerequisites @@ -52,6 +51,8 @@ anti-rollback state before starting its binary. The the target, in order. Collect every `BREAKING:` and `Deprecated` entry that names a config key, route, metric, or environment variable you use. Within a major line there are no breaking changes once the stack is at `v1.0.0`; before that, minors may contain them. + Follow every sequential release hop unless the target release explicitly certifies a direct + state path. 2. Download and verify the target release artifacts (cosign signature and, where present, SLSA provenance) before anything else touches them. @@ -65,7 +66,10 @@ anti-rollback state before starting its binary. The its digest references with mutable tags. For an intentionally separate verified lock location, set `REGISTRYCTL_IMAGE_LOCK` to that exact file. -3. Validate your current config against the new binary offline, before deploying it: +3. Validate your current config against the committed Draft 2020-12 product schemas at + `schemas/registry-relay.config.schema.json` and + `schemas/registry-notary.config.schema.json`. + Then validate it against the new binary offline, before deploying it: ```sh registry-relay doctor --config --format json @@ -101,6 +105,8 @@ anti-rollback state before starting its binary. The replica without traffic and admit the rest only after every replica attests the same schema fingerprint. Keep the old release's binary or image reference, config, database backup, role provisioning, and sensitive-state key version until the soak period ends. + Never start the previous Notary binary against the target schema, including as a diagnostic + shortcut. 7. Verify the new deployment: @@ -118,7 +124,7 @@ anti-rollback state before starting its binary. The - Metrics are flowing to your monitoring. This procedure has been partially exercised against a real topology: the - [v0.8.3 -> v0.8.4 exercise record](https://github.com/registrystack/registry-stack/blob/main/release/exercises/upgrade-v0.8.3-to-v0.8.4.md) + [v0.8.3 -> v0.8.4 exercise record](https://github.com/registrystack/registry-stack/blob/v0.11.0/release/exercises/upgrade-v0.8.3-to-v0.8.4.md) covers the upgrade and roll-back steps above; release-artifact verification, credential issuance, metrics, the historical Redis replay/nonce path used by that release, and anti-rollback monotonicity were not exercised by that run. Current Registry Notary releases @@ -137,7 +143,14 @@ anti-rollback state before starting its binary. The ## Roll back to the previous release -If the new release misbehaves and the cause is not a config error you can fix forward: +Before target-version traffic is admitted, the general rollback path restores the complete +previous-release recovery set. +After the target accepts traffic that writes correctness state, audit evidence, or credentials, +fix forward by default. +Rollback after target writes is permitted only when that release documents and proves a safe +recovery conversion. + +If rollback remains permitted: 1. Stop the target release. Restore the previous release's binary or image, the config that was running before the upgrade, and that release's exact anti-rollback state at its diff --git a/docs/site/src/content/docs/reference/api-stability.mdx b/docs/site/src/content/docs/reference/api-stability.mdx index d4d3f8cab..ff02191ac 100644 --- a/docs/site/src/content/docs/reference/api-stability.mdx +++ b/docs/site/src/content/docs/reference/api-stability.mdx @@ -1,11 +1,11 @@ --- title: API stability and versioning description: The compatibility promise Registry Stack makes at v1.0.0, the surfaces it covers, and what counts as a breaking change. -status: draft +status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-07-07" +last_reviewed: "2026-07-19" doc_type: reference locale: en standards_referenced: [] @@ -16,9 +16,6 @@ covered, what counts as a breaking change, and which surfaces stay exempt. The p effect at `v1.0.0`. Until then, Registry Stack is a pre-1.0 technical release and the [pre-1.0 rule](#versioning-scheme) applies. -{/* TODO[review]: this page is a draft policy proposal for GH#203. Open decisions for the - maintainer are marked with TODO[review] comments; resolve them before status: current. */} - ## Versioning scheme Registry Stack releases carry one stack-wide version, tagged `vMAJOR.MINOR.PATCH`. Every @@ -38,12 +35,6 @@ From `v1.0.0`: new error codes) and may deprecate surface, but not remove or change it incompatibly. - A patch release contains fixes only. -{/* TODO[review]: products/platform/docs/versioning.md carries a narrower pre-1.0 policy for - the platform export tree, and products/{manifest,notary,platform} keep independent - CHANGELOG versions (0.2.1 / 0.6.2 / 0.3.1). Decide whether the stack version becomes the - single promise unit at 1.0 and the product trees pin to it, or the product versions stay - independently promised. This draft assumes the stack version is the promise unit. */} - ## Covered surfaces The promise covers the surfaces in this table. For each, the contract artifact is the source @@ -53,25 +44,29 @@ The enforcement column names the repository CI checks so the mechanism is audita | Surface | Contract artifact | Enforcement today | | --- | --- | --- | -| Registry Relay HTTP API (default public and admin listener surface) | Committed OpenAPI document `crates/registry-relay/openapi/registry-relay.openapi.json` | `just openapi-contract` byte-for-byte check plus `oasdiff breaking` against the base ref | -| Registry Notary HTTP API (public and admin listeners) | Committed OpenAPI document `products/notary/openapi/registry-notary.openapi.json` plus the route inventory `products/notary/security/exposure-manifest.json` | `just openapi-check` and `just exposure-check` | -| Error contract and stable identifiers | RFC 9457 problem shape with the stable `code` member, the [error registry](../errors/), the `pdp.*` denial codes, and the `https://id.registrystack.org/` identifier space | Compatibility test `crates/registry-relay/tests/error_taxonomy.rs`; codes are additive-only | -| Configuration formats and documented environment variables | The YAML config schemas of both products (`registry-relay schema`, `registry-notary schema`) and the [environment variable reference](../environment-variables/) | `deny_unknown_fields` parsing plus the deprecated-field rejection guard in `registry-platform-config` | +| Registry Relay HTTP API (approved stable public and admin listener surface) | Committed OpenAPI document `crates/registry-relay/openapi/registry-relay.openapi.json`, scoped by the [authoritative 1.0 support roster](../apis/registry-relay/) | `just openapi-contract` checks byte-for-byte generation, filters only roster entries declared `included_unstable`, then runs `oasdiff breaking` against the base ref | +| Registry Notary HTTP API (public and admin listeners) | Committed OpenAPI document `products/notary/openapi/registry-notary.openapi.json` plus the route inventory `products/notary/security/exposure-manifest.json` | `just openapi-contract` compares against the base reference; `just openapi-check` and `just exposure-check` detect generated-contract drift | +| Error contract and stable identifiers | RFC 9457 problem shape with the stable `code` member, the [error registry](../errors/), the `pdp.*` denial codes, and the `https://id.registrystack.org/` identifier space | `release/scripts/check-stable-surface-compatibility.py` permits additions and rejects removal or changed meaning; product tests pin runtime rendering and status mappings | +| Configuration formats and documented environment variables | The committed Draft 2020-12 schemas `schemas/registry-relay.config.schema.json` and `schemas/registry-notary.config.schema.json`, plus the [environment variable reference](../environment-variables/) | Product `config-schema-check` commands reproduce the schemas from the typed config graphs; strict parsers and the deprecated-field guard reject unknown or retired fields | | Signed config bundle format and verification semantics | `registry.platform.config_bundle.v1` manifests, `registry.platform.config_bundle_signatures.v1` signature envelopes, and `registry.platform.config_trust_anchor.v1` trust anchors. Normal signed verification requires a valid, non-empty trust anchor and checks signature acceptance, file closure, product/environment/stream binding, optional instance pinning, and anti-rollback sequence. The hash-pinned `registry.platform.config_break_glass.v1` `accept_rollback` mode keeps signature, binding, closure, hash, and product checks but waives the monotonic sequence rejection for a signed bundle whose config hash matches the override. Its `accept_unsigned` mode skips signature, binding, and sequence checks while retaining exact hash pinning and product config validation. | Shared verifier tests in `registry-platform-config`; break-glass consumption, restart-pin, and sequence tests in `registry-platform-ops`; product CLI coverage in `crates/registry-relay/tests/config_verify_bundle_cli.rs` and `crates/registry-notary/tests/config_verify_bundle_cli.rs` | | Registry Manifest schema and rendered artifacts | `registry-manifest/v1` and the rendered artifact schema versions, governed by [RS-DM-MANIFEST](../../spec/rs-dm-manifest/) | `validate_manifest` accepts only `registry-manifest/v1`; REQ-DM-MANIFEST-013 requires strict unknown-key rejection at parse time | | Source-adaptation integration surfaces | Registry Relay's compiled `http`, `script`, and `snapshot` consultation plans, including the Rhai `consult(ctx)` host API and versioned protocol helpers | Registry Stack project-authoring schemas and fixtures; Relay source-plan compiler and consultation contract tests | | Command-line interfaces | Documented commands and flags of `registryctl`, `registry-relay`, `registry-notary`, and `registry-manifest`, and their machine-readable output modes (`--format json`, `openapi`, `schema`) | CLI reference pages; `registryctl` version test | | Release artifacts and verification interface | Released binary and image names (`ghcr.io/registrystack/registry-relay`, `ghcr.io/registrystack/registry-notary`), signature and provenance layout per `release/VERIFY.md` | Release workflow; signed assets verified as documented in [SECURITY.md](https://github.com/registrystack/registry-stack/blob/v0.11.0/SECURITY.md) | +| Selected Prometheus metrics | Family name, type, label keys, and label meaning in `release/contracts/selected-metrics.json` | `release/scripts/check-stable-surface-compatibility.py` permits additive families and rejects incompatible changes; product tests verify bounded labels and rendering | -{/* TODO[review]: Prometheus metric names are not in this table. Operators build dashboards - on them and both products have renamed metrics pre-1.0. Decide the tier: covered like an - API (rename only at major), or announced-in-release-notes best effort. */} +Metric help text, output ordering, and corrections to observed values are not covered. +New families and new values for an existing bounded label are additive changes. -A route that only exists when a non-default feature or config block enables it (for example -the OGC namespaces, the SP DCI adapter, or attribute release) is covered by its feature tests -and product docs, not by presence in the default OpenAPI artifact. When the deployment enables -it, it behaves as documented, but its availability depends on the deployment's build features -and configuration. +The Relay support roster, not mere presence in the default OpenAPI, decides whether a Relay +surface enters the 1.0 compatibility promise. +Only roster entries declared stable and canonical are covered. +Feature-gated experimental adapters and roster entries declared `included_unstable` remain +outside the promise even when they ship or appear in OpenAPI. +This specifically keeps CSV and SDMX-JSON aggregate output experimental and feature-frozen while +JSON aggregate output and the core protected entity record routes remain stable. +The compatibility filter reads the roster's machine selectors; it does not maintain another +format list. ## What counts as a breaking change @@ -79,7 +74,9 @@ For HTTP APIs: - Removing or renaming a route, parameter, request field, or response field - Changing the type, meaning, or optionality of an existing field -- Removing or renaming a stable error `code`, or changing the HTTP status a code maps to +- Removing or renaming a stable error `code` +- Changing the stack-wide meaning of a released error code, or changing an existing + product-and-operation HTTP mapping for that code - Tightening authentication or scope requirements on an existing route, except as a security fix announced in the release notes @@ -95,24 +92,37 @@ For CLIs: - Removing or renaming a documented command or flag - Changing the schema of a machine-readable output mode incompatibly +For selected metrics: + +- Removing or renaming a family +- Changing a family type, label key, or label meaning + Additive changes (new routes, new optional fields, new optional config keys, new error codes, new commands and flags) are minor-release material and are not breaking. ## Compatibility direction -The promise is backward compatibility: a new binary within a major line reads the config -files, manifests, and persisted state written for any earlier release of that line. +The promise is backward compatibility: a new binary within a major line reads the wire data, +config files, manifests, and persisted state written for any earlier release of that line. +Every release documents a forward state path from its immediate predecessor. +An upgrade that skips releases follows each sequential hop unless the target release explicitly +certifies a direct path. The reverse is not promised. Both products parse config with `deny_unknown_fields`, so a config file that uses keys introduced in a newer release fails to load on an older binary. Pin your config to the release you deploy. +Registry Notary state upgrades are forward-only. +An older Registry Notary binary must never start against a schema installed or changed by a newer +release. +The supported combined topology pairs exactly one Notary authority with each Relay authority; +replicas may share an authority only when they serve the same authority configuration. + ## Defaults are part of the contract -A default value is behavior an operator relies on without writing it down. Within a major -line, a default changes only in a minor release, with a migration note, and only when the -change hardens security posture. The migration note must document how to keep the previous -behavior explicitly. +A default value is behavior an operator relies on without writing it down. +Changing a covered default incompatibly requires a major release, except for a documented +security removal under the [deprecation policy](../deprecation-policy/). ## Exempt surfaces @@ -149,12 +159,17 @@ The promise is machine-checked where a checker exists: - Relay: `crates/registry-relay/scripts/check-openapi-contract.sh` regenerates the OpenAPI document from the reference config, requires a byte-for-byte match with the committed file, - and runs `oasdiff breaking` against the base ref. -- Notary: `just openapi-check` and `just exposure-check` from `products/notary` keep the - OpenAPI document and the exposure manifest in lockstep with the code. -- Errors: `crates/registry-relay/tests/error_taxonomy.rs` pins the error taxonomy. -- Config: `registry-platform-config` rejects removed or renamed keys with a pointer to the - migration note instead of silently ignoring them. + removes only the aggregate representations marked `included_unstable` by the authoritative + Relay roster, and runs `oasdiff breaking` against the base ref. +- Notary: `just openapi-contract` compares the regenerated OpenAPI document against the base + reference; `just openapi-check` and `just exposure-check` keep the document and exposure + manifest in lockstep with the code. +- Errors and metrics: `release/scripts/check-stable-surface-compatibility.py` compares the + released registry and selected metric contract against the base reference while permitting + additions. +- Config: each product reproduces its committed schema from its typed config graph; + `registry-platform-config` rejects removed or renamed keys with a pointer to the migration + note instead of silently ignoring them. A change that a gate flags as breaking lands only in a release whose version number permits it, together with the deprecation and migration steps required by the diff --git a/docs/site/src/content/docs/reference/deprecation-policy.mdx b/docs/site/src/content/docs/reference/deprecation-policy.mdx index df523e0c5..03824168b 100644 --- a/docs/site/src/content/docs/reference/deprecation-policy.mdx +++ b/docs/site/src/content/docs/reference/deprecation-policy.mdx @@ -1,11 +1,11 @@ --- title: Deprecation policy description: How Registry Stack retires covered surface, the notice operators get, and the migration notes every removal carries. -status: draft +status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-07-07" +last_reviewed: "2026-07-19" doc_type: reference locale: en standards_referenced: [] @@ -17,22 +17,20 @@ announced, and what a removal must ship with. It takes effect at `v1.0.0`; befor breaking changes follow the pre-1.0 rule and are announced with `BREAKING:` CHANGELOG entries. -{/* TODO[review]: draft policy proposal for GH#203. The notice period in this page (next major, and - no earlier than 6 months after announcement) is a proposal; confirm or change it. */} - ## The lifecycle of a covered element A covered element (route, field, error code, config key, CLI flag, artifact name) moves through three states: 1. Active. The element is documented and supported. -2. Deprecated. The element still works exactly as before, and its replacement is available +2. Deprecated. The element remains compatible, and its replacement is available and documented. Deprecation happens in a minor release. 3. Removed. The element is gone. Removal happens in a major release, and no earlier than 6 months after the release that announced the deprecation. -Nothing skips the deprecated state. A covered element that exists in `v1.0.0` or is added -later is never removed without a deprecation release first. +Except for a documented security removal, nothing skips the deprecated state. +A covered element that exists in `v1.0.0` or is added later is never removed without a +deprecation release first. ## How a deprecation is announced @@ -62,9 +60,9 @@ see the `BREAKING:` entries in `products/notary/CHANGELOG.md` and ## Security exceptions A fix for a vulnerability may tighten or remove surface without the full notice period when -keeping the surface functional would keep deployments exploitable. Such a change is announced -in the release notes as a security fix with migration steps, and the release notes state -explicitly that the deprecation window was shortened and why. +keeping the surface functional would keep deployments exploitable. +The release notes document the security removal, the shortened window, and migration steps +without disclosing information that would put unpatched deployments at additional risk. ## What this policy does not govern diff --git a/docs/site/src/content/docs/reference/errors.mdx b/docs/site/src/content/docs/reference/errors.mdx index bb062cd59..af0ab45b5 100644 --- a/docs/site/src/content/docs/reference/errors.mdx +++ b/docs/site/src/content/docs/reference/errors.mdx @@ -7,7 +7,7 @@ source_repos: - registry-notary - registry-relay - registry-platform -last_reviewed: "2026-07-07" +last_reviewed: "2026-07-19" doc_type: reference locale: en standards_referenced: [] @@ -19,9 +19,14 @@ Registry-owned problem type URIs use `https://id.registrystack.org/problems/...` Client code must branch on the stable `code` extension and the response `status`, not by parsing `type` URI paths. The `type` URI identifies and documents the problem type. -The HTTP status is omitted from the Registry Notary and Registry Relay tables. -The Relay source maps each code to a status, but the same code can render at different statuses across versions, and the Notary public code does not carry a status in the taxonomy itself. -Treat the standard `status` field of the problem response as authoritative for a given request. +The HTTP status is omitted from the Registry Notary and Registry Relay tables because a code can +map differently by product and operation. +Within a stable major release line, each existing product and operation keeps its released +code-to-status mapping. +Treat the standard `status` field of the problem response as authoritative for the request, and +use `code` for the stack-wide meaning. +The additive compatibility gate uses this reference as the error-code registry and the committed +OpenAPI documents for operation mappings they enumerate. ## Shared platform @@ -58,12 +63,12 @@ Registry Notary returns the codes in the table below from its evidence taxonomy. | `credential.issuance_failed` | credential issuance failed | The credential could not be issued for an internal reason. | | `claim.rule_evaluation_failed` | claim rule evaluation failed | A configured claim rule could not be evaluated. | | `idempotency.conflict` | idempotency key was reused with a different request | The idempotency key was reused with a different request body. | -| `auth.purpose_required` | purpose is required | The request omits a required purpose. | -| `auth.missing_credential` | credential is missing | No authentication credential was provided. | +| `auth.purpose_required` | required purpose is missing | The request omits a required purpose. | +| `auth.missing_credential` | authentication credential is missing | No authentication credential was provided. | | `auth.multiple_credentials` | multiple authentication credentials were provided | More than one authentication credential was provided. | | `auth.scope_denied` | required scope is missing | The caller's scopes do not include the required scope. | -| `self_attestation.denied` | self-attestation request is denied | A self-attestation request was denied; also the public code for invalid-token and assurance-denied conditions. The audit record carries the specific reason. | -| `self_attestation.rate_limited` | self-attestation request is rate limited | Too many self-attestation requests in the configured window. | +| `subject_access.denied` | subject access is denied | A subject-bound or delegated request was denied; also the public code for invalid-token and assurance-denied conditions. The audit record carries the specific reason. | +| `subject_access.rate_limited` | subject access is rate limited | Too many subject-access requests were made in the configured window. | Policy decision denials surface a stable `pdp.*` code carried through from the shared Policy Decision Point, for example `pdp.purpose_not_permitted`, `pdp.assurance_insufficient`, or `pdp.evidence_stale`. The same `pdp.*` codes are listed under [Registry Relay policy](#policy). @@ -135,11 +140,11 @@ Registry Relay returns namespaced codes from its error taxonomy. Codes in the co | Code | Meaning | Usual cause | | --- | --- | --- | -| `auth.missing_credential` | missing credential | No credential in the `Authorization` or `x-api-key` header. | +| `auth.missing_credential` | authentication credential is missing | No credential in the `Authorization` or `x-api-key` header. | | `auth.invalid_credential` | invalid credential | The credential did not match any configured key. | | `auth.malformed_credential` | malformed credential | The credential header could not be parsed. | -| `auth.scope_denied` | scope denied | The caller lacks the required scope. | -| `auth.purpose_required` | purpose header required | The `Data-Purpose` header is required for the resource. | +| `auth.scope_denied` | required scope is missing | The caller lacks the required scope. | +| `auth.purpose_required` | required purpose is missing | The `Data-Purpose` header is required for the resource. | | `auth.purpose_denied` | purpose denied | The `Data-Purpose` header is not permitted by policy for the resource. | | `auth.admin_required` | admin scope required | The endpoint requires the admin scope. | | `auth.token_expired` | token expired | The bearer token `exp` is in the past beyond the configured leeway. | @@ -340,9 +345,11 @@ Registry Relay returns namespaced codes from its error taxonomy. Codes in the co ## Source The codes above are transcribed from the error taxonomies in the owning crates. -For the canonical current definitions, read the -[Registry Notary `EvidenceError` taxonomy](https://github.com/registrystack/registry-stack/blob/main/crates/registry-notary-core/src/error.rs) +For the v0.11.0 source definitions, read the +[Registry Notary `EvidenceError` taxonomy](https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-core/src/error.rs) and the -[Registry Relay error taxonomy](https://github.com/registrystack/registry-stack/blob/main/crates/registry-relay/src/error.rs). +[Registry Relay error taxonomy](https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/src/error.rs). The Notary server-layer codes (federation, credential status, configuration, admin, readiness, posture, and request handling) are defined in the `registry-notary-server` crate, principally `src/federation/errors.rs`, `src/federation/mod.rs`, and `src/api.rs`. +The release compatibility gate compares this registry to its base reference and verifies that every +listed code remains anchored in Rust or OpenAPI source. diff --git a/docs/site/src/content/docs/security/support-window.mdx b/docs/site/src/content/docs/security/support-window.mdx index b6cf1bc2d..aeecd3cf8 100644 --- a/docs/site/src/content/docs/security/support-window.mdx +++ b/docs/site/src/content/docs/security/support-window.mdx @@ -1,11 +1,11 @@ --- title: Security support window description: Which Registry Stack versions receive security fixes, how fixes are delivered, and what operators must do to stay covered. -status: draft +status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-07-07" +last_reviewed: "2026-07-19" doc_type: reference locale: en standards_referenced: [] @@ -16,35 +16,39 @@ reach a deployment. It complements the [vulnerability reporting process](../report-a-vulnerability/), which covers how to report, what is in scope, and how reports are acknowledged. -{/* TODO[review]: draft policy proposal for GH#203. The roll-forward posture on this page is the - recommendation; the alternative (backporting critical fixes to the previous minor line - for 90 days after a new minor ships) is a maintainer decision with real ongoing cost. - Pick one before status: current. */} - ## Supported versions | Version line | Security fixes | | --- | --- | -| Latest release line (highest released minor of the highest major) | All security fixes | -| Earlier releases | None | +| Latest minor of the current stable major | All security fixes | +| Earlier minors of the current stable major | None; upgrade to the latest minor | +| Earlier major | Until the successor stable major is released, as described under [Major-line end of life](#major-line-end-of-life) | -Registry Stack's roll-forward model is a draft policy -([GH#203](https://github.com/registrystack/registry-stack/issues/203)): security fixes land on `main` and ship in the next release of the latest line, with -no long-term support branches and no backports to earlier minor lines. +Registry Stack uses a roll-forward security model. +Security fixes land on the current release line and ship in its latest minor or patch release. +Registry Stack does not promise backports to an earlier minor. The rest of the lifecycle policy is designed to make rolling forward cheap: -- Within a major line, upgrades are backward compatible per the draft +- Within a major line, upgrades are backward compatible per the [compatibility promise](../../reference/api-stability/), so moving to the fixed release does not require migration work beyond reading the release notes. - Patch releases contain fixes only, so a security patch is a minimal, low-risk step. -- The [upgrade procedure](../../operate/upgrade-and-rollback/) is documented and exercised, - including the revert path if a fixed release misbehaves in your environment. +- The [upgrade procedure](../../operate/upgrade-and-rollback/) documents the upgrade and revert + boundary. Stable release acceptance requires the procedure to pass against the exact candidate. What this means for an institution: plan to apply patch releases of your deployed line promptly. A deployment pinned to an old minor does not receive fixes in place; the fix is always in a newer release. +## Major-line end of life + +Registry Stack announces the end of support for a major line at least 90 days before the +successor stable major is released. +The announcement includes the exercised upgrade path. +The old major remains supported until the successor stable major is released, then security +maintenance moves to the successor's latest minor. + ## How fixes are delivered - A security fix ships as a release with signed artifacts and provenance, verifiable as From 56abf30a81880d81b663642a3ecc0ab87f5a1b93 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:48:04 +0700 Subject: [PATCH 23/65] ci: compare stable contracts to base revision Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f27e8aea..ba01c9dd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,9 @@ jobs: release/*) release_tool=true ;; + docs/site/src/content/docs/reference/errors.mdx) + release_tool=true + ;; esac case "${path}" in Cargo.toml|Cargo.lock|release/scripts/check-release-source-model.sh|release/scripts/test_check_release_source_model.py|release/manifests/*) @@ -157,6 +160,14 @@ jobs: - name: Test gate inventory run: python3 -m unittest release/scripts/test_check_gates_inventory.py + - name: Test stable surface compatibility checker + run: python3 -m unittest release/scripts/test_check_stable_surface_compatibility.py + + - name: Stable surface compatibility + env: + STABLE_SURFACE_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before }} + run: python3 release/scripts/check-stable-surface-compatibility.py + - name: Format run: cargo fmt --check @@ -182,12 +193,20 @@ jobs: working-directory: products/notary run: just openapi-check + - name: Notary OpenAPI contract + working-directory: products/notary + env: + OPENAPI_CONTRACT_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before }} + run: just openapi-contract + - name: Notary exposure check working-directory: products/notary run: just exposure-check - name: Relay OpenAPI contract working-directory: crates/registry-relay + env: + OPENAPI_CONTRACT_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before }} run: just openapi-contract - name: Relay exposure check @@ -272,6 +291,25 @@ jobs: - name: Test gate inventory run: python3 -m unittest release/scripts/test_check_gates_inventory.py + - name: Test stable surface compatibility checker + run: python3 -m unittest release/scripts/test_check_stable_surface_compatibility.py + + - name: Test Relay OpenAPI stability filter + run: python3 -m unittest release/scripts/test_filter_relay_openapi_stability.py + + - name: Test upgrade exercise validator + run: python3 -m unittest release/scripts/test_validate_upgrade_exercise.py + + - name: Validate upgrade exercise template + run: >- + python3 release/scripts/validate-upgrade-exercise.py --template + release/exercises/upgrade-exercise-v1.template.json + + - name: Stable surface compatibility + env: + STABLE_SURFACE_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before }} + run: python3 release/scripts/check-stable-surface-compatibility.py + - name: Validate release manifest shell: bash run: | From 182c62d46159bbed04b61c2e94544e4e30182632 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:51:34 +0700 Subject: [PATCH 24/65] test(release): require roster path coverage Signed-off-by: Jeremi Joslin --- release/scripts/check-gates-inventory.py | 4 ++++ release/scripts/test_check_gates_inventory.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index a0bb8a15b..b7f0f807b 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -78,6 +78,10 @@ "Stable error registry path filter", "docs/site/src/content/docs/reference/errors.mdx)", ), + ( + "Relay support roster path filter", + "docs/site/src/data/relay-support.yaml|docs/site/src/data/generated/relay-support.json)", + ), ("Docs dependency install", "run: npm ci"), ("Docs tests", "run: npm test"), ("Docs build check", "run: npm run check"), diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index fd3cdf411..0e95bdf80 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -95,6 +95,13 @@ def test_missing_stable_error_registry_path_filter_is_reported(self) -> None: ) self.assertIn("Stable error registry path filter", self.module.missing_gates(text)) + def test_missing_relay_support_roster_path_filter_is_reported(self) -> None: + text = self.workflow.replace( + "docs/site/src/data/relay-support.yaml|docs/site/src/data/generated/relay-support.json)", + "docs/site/src/data/removed-relay-support.yaml)", + ) + self.assertIn("Relay support roster path filter", self.module.missing_gates(text)) + def test_missing_registryctl_tutorial_path_filter_is_reported(self) -> None: text = self.workflow.replace( "registryctl_tutorial: ${{ steps.filter.outputs.registryctl_tutorial }}", From f157dfc52fadb650f714de68d7d5ae45a5f557a6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:51:34 +0700 Subject: [PATCH 25/65] fix(ci): validate Relay roster compatibility Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba01c9dd9..bc699b879 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,9 @@ jobs: Cargo.toml|Cargo.lock|deny.toml|rust-toolchain*|crates/*|products/*) rust=true ;; + docs/site/src/data/relay-support.yaml|docs/site/src/data/generated/relay-support.json) + rust=true + ;; esac case "${path}" in release/*) From 16b79b1b793af8a45009bbb84b34b6515c5da7c6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:52:30 +0700 Subject: [PATCH 26/65] fix(release): require forward upgrade evidence Signed-off-by: Jeremi Joslin --- release/scripts/test_validate_upgrade_exercise.py | 8 ++++++++ release/scripts/validate-upgrade-exercise.py | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/release/scripts/test_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py index c7c889dd4..49ab590af 100644 --- a/release/scripts/test_validate_upgrade_exercise.py +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -47,6 +47,8 @@ def replace(value): return "redacted-evidence" if "AT>" in value: return "2026-07-19T12:00:00Z" + if value == "": + return "v0.11.0" if "VERSION>" in value or "RELEASE_TAG>" in value: return "v1.0.0" if "COMMIT>" in value: @@ -75,6 +77,12 @@ def test_template_is_valid_preparation_but_not_candidate_evidence(self) -> None: def test_complete_candidate_record_is_valid(self) -> None: self.module.validate_record(self.candidate(), allow_template=False) + def test_candidate_must_be_a_forward_version_upgrade(self) -> None: + record = self.candidate() + record["source_release"]["version"] = record["target_release"]["version"] + with self.assertRaisesRegex(self.module.ExerciseError, "must be newer"): + self.module.validate_record(record, allow_template=False) + def test_unknown_field_is_rejected_to_prevent_raw_evidence(self) -> None: record = self.candidate() record["results"][0]["raw_output"] = "Authorization: Bearer secret" diff --git a/release/scripts/validate-upgrade-exercise.py b/release/scripts/validate-upgrade-exercise.py index f0653dc58..832817691 100644 --- a/release/scripts/validate-upgrade-exercise.py +++ b/release/scripts/validate-upgrade-exercise.py @@ -96,6 +96,11 @@ def validate_release(value: Any, label: str, *, template: bool) -> None: label, {"version", "source_commit", "relay_image_digest", "notary_image_digest"}, ) + + +def version_order(value: str) -> tuple[tuple[int, int, int], bool]: + core, separator, _prerelease = value.removeprefix("v").partition("-") + return tuple(int(part) for part in core.split(".")), not bool(separator) bounded_string(release["version"], f"{label}.version", VERSION, template=template) bounded_string(release["source_commit"], f"{label}.source_commit", COMMIT, template=template) bounded_string( @@ -299,6 +304,10 @@ def validate_record(data: Any, *, allow_template: bool, root: Path = ROOT) -> No bounded_string(record["recorded_at"], "recorded_at", TIMESTAMP, template=template) validate_release(record["source_release"], "source_release", template=template) validate_release(record["target_release"], "target_release", template=template) + if not template and version_order(record["target_release"]["version"]) <= version_order( + record["source_release"]["version"] + ): + raise ExerciseError("target_release.version must be newer than source_release.version") bounded_string( record["target_release_manifest_sha256"], "target_release_manifest_sha256", From b12dac832af603a8c3fd9d41cd0f11649343f004 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:53:55 +0700 Subject: [PATCH 27/65] test(release): pin candidate release identifiers Signed-off-by: Jeremi Joslin --- release/scripts/test_validate_upgrade_exercise.py | 12 ++++++++++++ release/scripts/validate-upgrade-exercise.py | 10 +++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/release/scripts/test_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py index 49ab590af..4adf52ed3 100644 --- a/release/scripts/test_validate_upgrade_exercise.py +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -83,6 +83,18 @@ def test_candidate_must_be_a_forward_version_upgrade(self) -> None: with self.assertRaisesRegex(self.module.ExerciseError, "must be newer"): self.module.validate_record(record, allow_template=False) + def test_candidate_release_identifiers_are_strict(self) -> None: + for field, value in ( + ("source_commit", "main"), + ("relay_image_digest", "latest"), + ("notary_image_digest", "latest"), + ): + with self.subTest(field=field): + record = self.candidate() + record["target_release"][field] = value + with self.assertRaisesRegex(self.module.ExerciseError, "invalid or unsafe"): + self.module.validate_record(record, allow_template=False) + def test_unknown_field_is_rejected_to_prevent_raw_evidence(self) -> None: record = self.candidate() record["results"][0]["raw_output"] = "Authorization: Bearer secret" diff --git a/release/scripts/validate-upgrade-exercise.py b/release/scripts/validate-upgrade-exercise.py index 832817691..f7c2cee85 100644 --- a/release/scripts/validate-upgrade-exercise.py +++ b/release/scripts/validate-upgrade-exercise.py @@ -96,11 +96,6 @@ def validate_release(value: Any, label: str, *, template: bool) -> None: label, {"version", "source_commit", "relay_image_digest", "notary_image_digest"}, ) - - -def version_order(value: str) -> tuple[tuple[int, int, int], bool]: - core, separator, _prerelease = value.removeprefix("v").partition("-") - return tuple(int(part) for part in core.split(".")), not bool(separator) bounded_string(release["version"], f"{label}.version", VERSION, template=template) bounded_string(release["source_commit"], f"{label}.source_commit", COMMIT, template=template) bounded_string( @@ -117,6 +112,11 @@ def version_order(value: str) -> tuple[tuple[int, int, int], bool]: ) +def version_order(value: str) -> tuple[tuple[int, int, int], bool]: + core, separator, _prerelease = value.removeprefix("v").partition("-") + return tuple(int(part) for part in core.split(".")), not bool(separator) + + def validate_config_schemas(value: Any, *, template: bool, root: Path) -> None: schemas = require_object(value, "config_schemas", set(CONFIG_SCHEMAS)) for product, expected_path in CONFIG_SCHEMAS.items(): From e89ca8ffd60c09b30f19632cfe19165d38a5fcfa Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 16:56:54 +0700 Subject: [PATCH 28/65] fix(release): protect stable roster selectors Signed-off-by: Jeremi Joslin --- .../scripts/filter-relay-openapi-stability.py | 24 ++++++++++++++++++- .../test_filter_relay_openapi_stability.py | 23 +++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/release/scripts/filter-relay-openapi-stability.py b/release/scripts/filter-relay-openapi-stability.py index 128cda877..1a64971ae 100644 --- a/release/scripts/filter-relay-openapi-stability.py +++ b/release/scripts/filter-relay-openapi-stability.py @@ -61,7 +61,17 @@ def unstable_aggregate_selectors(roster_value: Any) -> tuple[set[str], set[str]] """Read selectors only from authoritative included-unstable roster entries.""" tokens: set[str] = set() media_types: set[str] = set() - for entry_id, entry in index_roster(roster_value).items(): + roster = index_roster(roster_value) + stable_tokens = { + entry_id.removesuffix("-aggregate-output") + for entry_id, entry in roster.items() + if entry.get("category") == "aggregate_output" + and entry.get("stability_tier") == "stable" + and entry.get("canonical_release") is True + and entry.get("openapi_policy") == "included" + and entry_id.endswith("-aggregate-output") + } + for entry_id, entry in roster.items(): if entry.get("openapi_policy") != "included_unstable": continue if ( @@ -97,6 +107,18 @@ def unstable_aggregate_selectors(roster_value: Any) -> tuple[set[str], set[str]] ) tokens.update(entry_tokens) media_types.update(entry_media_types) + token_collisions = stable_tokens & tokens + media_type_collisions = { + media_type + for media_type in media_types + if media_type.partition(";")[0].partition("/")[2] in stable_tokens + } + if token_collisions or media_type_collisions: + collisions = sorted(token_collisions | media_type_collisions) + raise RosterError( + "included-unstable selectors overlap a stable aggregate representation: " + + ", ".join(collisions) + ) return tokens, media_types diff --git a/release/scripts/test_filter_relay_openapi_stability.py b/release/scripts/test_filter_relay_openapi_stability.py index 511581977..9686c7326 100644 --- a/release/scripts/test_filter_relay_openapi_stability.py +++ b/release/scripts/test_filter_relay_openapi_stability.py @@ -48,7 +48,7 @@ class RelayOpenapiStabilityFilterTest(unittest.TestCase): def setUp(self) -> None: self.module = load_module() self.roster = [ - roster_entry("json-output", stability="stable", policy="included"), + roster_entry("json-aggregate-output", stability="stable", policy="included"), roster_entry( "csv-output", stability="experimental", @@ -179,6 +179,27 @@ def test_included_unstable_entry_requires_authoritative_selectors(self) -> None: with self.assertRaisesRegex(self.module.RosterError, "exact OpenAPI selectors"): self.module.filter_openapi(self.document, invalid) + def test_unstable_selectors_cannot_overlap_stable_aggregate_representation(self) -> None: + for token, media_type in ( + ("json", "application/vnd.example"), + ("other", "application/json"), + ): + with self.subTest(token=token, media_type=media_type): + roster = [ + roster_entry( + "json-aggregate-output", stability="stable", policy="included" + ), + roster_entry( + "bad-experimental-output", + stability="experimental", + policy="included_unstable", + token=token, + media_type=media_type, + ), + ] + with self.assertRaisesRegex(self.module.RosterError, "overlap a stable"): + self.module.filter_openapi(self.document, roster) + if __name__ == "__main__": unittest.main() From 57b41d80dac36e81a10dff73ac34b450d2b8a5ef Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 17:13:12 +0700 Subject: [PATCH 29/65] fix(notary): preserve batch request compatibility Signed-off-by: Jeremi Joslin --- crates/registry-notary-server/src/openapi.rs | 3 +-- products/notary/openapi/registry-notary.openapi.json | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/registry-notary-server/src/openapi.rs b/crates/registry-notary-server/src/openapi.rs index d4e40d67b..d5f2cfee7 100644 --- a/crates/registry-notary-server/src/openapi.rs +++ b/crates/registry-notary-server/src/openapi.rs @@ -1950,7 +1950,6 @@ fn batch_evaluate_request_schema() -> Value { "properties": { "items": { "type": "array", - "minItems": 1, "maxItems": registry_notary_core::MAX_BATCH_EVALUATION_MEMBERS_V1, "items": { "$ref": "#/components/schemas/BatchEvaluateItemRequest" } }, @@ -4281,7 +4280,7 @@ mod tests { let batch_request = &doc["components"]["schemas"]["BatchEvaluateRequest"]["properties"]; assert!(batch_request.get("subjects").is_none()); assert!(batch_request.get("items").is_some()); - assert_eq!(batch_request["items"]["minItems"], json!(1)); + assert!(batch_request["items"].get("minItems").is_none()); assert_eq!( batch_request["items"]["maxItems"], json!(registry_notary_core::MAX_BATCH_EVALUATION_MEMBERS_V1) diff --git a/products/notary/openapi/registry-notary.openapi.json b/products/notary/openapi/registry-notary.openapi.json index 6aa791020..946af16fd 100644 --- a/products/notary/openapi/registry-notary.openapi.json +++ b/products/notary/openapi/registry-notary.openapi.json @@ -146,7 +146,6 @@ "$ref": "#/components/schemas/BatchEvaluateItemRequest" }, "maxItems": 100, - "minItems": 1, "type": "array" }, "purpose": { From 08c5cce8b45b02dbfd933ea4c2bc76c142d9a746 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 18:01:57 +0700 Subject: [PATCH 30/65] feat(notary): bind OID4VCI issuance to registry transactions Signed-off-by: Jeremi Joslin --- .../src/config/oid4vci.rs | 48 +- .../src/config/tests/issuance.rs | 25 +- .../src/config/tests/root.rs | 41 +- crates/registry-notary-core/src/model.rs | 30 ++ crates/registry-notary-core/src/tokens.rs | 30 ++ crates/registry-notary-server/src/api.rs | 15 +- .../src/api/attestation_policy.rs | 64 ++- .../src/api/oid4vci/credential.rs | 500 ++++++++---------- .../src/api/oid4vci/metadata.rs | 44 +- .../src/api/oid4vci/preauth.rs | 416 +++++++++++++-- .../src/api/oid4vci/proof.rs | 117 ++-- .../src/api/tests/support.rs | 3 + crates/registry-notary-server/src/metrics.rs | 4 +- crates/registry-notary-server/src/openapi.rs | 251 +-------- .../src/preauth_state.rs | 474 ++++++++++++++++- .../src/standalone/auth/middleware.rs | 2 - .../src/standalone/auth/oidc.rs | 18 + .../src/standalone/cors.rs | 2 - .../src/standalone/preauth.rs | 42 ++ .../src/standalone/tests/assembly.inc | 18 - .../src/standalone/tests/auth.inc | 4 + .../src/state_plane/migration.rs | 469 +++++++++++++++- .../src/state_plane/mod.rs | 3 +- .../src/state_plane/sensitive.rs | 376 ++++++++++++- .../tests/standalone_http/preauth.rs | 204 +++---- .../tests/standalone_http/preauth_support.rs | 2 + .../tests/standalone_http/support.rs | 49 +- .../tests/subject_access_guard_test.rs | 3 + .../src/project_authoring/compiler/notary.rs | 2 - .../openapi/registry-notary.openapi.json | 226 +------- .../notary/security/auth-none-allowlist.yml | 8 - .../notary/security/exposure-manifest.json | 49 +- products/notary/security/route-inventory.json | 16 - schemas/registry-notary.config.schema.json | 2 +- 34 files changed, 2401 insertions(+), 1156 deletions(-) diff --git a/crates/registry-notary-core/src/config/oid4vci.rs b/crates/registry-notary-core/src/config/oid4vci.rs index a9b261a46..89a17f0b2 100644 --- a/crates/registry-notary-core/src/config/oid4vci.rs +++ b/crates/registry-notary-core/src/config/oid4vci.rs @@ -26,8 +26,8 @@ pub struct Oid4vciConfig { pub authorization: Oid4vciAuthorizationConfig, #[serde(default)] pub proof: Oid4vciProofConfig, - /// Pre-authorized-code flow settings. Disabled by default; offers fall - /// back to the `authorization_code` grant unless this is enabled. + /// Issuer-initiated pre-authorized-code flow settings. This is the only + /// wallet-facing issuance grant in the 1.0 profile. #[serde(default)] pub pre_authorized_code: Oid4vciPreAuthorizedCodeConfig, #[serde(default)] @@ -107,6 +107,11 @@ impl Oid4vciConfig { if !self.enabled { return Ok(()); } + if !self.pre_authorized_code.enabled { + return invalid_oid4vci( + "enabled oid4vci requires pre_authorized_code.enabled = true; wallet-facing authorization_code issuance is not supported in 1.0", + ); + } if !subject_access.enabled { return invalid_oid4vci("enabled oid4vci requires subject_access.enabled = true"); } @@ -116,11 +121,13 @@ impl Oid4vciConfig { &self.credential_endpoint, &self.credential_issuer, )?; - validate_oid4vci_endpoint_url( - "oid4vci.offer_endpoint", - &self.offer_endpoint, - &self.credential_issuer, - )?; + if !self.offer_endpoint.trim().is_empty() { + validate_oid4vci_endpoint_url( + "oid4vci.offer_endpoint", + &self.offer_endpoint, + &self.credential_issuer, + )?; + } validate_oid4vci_non_empty_entries( "oid4vci.authorization_servers", &self.authorization_servers, @@ -135,24 +142,15 @@ impl Oid4vciConfig { if self.credential_configurations.is_empty() { return invalid_oid4vci("credential_configurations must not be empty"); } - if self.nonce.enabled { - let nonce_endpoint = self.nonce_endpoint.as_deref().ok_or_else(|| { - EvidenceConfigError::InvalidOid4vciConfig { - reason: "nonce_endpoint must be configured when nonce.enabled = true" - .to_string(), - } - })?; - validate_oid4vci_endpoint_url( - "oid4vci.nonce_endpoint", - nonce_endpoint, - &self.credential_issuer, - )?; - } else if let Some(nonce_endpoint) = self.nonce_endpoint.as_deref() { - validate_oid4vci_endpoint_url( - "oid4vci.nonce_endpoint", - nonce_endpoint, - &self.credential_issuer, - )?; + if !self.nonce.enabled { + return invalid_oid4vci( + "oid4vci.nonce.enabled must be true for the transaction-scoped token nonce", + ); + } + if self.nonce_endpoint.is_some() { + return invalid_oid4vci( + "oid4vci.nonce_endpoint must be omitted; 1.0 has no public unbound nonce endpoint", + ); } self.nonce.validate()?; self.authorization.validate()?; diff --git a/crates/registry-notary-core/src/config/tests/issuance.rs b/crates/registry-notary-core/src/config/tests/issuance.rs index 5a06da99c..ed51187fe 100644 --- a/crates/registry-notary-core/src/config/tests/issuance.rs +++ b/crates/registry-notary-core/src/config/tests/issuance.rs @@ -59,6 +59,19 @@ pub(super) fn valid_oid4vci_config_passes_validation() { assert!(config.validate().is_ok()); } +#[test] +pub(super) fn oid4vci_rejects_wallet_authorization_code_profile() { + let mut config = valid_oid4vci_config(); + config.oid4vci.pre_authorized_code = Oid4vciPreAuthorizedCodeConfig::default(); + + let reason = expect_oid4vci_error(&config); + assert!( + reason.contains("pre_authorized_code.enabled = true") + && reason.contains("authorization_code issuance is not supported"), + "unexpected: {reason}" + ); +} + #[test] pub(super) fn valid_oid4vci_projection_config_passes_validation() { let config = valid_oid4vci_projection_config(); @@ -434,9 +447,6 @@ pub(super) fn oid4vci_accepts_vct_under_path_prefixed_credential_issuer() { config.oid4vci.credential_issuer = "http://127.0.0.1:4325/notary".to_string(); config.oid4vci.credential_endpoint = "http://127.0.0.1:4325/notary/oid4vci/credential".to_string(); - config.oid4vci.offer_endpoint = - "http://127.0.0.1:4325/notary/oid4vci/credential-offer".to_string(); - config.oid4vci.nonce_endpoint = Some("http://127.0.0.1:4325/notary/oid4vci/nonce".to_string()); config .evidence .credential_profiles @@ -607,12 +617,15 @@ pub(super) fn oid4vci_rejects_duplicate_credential_configuration_vct() { } #[test] -pub(super) fn oid4vci_rejects_missing_nonce_endpoint_when_nonce_enabled() { +pub(super) fn oid4vci_rejects_public_nonce_endpoint() { let mut config = valid_oid4vci_config(); - config.oid4vci.nonce_endpoint = None; + config.oid4vci.nonce_endpoint = Some("http://127.0.0.1:4325/oid4vci/nonce".to_string()); let reason = expect_oid4vci_error(&config); - assert!(reason.contains("nonce_endpoint"), "unexpected: {reason}"); + assert!( + reason.contains("nonce_endpoint") && reason.contains("must be omitted"), + "unexpected: {reason}" + ); } #[test] diff --git a/crates/registry-notary-core/src/config/tests/root.rs b/crates/registry-notary-core/src/config/tests/root.rs index abb364f58..a61c11650 100644 --- a/crates/registry-notary-core/src/config/tests/root.rs +++ b/crates/registry-notary-core/src/config/tests/root.rs @@ -867,6 +867,14 @@ token_file: /run/secrets/registry-notary-relay.jwt pub(super) fn valid_oid4vci_config() -> StandaloneRegistryNotaryConfig { let mut config = valid_subject_access_config(); + config + .subject_access + .rate_limits + .tx_code_attempts_per_code_per_minute = 5; + config + .evidence + .signing_keys + .insert("access-token-key".to_string(), second_signing_key()); config .evidence .credential_profiles @@ -882,8 +890,6 @@ authorization_servers: accepted_token_audiences: - http://127.0.0.1:4325 credential_endpoint: http://127.0.0.1:4325/oid4vci/credential -offer_endpoint: http://127.0.0.1:4325/oid4vci/credential-offer -nonce_endpoint: http://127.0.0.1:4325/oid4vci/nonce nonce: enabled: true ttl_seconds: 300 @@ -904,9 +910,40 @@ credential_configurations: - EdDSA cryptographic_binding_methods_supported: - did:jwk +pre_authorized_code: + enabled: true + tx_code: + required: true + input_mode: numeric + length: 6 + esignet: + client_id: registry-lab-live-client + client_signing_key_id: issuer-key + redirect_uri: http://127.0.0.1:4325/oid4vci/offer/callback + authorize_url: https://id.example.gov/authorize + token_url: https://id.example.gov/oauth/v2/token + issuer: https://id.example.gov + jwks_uri: https://id.example.gov/oauth/.well-known/jwks.json + scopes: + - openid + pre_authorized_code_ttl_seconds: 300 "#, ) .expect("oid4vci config is valid YAML"); + config.auth.access_token_signing = serde_norway::from_str( + r#" +enabled: true +issuer: http://127.0.0.1:4325 +audiences: + - http://127.0.0.1:4325 +allowed_algorithms: + - EdDSA +token_typ: registry-notary-access+jwt +signing_key_id: access-token-key +access_token_ttl_seconds: 300 +"#, + ) + .expect("access-token signing config is valid YAML"); config } diff --git a/crates/registry-notary-core/src/model.rs b/crates/registry-notary-core/src/model.rs index d284f78c2..2ea27daab 100644 --- a/crates/registry-notary-core/src/model.rs +++ b/crates/registry-notary-core/src/model.rs @@ -465,6 +465,12 @@ pub struct BoundedVerifiedClaims { pub client_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub token_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credential_configuration_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuance_transaction_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuance_transaction_commitment: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub scopes: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -492,6 +498,15 @@ impl fmt::Debug for BoundedVerifiedClaims { .field("audiences", &self.audiences) .field("client_id", &self.client_id) .field("token_type", &self.token_type) + .field( + "credential_configuration_id", + &self.credential_configuration_id, + ) + .field("issuance_transaction_id", &"") + .field( + "issuance_transaction_commitment", + &self.issuance_transaction_commitment, + ) .field("scopes", &self.scopes) .field("subject", &self.subject.as_ref().map(|_| "")) .field("subject_binding_claim", &self.subject_binding_claim) @@ -522,6 +537,15 @@ impl BoundedVerifiedClaims { "iss" => Some(self.issuer.as_str()), "sub" => self.subject.as_ref().map(Bounded::as_str), "typ" | "token_type" => self.token_type.as_ref().map(Bounded::as_str), + "credential_configuration_id" => self + .credential_configuration_id + .as_ref() + .map(Bounded::as_str), + "issuance_transaction_id" => self.issuance_transaction_id.as_ref().map(Bounded::as_str), + "issuance_transaction_commitment" => self + .issuance_transaction_commitment + .as_ref() + .map(Bounded::as_str), "client_id" | "azp" => self.client_id.as_ref().map(Bounded::as_str), "acr" => self.acr.as_ref().map(Bounded::as_str), other => self @@ -2431,6 +2455,9 @@ mod tests { audiences: vec![bounded("registry-notary-citizen")], client_id: Some(bounded("citizen-portal")), token_type: Some(bounded("JWT")), + credential_configuration_id: None, + issuance_transaction_id: None, + issuance_transaction_commitment: None, scopes: vec![bounded("subject_access")], subject: Some(bounded("login-subject")), subject_binding_claim: Some(bounded("https://id.example.gov/claims/national_id")), @@ -2459,6 +2486,9 @@ mod tests { audiences: vec![bounded("registry-notary-citizen")], client_id: Some(bounded("citizen-portal")), token_type: Some(bounded("JWT")), + credential_configuration_id: None, + issuance_transaction_id: None, + issuance_transaction_commitment: None, scopes: vec![bounded("subject_access")], subject: Some(bounded("login-subject")), subject_binding_claim: Some(bounded("https://id.example.gov/claims/national_id")), diff --git a/crates/registry-notary-core/src/tokens.rs b/crates/registry-notary-core/src/tokens.rs index 097446ee4..407d4c934 100644 --- a/crates/registry-notary-core/src/tokens.rs +++ b/crates/registry-notary-core/src/tokens.rs @@ -87,6 +87,10 @@ pub struct PreAuthorizedCodeClaims { pub jti: String, /// Selected credential configuration the code is bound to. pub credential_configuration_id: String, + /// Opaque identifier of the immutable registry-backed issuance transaction. + pub issuance_transaction_id: String, + /// Versioned commitment over the authority-bearing transaction fields. + pub issuance_transaction_commitment: String, /// Whether this individual code requires the holder-presented transaction /// code advertised with its credential offer. pub tx_code_required: bool, @@ -114,6 +118,10 @@ pub struct AccessTokenClaims { pub token_type: String, /// Credential configuration the token is scoped to. pub credential_configuration_id: String, + /// Opaque identifier of the immutable registry-backed issuance transaction. + pub issuance_transaction_id: String, + /// Versioned commitment copied from the pre-authorized code transaction. + pub issuance_transaction_commitment: String, /// eSignet-verified subject claims. pub subject: BoundSubject, /// OAuth 2.0 Rich Authorization Requests-shaped authorization details. @@ -165,6 +173,14 @@ pub async fn mint_pre_authorized_code( "credential_configuration_id".to_string(), Value::String(claims.credential_configuration_id.clone()), ); + payload.insert( + "issuance_transaction_id".to_string(), + Value::String(claims.issuance_transaction_id.clone()), + ); + payload.insert( + "issuance_transaction_commitment".to_string(), + Value::String(claims.issuance_transaction_commitment.clone()), + ); payload.insert( "tx_code_required".to_string(), Value::Bool(claims.tx_code_required), @@ -205,6 +221,14 @@ pub async fn mint_access_token( "credential_configuration_id".to_string(), Value::String(claims.credential_configuration_id.clone()), ); + payload.insert( + "issuance_transaction_id".to_string(), + Value::String(claims.issuance_transaction_id.clone()), + ); + payload.insert( + "issuance_transaction_commitment".to_string(), + Value::String(claims.issuance_transaction_commitment.clone()), + ); payload.insert("iat".to_string(), Value::from(claims.iat)); payload.insert("nbf".to_string(), Value::from(claims.iat)); payload.insert("exp".to_string(), Value::from(claims.exp)); @@ -542,6 +566,8 @@ mod tests { audiences: vec![AUDIENCE.to_string()], token_type: "Bearer".to_string(), credential_configuration_id: "date_of_birth_sd_jwt".to_string(), + issuance_transaction_id: "transaction-123".to_string(), + issuance_transaction_commitment: "sha256:transaction".to_string(), subject: bound_subject(), authorization_details: Vec::new(), confirmation: None, @@ -558,6 +584,8 @@ mod tests { audiences: vec![AUDIENCE.to_string()], token_type: "Bearer".to_string(), credential_configuration_id: "date_of_birth_sd_jwt".to_string(), + issuance_transaction_id: "transaction-123".to_string(), + issuance_transaction_commitment: "sha256:transaction".to_string(), subject: bound_subject(), authorization_details: vec![serde_json::json!({ "type": NOTARY_AUTHORIZATION_DETAILS_TYPE, @@ -581,6 +609,8 @@ mod tests { issuer: ISSUER.to_string(), jti: "01J0000000000000000000PREAU".to_string(), credential_configuration_id: "date_of_birth_sd_jwt".to_string(), + issuance_transaction_id: "01J0000000000000000000PREAU".to_string(), + issuance_transaction_commitment: "sha256:transaction".to_string(), tx_code_required: true, subject: bound_subject(), iat: NOW, diff --git a/crates/registry-notary-server/src/api.rs b/crates/registry-notary-server/src/api.rs index bdb3e38fd..fb0e1eb0e 100644 --- a/crates/registry-notary-server/src/api.rs +++ b/crates/registry-notary-server/src/api.rs @@ -77,10 +77,9 @@ use registry_platform_oid4vci::{ consume_validated_proof_nonce_once, validate_proof_jwt, CredentialConfigurationMetadata, CredentialIssuerMetadata, CredentialOffer, CredentialRequest as Oid4vciCredentialRequest, CredentialResponse as Oid4vciCredentialResponse, CredentialResponseCredential, - DisplayImageMetadata, DisplayMetadata, NonceRequest as Oid4vciNonceRequest, NonceResponse, - ProofValidationPolicy, TokenRequest as Oid4vciTokenRequest, - TokenResponse as Oid4vciTokenResponse, TxCode, ValidatedProof, WireError, - PRE_AUTHORIZED_CODE_GRANT_TYPE, PROOF_TYPE_JWT, SD_JWT_VC_FORMAT, + DisplayImageMetadata, DisplayMetadata, ProofValidationPolicy, + TokenRequest as Oid4vciTokenRequest, TokenResponse as Oid4vciTokenResponse, TxCode, + ValidatedProof, WireError, PRE_AUTHORIZED_CODE_GRANT_TYPE, PROOF_TYPE_JWT, SD_JWT_VC_FORMAT, }; use registry_platform_ops::{ AckObservation, ConfigOverridePin, ConfigProvenance, ConfigSource, PostureApplyResult, @@ -89,7 +88,7 @@ use registry_platform_pdp::{ decide as pdp_decide, Decision as PdpDecision, EvidenceRequestContext as PdpRequestContext, PolicyInput as PdpPolicyInput, }; -use registry_platform_replay::{ReplayKey, ReplayScope, ReplayStoreError, RequiredReplayError}; +use registry_platform_replay::{ReplayKey, ReplayScope, RequiredReplayError}; use registry_platform_sdjwt::{validate_holder_proof, HolderProofBindings, HolderProofPolicy}; use serde::Deserialize; use serde_json::{json, Value}; @@ -110,7 +109,9 @@ use crate::{ metrics::AppMetrics, openapi_document, posture::{posture_document, PostureContext, PostureDocumentError}, - preauth_state::{LoginState, PreauthorizationStateError}, + preauth_state::{ + CredentialMaterialization, IssuanceTransaction, LoginState, PreauthorizationStateError, + }, replay::{require_replay_insert, ReplayReadiness, ReplayStores}, runtime::{ build_claim_levels, claim_ids, claim_semantics_metadata, requested_claim_versions, @@ -182,11 +183,9 @@ where "/.well-known/vct/{*vct_path}", get(oid4vci_well_known_type_metadata), ) - .route("/oid4vci/credential-offer", get(oid4vci_credential_offer)) .route("/oid4vci/offer/start", get(oid4vci_offer_start)) .route("/oid4vci/offer/callback", get(oid4vci_offer_callback)) .route("/oid4vci/token", post(oid4vci_token)) - .route("/oid4vci/nonce", post(oid4vci_nonce)) .route("/oid4vci/credential", post(oid4vci_credential)) .route("/v1/claims", get(list_claims)) .route("/v1/claims/{claim_id}", get(get_claim)) diff --git a/crates/registry-notary-server/src/api/attestation_policy.rs b/crates/registry-notary-server/src/api/attestation_policy.rs index b22b6a0a7..3c4d4d8ce 100644 --- a/crates/registry-notary-server/src/api/attestation_policy.rs +++ b/crates/registry-notary-server/src/api/attestation_policy.rs @@ -1109,6 +1109,63 @@ pub(super) fn require_subject_access_stored_access( disclosure: &str, format: &str, issue_credential: bool, +) -> Result<(), EvidenceError> { + require_subject_access_stored_access_inner( + state, + evidence, + principal, + evaluation, + requested_claims, + disclosure, + format, + issue_credential, + false, + ) +} + +/// Validate a stored evaluation when the credential request is authenticated +/// by the Notary access token minted from the issuer-initiated transaction. +/// +/// The callback evaluation records the external identity provider as its +/// authenticated issuer and audience. The wallet later presents a +/// Notary-signed, transaction-bound token, so those two token-envelope fields +/// intentionally change. All subject, client, policy, claim, disclosure, and +/// format bindings remain enforced here; the caller separately verifies the +/// Notary token's issuer, audience, transaction id, and commitment. +#[allow(clippy::too_many_arguments)] +pub(super) fn require_oid4vci_transaction_stored_access( + state: &RegistryNotaryApiState, + evidence: &EvidenceConfig, + principal: &EvidencePrincipal, + evaluation: ®istry_notary_core::StoredEvaluation, + requested_claims: &[String], + disclosure: &str, + format: &str, +) -> Result<(), EvidenceError> { + require_subject_access_stored_access_inner( + state, + evidence, + principal, + evaluation, + requested_claims, + disclosure, + format, + true, + true, + ) +} + +#[allow(clippy::too_many_arguments)] +fn require_subject_access_stored_access_inner( + state: &RegistryNotaryApiState, + evidence: &EvidenceConfig, + principal: &EvidencePrincipal, + evaluation: ®istry_notary_core::StoredEvaluation, + requested_claims: &[String], + disclosure: &str, + format: &str, + issue_credential: bool, + allow_token_envelope_transition: bool, ) -> Result<(), EvidenceError> { let Some(metadata) = evaluation.subject_access.as_ref() else { if principal.is_subject_access() { @@ -1199,9 +1256,10 @@ pub(super) fn require_subject_access_stored_access( .verified_claims .as_ref() .ok_or(EvidenceError::SubjectAccessInvalidToken)?; - if claims.issuer != metadata.issuer - || claims.client_id != metadata.client_id - || !verified_audiences_match(&claims.audiences, &metadata.audiences) + if claims.client_id != metadata.client_id + || (!allow_token_envelope_transition + && (claims.issuer != metadata.issuer + || !verified_audiences_match(&claims.audiences, &metadata.audiences))) { return Err(EvidenceError::EvaluationBindingMismatch); } diff --git a/crates/registry-notary-server/src/api/oid4vci/credential.rs b/crates/registry-notary-server/src/api/oid4vci/credential.rs index f8526d1cd..5953c0b55 100644 --- a/crates/registry-notary-server/src/api/oid4vci/credential.rs +++ b/crates/registry-notary-server/src/api/oid4vci/credential.rs @@ -22,6 +22,9 @@ pub(in crate::api) async fn oid4vci_credential( Ok(evidence) => evidence, Err(_) => return oid4vci_error_response(Oid4vciWireError::ServerError), }; + let Some(preauth) = preauth_runtime(&state) else { + return StatusCode::NOT_FOUND.into_response(); + }; let principal = match classify_subject_access_principal(&state.subject_access, &principal) { Ok(principal) if principal.is_subject_access() => principal, _ => return oid4vci_error_response(Oid4vciWireError::InvalidToken), @@ -44,9 +47,6 @@ pub(in crate::api) async fn oid4vci_credential( Err(error) => return oid4vci_error_response(error), }; let configuration_claim_ids = configuration.credential_claim_ids(); - if require_registry_backed_credential_claims(evidence, &configuration_claim_ids).is_err() { - return oid4vci_error_response(Oid4vciWireError::AccessDenied); - } if requested_attestation_access_mode(&principal) == AccessMode::DelegatedAttestation { let mut response = oid4vci_error_response(Oid4vciWireError::AccessDenied); attach_oid4vci_subject_access_denial_audit( @@ -71,7 +71,6 @@ pub(in crate::api) async fn oid4vci_credential( ); return response; } - let preauth = preauth_runtime(&state); if let Err(error) = require_oid4vci_issuance_authorization_details( evidence, &state.subject_access, @@ -80,7 +79,7 @@ pub(in crate::api) async fn oid4vci_credential( oid4vci_requires_authorization_details( &principal, state.runtime_config().as_deref(), - preauth.as_deref(), + Some(preauth.as_ref()), ), ) { let denial_code = denial_code_from_error(&error); @@ -95,205 +94,233 @@ pub(in crate::api) async fn oid4vci_credential( ); return response; } - let expected_nonce = if state.oid4vci.nonce.enabled { - let Some(nonce) = validated_proof.nonce.as_deref() else { - return oid4vci_error_response(Oid4vciWireError::InvalidProof); - }; - Some(nonce) - } else { - None + let Some(claims) = principal.verified_claims.as_ref() else { + return oid4vci_error_response(Oid4vciWireError::InvalidToken); }; - let profile = match evidence - .credential_profiles - .get(&configuration.credential_profile) + let (Some(transaction_id), Some(transaction_commitment), Some(token_configuration_id)) = ( + claims + .issuance_transaction_id + .as_ref() + .map(VerifiedClaimValue::as_str), + claims + .issuance_transaction_commitment + .as_ref() + .map(VerifiedClaimValue::as_str), + claims + .credential_configuration_id + .as_ref() + .map(VerifiedClaimValue::as_str), + ) else { + return oid4vci_error_response(Oid4vciWireError::InvalidToken); + }; + if token_configuration_id != configuration_id { + return oid4vci_error_response(Oid4vciWireError::InvalidToken); + } + let Some(nonce) = validated_proof.nonce.as_deref() else { + return oid4vci_error_response(Oid4vciWireError::InvalidProof); + }; + let holder_thumbprint = match validated_proof.holder_jwk.jkt() { + Ok(thumbprint) => thumbprint, + Err(_) => return oid4vci_error_response(Oid4vciWireError::InvalidProof), + }; + let request_hash = match serde_json::to_value(&request) + .map_err(|_| EvidenceError::InvalidRequest) + .and_then(|value| sha256_canonical_json(&value)) { - Some(profile) => profile, - None => return oid4vci_error_response(Oid4vciWireError::UnsupportedCredentialType), + Ok(hash) => hash, + Err(_) => return oid4vci_error_response(Oid4vciWireError::InvalidRequest), }; - if let Some(nonce) = expected_nonce { - let key = match state.subject_access_rate_keys.oid4vci_nonce( - &state.oid4vci.credential_issuer, + let transaction = match preauth + .preauthorization_state() + .begin_credential_materialization( + transaction_id, + transaction_commitment, configuration_id, nonce, - ) { - Ok(key) => key, - Err(_) => return oid4vci_error_response(Oid4vciWireError::ServerError), - }; - let replay_scope = match oid4vci_nonce_replay_scope(&state, configuration_id) { - Ok(scope) => scope, - Err(_) => return oid4vci_error_response(Oid4vciWireError::ServerError), - }; - let replay_key = match ReplayKey::new(key) { - Ok(key) => key, - Err(_) => return oid4vci_error_response(Oid4vciWireError::ServerError), - }; - match consume_validated_proof_nonce_once( - &validated_proof, - nonce, - state.replay.nonce_store().as_ref(), - &replay_scope, - &replay_key, + &holder_thumbprint, + &request_hash, ) .await - { - Ok(()) => { - state.metrics.record_replay("oid4vci_nonce", "consumed"); - } - Err(registry_platform_oid4vci::ProofError::InvalidNonce) => { - state.metrics.record_replay("oid4vci_nonce", "replayed"); - return oid4vci_error_response(Oid4vciWireError::InvalidProof); - } - Err(_) => { - state.metrics.record_replay("oid4vci_nonce", "invalid"); - return oid4vci_error_response(Oid4vciWireError::InvalidProof); - } - } - } - let holder_id = validated_proof.holder_id.as_str(); - if let Err(error) = - check_oid4vci_subject_access_rate_limit(&state, &principal, Some(holder_id)).await { - let mut response = oid4vci_error_response(Oid4vciWireError::RateLimited); - attach_subject_access_rate_limit_audit( - &mut response, - "oid4vci_rate_limited", - &configuration_claim_ids, - error.bucket(), - ); - return response; - } - let target = match oid4vci_bound_subject(&state.subject_access, &principal) { - Ok(subject) => EvidenceEntity::from_subject_request("Person", subject), - Err(_) => { - let mut response = oid4vci_error_response(Oid4vciWireError::InvalidToken); - attach_oid4vci_subject_access_denial_audit( - &mut response, - "oid4vci_credential_denied", - &configuration_claim_ids, - configuration_id, - Some(SubjectAccessDenialCode::InvalidToken), - Some(state.subject_access.subject_binding.token_claim.as_str()), - ); - return response; + Ok(CredentialMaterialization::Cached(response)) => { + state + .metrics + .record_credential("openid4vci", "retry_cached"); + return Json(response).into_response(); } - }; - let request = EvaluateRequest { - requester: Some(target.clone()), - target: Some(target), - relationship: Some(EvidenceRelationship { - relationship_type: "self".to_string(), - attributes: Default::default(), - }), - on_behalf_of: None, - variables: Default::default(), - claims: configuration_claim_ids - .iter() - .map(|claim_id| ClaimRef::from(claim_id.as_str())) - .collect(), - disclosure: None, - format: Some(FORMAT_CLAIM_RESULT_JSON.to_string()), - purpose: None, - }; - let mut request = request; - let context = match prepare_subject_access_credential_evaluation( - &state, evidence, &principal, &request, - ) { - Ok(context) => { - request.purpose = Some(context.purpose.clone()); - context + Ok(CredentialMaterialization::Acquired(transaction)) => transaction, + Ok(CredentialMaterialization::Busy) => { + return oid4vci_error_response(Oid4vciWireError::ServerError); } - Err(error) => { - let denial_code = denial_code_from_error(&error); - let mut response = oid4vci_error_response(oid4vci_error_from_evidence(&error)); - attach_oid4vci_subject_access_denial_audit( - &mut response, - "oid4vci_credential_denied", - &configuration_claim_ids, - configuration_id, - denial_code, - Some(state.subject_access.subject_binding.token_claim.as_str()), - ); - return response; + Ok(CredentialMaterialization::Denied) | Err(_) => { + return oid4vci_error_response(Oid4vciWireError::InvalidToken); } }; - let results = match state - .runtime() - .evaluate_with_capability( - Arc::clone(&state.evidence), - &state.store, - &principal, - context.evaluation_capability, - request, - None, - Some(context.metadata.clone()), - None, - ) - .await - { - Ok(results) => results, + let materialized = materialize_oid4vci_transaction( + &state, + evidence, + &principal, + &validated_proof, + configuration_id, + configuration, + &transaction, + nonce, + ) + .await; + let (response_body, evaluation) = match materialized { + Ok(materialized) => materialized, Err(error) => { - let denial_code = denial_code_from_error(&error); - let mut response = oid4vci_error_response(oid4vci_error_from_evidence(&error)); - attach_oid4vci_subject_access_denial_audit( - &mut response, - "oid4vci_credential_denied", - &configuration_claim_ids, - configuration_id, - denial_code, - Some(state.subject_access.subject_binding.token_claim.as_str()), - ); - return response; + let _ = preauth + .preauthorization_state() + .fail_credential_materialization(transaction_id, &holder_thumbprint) + .await; + return oid4vci_error_response(error); } }; - let evaluation_id = results - .first() - .map(|result| result.evaluation_id.clone()) - .unwrap_or_default(); - let lookup_client_id = match stored_evaluation_client_id(&state, &principal) { - Ok(client_id) => client_id, - Err(error) => return oid4vci_error_response(oid4vci_error_from_evidence(&error)), - }; - let evaluation = match state.store.get(&evaluation_id, &lookup_client_id).await { - Ok(Some(evaluation)) => evaluation, - Ok(None) | Err(_) => return oid4vci_error_response(Oid4vciWireError::ServerError), + if !matches!( + preauth + .preauthorization_state() + .complete_credential_materialization( + transaction_id, + &holder_thumbprint, + &request_hash, + response_body.clone(), + ) + .await, + Ok(true) + ) { + let _ = preauth + .preauthorization_state() + .fail_credential_materialization(transaction_id, &holder_thumbprint) + .await; + return oid4vci_error_response(Oid4vciWireError::ServerError); + } + let profile = match evidence + .credential_profiles + .get(&configuration.credential_profile) + { + Some(profile) => profile, + None => return oid4vci_error_response(Oid4vciWireError::ServerError), }; - if let Err(error) = require_subject_access_stored_access( - &state, + let mut response = Json(response_body).into_response(); + state.metrics.record_credential("openid4vci", "issued"); + if attach_subject_access_credential_audit( + &mut response, + &state.subject_access_rate_keys, + &transaction.evaluation_id, + &evaluation.claim_ids, + &evaluation.results, + evaluation.results.len() as u64, + SubjectAccessCredentialAuditDetails { + profile_id: &configuration.credential_profile, + holder_binding_mode: &profile.holder_binding.mode, + policy_hash: evaluation + .subject_access + .as_ref() + .and_then(|metadata| metadata.policy_hash.clone()), + purposes: Some(vec![evaluation.purpose.clone()]), + protocol: Some("openid4vci"), + credential_configuration_id: Some(configuration_id), + }, + ) + .is_err() + { + return oid4vci_error_response(Oid4vciWireError::ServerError); + } + response +} + +pub(in crate::api) async fn materialize_oid4vci_transaction( + state: &RegistryNotaryApiState, + evidence: &EvidenceConfig, + principal: &EvidencePrincipal, + validated_proof: &ValidatedProof, + configuration_id: &str, + configuration: &Oid4vciCredentialConfigurationConfig, + transaction: &IssuanceTransaction, + nonce: &str, +) -> Result<(Value, registry_notary_core::StoredEvaluation), Oid4vciWireError> { + let key = state + .subject_access_rate_keys + .oid4vci_nonce(&state.oid4vci.credential_issuer, configuration_id, nonce) + .map_err(|_| Oid4vciWireError::ServerError)?; + let replay_scope = oid4vci_nonce_replay_scope(state, configuration_id)?; + let replay_key = ReplayKey::new(key).map_err(|_| Oid4vciWireError::ServerError)?; + consume_validated_proof_nonce_once( + validated_proof, + nonce, + state.replay.nonce_store().as_ref(), + &replay_scope, + &replay_key, + ) + .await + .map_err(|_| Oid4vciWireError::InvalidProof)?; + state.metrics.record_replay("oid4vci_nonce", "consumed"); + check_oid4vci_subject_access_rate_limit( + state, + principal, + Some(validated_proof.holder_id.as_str()), + ) + .await + .map_err(|_| Oid4vciWireError::RateLimited)?; + let evaluation = state + .store + .get( + &transaction.evaluation_id, + &transaction.evaluation_client_id, + ) + .await + .map_err(|_| Oid4vciWireError::ServerError)? + .ok_or(Oid4vciWireError::AccessDenied)?; + if evaluation.claim_ids != configuration.credential_claim_ids() { + return Err(Oid4vciWireError::AccessDenied); + } + require_oid4vci_transaction_stored_access( + state, evidence, - &principal, + principal, &evaluation, &evaluation.claim_ids, &evaluation.disclosure, &evaluation.format, - true, - ) { - return oid4vci_error_response(oid4vci_error_from_evidence(&error)); - } - if !state.subject_access.allowed_operations.issue_credential { - return oid4vci_error_response(Oid4vciWireError::AccessDenied); - } - if let Err(error) = require_subject_access_credential_profile_policy( + ) + .map_err(|error| oid4vci_error_from_evidence(&error))?; + let profile = evidence + .credential_profiles + .get(&configuration.credential_profile) + .ok_or(Oid4vciWireError::UnsupportedCredentialType)?; + require_subject_access_credential_profile_policy( &state.subject_access, &configuration.credential_profile, profile, - ) { - return oid4vci_error_response(oid4vci_error_from_evidence(&error)); - } - if let Err(error) = - require_issuable_evaluation_provenance(evidence, &evaluation_id, &evaluation) - { - return oid4vci_error_response(oid4vci_error_from_evidence(&error)); + ) + .map_err(|error| oid4vci_error_from_evidence(&error))?; + require_issuable_evaluation_provenance(evidence, &transaction.evaluation_id, &evaluation) + .map_err(|error| oid4vci_error_from_evidence(&error))?; + let configuration_fingerprint = + oid4vci_configuration_fingerprint(evidence, configuration_id, configuration) + .map_err(|_| Oid4vciWireError::ServerError)?; + let commitment = oid4vci_issuance_transaction_commitment( + &transaction.transaction_id, + evidence, + configuration_id, + configuration, + &configuration_fingerprint, + &transaction.evaluation_id, + &evaluation, + ) + .map_err(|_| Oid4vciWireError::ServerError)?; + if commitment != transaction.commitment { + return Err(Oid4vciWireError::AccessDenied); } - let issuer = match state + let issuer = state .issuer_resolver() .issuer(&configuration.credential_profile) - { - Ok(issuer) => issuer, - Err(_) => return oid4vci_error_response(Oid4vciWireError::ServerError), - }; + .map_err(|_| Oid4vciWireError::ServerError)?; if holder_key_matches_issuer_key(&validated_proof.holder_jwk, &issuer.public_jwk()) { - return oid4vci_error_response(Oid4vciWireError::InvalidProof); + return Err(Oid4vciWireError::InvalidProof); } + let holder_id = validated_proof.holder_id.as_str(); let iat = earliest_issued_at(&evaluation.results).unwrap_or_else(OffsetDateTime::now_utc); let credential_id = state .credential_status @@ -302,8 +329,7 @@ pub(in crate::api) async fn oid4vci_credential( let status_claim = credential_id .as_deref() .and_then(|credential_id| state.credential_status.status_claim(credential_id)); - let projection = oid4vci_sd_jwt_projection(configuration); - let signed = match sd_jwt::issue( + let signed = sd_jwt::issue( profile, &issuer, &evaluation.results, @@ -313,20 +339,16 @@ pub(in crate::api) async fn oid4vci_credential( sd_jwt::IssueOptions { credential_id, status: status_claim, - projection, + projection: oid4vci_sd_jwt_projection(configuration), }, ) .await - { - Ok(signed) => signed, - Err(_) => return oid4vci_error_response(Oid4vciWireError::ServerError), - }; - let expires_at = match iat.checked_add(time::Duration::seconds(profile.validity_seconds)) { - Some(expires_at) => expires_at, - None => return oid4vci_error_response(Oid4vciWireError::ServerError), - }; - if state.credential_status.is_enabled() - && state + .map_err(|_| Oid4vciWireError::ServerError)?; + let expires_at = iat + .checked_add(time::Duration::seconds(profile.validity_seconds)) + .ok_or(Oid4vciWireError::ServerError)?; + if state.credential_status.is_enabled() { + state .credential_status .record_issued( signed.credential_id.clone(), @@ -336,86 +358,21 @@ pub(in crate::api) async fn oid4vci_credential( expires_at, ) .await - .is_err() - { - return oid4vci_error_response(Oid4vciWireError::ServerError); + .map_err(|_| Oid4vciWireError::ServerError)?; } - let next_nonce = if state.oid4vci.nonce.enabled { - match generate_nonce() { - Ok(nonce) => { - if let Ok(key) = state.subject_access_rate_keys.oid4vci_nonce( - &state.oid4vci.credential_issuer, - configuration_id, - &nonce, - ) { - let expires_at = OffsetDateTime::now_utc() - + time::Duration::seconds(state.oid4vci.nonce.ttl_seconds as i64); - let replay_scope = oid4vci_nonce_replay_scope(&state, configuration_id).ok(); - let replay_key = ReplayKey::new(key).ok(); - match (replay_scope, replay_key) { - (Some(scope), Some(key)) => { - if state - .replay - .nonce_store() - .reserve_nonce(&scope, &key, expires_at) - .await - .is_ok() - { - state.metrics.record_replay("oid4vci_nonce", "reserved"); - Some(nonce) - } else { - state.metrics.record_replay("oid4vci_nonce", "error"); - None - } - } - _ => None, - } - } else { - None - } - } - Err(_) => None, - } - } else { - None - }; let credential = signed.compact; - let mut response = Json(Oid4vciCredentialResponse { + let response = Oid4vciCredentialResponse { credential: credential.clone().into(), credentials: vec![CredentialResponseCredential { credential: credential.into(), }], format: Some(SD_JWT_VC_FORMAT.to_string()), - c_nonce: next_nonce, - c_nonce_expires_in: state - .oid4vci - .nonce - .enabled - .then_some(state.oid4vci.nonce.ttl_seconds), - }) - .into_response(); - state.metrics.record_credential("openid4vci", "issued"); - if attach_subject_access_credential_audit( - &mut response, - &state.subject_access_rate_keys, - &evaluation_id, - &evaluation.claim_ids, - &evaluation.results, - evaluation.results.len() as u64, - SubjectAccessCredentialAuditDetails { - profile_id: &configuration.credential_profile, - holder_binding_mode: &profile.holder_binding.mode, - policy_hash: context.metadata.policy_hash, - purposes: Some(vec![evaluation.purpose.clone()]), - protocol: Some("openid4vci"), - credential_configuration_id: Some(configuration_id), - }, - ) - .is_err() - { - return oid4vci_error_response(Oid4vciWireError::ServerError); - } - response + // The 1.0 profile has no response next-nonce. + c_nonce: None, + c_nonce_expires_in: None, + }; + let response = serde_json::to_value(response).map_err(|_| Oid4vciWireError::ServerError)?; + Ok((response, evaluation)) } pub(in crate::api) fn earliest_issued_at( @@ -470,27 +427,6 @@ pub(in crate::api) fn oid4vci_configuration_for_request<'a>( .ok_or(Oid4vciWireError::UnsupportedCredentialType) } -pub(in crate::api) fn oid4vci_nonce_configuration_id( - config: &Oid4vciConfig, - requested_id: Option, -) -> Result<&str, Oid4vciWireError> { - if let Some(id) = requested_id { - return config - .credential_configurations - .get_key_value(&id) - .map(|(id, _)| id.as_str()) - .ok_or(Oid4vciWireError::InvalidRequest); - } - let mut ids = config.credential_configurations.keys(); - let Some(first) = ids.next() else { - return Err(Oid4vciWireError::InvalidRequest); - }; - if ids.next().is_some() { - return Err(Oid4vciWireError::InvalidRequest); - } - Ok(first.as_str()) -} - pub(in crate::api) fn oid4vci_nonce_replay_scope( state: &RegistryNotaryApiState, configuration_id: &str, diff --git a/crates/registry-notary-server/src/api/oid4vci/metadata.rs b/crates/registry-notary-server/src/api/oid4vci/metadata.rs index e404c3efc..81bf1a3b2 100644 --- a/crates/registry-notary-server/src/api/oid4vci/metadata.rs +++ b/crates/registry-notary-server/src/api/oid4vci/metadata.rs @@ -18,38 +18,6 @@ pub(in crate::api) async fn oid4vci_issuer_metadata( } } -pub(in crate::api) async fn oid4vci_credential_offer( - state: Option>>, - Query(query): Query, -) -> Response { - let Some(Extension(state)) = state else { - return oid4vci_error_response(Oid4vciWireError::ServerError); - }; - if !state.oid4vci.enabled { - return StatusCode::NOT_FOUND.into_response(); - } - let credential_configuration_ids = if let Some(id) = query.credential_configuration_id { - if !state.oid4vci.credential_configurations.contains_key(&id) { - return oid4vci_error_response(Oid4vciWireError::InvalidRequest); - } - vec![id] - } else { - state - .oid4vci - .credential_configurations - .keys() - .cloned() - .collect() - }; - Json(CredentialOffer::authorization_code( - state.oid4vci.credential_issuer.clone(), - credential_configuration_ids, - generate_nonce().unwrap_or_else(|_| "registry-notary:subject-access".to_string()), - state.oid4vci.authorization_servers.first().cloned(), - )) - .into_response() -} - pub(in crate::api) async fn oid4vci_type_metadata( state: Option>>, connect_info: Option>>, @@ -132,10 +100,6 @@ pub(in crate::api) fn oid4vci_type_metadata_response( .into_response() } -#[derive(Debug, Deserialize)] -pub(in crate::api) struct Oid4vciCredentialOfferQuery { - pub(in crate::api) credential_configuration_id: Option, -} pub(in crate::api) fn oid4vci_metadata( config: &Oid4vciConfig, evidence: &EvidenceConfig, @@ -143,11 +107,9 @@ pub(in crate::api) fn oid4vci_metadata( let metadata = CredentialIssuerMetadata::new( config.credential_issuer.clone(), config.credential_endpoint.clone(), - config - .nonce - .enabled - .then(|| config.nonce_endpoint.clone()) - .flatten(), + // The initial nonce is transaction-scoped and returned only by the + // token endpoint. The 1.0 profile has no public nonce endpoint. + None, config.authorization_servers.clone(), config .credential_configurations diff --git a/crates/registry-notary-server/src/api/oid4vci/preauth.rs b/crates/registry-notary-server/src/api/oid4vci/preauth.rs index 442437867..3d2efe3f0 100644 --- a/crates/registry-notary-server/src/api/oid4vci/preauth.rs +++ b/crates/registry-notary-server/src/api/oid4vci/preauth.rs @@ -62,6 +62,8 @@ pub(in crate::api) async fn oid4vci_offer_start( oid4vci_error_response(Oid4vciWireError::RateLimited) } PreauthorizationStateError::DuplicateLoginState + | PreauthorizationStateError::DuplicateIssuanceTransaction + | PreauthorizationStateError::IssuanceTransactionCapacity | PreauthorizationStateError::Unavailable | PreauthorizationStateError::IncompatibleTransactionCodeProof | PreauthorizationStateError::InvalidExpiry @@ -83,6 +85,242 @@ pub(in crate::api) struct Oid4vciOfferCallbackQuery { pub(in crate::api) state: Option, } +pub(in crate::api) async fn prepare_registry_backed_issuance_transaction( + state: &RegistryNotaryApiState, + preauth: &PreAuthRuntime, + bound_subject: &BoundSubject, + configuration_id: &str, + transaction_id: &str, +) -> Result { + let evidence = state.enabled_evidence()?; + let (configuration_id, configuration) = state + .oid4vci + .credential_configurations + .get_key_value(configuration_id) + .ok_or(EvidenceError::InvalidRequest)?; + let configuration_claim_ids = configuration.credential_claim_ids(); + require_registry_backed_credential_claims(evidence, &configuration_claim_ids)?; + let mut principal = preauth.principal_for_subject(bound_subject)?; + add_scope_if_missing(&mut principal.scopes, &configuration.scope); + let principal = classify_subject_access_principal(&state.subject_access, &principal)?; + if !principal.is_subject_access() + || requested_attestation_access_mode(&principal) == AccessMode::DelegatedAttestation + { + return Err(EvidenceError::SubjectAccessInvalidToken); + } + let target = EvidenceEntity::from_subject_request( + "Person", + oid4vci_bound_subject(&state.subject_access, &principal)?, + ); + let mut request = EvaluateRequest { + requester: Some(target.clone()), + target: Some(target), + relationship: Some(EvidenceRelationship { + relationship_type: "self".to_string(), + attributes: Default::default(), + }), + on_behalf_of: None, + variables: Default::default(), + claims: configuration_claim_ids + .iter() + .map(|claim_id| ClaimRef::from(claim_id.as_str())) + .collect(), + disclosure: None, + format: Some(FORMAT_CLAIM_RESULT_JSON.to_string()), + purpose: None, + }; + let context = + prepare_subject_access_credential_evaluation(state, evidence, &principal, &request)?; + request.purpose = Some(context.purpose.clone()); + let results = state + .runtime() + .evaluate_with_capability( + Arc::clone(&state.evidence), + &state.store, + &principal, + context.evaluation_capability, + request, + None, + Some(context.metadata), + None, + ) + .await?; + let evaluation_id = results + .first() + .map(|result| result.evaluation_id.clone()) + .filter(|id| !id.is_empty()) + .ok_or(EvidenceError::CredentialIssuanceFailed)?; + let evaluation_client_id = stored_evaluation_client_id(state, &principal)?; + let evaluation = state + .store + .get(&evaluation_id, &evaluation_client_id) + .await? + .ok_or(EvidenceError::EvaluationNotFound)?; + require_subject_access_stored_access( + state, + evidence, + &principal, + &evaluation, + &evaluation.claim_ids, + &evaluation.disclosure, + &evaluation.format, + true, + )?; + if !state.subject_access.allowed_operations.issue_credential { + return Err(EvidenceError::SubjectAccessDenied { + reason: SubjectAccessDenialCode::OperationDenied, + }); + } + let profile = evidence + .credential_profiles + .get(&configuration.credential_profile) + .ok_or(EvidenceError::ProfileUnsupported)?; + require_subject_access_credential_profile_policy( + &state.subject_access, + &configuration.credential_profile, + profile, + )?; + require_issuable_evaluation_provenance(evidence, &evaluation_id, &evaluation)?; + let configuration_fingerprint = + oid4vci_configuration_fingerprint(evidence, configuration_id, configuration)?; + let commitment = oid4vci_issuance_transaction_commitment( + transaction_id, + evidence, + configuration_id, + configuration, + &configuration_fingerprint, + &evaluation_id, + &evaluation, + )?; + Ok(IssuanceTransaction { + transaction_id: transaction_id.to_string(), + evaluation_id, + evaluation_client_id, + credential_configuration_id: configuration_id.clone(), + commitment, + }) +} + +pub(in crate::api) fn oid4vci_configuration_fingerprint( + evidence: &EvidenceConfig, + configuration_id: &str, + configuration: &Oid4vciCredentialConfigurationConfig, +) -> Result { + let profile = evidence + .credential_profiles + .get(&configuration.credential_profile) + .ok_or(EvidenceError::ProfileUnsupported)?; + let signing_key = evidence + .signing_keys + .get(&profile.signing_key) + .ok_or(EvidenceError::CredentialIssuerNotConfigured)?; + let mut normalized = BTreeMap::new(); + normalized.insert("schema_version", json!("registry-notary-oid4vci-config/v1")); + normalized.insert("service_id", json!(evidence.service_id)); + normalized.insert("credential_configuration_id", json!(configuration_id)); + normalized.insert( + "credential_configuration", + serde_json::to_value(configuration).map_err(|_| EvidenceError::InvalidRequest)?, + ); + normalized.insert( + "credential_profile", + json!({ + "id": configuration.credential_profile, + "format": profile.format, + "issuer": profile.issuer, + "signing_key": profile.signing_key, + "vct": profile.vct, + "validity_seconds": profile.validity_seconds, + "holder_binding": profile.holder_binding, + "allowed_claims": profile.allowed_claims, + "disclosure": profile.disclosure, + }), + ); + normalized.insert( + "signing_key", + json!({ + "id": profile.signing_key, + "provider": signing_key.provider, + "alg": signing_key.alg, + "kid": signing_key.kid, + "status": signing_key.status, + "publish_until_unix_seconds": signing_key.publish_until_unix_seconds, + }), + ); + sha256_canonical_json( + &serde_json::to_value(normalized).map_err(|_| EvidenceError::InvalidRequest)?, + ) +} + +pub(in crate::api) fn oid4vci_issuance_transaction_commitment( + transaction_id: &str, + evidence: &EvidenceConfig, + configuration_id: &str, + configuration: &Oid4vciCredentialConfigurationConfig, + configuration_fingerprint: &str, + evaluation_id: &str, + evaluation: ®istry_notary_core::StoredEvaluation, +) -> Result { + let subject_access = evaluation + .subject_access + .as_ref() + .ok_or(EvidenceError::EvaluationBindingMismatch)?; + let provenance = evaluation + .issuance_provenance + .as_ref() + .ok_or(EvidenceError::CredentialIssuanceFailed)?; + if !subject_access + .principal_hash + .as_str() + .starts_with("hmac-sha256:") + || !subject_access + .subject_binding_hash + .as_str() + .starts_with("hmac-sha256:") + { + return Err(EvidenceError::EvaluationBindingMismatch); + } + let mut normalized = BTreeMap::new(); + normalized.insert( + "schema_version", + json!("registry-notary-oid4vci-issuance-transaction/v1"), + ); + normalized.insert("transaction_id", json!(transaction_id)); + normalized.insert( + "authenticated_principal_hash", + json!(subject_access.principal_hash), + ); + normalized.insert( + "authenticated_subject_binding_hash", + json!(subject_access.subject_binding_hash), + ); + normalized.insert("authenticated_issuer", json!(subject_access.issuer)); + normalized.insert("authenticated_client", json!(subject_access.client_id)); + normalized.insert("service", json!(evidence.service_id)); + normalized.insert("purpose", json!(evaluation.purpose)); + normalized.insert( + "canonical_claim_references", + json!(evaluation.selected_claim_refs()), + ); + normalized.insert("credential_configuration_id", json!(configuration_id)); + normalized.insert( + "credential_profile", + json!(configuration.credential_profile), + ); + normalized.insert( + "configuration_fingerprint", + json!(configuration_fingerprint), + ); + normalized.insert( + "relay_contract_and_provenance", + serde_json::to_value(provenance).map_err(|_| EvidenceError::InvalidRequest)?, + ); + normalized.insert("stored_evaluation_id", json!(evaluation_id)); + sha256_canonical_json( + &serde_json::to_value(normalized).map_err(|_| EvidenceError::InvalidRequest)?, + ) +} + /// `GET /oid4vci/offer/callback` (public): consume the login state, exchange the /// eSignet code via `private_key_jwt`, validate the `id_token`, mint a single-use /// `pre-authorized_code`, and render the offer page. @@ -176,14 +414,69 @@ pub(in crate::api) async fn oid4vci_offer_callback( return preauth_server_error(&preauth, path, "GET", &stored.credential_configuration_id) .await; }; + // The registry-backed evaluation is completed before any offer is minted. + // A denied, unavailable, stale, malformed, or provenance-invalid Relay + // outcome therefore leaves the caller with no wallet grant at all. + let transaction = match prepare_registry_backed_issuance_transaction( + &state, + &preauth, + &bound_subject, + &stored.credential_configuration_id, + &jti, + ) + .await + { + Ok(transaction) => transaction, + Err(error) => { + tracing::warn!( + code = error.audit_code(), + "registry-backed OID4VCI evaluation denied before offer minting" + ); + return preauth_denied( + &preauth, + path, + "GET", + Some(&stored.credential_configuration_id), + denial_code_from_error(&error).unwrap_or(SubjectAccessDenialCode::OperationDenied), + oid4vci_error_from_evidence(&error), + ) + .await; + } + }; + let code_exp = now + preauth.pre_authorized_code_ttl_seconds() as i64; + let transaction_expires_at = match OffsetDateTime::from_unix_timestamp( + code_exp + preauth.access_token_ttl_seconds() as i64, + ) { + Ok(expires_at) => expires_at, + Err(_) => { + return preauth_server_error( + &preauth, + path, + "GET", + &stored.credential_configuration_id, + ) + .await; + } + }; + if preauth + .preauthorization_state() + .reserve_issuance_transaction(&jti, transaction.clone(), transaction_expires_at) + .await + .is_err() + { + return preauth_server_error(&preauth, path, "GET", &stored.credential_configuration_id) + .await; + } let code_claims = PreAuthorizedCodeClaims { issuer: preauth.notary_issuer().to_string(), jti: jti.clone(), credential_configuration_id: stored.credential_configuration_id.clone(), + issuance_transaction_id: jti.clone(), + issuance_transaction_commitment: transaction.commitment.clone(), tx_code_required: preauth.tx_code_required(), subject: bound_subject, iat: now, - exp: now + preauth.pre_authorized_code_ttl_seconds() as i64, + exp: code_exp, }; let signed_code = match mint_pre_authorized_code( preauth.access_token_signer(), @@ -517,6 +810,62 @@ pub(in crate::api) async fn oid4vci_token( ) .await; }; + let Some(transaction_id) = verified.claim_str("issuance_transaction_id") else { + return token_error_after_invalid_attempt( + &state, + &preauth, + path, + &client_address, + Some(configuration_id), + TokenWireError::InvalidGrant, + ) + .await; + }; + let Some(transaction_commitment) = verified.claim_str("issuance_transaction_commitment") else { + return token_error_after_invalid_attempt( + &state, + &preauth, + path, + &client_address, + Some(configuration_id), + TokenWireError::InvalidGrant, + ) + .await; + }; + if transaction_id != jti { + return token_error_after_invalid_attempt( + &state, + &preauth, + path, + &client_address, + Some(configuration_id), + TokenWireError::InvalidGrant, + ) + .await; + } + let transaction = match preauth + .preauthorization_state() + .transaction(transaction_id) + .await + { + Ok(Some(transaction)) + if transaction.commitment == transaction_commitment + && transaction.credential_configuration_id == *configuration_id => + { + transaction + } + _ => { + return token_error_after_invalid_attempt( + &state, + &preauth, + path, + &client_address, + Some(configuration_id), + TokenWireError::InvalidGrant, + ) + .await; + } + }; let mut bound_subject = bound_subject; add_scope_if_missing(&mut bound_subject.scopes, &configuration.scope); let authorization_details = match oid4vci_issuance_authorization_details( @@ -590,12 +939,43 @@ pub(in crate::api) async fn oid4vci_token( } } let configuration_id = configuration_id.as_str(); + let c_nonce = match issue_c_nonce(&state, configuration_id).await { + Some(c_nonce) => c_nonce, + None => { + return token_error_with_audit( + &preauth, + path, + Some(configuration_id), + SubjectAccessDenialCode::OperationDenied, + TokenWireError::ServerError, + ) + .await; + } + }; + if !matches!( + preauth + .preauthorization_state() + .bind_transaction_nonce(transaction_id, &transaction.commitment, c_nonce.clone(),) + .await, + Ok(true) + ) { + return token_error_with_audit( + &preauth, + path, + Some(configuration_id), + SubjectAccessDenialCode::OperationDenied, + TokenWireError::ServerError, + ) + .await; + } let access_token_claims = AccessTokenClaims { issuer: preauth.notary_issuer().to_string(), jti: None, audiences: preauth.notary_audiences().to_vec(), token_type: "Bearer".to_string(), credential_configuration_id: configuration_id.to_string(), + issuance_transaction_id: transaction_id.to_string(), + issuance_transaction_commitment: transaction.commitment.clone(), subject: bound_subject, authorization_details, confirmation: None, @@ -622,19 +1002,6 @@ pub(in crate::api) async fn oid4vci_token( .await; } }; - let c_nonce = match issue_c_nonce(&state, configuration_id).await { - Some(c_nonce) => c_nonce, - None => { - return token_error_with_audit( - &preauth, - path, - Some(configuration_id), - SubjectAccessDenialCode::OperationDenied, - TokenWireError::ServerError, - ) - .await; - } - }; let audit = pre_auth_audit_event( "POST", path, @@ -907,27 +1274,6 @@ pub(in crate::api) async fn check_token_client_address_rate_limit( .await } -pub(in crate::api) async fn consume_public_client_address_rate_limit( - state: &RegistryNotaryApiState, - client_address: &str, -) -> Result<(), SubjectAccessRateLimitError> { - let hashed = state - .subject_access_rate_keys - .client_address(client_address)?; - state - .subject_access_rate_limiter - .check_invalid_token_for_client_address(&hashed) - .await -} - -pub(in crate::api) fn replay_store_error_is_capacity(error: &ReplayStoreError) -> bool { - matches!( - error, - ReplayStoreError::Operation { message } - if message.contains("in-memory cache store is full") - ) -} - /// Record one `tx_code` attempt against the hashed pre-authorized code. After /// the configured cap the code is locked. pub(in crate::api) async fn check_tx_code_attempt( diff --git a/crates/registry-notary-server/src/api/oid4vci/proof.rs b/crates/registry-notary-server/src/api/oid4vci/proof.rs index 28b1c2a18..0df5d26b4 100644 --- a/crates/registry-notary-server/src/api/oid4vci/proof.rs +++ b/crates/registry-notary-server/src/api/oid4vci/proof.rs @@ -62,87 +62,28 @@ pub async fn oid4vci_proof_precheck_middleware( Ok(proof) => proof, Err(_) => return oid4vci_error_response(Oid4vciWireError::InvalidProof), }; + if require_oid4vci_did_jwk_proof(&validated_proof).is_err() { + return oid4vci_error_response(Oid4vciWireError::InvalidProof); + } let mut request = Request::from_parts(parts, Body::from(bytes)); request.extensions_mut().insert(validated_proof); next.run(request).await } -pub(in crate::api) async fn oid4vci_nonce( - state: Option>>, - connect_info: Option>>, - headers: HeaderMap, - body: Bytes, -) -> Response { - let Some(Extension(state)) = state else { - return oid4vci_error_response(Oid4vciWireError::ServerError); - }; - if !state.oid4vci.enabled || !state.oid4vci.nonce.enabled { - return StatusCode::NOT_FOUND.into_response(); - } - let client_address = token_client_address(&state, &headers, connect_info.as_deref()); - if consume_public_client_address_rate_limit(&state, &client_address) - .await - .is_err() + +pub(in crate::api) fn require_oid4vci_did_jwk_proof( + proof: &ValidatedProof, +) -> Result<(), Oid4vciWireError> { + if proof + .kid + .as_deref() + .is_some_and(|kid| kid.starts_with("did:jwk:")) + && proof.holder_id.starts_with("did:jwk:") { - return oid4vci_error_response(Oid4vciWireError::RateLimited); - } - let request = if body.is_empty() { - Oid4vciNonceRequest { - credential_configuration_id: None, - } + Ok(()) } else { - match serde_json::from_slice::(&body) { - Ok(request) => request, - Err(_) => return oid4vci_error_response(Oid4vciWireError::InvalidRequest), - } - }; - let configuration_id = - match oid4vci_nonce_configuration_id(&state.oid4vci, request.credential_configuration_id) { - Ok(configuration_id) => configuration_id, - Err(error) => return oid4vci_error_response(error), - }; - let nonce = match generate_nonce() { - Ok(nonce) => nonce, - Err(error) => return evidence_error_response(error), - }; - let key = match state.subject_access_rate_keys.oid4vci_nonce( - &state.oid4vci.credential_issuer, - configuration_id, - &nonce, - ) { - Ok(key) => key, - Err(error) => return evidence_error_response(error.evidence_error()), - }; - let replay_scope = match oid4vci_nonce_replay_scope(&state, configuration_id) { - Ok(scope) => scope, - Err(_) => return oid4vci_error_response(Oid4vciWireError::ServerError), - }; - let replay_key = match ReplayKey::new(key) { - Ok(key) => key, - Err(_) => return oid4vci_error_response(Oid4vciWireError::ServerError), - }; - let expires_at = - OffsetDateTime::now_utc() + time::Duration::seconds(state.oid4vci.nonce.ttl_seconds as i64); - if let Err(error) = state - .replay - .nonce_store() - .reserve_nonce(&replay_scope, &replay_key, expires_at) - .await - { - if replay_store_error_is_capacity(&error) { - state.metrics.record_replay("oid4vci_nonce", "rate_limited"); - return oid4vci_error_response(Oid4vciWireError::RateLimited); - } - state.metrics.record_replay("oid4vci_nonce", "error"); - return oid4vci_error_response(Oid4vciWireError::ServerError); + Err(Oid4vciWireError::InvalidProof) } - state.metrics.record_replay("oid4vci_nonce", "reserved"); - Json(NonceResponse { - c_nonce: nonce, - c_nonce_expires_in: state.oid4vci.nonce.ttl_seconds, - }) - .into_response() } - pub(in crate::api) fn oid4vci_proof_nonce(proof_jwt: &str) -> Result { #[derive(Deserialize)] struct NonceClaims { @@ -193,6 +134,36 @@ pub(in crate::api) fn generate_nonce() -> Result { Ok(URL_SAFE_NO_PAD.encode(nonce)) } +#[cfg(test)] +mod oid4vci_proof_tests { + use super::*; + + fn proof(kid: Option<&str>) -> ValidatedProof { + ValidatedProof { + holder_jwk: PublicJwk::parse( + r#"{"kty":"OKP","crv":"Ed25519","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA"}"#, + ) + .expect("public JWK parses"), + holder_id: "did:jwk:eyJjcnYiOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwieCI6IjFhal9yTEpzR0Zndy01djkyNUVNbWVaalBKcDQ0eGVnYWZFS2ZaYmR4YyJ9".to_string(), + kid: kid.map(str::to_string), + nonce: Some("nonce".to_string()), + iat: 1, + exp: Some(2), + raw_claims: json!({}), + } + } + + #[test] + fn oid4vci_holder_proof_requires_explicit_did_jwk_key_reference() { + let did = proof(None).holder_id; + assert!(require_oid4vci_did_jwk_proof(&proof(Some(&did))).is_ok()); + assert_eq!( + require_oid4vci_did_jwk_proof(&proof(None)), + Err(Oid4vciWireError::InvalidProof) + ); + } +} + #[derive(Debug)] pub(in crate::api) struct HolderProofBinding { pub(in crate::api) scope: ReplayScope, diff --git a/crates/registry-notary-server/src/api/tests/support.rs b/crates/registry-notary-server/src/api/tests/support.rs index 81ebd4ee1..79c3312db 100644 --- a/crates/registry-notary-server/src/api/tests/support.rs +++ b/crates/registry-notary-server/src/api/tests/support.rs @@ -500,6 +500,9 @@ audiences: vec![bounded("registry-notary-citizen")], client_id: client_id.map(bounded), token_type: Some(bounded("JWT")), + credential_configuration_id: None, + issuance_transaction_id: None, + issuance_transaction_commitment: None, scopes: scopes.iter().map(|scope| bounded(scope)).collect(), subject: Some(bounded("login-subject")), subject_binding_claim: Some( diff --git a/crates/registry-notary-server/src/metrics.rs b/crates/registry-notary-server/src/metrics.rs index 91587fda8..4fd2220e6 100644 --- a/crates/registry-notary-server/src/metrics.rs +++ b/crates/registry-notary-server/src/metrics.rs @@ -309,11 +309,9 @@ fn endpoint_kind_from_route(route: &str) -> &'static str { | "/.well-known/evidence/jwks.json" | "/.well-known/openid-credential-issuer" => "well_known", "/.well-known/vct/{*vct_path}" | "/credentials/{*vct_path}" => "credential_metadata", - "/oid4vci/credential-offer" - | "/oid4vci/offer/start" + "/oid4vci/offer/start" | "/oid4vci/offer/callback" | "/oid4vci/token" - | "/oid4vci/nonce" | "/oid4vci/credential" => "oid4vci", "/v1/claims" | "/v1/claims/{claim_id}" | "/v1/formats" => "catalog", "/v1/evaluations" | "/v1/batch-evaluations" | "/v1/evaluations/{evaluation_id}/render" => { diff --git a/crates/registry-notary-server/src/openapi.rs b/crates/registry-notary-server/src/openapi.rs index d5f2cfee7..3efc36f44 100644 --- a/crates/registry-notary-server/src/openapi.rs +++ b/crates/registry-notary-server/src/openapi.rs @@ -248,105 +248,11 @@ fn build_openapi_document() -> Value { } } }, - "/oid4vci/credential-offer": { - "get": { - "summary": "Create an OpenID4VCI credential offer", - "operationId": "getOid4vciCredentialOffer", - "description": "Returns an authorization-code credential offer. Error responses use the OpenID4VCI error envelope, not RFC 9457 Problem Details.", - "security": [], - "parameters": [ - { - "name": "credential_configuration_id", - "in": "query", - "required": false, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "Credential offer", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CredentialOffer" } - } - } - }, - "400": { - "description": "Invalid credential offer request", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Oid4vciError" } - } - } - }, - "404": { "description": "OpenID4VCI issuer is disabled" }, - "500": { - "description": "OpenID4VCI issuer failed", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Oid4vciError" } - } - } - } - } - } - }, - "/oid4vci/nonce": { - "post": { - "summary": "Create an OpenID4VCI credential nonce", - "operationId": "createOid4vciNonce", - "description": "Returns a c_nonce for proof-of-possession. Error responses use the OpenID4VCI error envelope, not RFC 9457 Problem Details.", - "security": [], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/NonceRequest" } - } - } - }, - "responses": { - "200": { - "description": "Nonce response", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/NonceResponse" } - } - } - }, - "400": { - "description": "Invalid nonce request", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Oid4vciError" } - } - } - }, - "404": { "description": "OpenID4VCI nonce endpoint is disabled" }, - "429": { - "description": "Nonce store is rate limited", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Oid4vciError" } - } - } - }, - "500": { - "description": "OpenID4VCI issuer failed", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Oid4vciError" } - } - } - } - } - } - }, "/oid4vci/credential": { "post": { "summary": "Issue a credential through OpenID4VCI", "operationId": "issueOid4vciCredential", - "description": "Issues a dc+sd-jwt credential for an authenticated direct subject-access principal only after a fresh non-delegated registry-backed evaluation records exact compiler pins and normalized unique Relay executions for every selected root's dependency closure. Source-free, delegated, and legacy evaluations are not issuable. Error responses use the OpenID4VCI error envelope, not RFC 9457 Problem Details.", + "description": "Materializes at most one dc+sd-jwt credential from the immutable registry-backed evaluation transaction authorized before the issuer-initiated offer. The access token supplies the transaction nonce, holder proof must use EdDSA with did:jwk, and an exact retry returns the cached response without another Relay call or signature. Source-free, delegated, and legacy evaluations are not issuable. Error responses use the OpenID4VCI error envelope, not RFC 9457 Problem Details.", "security": [ { "bearerAuth": [] } ], @@ -450,7 +356,7 @@ fn build_openapi_document() -> Value { "get": { "summary": "Complete eSignet login and render a pre-authorized-code offer", "operationId": "completeOid4vciOffer", - "description": "Public and unauthenticated. Consumes the login state, exchanges the eSignet code with private_key_jwt, validates the id_token, and mints one single-use pre-authorized_code. When configured, the offer also includes one numeric tx_code (PIN) shown out-of-band from the QR. Returns 404 when the pre-authorized-code flow is disabled.", + "description": "Public and unauthenticated. Consumes the login state, exchanges the eSignet code with private_key_jwt, validates the id_token, completes the exact registry-backed Relay evaluation, and only then mints one single-use pre-authorized_code bound to that immutable transaction. Denied, unavailable, malformed, source-free, or provenance-invalid evaluations produce no offer. When configured, the offer also includes one numeric tx_code (PIN) shown out-of-band from the QR. Returns 404 when the pre-authorized-code flow is disabled.", "security": [], "parameters": [ { @@ -483,6 +389,14 @@ fn build_openapi_document() -> Value { } } }, + "403": { + "description": "Registry-backed evaluation or issuance policy denied the transaction", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Oid4vciError" } + } + } + }, "404": { "description": "Pre-authorized-code flow is disabled" }, "500": { "description": "OpenID4VCI issuer failed", @@ -806,9 +720,6 @@ fn build_openapi_document() -> Value { "CredentialIssuerMetadata": credential_issuer_metadata_schema(), "CredentialConfigurationMetadata": credential_configuration_metadata_schema(), "SdJwtVcTypeMetadata": sd_jwt_vc_type_metadata_schema(), - "CredentialOffer": credential_offer_schema(), - "NonceRequest": nonce_request_schema(), - "NonceResponse": nonce_response_schema(), "CredentialRequest": credential_request_schema(), "CredentialResponse": credential_response_schema(), "TokenRequest": token_request_schema(), @@ -1440,61 +1351,6 @@ fn add_response_examples(document: &mut Value) { "SD-JWT VC Type Metadata", sd_jwt_vc_type_metadata_example(), ); - set_json_response( - document, - "/oid4vci/credential-offer", - "get", - "200", - "Credential offer", - oid4vci_credential_offer_example(), - ); - for (status, code, description) in [ - ("400", "invalid_request", "credential request is invalid"), - ("500", "server_error", "credential issuer failed"), - ] { - set_oid4vci_error_response( - document, - "/oid4vci/credential-offer", - "get", - status, - if status == "400" { - "Invalid credential offer request" - } else { - "OpenID4VCI issuer failed" - }, - oid4vci_error_example(code, description), - ); - } - set_json_response( - document, - "/oid4vci/nonce", - "post", - "200", - "Nonce response", - oid4vci_nonce_example(), - ); - for (status, code, description) in [ - ("400", "invalid_request", "credential request is invalid"), - ( - "429", - "temporarily_unavailable", - "credential request is rate limited", - ), - ("500", "server_error", "credential issuer failed"), - ] { - set_oid4vci_error_response( - document, - "/oid4vci/nonce", - "post", - status, - match status { - "400" => "Invalid nonce request", - "429" => "Nonce store is rate limited", - _ => "OpenID4VCI issuer failed", - }, - oid4vci_error_example(code, description), - ); - } set_json_response( document, "/oid4vci/credential", @@ -3045,18 +2901,6 @@ fn credential_issuer_metadata_schema() -> Value { }) } -fn credential_offer_schema() -> Value { - json!({ - "type": "object", - "required": ["credential_issuer", "credential_configuration_ids"], - "properties": { - "credential_issuer": { "type": "string", "format": "uri" }, - "credential_configuration_ids": { "type": "array", "items": { "type": "string" } }, - "grants": { "type": "object", "additionalProperties": true } - } - }) -} - fn credential_configuration_metadata_schema() -> Value { json!({ "type": "object", @@ -3132,27 +2976,6 @@ fn sd_jwt_vc_type_metadata_schema() -> Value { }) } -fn nonce_request_schema() -> Value { - json!({ - "type": "object", - "properties": { - "credential_configuration_id": { "type": "string" } - }, - "additionalProperties": false - }) -} - -fn nonce_response_schema() -> Value { - json!({ - "type": "object", - "required": ["c_nonce", "c_nonce_expires_in"], - "properties": { - "c_nonce": { "type": "string" }, - "c_nonce_expires_in": { "type": "integer", "format": "uint64" } - } - }) -} - fn credential_request_schema() -> Value { json!({ "type": "object", @@ -3208,9 +3031,7 @@ fn credential_response_schema() -> Value { } }, "credential_profile": { "type": "string" }, - "format": { "type": "string" }, - "c_nonce": { "type": "string" }, - "c_nonce_expires_in": { "type": "integer", "format": "uint64" } + "format": { "type": "string" } } }) } @@ -3430,7 +3251,6 @@ fn oid4vci_issuer_metadata_example() -> Value { json!({ "credential_issuer": "https://issuer.example.gov", "credential_endpoint": "https://issuer.example.gov/oid4vci/credential", - "nonce_endpoint": "https://issuer.example.gov/oid4vci/nonce", "authorization_servers": ["https://id.example.gov"], "display": [ { @@ -3498,32 +3318,10 @@ fn sd_jwt_vc_type_metadata_example() -> Value { }) } -fn oid4vci_credential_offer_example() -> Value { - json!({ - "credential_issuer": "https://issuer.example.gov", - "credential_configuration_ids": ["person_is_alive_sd_jwt"], - "grants": { - "authorization_code": { - "issuer_state": "issuer-state", - "authorization_server": "https://id.example.gov" - } - } - }) -} - -fn oid4vci_nonce_example() -> Value { - json!({ - "c_nonce": "b64url-nonce", - "c_nonce_expires_in": 300 - }) -} - fn oid4vci_credential_response_example() -> Value { json!({ "credential": "eyJhbGciOiJFZERTQSIsInR5cCI6ImRjK3NkLWp3dCJ9.payload.signature~disclosure~", - "format": "dc+sd-jwt", - "c_nonce": "next-b64url-nonce", - "c_nonce_expires_in": 300 + "format": "dc+sd-jwt" }) } @@ -3911,8 +3709,6 @@ mod tests { "/.well-known/openid-credential-issuer", "/credentials/{vct_path}", "/.well-known/vct/{vct_path}", - "/oid4vci/credential-offer", - "/oid4vci/nonce", "/oid4vci/credential", "/oid4vci/offer/start", "/oid4vci/offer/callback", @@ -3938,6 +3734,8 @@ mod tests { "/v1/credentials/batch", "/oid4vci/batch-credential", "/oid4vci/batch-credentials", + "/oid4vci/credential-offer", + "/oid4vci/nonce", ] { assert!( !paths.contains_key(route), @@ -4063,14 +3861,6 @@ mod tests { doc["paths"]["/.well-known/vct/{vct_path}"]["get"]["security"], json!([]) ); - assert_eq!( - doc["paths"]["/oid4vci/credential-offer"]["get"]["security"], - json!([]) - ); - assert_eq!( - doc["paths"]["/oid4vci/nonce"]["post"]["security"], - json!([]) - ); assert_eq!( doc["paths"]["/oid4vci/offer/start"]["get"]["security"], json!([]) @@ -4179,8 +3969,6 @@ mod tests { ("/.well-known/openid-credential-issuer", "get", "200"), ("/credentials/{vct_path}", "get", "200"), ("/.well-known/vct/{vct_path}", "get", "200"), - ("/oid4vci/credential-offer", "get", "200"), - ("/oid4vci/nonce", "post", "200"), ("/oid4vci/credential", "post", "200"), ("/oid4vci/token", "post", "200"), ("/v1/claims", "get", "200"), @@ -4431,9 +4219,14 @@ mod tests { ["application/json"]["schema"]["$ref"], json!("#/components/schemas/Oid4vciError") ); - assert_eq!( - doc["paths"]["/oid4vci/credential"]["post"]["description"], - json!("Issues a dc+sd-jwt credential for an authenticated direct subject-access principal only after a fresh non-delegated registry-backed evaluation records exact compiler pins and normalized unique Relay executions for every selected root's dependency closure. Source-free, delegated, and legacy evaluations are not issuable. Error responses use the OpenID4VCI error envelope, not RFC 9457 Problem Details.") + let credential_description = doc["paths"]["/oid4vci/credential"]["post"]["description"] + .as_str() + .expect("credential endpoint has a description"); + assert!( + credential_description.contains("immutable registry-backed evaluation transaction") + && credential_description.contains("exact retry") + && credential_description.contains("without another Relay call or signature"), + "credential endpoint documents transaction-bound materialization and retry semantics" ); assert_eq!( doc["components"]["schemas"]["TokenRequest"]["type"], diff --git a/crates/registry-notary-server/src/preauth_state.rs b/crates/registry-notary-server/src/preauth_state.rs index 4e0b13eca..cd5319da4 100644 --- a/crates/registry-notary-server/src/preauth_state.rs +++ b/crates/registry-notary-server/src/preauth_state.rs @@ -12,6 +12,8 @@ use std::{ }; use registry_platform_replay::ReplayScope; +use serde::{Deserialize, Serialize}; +use serde_json::Value; use subtle::ConstantTimeEq; use thiserror::Error; use time::{Duration, OffsetDateTime}; @@ -23,6 +25,67 @@ use crate::{ }; const PREAUTH_LOGIN_STATE_MAX_ENTRIES: usize = 4_096; +const OID4VCI_ISSUANCE_TRANSACTION_MAX_ENTRIES: usize = 4_096; + +/// Immutable authority-bearing portion of one registry-backed issuance. +/// +/// The raw authenticated civil identifier is deliberately absent. The stored +/// evaluation is retrieved through its secret-keyed client binding, while the +/// commitment covers only normalized, authority-bearing values. +#[derive(Clone, Serialize, Deserialize)] +pub(crate) struct IssuanceTransaction { + pub(crate) transaction_id: String, + pub(crate) evaluation_id: String, + pub(crate) evaluation_client_id: String, + pub(crate) credential_configuration_id: String, + pub(crate) commitment: String, +} + +impl std::fmt::Debug for IssuanceTransaction { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("IssuanceTransaction") + .field("transaction_id", &"[redacted]") + .field("evaluation_id", &self.evaluation_id) + .field("evaluation_client_id", &"[redacted]") + .field( + "credential_configuration_id", + &self.credential_configuration_id, + ) + .field("commitment", &self.commitment) + .finish() + } +} + +#[derive(Debug)] +pub(crate) enum CredentialMaterialization { + Acquired(IssuanceTransaction), + Cached(Value), + Busy, + Denied, +} + +#[derive(Clone)] +enum MaterializationState { + Ready, + Issuing { + holder_thumbprint: String, + request_hash: String, + }, + Completed { + holder_thumbprint: String, + request_hash: String, + response: Value, + }, + Failed, +} + +#[derive(Clone)] +struct StoredIssuanceTransaction { + transaction: IssuanceTransaction, + nonce: Option, + state: MaterializationState, +} /// The login state reserved at `offer/start` and consumed exactly once at the /// eSignet callback. Secret fields are redacted from `Debug`. @@ -81,8 +144,12 @@ impl std::fmt::Debug for VerifiedTransactionCode { pub(crate) enum PreauthorizationStateError { #[error("preauthorization login state already exists")] DuplicateLoginState, + #[error("issuance transaction already exists")] + DuplicateIssuanceTransaction, #[error("preauthorization login-state capacity is exhausted")] LoginStateCapacity, + #[error("issuance transaction capacity is exhausted")] + IssuanceTransactionCapacity, #[error("preauthorization state is unavailable")] Unavailable, #[error("preauthorization transaction-code proof is incompatible")] @@ -98,6 +165,11 @@ pub(crate) enum PreauthorizationStateError { /// operations. pub(crate) struct PreauthorizationState { backend: PreauthorizationBackend, + // This store is process-local for the in-memory state profile. PostgreSQL + // support is intentionally rejected by the methods below until the fixed + // state-plane transaction functions are active, rather than silently + // weakening cross-replica correctness. + issuance: Arc>>>, } enum PreauthorizationBackend { @@ -114,7 +186,249 @@ impl PreauthorizationState { } else { PreauthorizationBackend::Postgresql(state_plane) }; - Ok(Self { backend }) + Ok(Self { + backend, + issuance: Arc::new(Mutex::new(HashMap::new())), + }) + } + + pub(crate) async fn reserve_issuance_transaction( + &self, + transaction_id: &str, + transaction: IssuanceTransaction, + expires_at: OffsetDateTime, + ) -> Result<(), PreauthorizationStateError> { + if let PreauthorizationBackend::Postgresql(handle) = &self.backend { + use crate::state_plane::IssuanceReserveOutcome; + return match handle + .sensitive_state()? + .reserve_issuance_transaction(transaction_id, &transaction, expires_at) + .await? + { + IssuanceReserveOutcome::Reserved => Ok(()), + IssuanceReserveOutcome::Duplicate => { + Err(PreauthorizationStateError::DuplicateIssuanceTransaction) + } + IssuanceReserveOutcome::Capacity => { + Err(PreauthorizationStateError::IssuanceTransactionCapacity) + } + }; + } + let now = OffsetDateTime::now_utc(); + if expires_at <= now { + return Err(PreauthorizationStateError::InvalidExpiry); + } + let key = replay_identifier_hash(transaction_id); + let mut records = self + .issuance + .lock() + .map_err(|_| PreauthorizationStateError::Unavailable)?; + records.retain(|_, record| record.expires_at > now); + if records.contains_key(&key) { + return Err(PreauthorizationStateError::DuplicateIssuanceTransaction); + } + if records.len() >= OID4VCI_ISSUANCE_TRANSACTION_MAX_ENTRIES { + return Err(PreauthorizationStateError::IssuanceTransactionCapacity); + } + records.insert( + key, + Stored { + value: StoredIssuanceTransaction { + transaction, + nonce: None, + state: MaterializationState::Ready, + }, + expires_at, + }, + ); + Ok(()) + } + + pub(crate) async fn transaction( + &self, + transaction_id: &str, + ) -> Result, PreauthorizationStateError> { + if let PreauthorizationBackend::Postgresql(handle) = &self.backend { + return Ok(handle + .sensitive_state()? + .issuance_transaction(transaction_id) + .await?); + } + let now = OffsetDateTime::now_utc(); + let key = replay_identifier_hash(transaction_id); + let mut records = self + .issuance + .lock() + .map_err(|_| PreauthorizationStateError::Unavailable)?; + records.retain(|_, record| record.expires_at > now); + Ok(records + .get(&key) + .map(|record| record.value.transaction.clone())) + } + + pub(crate) async fn bind_transaction_nonce( + &self, + transaction_id: &str, + commitment: &str, + nonce: String, + ) -> Result { + if let PreauthorizationBackend::Postgresql(handle) = &self.backend { + return Ok(handle + .sensitive_state()? + .bind_issuance_nonce(transaction_id, commitment, &nonce) + .await?); + } + let now = OffsetDateTime::now_utc(); + let key = replay_identifier_hash(transaction_id); + let mut records = self + .issuance + .lock() + .map_err(|_| PreauthorizationStateError::Unavailable)?; + records.retain(|_, record| record.expires_at > now); + let Some(record) = records.get_mut(&key) else { + return Ok(false); + }; + if record.value.transaction.commitment != commitment || record.value.nonce.is_some() { + return Ok(false); + } + record.value.nonce = Some(nonce); + Ok(true) + } + + pub(crate) async fn begin_credential_materialization( + &self, + transaction_id: &str, + commitment: &str, + configuration_id: &str, + nonce: &str, + holder_thumbprint: &str, + request_hash: &str, + ) -> Result { + if let PreauthorizationBackend::Postgresql(handle) = &self.backend { + return Ok(handle + .sensitive_state()? + .begin_issuance_materialization( + transaction_id, + commitment, + configuration_id, + nonce, + holder_thumbprint, + request_hash, + ) + .await?); + } + let now = OffsetDateTime::now_utc(); + let key = replay_identifier_hash(transaction_id); + let mut records = self + .issuance + .lock() + .map_err(|_| PreauthorizationStateError::Unavailable)?; + records.retain(|_, record| record.expires_at > now); + let Some(record) = records.get_mut(&key) else { + return Ok(CredentialMaterialization::Denied); + }; + let transaction = &record.value.transaction; + if transaction.commitment != commitment + || transaction.credential_configuration_id != configuration_id + || record.value.nonce.as_deref() != Some(nonce) + { + return Ok(CredentialMaterialization::Denied); + } + match &record.value.state { + MaterializationState::Ready => { + let transaction = transaction.clone(); + record.value.state = MaterializationState::Issuing { + holder_thumbprint: holder_thumbprint.to_string(), + request_hash: request_hash.to_string(), + }; + Ok(CredentialMaterialization::Acquired(transaction)) + } + MaterializationState::Issuing { + holder_thumbprint: bound, + request_hash: bound_request, + } if bound == holder_thumbprint && bound_request == request_hash => { + Ok(CredentialMaterialization::Busy) + } + MaterializationState::Completed { + holder_thumbprint: bound, + request_hash: bound_request, + response, + } if bound == holder_thumbprint && bound_request == request_hash => { + Ok(CredentialMaterialization::Cached(response.clone())) + } + MaterializationState::Issuing { .. } + | MaterializationState::Completed { .. } + | MaterializationState::Failed => Ok(CredentialMaterialization::Denied), + } + } + + pub(crate) async fn complete_credential_materialization( + &self, + transaction_id: &str, + holder_thumbprint: &str, + request_hash: &str, + response: Value, + ) -> Result { + if let PreauthorizationBackend::Postgresql(handle) = &self.backend { + return Ok(handle + .sensitive_state()? + .complete_issuance_materialization( + transaction_id, + holder_thumbprint, + request_hash, + &response, + ) + .await?); + } + let key = replay_identifier_hash(transaction_id); + let mut records = self + .issuance + .lock() + .map_err(|_| PreauthorizationStateError::Unavailable)?; + let Some(record) = records.get_mut(&key) else { + return Ok(false); + }; + match &record.value.state { + MaterializationState::Issuing { + holder_thumbprint: bound, + request_hash: bound_request, + } if bound == holder_thumbprint && bound_request == request_hash => { + record.value.state = MaterializationState::Completed { + holder_thumbprint: holder_thumbprint.to_string(), + request_hash: request_hash.to_string(), + response, + }; + Ok(true) + } + _ => Ok(false), + } + } + + pub(crate) async fn fail_credential_materialization( + &self, + transaction_id: &str, + holder_thumbprint: &str, + ) -> Result<(), PreauthorizationStateError> { + if let PreauthorizationBackend::Postgresql(handle) = &self.backend { + return Ok(handle + .sensitive_state()? + .fail_issuance_materialization(transaction_id, holder_thumbprint) + .await?); + } + let key = replay_identifier_hash(transaction_id); + let mut records = self + .issuance + .lock() + .map_err(|_| PreauthorizationStateError::Unavailable)?; + if let Some(record) = records.get_mut(&key) { + if matches!( + &record.value.state, + MaterializationState::Issuing { holder_thumbprint: bound, .. } if bound == holder_thumbprint + ) { + record.value.state = MaterializationState::Failed; + } + } + Ok(()) } pub(crate) async fn reserve_login( @@ -239,6 +553,7 @@ impl std::fmt::Debug for PreauthorizationState { PreauthorizationBackend::Postgresql(_) => "postgresql", }, ) + .field("issuance", &"") .finish() } } @@ -448,6 +763,7 @@ mod tests { backend: PreauthorizationBackend::InMemory(Arc::new( InMemoryPreauthorizationState::new().unwrap(), )), + issuance: Arc::new(Mutex::new(HashMap::new())), } } @@ -455,6 +771,16 @@ mod tests { ReplayScope::new([("tenant", "tenant-a"), ("kind", "oid4vci-preauth-code")]).unwrap() } + fn issuance_transaction() -> IssuanceTransaction { + IssuanceTransaction { + transaction_id: "transaction-1".to_string(), + evaluation_id: "evaluation-1".to_string(), + evaluation_client_id: "client-1".to_string(), + credential_configuration_id: "person_is_alive_sd_jwt".to_string(), + commitment: "a".repeat(64), + } + } + #[tokio::test] async fn login_state_is_consumed_exactly_once() { let state = memory_state(); @@ -466,6 +792,150 @@ mod tests { assert!(state.consume_login("opaque").await.unwrap().is_none()); } + #[tokio::test] + async fn issuance_materialization_binds_holder_and_exact_request_and_caches_response() { + let state = memory_state(); + let transaction = issuance_transaction(); + let expires_at = OffsetDateTime::now_utc() + Duration::minutes(5); + state + .reserve_issuance_transaction( + &transaction.transaction_id, + transaction.clone(), + expires_at, + ) + .await + .unwrap(); + assert!(state + .bind_transaction_nonce( + &transaction.transaction_id, + &transaction.commitment, + "token-nonce".to_string(), + ) + .await + .unwrap()); + assert!(matches!( + state + .begin_credential_materialization( + &transaction.transaction_id, + &transaction.commitment, + &transaction.credential_configuration_id, + "token-nonce", + "holder-one", + "request-one", + ) + .await + .unwrap(), + CredentialMaterialization::Acquired(_) + )); + assert!(matches!( + state + .begin_credential_materialization( + &transaction.transaction_id, + &transaction.commitment, + &transaction.credential_configuration_id, + "token-nonce", + "holder-two", + "request-one", + ) + .await + .unwrap(), + CredentialMaterialization::Denied + )); + let response = serde_json::json!({"credential": "signed-once"}); + assert!(state + .complete_credential_materialization( + &transaction.transaction_id, + "holder-one", + "request-one", + response.clone(), + ) + .await + .unwrap()); + match state + .begin_credential_materialization( + &transaction.transaction_id, + &transaction.commitment, + &transaction.credential_configuration_id, + "token-nonce", + "holder-one", + "request-one", + ) + .await + .unwrap() + { + CredentialMaterialization::Cached(cached) => assert_eq!(cached, response), + outcome => panic!("expected cached response, got {outcome:?}"), + } + assert!(matches!( + state + .begin_credential_materialization( + &transaction.transaction_id, + &transaction.commitment, + &transaction.credential_configuration_id, + "token-nonce", + "holder-one", + "different-request", + ) + .await + .unwrap(), + CredentialMaterialization::Denied + )); + } + + #[tokio::test] + async fn failed_issuance_materialization_is_terminal() { + let state = memory_state(); + let transaction = issuance_transaction(); + state + .reserve_issuance_transaction( + &transaction.transaction_id, + transaction.clone(), + OffsetDateTime::now_utc() + Duration::minutes(5), + ) + .await + .unwrap(); + assert!(state + .bind_transaction_nonce( + &transaction.transaction_id, + &transaction.commitment, + "token-nonce".to_string(), + ) + .await + .unwrap()); + assert!(matches!( + state + .begin_credential_materialization( + &transaction.transaction_id, + &transaction.commitment, + &transaction.credential_configuration_id, + "token-nonce", + "holder-one", + "request-one", + ) + .await + .unwrap(), + CredentialMaterialization::Acquired(_) + )); + state + .fail_credential_materialization(&transaction.transaction_id, "holder-one") + .await + .unwrap(); + assert!(matches!( + state + .begin_credential_materialization( + &transaction.transaction_id, + &transaction.commitment, + &transaction.credential_configuration_id, + "token-nonce", + "holder-one", + "request-one", + ) + .await + .unwrap(), + CredentialMaterialization::Denied + )); + } + #[tokio::test] async fn wrong_pin_preserves_offer_and_successful_redemption_is_single_use() { let state = memory_state(); @@ -504,6 +974,7 @@ mod tests { let backend = Arc::new(InMemoryPreauthorizationState::new().unwrap()); let issuing_runtime = PreauthorizationState { backend: PreauthorizationBackend::InMemory(Arc::clone(&backend)), + issuance: Arc::new(Mutex::new(HashMap::new())), }; let expires_at = OffsetDateTime::now_utc() + Duration::minutes(5); assert!(issuing_runtime @@ -513,6 +984,7 @@ mod tests { let reconfigured_runtime = PreauthorizationState { backend: PreauthorizationBackend::InMemory(backend), + issuance: Arc::clone(&issuing_runtime.issuance), }; assert!(matches!( reconfigured_runtime diff --git a/crates/registry-notary-server/src/standalone/auth/middleware.rs b/crates/registry-notary-server/src/standalone/auth/middleware.rs index 24eefb3d7..d0ce1a9fc 100644 --- a/crates/registry-notary-server/src/standalone/auth/middleware.rs +++ b/crates/registry-notary-server/src/standalone/auth/middleware.rs @@ -230,11 +230,9 @@ pub(in super::super) fn is_auth_exempt_path(path: &str, policy: AuthExemptionPol | "/ready" | "/.well-known/evidence/jwks.json" | "/.well-known/openid-credential-issuer" - | "/oid4vci/credential-offer" | "/oid4vci/offer/start" | "/oid4vci/offer/callback" | "/oid4vci/token" - | "/oid4vci/nonce" // Auth-exempt only from API-key/OIDC middleware. The federation // handler still requires and verifies the peer-signed JWS. | "/federation/v1/evaluations" diff --git a/crates/registry-notary-server/src/standalone/auth/oidc.rs b/crates/registry-notary-server/src/standalone/auth/oidc.rs index c3dd127dd..937a4b411 100644 --- a/crates/registry-notary-server/src/standalone/auth/oidc.rs +++ b/crates/registry-notary-server/src/standalone/auth/oidc.rs @@ -178,6 +178,24 @@ pub(in super::super) fn bounded_verified_claims_from_oidc( audiences: bounded_audience(verified.claims.aud.as_ref()), client_id: verified_client(verified), token_type, + credential_configuration_id: verified + .claims + .extra + .get("credential_configuration_id") + .and_then(Value::as_str) + .and_then(verified_claim_value), + issuance_transaction_id: verified + .claims + .extra + .get("issuance_transaction_id") + .and_then(Value::as_str) + .and_then(verified_claim_value), + issuance_transaction_commitment: verified + .claims + .extra + .get("issuance_transaction_commitment") + .and_then(Value::as_str) + .and_then(verified_claim_value), scopes: bounded_scopes(&verified.scopes), subject: verified .claims diff --git a/crates/registry-notary-server/src/standalone/cors.rs b/crates/registry-notary-server/src/standalone/cors.rs index 4b951168e..a991d6df8 100644 --- a/crates/registry-notary-server/src/standalone/cors.rs +++ b/crates/registry-notary-server/src/standalone/cors.rs @@ -81,8 +81,6 @@ pub(super) fn is_subject_access_wallet_cors_path(path: &str) -> bool { "/.well-known/evidence-service" | "/.well-known/evidence/jwks.json" | "/.well-known/openid-credential-issuer" - | "/oid4vci/credential-offer" - | "/oid4vci/nonce" | "/oid4vci/credential" | "/v1/formats" | "/v1/evaluations" diff --git a/crates/registry-notary-server/src/standalone/preauth.rs b/crates/registry-notary-server/src/standalone/preauth.rs index c52121ed1..b6eb713c6 100644 --- a/crates/registry-notary-server/src/standalone/preauth.rs +++ b/crates/registry-notary-server/src/standalone/preauth.rs @@ -307,6 +307,48 @@ impl PreAuthRuntime { self.preauthorization_state.as_ref() } + /// Adapt the eSignet-verified callback subject into the same bounded + /// principal shape used by the normal subject-access policy path. Only + /// modeled, verified fields are carried forward; arbitrary authorization + /// response extensions never enter the issuance transaction. + pub(crate) fn principal_for_subject( + &self, + subject: ®istry_notary_core::tokens::BoundSubject, + ) -> Result { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let mut payload = json!({ + "iss": self.esignet_issuer, + "aud": self.notary_audiences, + "sub": subject.subject, + "client_id": subject.client_id, + "scope": subject.scopes.join(" "), + "iat": now, + "nbf": now, + "exp": now + self.access_token_ttl_seconds as i64, + }); + payload[subject.subject_binding_claim.as_str()] = + Value::String(subject.subject_binding_value.clone()); + if let Some(acr) = subject.acr.as_deref() { + payload["acr"] = Value::String(acr.to_string()); + } + if let Some(auth_time) = subject.auth_time { + payload["auth_time"] = Value::from(auth_time); + } + let verified = + verified_token_from_notary_payload(&payload).ok_or(EvidenceError::MissingCredential)?; + principal_from_oidc( + &verified, + EvidenceAuthProfileId::ExternalOidc, + None, + None, + None, + "sub", + Some(subject.subject_binding_claim.as_str()), + SubjectAccessClaimSource::AccessToken, + SubjectAccessAssuranceClaimSource::AccessToken, + ) + } + /// Build the eSignet authorization redirect URL for the citizen browser. /// /// PKCE S256, the confidential RP `client_id`, the configured redirect URI diff --git a/crates/registry-notary-server/src/standalone/tests/assembly.inc b/crates/registry-notary-server/src/standalone/tests/assembly.inc index 7f66e4330..71d104aee 100644 --- a/crates/registry-notary-server/src/standalone/tests/assembly.inc +++ b/crates/registry-notary-server/src/standalone/tests/assembly.inc @@ -137,15 +137,6 @@ async fn static_api_routes_preserve_listener_and_middleware_security_policy() { wallet_cors: true, proof_precheck_before_auth: false, }, - RouteSecurityCharacterization { - matched_path: "/oid4vci/credential-offer", - sample_path: "/oid4vci/credential-offer", - listener: Public, - method: Get, - auth: Exempt, - wallet_cors: true, - proof_precheck_before_auth: false, - }, RouteSecurityCharacterization { matched_path: "/oid4vci/offer/start", sample_path: "/oid4vci/offer/start", @@ -173,15 +164,6 @@ async fn static_api_routes_preserve_listener_and_middleware_security_policy() { wallet_cors: false, proof_precheck_before_auth: false, }, - RouteSecurityCharacterization { - matched_path: "/oid4vci/nonce", - sample_path: "/oid4vci/nonce", - listener: Public, - method: Post, - auth: Exempt, - wallet_cors: true, - proof_precheck_before_auth: false, - }, RouteSecurityCharacterization { matched_path: "/oid4vci/credential", sample_path: "/oid4vci/credential", diff --git a/crates/registry-notary-server/src/standalone/tests/auth.inc b/crates/registry-notary-server/src/standalone/tests/auth.inc index 8ed429945..0a68b6c53 100644 --- a/crates/registry-notary-server/src/standalone/tests/auth.inc +++ b/crates/registry-notary-server/src/standalone/tests/auth.inc @@ -16,6 +16,8 @@ audiences: vec!["registry-notary".to_string()], token_type: "Bearer".to_string(), credential_configuration_id: "identity_credential".to_string(), + issuance_transaction_id: "transaction-1".to_string(), + issuance_transaction_commitment: "commitment-1".to_string(), subject: BoundSubject { subject: "subject-1".to_string(), subject_binding_claim: "civil_id".to_string(), @@ -83,6 +85,8 @@ audiences: vec!["registry-notary".to_string()], token_type: "Bearer".to_string(), credential_configuration_id: "identity_credential".to_string(), + issuance_transaction_id: "transaction-2".to_string(), + issuance_transaction_commitment: "commitment-2".to_string(), subject: BoundSubject { subject: "subject-1".to_string(), subject_binding_claim: "civil_id".to_string(), diff --git a/crates/registry-notary-server/src/state_plane/migration.rs b/crates/registry-notary-server/src/state_plane/migration.rs index 6a980e540..4c4b42135 100644 --- a/crates/registry-notary-server/src/state_plane/migration.rs +++ b/crates/registry-notary-server/src/state_plane/migration.rs @@ -33,14 +33,15 @@ const STATE_PLANE_SCHEMA_IDENTITY_PREIMAGE_V1: &str = concat!( "subject-access-quota=keyed-pseudonym-six-closed-buckets-fixed-windows-canonical-lock-order-caller-denial-order-atomic-all-or-none-check-only-no-mutation-v1\0", "preauthorization-login=keyed-state-capacity-4096-encrypted-single-consume-expiry-live-key-attestation-v2\0", "preauthorization-tx-code=verified-notary-issuer-stable-scope-jti-keyed-pin-verifier-peek-redeem-one-winner-expiry-live-key-attestation-v3\0", + "oid4vci-issuance-transaction=keyed-id-encrypted-immutable-record-token-nonce-bind-holder-and-request-atomic-one-materialization-encrypted-response-terminal-failure-expiry-v1\0", "retention=bounded-expiry-prune-skip-locked-saturation-catch-up-v2\0", ); pub const STATE_PLANE_SCHEMA_FINGERPRINT_V1: &str = - "c4d71ac9215da182779ec1305b2c1bc62bb249143e4b7d8c01eacc38f4e2cc10"; + "bb0e4d21cc3313ee0b5156d83a16623b0683da5af2cdfdd3ba4ce529b481032b"; const MIGRATION_ADVISORY_LOCK_KEY_V1: i64 = 0x4e4f_5441_5259_0001; -const EXPECTED_PRIVATE_TABLE_COUNT_V1: i64 = 10; -const EXPECTED_API_FUNCTION_COUNT_V1: i64 = 25; +const EXPECTED_PRIVATE_TABLE_COUNT_V1: i64 = 11; +const EXPECTED_API_FUNCTION_COUNT_V1: i64 = 31; /// The `NOLOGIN` role that owns the Notary schemas and fixed functions. #[derive(Clone, PartialEq, Eq)] @@ -443,6 +444,12 @@ fn state_plane_acl_sql(runtime_role: &RuntimeDatabaseRole) -> String { GRANT EXECUTE ON FUNCTION registry_notary_api.preauthorization_tx_code_peek_v1(bytea) TO {role};\n\ GRANT EXECUTE ON FUNCTION registry_notary_api.preauthorization_key_attest_v1(bytea) TO {role};\n\ GRANT EXECUTE ON FUNCTION registry_notary_api.preauthorization_redeem_v1(bytea, bytea, timestamptz, boolean, bytea) TO {role};\n\ + GRANT EXECUTE ON FUNCTION registry_notary_api.oid4vci_transaction_reserve_v1(bytea, bytea, text, text, bytea, bytea, timestamptz) TO {role};\n\ + GRANT EXECUTE ON FUNCTION registry_notary_api.oid4vci_transaction_get_v1(bytea) TO {role};\n\ + GRANT EXECUTE ON FUNCTION registry_notary_api.oid4vci_transaction_bind_nonce_v1(bytea, text, bytea) TO {role};\n\ + GRANT EXECUTE ON FUNCTION registry_notary_api.oid4vci_transaction_begin_v1(bytea, text, text, bytea, bytea, bytea) TO {role};\n\ + GRANT EXECUTE ON FUNCTION registry_notary_api.oid4vci_transaction_complete_v1(bytea, bytea, bytea, bytea, bytea) TO {role};\n\ + GRANT EXECUTE ON FUNCTION registry_notary_api.oid4vci_transaction_fail_v1(bytea, bytea) TO {role};\n\ GRANT EXECUTE ON FUNCTION registry_notary_api.retention_prune_v1(integer) TO {role};" ) } @@ -780,11 +787,11 @@ fn expected_catalog_definition_fingerprint( // These fingerprints are derived from the deterministic catalog projection // below and are pinned separately for every supported PostgreSQL major. const EXPECTED_CATALOG_DEFINITION_FINGERPRINT_PG16_V1: &str = - "a4b2fe9ac6210ade086f9eab2909687c24227b3537188d448c6fa87f35c7171d"; + "7722e1e6b41b96109ea96f9939a1f6860ed6ff96203f0d16bfa1bc5be6f8000d"; const EXPECTED_CATALOG_DEFINITION_FINGERPRINT_PG17_V1: &str = - "a4b2fe9ac6210ade086f9eab2909687c24227b3537188d448c6fa87f35c7171d"; + "7722e1e6b41b96109ea96f9939a1f6860ed6ff96203f0d16bfa1bc5be6f8000d"; const EXPECTED_CATALOG_DEFINITION_FINGERPRINT_PG18_V1: &str = - "896946d10c4065ede59d1c0fb14b0c305c326e3e76f39bfd88290b15b2adad60"; + "96a28f3cc5fed47adb9732a45765e744989994a1e7129e8e15366e738bd23b33"; const CATALOG_DEFINITION_QUERY_V1: &str = r#" SELECT COALESCE(( @@ -1064,6 +1071,52 @@ CREATE TABLE registry_notary_private.preauthorization_tx_code ( CREATE INDEX preauthorization_tx_code_expiry_idx ON registry_notary_private.preauthorization_tx_code (expires_at); +CREATE TABLE registry_notary_private.oid4vci_issuance_transaction ( + transaction_hash bytea PRIMARY KEY CHECK (pg_catalog.octet_length(transaction_hash) = 32), + key_id bytea NOT NULL CHECK (pg_catalog.octet_length(key_id) = 32), + credential_configuration_id text NOT NULL + CHECK (pg_catalog.length(credential_configuration_id) BETWEEN 1 AND 256), + commitment text NOT NULL CHECK (commitment ~ '^[0-9a-f]{64}$'), + record_aead_nonce bytea NOT NULL + CHECK (pg_catalog.octet_length(record_aead_nonce) BETWEEN 12 AND 24), + record_ciphertext bytea NOT NULL + CHECK (pg_catalog.octet_length(record_ciphertext) BETWEEN 17 AND 16384), + token_nonce_hash bytea CHECK ( + token_nonce_hash IS NULL OR pg_catalog.octet_length(token_nonce_hash) = 32 + ), + state text NOT NULL CHECK (state IN ('ready', 'issuing', 'completed', 'failed')), + holder_thumbprint_hash bytea CHECK ( + holder_thumbprint_hash IS NULL OR pg_catalog.octet_length(holder_thumbprint_hash) = 32 + ), + request_hash bytea CHECK ( + request_hash IS NULL OR pg_catalog.octet_length(request_hash) = 32 + ), + response_aead_nonce bytea CHECK ( + response_aead_nonce IS NULL + OR pg_catalog.octet_length(response_aead_nonce) BETWEEN 12 AND 24 + ), + response_ciphertext bytea CHECK ( + response_ciphertext IS NULL + OR pg_catalog.octet_length(response_ciphertext) BETWEEN 17 AND 65536 + ), + created_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + expires_at timestamptz NOT NULL, + CHECK (expires_at > created_at), + CHECK ( + (state = 'ready' AND holder_thumbprint_hash IS NULL AND request_hash IS NULL + AND response_aead_nonce IS NULL AND response_ciphertext IS NULL) + OR (state = 'issuing' AND holder_thumbprint_hash IS NOT NULL AND request_hash IS NOT NULL + AND response_aead_nonce IS NULL AND response_ciphertext IS NULL) + OR (state = 'completed' AND holder_thumbprint_hash IS NOT NULL AND request_hash IS NOT NULL + AND response_aead_nonce IS NOT NULL AND response_ciphertext IS NOT NULL) + OR (state = 'failed' AND holder_thumbprint_hash IS NOT NULL AND request_hash IS NOT NULL + AND response_aead_nonce IS NULL AND response_ciphertext IS NULL) + ) +); +CREATE INDEX oid4vci_issuance_transaction_expiry_idx + ON registry_notary_private.oid4vci_issuance_transaction (expires_at); + ALTER DEFAULT PRIVILEGES IN SCHEMA registry_notary_private REVOKE ALL ON TABLES FROM PUBLIC; ALTER DEFAULT PRIVILEGES IN SCHEMA registry_notary_private @@ -1996,6 +2049,9 @@ BEGIN UNION ALL SELECT 1 FROM registry_notary_private.preauthorization_tx_code WHERE expires_at > v_now AND key_id <> p_key_id + UNION ALL + SELECT 1 FROM registry_notary_private.oid4vci_issuance_transaction + WHERE expires_at > v_now AND key_id <> p_key_id ) THEN RAISE EXCEPTION USING ERRCODE = '55000', MESSAGE = 'sensitive-state key generation mismatch'; @@ -2078,6 +2134,9 @@ BEGIN UNION ALL SELECT 1 FROM registry_notary_private.preauthorization_tx_code WHERE expires_at > v_now AND key_id <> p_key_id + UNION ALL + SELECT 1 FROM registry_notary_private.oid4vci_issuance_transaction + WHERE expires_at > v_now AND key_id <> p_key_id ) THEN RAISE EXCEPTION USING ERRCODE = '55000', MESSAGE = 'sensitive-state key generation mismatch'; @@ -2110,6 +2169,9 @@ AS $function$ UNION ALL SELECT 1 FROM registry_notary_private.preauthorization_tx_code WHERE expires_at > pg_catalog.statement_timestamp() AND key_id <> p_key_id + UNION ALL + SELECT 1 FROM registry_notary_private.oid4vci_issuance_transaction + WHERE expires_at > pg_catalog.statement_timestamp() AND key_id <> p_key_id ) $function$; @@ -2190,6 +2252,225 @@ BEGIN END $function$; +CREATE FUNCTION registry_notary_api.oid4vci_transaction_reserve_v1( + p_transaction_hash bytea, + p_key_id bytea, + p_configuration_id text, + p_commitment text, + p_record_aead_nonce bytea, + p_record_ciphertext bytea, + p_expires_at timestamptz +) +RETURNS smallint +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog +AS $function$ +DECLARE + v_now timestamptz := pg_catalog.clock_timestamp(); + v_count bigint; +BEGIN + IF pg_catalog.octet_length(p_transaction_hash) <> 32 + OR pg_catalog.octet_length(p_key_id) <> 32 + OR p_commitment !~ '^[0-9a-f]{64}$' + OR p_expires_at <= v_now THEN + RAISE EXCEPTION USING ERRCODE = '22023', MESSAGE = 'invalid issuance transaction'; + END IF; + PERFORM pg_catalog.pg_advisory_xact_lock(5642808141211099137); + IF EXISTS ( + SELECT 1 FROM registry_notary_private.preauthorization_login_state + WHERE expires_at > v_now AND key_id <> p_key_id + UNION ALL + SELECT 1 FROM registry_notary_private.preauthorization_tx_code + WHERE expires_at > v_now AND key_id <> p_key_id + UNION ALL + SELECT 1 FROM registry_notary_private.oid4vci_issuance_transaction + WHERE expires_at > v_now AND key_id <> p_key_id + ) THEN + RAISE EXCEPTION USING ERRCODE = '55000', + MESSAGE = 'sensitive-state key generation mismatch'; + END IF; + LOCK TABLE registry_notary_private.oid4vci_issuance_transaction + IN SHARE ROW EXCLUSIVE MODE; + DELETE FROM registry_notary_private.oid4vci_issuance_transaction + WHERE expires_at <= v_now; + IF EXISTS ( + SELECT 1 FROM registry_notary_private.oid4vci_issuance_transaction + WHERE transaction_hash = p_transaction_hash + ) THEN + RETURN 0; + END IF; + SELECT pg_catalog.count(*) INTO v_count + FROM registry_notary_private.oid4vci_issuance_transaction; + IF v_count >= 4096 THEN + RETURN -1; + END IF; + INSERT INTO registry_notary_private.oid4vci_issuance_transaction ( + transaction_hash, key_id, credential_configuration_id, commitment, + record_aead_nonce, record_ciphertext, state, created_at, updated_at, expires_at + ) VALUES ( + p_transaction_hash, p_key_id, p_configuration_id, p_commitment, + p_record_aead_nonce, p_record_ciphertext, 'ready', v_now, v_now, p_expires_at + ); + RETURN 1; +END +$function$; + +CREATE FUNCTION registry_notary_api.oid4vci_transaction_get_v1(p_transaction_hash bytea) +RETURNS TABLE ( + key_id bytea, + credential_configuration_id text, + commitment text, + record_aead_nonce bytea, + record_ciphertext bytea, + expires_at timestamptz +) +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog +AS $function$ + SELECT stored.key_id, stored.credential_configuration_id, stored.commitment, + stored.record_aead_nonce, stored.record_ciphertext, stored.expires_at + FROM registry_notary_private.oid4vci_issuance_transaction AS stored + WHERE stored.transaction_hash = p_transaction_hash + AND stored.expires_at > pg_catalog.clock_timestamp() +$function$; + +CREATE FUNCTION registry_notary_api.oid4vci_transaction_bind_nonce_v1( + p_transaction_hash bytea, + p_commitment text, + p_token_nonce_hash bytea +) +RETURNS boolean +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog +AS $function$ + UPDATE registry_notary_private.oid4vci_issuance_transaction AS stored + SET token_nonce_hash = p_token_nonce_hash, + updated_at = pg_catalog.clock_timestamp() + WHERE stored.transaction_hash = p_transaction_hash + AND stored.commitment = p_commitment + AND stored.state = 'ready' + AND stored.token_nonce_hash IS NULL + AND stored.expires_at > pg_catalog.clock_timestamp() + AND pg_catalog.octet_length(p_token_nonce_hash) = 32 + RETURNING TRUE +$function$; + +CREATE FUNCTION registry_notary_api.oid4vci_transaction_begin_v1( + p_transaction_hash bytea, + p_commitment text, + p_configuration_id text, + p_token_nonce_hash bytea, + p_holder_thumbprint_hash bytea, + p_request_hash bytea +) +RETURNS TABLE ( + outcome smallint, + key_id bytea, + credential_configuration_id text, + commitment text, + record_aead_nonce bytea, + record_ciphertext bytea, + response_aead_nonce bytea, + response_ciphertext bytea, + expires_at timestamptz +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog +AS $function$ +DECLARE + v_stored registry_notary_private.oid4vci_issuance_transaction%ROWTYPE; +BEGIN + IF pg_catalog.octet_length(p_token_nonce_hash) <> 32 + OR pg_catalog.octet_length(p_holder_thumbprint_hash) <> 32 + OR pg_catalog.octet_length(p_request_hash) <> 32 THEN + RAISE EXCEPTION USING ERRCODE = '22023', MESSAGE = 'invalid materialization binding'; + END IF; + SELECT * INTO v_stored + FROM registry_notary_private.oid4vci_issuance_transaction + WHERE transaction_hash = p_transaction_hash + FOR UPDATE; + IF NOT FOUND OR v_stored.expires_at <= pg_catalog.clock_timestamp() + OR v_stored.commitment <> p_commitment + OR v_stored.credential_configuration_id <> p_configuration_id + OR v_stored.token_nonce_hash IS DISTINCT FROM p_token_nonce_hash THEN + RETURN QUERY SELECT -1::smallint, NULL::bytea, NULL::text, NULL::text, + NULL::bytea, NULL::bytea, NULL::bytea, NULL::bytea, NULL::timestamptz; + RETURN; + END IF; + IF v_stored.state = 'ready' THEN + UPDATE registry_notary_private.oid4vci_issuance_transaction + SET state = 'issuing', holder_thumbprint_hash = p_holder_thumbprint_hash, + request_hash = p_request_hash, updated_at = pg_catalog.clock_timestamp() + WHERE transaction_hash = p_transaction_hash; + RETURN QUERY SELECT 1::smallint, v_stored.key_id, + v_stored.credential_configuration_id, v_stored.commitment, + v_stored.record_aead_nonce, v_stored.record_ciphertext, + NULL::bytea, NULL::bytea, v_stored.expires_at; + ELSIF v_stored.state = 'issuing' + AND v_stored.holder_thumbprint_hash = p_holder_thumbprint_hash + AND v_stored.request_hash = p_request_hash THEN + RETURN QUERY SELECT 0::smallint, NULL::bytea, NULL::text, NULL::text, + NULL::bytea, NULL::bytea, NULL::bytea, NULL::bytea, NULL::timestamptz; + ELSIF v_stored.state = 'completed' + AND v_stored.holder_thumbprint_hash = p_holder_thumbprint_hash + AND v_stored.request_hash = p_request_hash THEN + RETURN QUERY SELECT 2::smallint, v_stored.key_id, + v_stored.credential_configuration_id, v_stored.commitment, + v_stored.record_aead_nonce, v_stored.record_ciphertext, + v_stored.response_aead_nonce, v_stored.response_ciphertext, v_stored.expires_at; + ELSE + RETURN QUERY SELECT -1::smallint, NULL::bytea, NULL::text, NULL::text, + NULL::bytea, NULL::bytea, NULL::bytea, NULL::bytea, NULL::timestamptz; + END IF; +END +$function$; + +CREATE FUNCTION registry_notary_api.oid4vci_transaction_complete_v1( + p_transaction_hash bytea, + p_holder_thumbprint_hash bytea, + p_request_hash bytea, + p_response_aead_nonce bytea, + p_response_ciphertext bytea +) +RETURNS boolean +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog +AS $function$ + UPDATE registry_notary_private.oid4vci_issuance_transaction AS stored + SET state = 'completed', response_aead_nonce = p_response_aead_nonce, + response_ciphertext = p_response_ciphertext, + updated_at = pg_catalog.clock_timestamp() + WHERE stored.transaction_hash = p_transaction_hash + AND stored.state = 'issuing' + AND stored.holder_thumbprint_hash = p_holder_thumbprint_hash + AND stored.request_hash = p_request_hash + AND stored.expires_at > pg_catalog.clock_timestamp() + RETURNING TRUE +$function$; + +CREATE FUNCTION registry_notary_api.oid4vci_transaction_fail_v1( + p_transaction_hash bytea, + p_holder_thumbprint_hash bytea +) +RETURNS boolean +LANGUAGE sql +SECURITY DEFINER +SET search_path = pg_catalog +AS $function$ + UPDATE registry_notary_private.oid4vci_issuance_transaction AS stored + SET state = 'failed', response_aead_nonce = NULL, response_ciphertext = NULL, + updated_at = pg_catalog.clock_timestamp() + WHERE stored.transaction_hash = p_transaction_hash + AND stored.state = 'issuing' + AND stored.holder_thumbprint_hash = p_holder_thumbprint_hash + RETURNING TRUE +$function$; + CREATE FUNCTION registry_notary_api.retention_prune_v1(p_batch_size integer) RETURNS TABLE (deleted_count bigint, batch_saturated boolean) LANGUAGE plpgsql @@ -2333,6 +2614,20 @@ BEGIN v_total := v_total + v_count; v_saturated := v_saturated OR v_count = p_batch_size; + WITH candidates AS ( + SELECT transaction_hash + FROM registry_notary_private.oid4vci_issuance_transaction + WHERE expires_at <= v_now + ORDER BY expires_at, transaction_hash + LIMIT p_batch_size FOR UPDATE SKIP LOCKED + ), deleted AS ( + DELETE FROM registry_notary_private.oid4vci_issuance_transaction AS stored + USING candidates WHERE stored.transaction_hash = candidates.transaction_hash + RETURNING 1 + ) SELECT pg_catalog.count(*) INTO v_count FROM deleted; + v_total := v_total + v_count; + v_saturated := v_saturated OR v_count = p_batch_size; + RETURN QUERY SELECT v_total, v_saturated; END $function$; @@ -2349,7 +2644,9 @@ mod tests { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use registry_notary_core::{StateConfig, StatePostgresqlConfig, STATE_STORAGE_POSTGRESQL}; - use crate::preauth_state::{LoginState, PreauthorizationState}; + use crate::preauth_state::{ + CredentialMaterialization, IssuanceTransaction, LoginState, PreauthorizationState, + }; use crate::state_plane::{ attest_postgres_state_plane_runtime, LoginReserveOutcome, NotaryPostgresStatePlaneError, NotaryPostgresStatePlaneReadiness, NotaryPostgresStatePlaneRuntime, NotaryStatePlaneHandle, @@ -2395,6 +2692,7 @@ mod tests { "subject-access-quota=keyed-pseudonym-six-closed-buckets-fixed-windows-canonical-lock-order-caller-denial-order-atomic-all-or-none-check-only-no-mutation-v1", "preauthorization-login=keyed-state-capacity-4096-encrypted-single-consume-expiry-live-key-attestation-v2", "preauthorization-tx-code=verified-notary-issuer-stable-scope-jti-keyed-pin-verifier-peek-redeem-one-winner-expiry-live-key-attestation-v3", + "oid4vci-issuance-transaction=keyed-id-encrypted-immutable-record-token-nonce-bind-holder-and-request-atomic-one-materialization-encrypted-response-terminal-failure-expiry-v1", "retention=bounded-expiry-prune-skip-locked-saturation-catch-up-v2", ] { assert!( @@ -2450,6 +2748,7 @@ mod tests { "subject_access_quota", "preauthorization_login_state", "preauthorization_tx_code", + "oid4vci_issuance_transaction", ] { assert!(POSTGRES_STATE_PLANE_MIGRATION_V1.contains(table)); } @@ -4430,10 +4729,141 @@ mod tests { .await? ); + let transaction_id = "adapter-issuance-transaction"; + let transaction = IssuanceTransaction { + transaction_id: transaction_id.to_string(), + evaluation_id: "adapter-evaluation".to_string(), + evaluation_client_id: "hmac-sha256:adapter-client".to_string(), + credential_configuration_id: "adapter-config".to_string(), + commitment: "c".repeat(64), + }; + preauthorization_state + .reserve_issuance_transaction(transaction_id, transaction.clone(), expires_at) + .await?; + assert_eq!( + preauthorization_state + .transaction(transaction_id) + .await? + .expect("encrypted transaction must round trip") + .evaluation_id, + transaction.evaluation_id.as_str() + ); + assert!( + !preauthorization_state + .bind_transaction_nonce( + transaction_id, + &"d".repeat(64), + "adapter-token-nonce".to_string(), + ) + .await?, + "a mismatched commitment must not bind the token nonce" + ); + assert!( + preauthorization_state + .bind_transaction_nonce( + transaction_id, + &transaction.commitment, + "adapter-token-nonce".to_string(), + ) + .await? + ); + let acquired = preauthorization_state + .begin_credential_materialization( + transaction_id, + &transaction.commitment, + &transaction.credential_configuration_id, + "adapter-token-nonce", + "adapter-holder-thumbprint", + "adapter-request-hash", + ) + .await?; + assert!(matches!(acquired, CredentialMaterialization::Acquired(_))); + assert!(matches!( + preauthorization_state + .begin_credential_materialization( + transaction_id, + &transaction.commitment, + &transaction.credential_configuration_id, + "adapter-token-nonce", + "adapter-holder-thumbprint", + "adapter-request-hash", + ) + .await?, + CredentialMaterialization::Busy + )); + assert!(matches!( + preauthorization_state + .begin_credential_materialization( + transaction_id, + &transaction.commitment, + &transaction.credential_configuration_id, + "adapter-token-nonce", + "different-holder", + "adapter-request-hash", + ) + .await?, + CredentialMaterialization::Denied + )); + let cached_response = serde_json::json!({ + "format": "dc+sd-jwt", + "credential": "adapter-signed-credential", + }); + assert!( + preauthorization_state + .complete_credential_materialization( + transaction_id, + "adapter-holder-thumbprint", + "adapter-request-hash", + cached_response.clone(), + ) + .await? + ); + match preauthorization_state + .begin_credential_materialization( + transaction_id, + &transaction.commitment, + &transaction.credential_configuration_id, + "adapter-token-nonce", + "adapter-holder-thumbprint", + "adapter-request-hash", + ) + .await? + { + CredentialMaterialization::Cached(response) => { + assert_eq!(response, cached_response); + } + _ => panic!("an exact PostgreSQL retry must return the cached response"), + } + let stored_transaction = admin + .query_one( + "SELECT record_ciphertext, response_ciphertext FROM \ + registry_notary_private.oid4vci_issuance_transaction", + &[], + ) + .await?; + let record_ciphertext: Vec = stored_transaction.get("record_ciphertext"); + let response_ciphertext: Vec = stored_transaction.get("response_ciphertext"); + for secret in [ + transaction.evaluation_id.as_bytes(), + transaction.evaluation_client_id.as_bytes(), + b"adapter-signed-credential".as_slice(), + ] { + assert!( + !record_ciphertext + .windows(secret.len()) + .any(|window| window == secret) + && !response_ciphertext + .windows(secret.len()) + .any(|window| window == secret), + "issuance transaction plaintext must not be stored" + ); + } + admin .batch_execute( "DELETE FROM registry_notary_private.preauthorization_login_state; \ - DELETE FROM registry_notary_private.preauthorization_tx_code;", + DELETE FROM registry_notary_private.preauthorization_tx_code; \ + DELETE FROM registry_notary_private.oid4vci_issuance_transaction;", ) .await?; @@ -4483,7 +4913,8 @@ mod tests { registry_notary_private.machine_quota, registry_notary_private.subject_access_quota, registry_notary_private.preauthorization_login_state, - registry_notary_private.preauthorization_tx_code; + registry_notary_private.preauthorization_tx_code, + registry_notary_private.oid4vci_issuance_transaction; INSERT INTO registry_notary_private.replay_identifier (scope_hash, identifier_hash, created_at, expires_at) SELECT decode(repeat('90', 32), 'hex'), decode(repeat(marker, 32), 'hex'), @@ -4554,7 +4985,18 @@ mod tests { decode(repeat('a7', 32), 'hex'), 6, clock_timestamp() - interval '2 seconds', clock_timestamp() + lifetime FROM (VALUES ('a8', interval '-1 second'), - ('a9', interval '5 minutes')) AS rows(marker, lifetime);", + ('a9', interval '5 minutes')) AS rows(marker, lifetime); + INSERT INTO registry_notary_private.oid4vci_issuance_transaction + (transaction_hash, key_id, credential_configuration_id, commitment, + record_aead_nonce, record_ciphertext, state, created_at, updated_at, + expires_at) + SELECT decode(repeat(marker, 32), 'hex'), decode(repeat('b2', 32), 'hex'), + 'retention', repeat('c', 64), decode(repeat('b3', 12), 'hex'), + decode(repeat('b4', 17), 'hex'), 'ready', + clock_timestamp() - interval '2 seconds', clock_timestamp(), + clock_timestamp() + lifetime + FROM (VALUES ('b0', interval '-1 second'), + ('b1', interval '5 minutes')) AS rows(marker, lifetime);", ) .await?; let prune = runtime @@ -4566,7 +5008,7 @@ mod tests { .await?; let pruned: i64 = prune.get("deleted_count"); assert_eq!( - pruned, 9, + pruned, 10, "each typed state table must prune its expired row" ); assert!( @@ -4584,12 +5026,13 @@ mod tests { (SELECT count(*) FROM registry_notary_private.machine_quota) + (SELECT count(*) FROM registry_notary_private.subject_access_quota) + (SELECT count(*) FROM registry_notary_private.preauthorization_login_state) + - (SELECT count(*) FROM registry_notary_private.preauthorization_tx_code)", + (SELECT count(*) FROM registry_notary_private.preauthorization_tx_code) + + (SELECT count(*) FROM registry_notary_private.oid4vci_issuance_transaction)", &[], ) .await? .get(0); - assert_eq!(remaining, 9, "retention must preserve every live row"); + assert_eq!(remaining, 10, "retention must preserve every live row"); admin .batch_execute( diff --git a/crates/registry-notary-server/src/state_plane/mod.rs b/crates/registry-notary-server/src/state_plane/mod.rs index 9691adf06..02cfbb02d 100644 --- a/crates/registry-notary-server/src/state_plane/mod.rs +++ b/crates/registry-notary-server/src/state_plane/mod.rs @@ -13,7 +13,8 @@ mod sensitive; pub use handle::attest_postgres_state_plane_runtime; pub(crate) use handle::NotaryStatePlaneHandle; pub(crate) use sensitive::{ - LoginReserveOutcome, PostgresSensitiveState, SensitiveStateKeyConfig, SensitiveStateKeys, + IssuanceReserveOutcome, LoginReserveOutcome, PostgresSensitiveState, SensitiveStateKeyConfig, + SensitiveStateKeys, }; pub use migration::{ diff --git a/crates/registry-notary-server/src/state_plane/sensitive.rs b/crates/registry-notary-server/src/state_plane/sensitive.rs index 3becca3c1..ece287f20 100644 --- a/crates/registry-notary-server/src/state_plane/sensitive.rs +++ b/crates/registry-notary-server/src/state_plane/sensitive.rs @@ -15,6 +15,7 @@ use aws_lc_rs::{ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use registry_platform_replay::ReplayScope; use serde::{Deserialize, Serialize}; +use serde_json::Value; use subtle::ConstantTimeEq; use thiserror::Error; use time::OffsetDateTime; @@ -22,16 +23,25 @@ use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use super::{NotaryPostgresStatePlaneError, NotaryPostgresStatePlaneRuntime}; use crate::{ - preauth_state::{LoginState, VerifiedTransactionCode}, + preauth_state::{ + CredentialMaterialization, IssuanceTransaction, LoginState, VerifiedTransactionCode, + }, replay::{replay_identifier_hash, replay_scope_hash}, }; const KEY_BYTES: usize = 32; const LOGIN_RECORD_VERSION: u8 = 1; +const ISSUANCE_RECORD_VERSION: u8 = 1; const KDF_CONTEXT: &[u8] = b"registry-notary/preauthorization/kdf/v1"; const LOGIN_AAD_CONTEXT: &[u8] = b"registry-notary/preauthorization/login-aad/v1"; const STATE_IDENTIFIER_CONTEXT: &[u8] = b"login-state"; const PIN_VERIFIER_CONTEXT: &[u8] = b"transaction-code"; +const ISSUANCE_TRANSACTION_CONTEXT: &[u8] = b"oid4vci-issuance-transaction"; +const ISSUANCE_NONCE_CONTEXT: &[u8] = b"oid4vci-token-nonce"; +const ISSUANCE_HOLDER_CONTEXT: &[u8] = b"oid4vci-holder-thumbprint"; +const ISSUANCE_REQUEST_CONTEXT: &[u8] = b"oid4vci-credential-request"; +const ISSUANCE_RECORD_AAD_CONTEXT: &[u8] = b"registry-notary/oid4vci/transaction-aad/v1"; +const ISSUANCE_RESPONSE_AAD_CONTEXT: &[u8] = b"registry-notary/oid4vci/response-aad/v1"; /// The configured environment variable name is retained, but its value is /// read only by [`PostgresSensitiveState::activate`]. @@ -240,6 +250,272 @@ impl PostgresSensitiveState { })) } + pub(crate) async fn reserve_issuance_transaction( + &self, + transaction_id: &str, + transaction: &IssuanceTransaction, + expires_at: OffsetDateTime, + ) -> Result { + let expires_at = normalize_expiry(expires_at)?; + let transaction_hash = self + .keys + .identifier_hash(ISSUANCE_TRANSACTION_CONTEXT, transaction_id); + let aad = issuance_record_aad( + &transaction_hash, + &self.keys.key_id, + &transaction.credential_configuration_id, + &transaction.commitment, + expires_at, + )?; + let plaintext = EncryptedIssuanceTransaction { + version: ISSUANCE_RECORD_VERSION, + transaction, + }; + let plaintext = serde_json::to_vec(&plaintext) + .map_err(|_| SensitiveStateError::CryptographyUnavailable)?; + let (nonce, ciphertext) = seal(&self.keys.aead, &aad, &plaintext)?; + let session = self.runtime.open_domain_session().await?; + let row = session + .run_operation(session.client().query_one( + "SELECT registry_notary_api.oid4vci_transaction_reserve_v1(\ + $1::bytea, $2::bytea, $3::text, $4::text, $5::bytea, $6::bytea, $7::timestamptz)", + &[ + &&transaction_hash[..], + &&self.keys.key_id[..], + &transaction.credential_configuration_id, + &transaction.commitment, + &nonce, + &ciphertext, + &expires_at, + ], + )) + .await?; + match row.get::<_, i16>(0) { + 1 => Ok(IssuanceReserveOutcome::Reserved), + 0 => Ok(IssuanceReserveOutcome::Duplicate), + -1 => Ok(IssuanceReserveOutcome::Capacity), + _ => Err(SensitiveStateError::InvalidStoredRecord), + } + } + + pub(crate) async fn issuance_transaction( + &self, + transaction_id: &str, + ) -> Result, SensitiveStateError> { + let transaction_hash = self + .keys + .identifier_hash(ISSUANCE_TRANSACTION_CONTEXT, transaction_id); + let session = self.runtime.open_domain_session().await?; + let row = session + .run_operation(session.client().query_opt( + "SELECT * FROM registry_notary_api.oid4vci_transaction_get_v1($1::bytea)", + &[&&transaction_hash[..]], + )) + .await?; + row.map(|row| self.decrypt_issuance_transaction(&transaction_hash, &row)) + .transpose() + } + + pub(crate) async fn bind_issuance_nonce( + &self, + transaction_id: &str, + commitment: &str, + nonce: &str, + ) -> Result { + let transaction_hash = self + .keys + .identifier_hash(ISSUANCE_TRANSACTION_CONTEXT, transaction_id); + let nonce_hash = self.keys.identifier_hash(ISSUANCE_NONCE_CONTEXT, nonce); + let session = self.runtime.open_domain_session().await?; + let row = session + .run_operation(session.client().query_one( + "SELECT registry_notary_api.oid4vci_transaction_bind_nonce_v1(\ + $1::bytea, $2::text, $3::bytea)", + &[&&transaction_hash[..], &commitment, &&nonce_hash[..]], + )) + .await?; + Ok(row.try_get::<_, Option>(0).ok().flatten() == Some(true)) + } + + pub(crate) async fn begin_issuance_materialization( + &self, + transaction_id: &str, + commitment: &str, + configuration_id: &str, + nonce: &str, + holder_thumbprint: &str, + request_hash: &str, + ) -> Result { + let transaction_hash = self + .keys + .identifier_hash(ISSUANCE_TRANSACTION_CONTEXT, transaction_id); + let nonce_hash = self.keys.identifier_hash(ISSUANCE_NONCE_CONTEXT, nonce); + let holder_hash = self + .keys + .identifier_hash(ISSUANCE_HOLDER_CONTEXT, holder_thumbprint); + let request_hash = self + .keys + .identifier_hash(ISSUANCE_REQUEST_CONTEXT, request_hash); + let session = self.runtime.open_domain_session().await?; + let row = session + .run_operation(session.client().query_one( + "SELECT * FROM registry_notary_api.oid4vci_transaction_begin_v1(\ + $1::bytea, $2::text, $3::text, $4::bytea, $5::bytea, $6::bytea)", + &[ + &&transaction_hash[..], + &commitment, + &configuration_id, + &&nonce_hash[..], + &&holder_hash[..], + &&request_hash[..], + ], + )) + .await?; + match row.get::<_, i16>("outcome") { + 1 => self + .decrypt_issuance_transaction(&transaction_hash, &row) + .map(CredentialMaterialization::Acquired), + 2 => { + let transaction = self.decrypt_issuance_transaction(&transaction_hash, &row)?; + let key_id: Vec = row.get("key_id"); + let expires_at: OffsetDateTime = row.get("expires_at"); + let response_nonce: Vec = row.get("response_aead_nonce"); + let mut response_ciphertext: Vec = row.get("response_ciphertext"); + let aad = issuance_response_aad( + &transaction_hash, + &key_id, + &holder_hash, + &request_hash, + expires_at, + )?; + let plaintext = open( + &self.keys.aead, + &aad, + &response_nonce, + &mut response_ciphertext, + )?; + let response: Value = serde_json::from_slice(plaintext) + .map_err(|_| SensitiveStateError::InvalidStoredRecord)?; + if transaction.transaction_id != transaction_id { + return Err(SensitiveStateError::InvalidStoredRecord); + } + Ok(CredentialMaterialization::Cached(response)) + } + 0 => Ok(CredentialMaterialization::Busy), + -1 => Ok(CredentialMaterialization::Denied), + _ => Err(SensitiveStateError::InvalidStoredRecord), + } + } + + pub(crate) async fn complete_issuance_materialization( + &self, + transaction_id: &str, + holder_thumbprint: &str, + request_hash: &str, + response: &Value, + ) -> Result { + let transaction_hash = self + .keys + .identifier_hash(ISSUANCE_TRANSACTION_CONTEXT, transaction_id); + let holder_hash = self + .keys + .identifier_hash(ISSUANCE_HOLDER_CONTEXT, holder_thumbprint); + let request_hash = self + .keys + .identifier_hash(ISSUANCE_REQUEST_CONTEXT, request_hash); + let session = self.runtime.open_domain_session().await?; + let record = session + .run_operation(session.client().query_opt( + "SELECT * FROM registry_notary_api.oid4vci_transaction_get_v1($1::bytea)", + &[&&transaction_hash[..]], + )) + .await? + .ok_or(SensitiveStateError::InvalidStoredRecord)?; + let key_id: Vec = record.get("key_id"); + if key_id.ct_eq(&self.keys.key_id).unwrap_u8() != 1 { + return Err(SensitiveStateError::InvalidStoredRecord); + } + let expires_at: OffsetDateTime = record.get("expires_at"); + let aad = issuance_response_aad( + &transaction_hash, + &key_id, + &holder_hash, + &request_hash, + expires_at, + )?; + let plaintext = serde_json::to_vec(response) + .map_err(|_| SensitiveStateError::CryptographyUnavailable)?; + let (nonce, ciphertext) = seal(&self.keys.aead, &aad, &plaintext)?; + let row = session + .run_operation(session.client().query_one( + "SELECT registry_notary_api.oid4vci_transaction_complete_v1(\ + $1::bytea, $2::bytea, $3::bytea, $4::bytea, $5::bytea)", + &[ + &&transaction_hash[..], + &&holder_hash[..], + &&request_hash[..], + &nonce, + &ciphertext, + ], + )) + .await?; + Ok(row.try_get::<_, Option>(0).ok().flatten() == Some(true)) + } + + pub(crate) async fn fail_issuance_materialization( + &self, + transaction_id: &str, + holder_thumbprint: &str, + ) -> Result<(), SensitiveStateError> { + let transaction_hash = self + .keys + .identifier_hash(ISSUANCE_TRANSACTION_CONTEXT, transaction_id); + let holder_hash = self + .keys + .identifier_hash(ISSUANCE_HOLDER_CONTEXT, holder_thumbprint); + let session = self.runtime.open_domain_session().await?; + session + .run_operation(session.client().query_opt( + "SELECT registry_notary_api.oid4vci_transaction_fail_v1($1::bytea, $2::bytea)", + &[&&transaction_hash[..], &&holder_hash[..]], + )) + .await?; + Ok(()) + } + + fn decrypt_issuance_transaction( + &self, + transaction_hash: &[u8; KEY_BYTES], + row: &tokio_postgres::Row, + ) -> Result { + let key_id: Vec = row.get("key_id"); + let configuration_id: String = row.get("credential_configuration_id"); + let commitment: String = row.get("commitment"); + let nonce: Vec = row.get("record_aead_nonce"); + let mut ciphertext: Vec = row.get("record_ciphertext"); + let expires_at: OffsetDateTime = row.get("expires_at"); + if key_id.ct_eq(&self.keys.key_id).unwrap_u8() != 1 { + return Err(SensitiveStateError::InvalidStoredRecord); + } + let aad = issuance_record_aad( + transaction_hash, + &self.keys.key_id, + &configuration_id, + &commitment, + expires_at, + )?; + let plaintext = open(&self.keys.aead, &aad, &nonce, &mut ciphertext)?; + let decrypted: DecryptedIssuanceTransaction = serde_json::from_slice(plaintext) + .map_err(|_| SensitiveStateError::InvalidStoredRecord)?; + if decrypted.version != ISSUANCE_RECORD_VERSION + || decrypted.transaction.credential_configuration_id != configuration_id + || decrypted.transaction.commitment != commitment + { + return Err(SensitiveStateError::InvalidStoredRecord); + } + Ok(decrypted.transaction) + } + pub(crate) async fn reserve_transaction_code( &self, jti: &str, @@ -387,6 +663,13 @@ pub(crate) enum LoginReserveOutcome { Capacity, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum IssuanceReserveOutcome { + Reserved, + Duplicate, + Capacity, +} + #[derive(Zeroize, ZeroizeOnDrop)] pub(crate) struct SensitiveStateKeys { aead: [u8; KEY_BYTES], @@ -450,6 +733,18 @@ struct DecryptedLoginState { nonce: String, } +#[derive(Serialize)] +struct EncryptedIssuanceTransaction<'a> { + version: u8, + transaction: &'a IssuanceTransaction, +} + +#[derive(Deserialize)] +struct DecryptedIssuanceTransaction { + version: u8, + transaction: IssuanceTransaction, +} + fn derive_key(master: &[u8; KEY_BYTES], label: &[u8]) -> [u8; KEY_BYTES] { hmac_framed(master, &[KDF_CONTEXT, label]) } @@ -493,6 +788,85 @@ fn login_aad( Ok(aad) } +fn issuance_record_aad( + transaction_hash: &[u8; KEY_BYTES], + key_id: &[u8], + configuration_id: &str, + commitment: &str, + expires_at: OffsetDateTime, +) -> Result, SensitiveStateError> { + if key_id.len() != KEY_BYTES || commitment.len() != 64 { + return Err(SensitiveStateError::InvalidStoredRecord); + } + let mut aad = Vec::with_capacity(192 + configuration_id.len()); + aad.extend_from_slice(ISSUANCE_RECORD_AAD_CONTEXT); + aad.push(ISSUANCE_RECORD_VERSION); + aad.extend_from_slice(transaction_hash); + aad.extend_from_slice(key_id); + aad.extend_from_slice(&expires_at.unix_timestamp().to_be_bytes()); + append_aad_text(&mut aad, configuration_id)?; + append_aad_text(&mut aad, commitment)?; + Ok(aad) +} + +fn issuance_response_aad( + transaction_hash: &[u8; KEY_BYTES], + key_id: &[u8], + holder_hash: &[u8; KEY_BYTES], + request_hash: &[u8; KEY_BYTES], + expires_at: OffsetDateTime, +) -> Result, SensitiveStateError> { + if key_id.len() != KEY_BYTES { + return Err(SensitiveStateError::InvalidStoredRecord); + } + let mut aad = Vec::with_capacity(160); + aad.extend_from_slice(ISSUANCE_RESPONSE_AAD_CONTEXT); + aad.push(ISSUANCE_RECORD_VERSION); + aad.extend_from_slice(transaction_hash); + aad.extend_from_slice(key_id); + aad.extend_from_slice(holder_hash); + aad.extend_from_slice(request_hash); + aad.extend_from_slice(&expires_at.unix_timestamp().to_be_bytes()); + Ok(aad) +} + +fn append_aad_text(aad: &mut Vec, value: &str) -> Result<(), SensitiveStateError> { + let length = + u32::try_from(value.len()).map_err(|_| SensitiveStateError::InvalidStoredRecord)?; + aad.extend_from_slice(&length.to_be_bytes()); + aad.extend_from_slice(value.as_bytes()); + Ok(()) +} + +fn seal( + key_bytes: &[u8; KEY_BYTES], + aad: &[u8], + plaintext: &[u8], +) -> Result<(Vec, Vec), SensitiveStateError> { + let mut ciphertext = plaintext.to_vec(); + let aead = RandomizedNonceKey::new(&AES_256_GCM, key_bytes) + .map_err(|_| SensitiveStateError::CryptographyUnavailable)?; + let nonce = aead + .seal_in_place_append_tag(Aad::from(aad), &mut ciphertext) + .map_err(|_| SensitiveStateError::CryptographyUnavailable)?; + Ok((nonce.as_ref().as_slice().to_vec(), ciphertext)) +} + +fn open<'a>( + key_bytes: &[u8; KEY_BYTES], + aad: &[u8], + nonce: &[u8], + ciphertext: &'a mut [u8], +) -> Result<&'a [u8], SensitiveStateError> { + let nonce = Nonce::try_assume_unique_for_key(nonce) + .map_err(|_| SensitiveStateError::InvalidStoredRecord)?; + let aead = RandomizedNonceKey::new(&AES_256_GCM, key_bytes) + .map_err(|_| SensitiveStateError::CryptographyUnavailable)?; + aead.open_in_place(nonce, Aad::from(aad), ciphertext) + .map(|plaintext| &*plaintext) + .map_err(|_| SensitiveStateError::InvalidStoredRecord) +} + fn normalize_expiry(expires_at: OffsetDateTime) -> Result { OffsetDateTime::from_unix_timestamp(expires_at.unix_timestamp()) .map_err(|_| SensitiveStateError::InvalidStoredRecord) diff --git a/crates/registry-notary-server/tests/standalone_http/preauth.rs b/crates/registry-notary-server/tests/standalone_http/preauth.rs index ea558254a..ce96c4368 100644 --- a/crates/registry-notary-server/tests/standalone_http/preauth.rs +++ b/crates/registry-notary-server/tests/standalone_http/preauth.rs @@ -183,6 +183,41 @@ pub(super) async fn preauth_offer_start_rejects_unknown_configuration_id() { idp.stop().await; } +#[tokio::test] +pub(super) async fn preauth_credential_rejects_inline_jwk_holder_proof_before_auth() { + set_preauth_env(); + let idp = MockIdp::start().await; + let token_upstream = MockHttpUpstream::start().await; + let tmp = TempDir::new().expect("tempdir"); + let audit_path = tmp.path().join("audit.jsonl"); + let app = standalone_router(subject_access_preauth_config( + "http://127.0.0.1:1", + audit_path.to_str().expect("audit path is UTF-8"), + &idp.issuer(), + &idp.jwks_uri(), + &format!("{}/authorize", idp.issuer()), + &format!("{}/token", token_upstream.url()), + )) + .await + .expect("standalone router builds"); + let server = TestServer::builder().http_transport().build(app); + let response = server + .post("/oid4vci/credential") + .json(&json!({ + "format": "dc+sd-jwt", + "credential_configuration_id": "person_is_alive_sd_jwt", + "proof": { + "proof_type": "jwt", + "jwt": sign_oid4vci_inline_jwk_proof(NOTARY_ISSUER, "transaction-nonce") + } + })) + .await; + response.assert_status(StatusCode::BAD_REQUEST); + let body: Value = response.json(); + assert_eq!(body["error"], json!("invalid_proof")); + idp.stop().await; +} + #[tokio::test] pub(super) async fn preauth_callback_mints_pre_authorized_offer_with_tx_code() { set_preauth_env(); @@ -1105,20 +1140,22 @@ pub(super) async fn preauth_random_code_flood_is_throttled_per_client_address() } #[tokio::test] -pub(super) async fn preauth_disabled_returns_404_and_offer_is_authorization_code() { +pub(super) async fn preauth_disabled_exposes_no_wallet_issuance_grant() { set_preauth_env(); let idp = MockIdp::start().await; let tmp = TempDir::new().expect("tempdir"); let audit_path = tmp.path().join("audit.jsonl"); // Default config: pre-auth disabled. - let app = standalone_router(subject_access_oid4vci_config( + let mut config = subject_access_oid4vci_config( "http://127.0.0.1:1", audit_path.to_str().expect("audit path is UTF-8"), &idp.issuer(), &idp.jwks_uri(), - )) - .await - .expect("standalone router builds"); + ); + config.oid4vci.enabled = false; + let app = standalone_router(config) + .await + .expect("standalone router builds"); let server = TestServer::builder().http_transport().build(app); server @@ -1136,23 +1173,18 @@ pub(super) async fn preauth_disabled_returns_404_and_offer_is_authorization_code .await .assert_status(StatusCode::NOT_FOUND); - // Offers fall back to authorization_code. - let offer = server.get("/oid4vci/credential-offer").await; - offer.assert_status_ok(); - let body: Value = offer.json(); - assert!(body["grants"]["authorization_code"].is_object()); - assert!(body["grants"] - .get("urn:ietf:params:oauth:grant-type:pre-authorized_code") - .is_none()); + server + .get("/oid4vci/credential-offer") + .await + .assert_status(StatusCode::NOT_FOUND); + server + .post("/oid4vci/nonce") + .await + .assert_status(StatusCode::NOT_FOUND); - // Issuer metadata advertises no token endpoint when pre-auth is disabled. + // Disabling the only supported issuance grant disables issuer metadata. let metadata = server.get("/.well-known/openid-credential-issuer").await; - metadata.assert_status_ok(); - let metadata_body: Value = metadata.json(); - assert!( - metadata_body.get("token_endpoint").is_none(), - "disabled pre-auth must not advertise a token endpoint" - ); + metadata.assert_status(StatusCode::NOT_FOUND); idp.stop().await; } @@ -1537,16 +1569,7 @@ pub(super) async fn preauth_notary_access_token_with_empty_authorization_details "subject_access person-is-alive", Some(json!([])), ); - let nonce = server - .post("/oid4vci/nonce") - .json(&json!({"credential_configuration_id": "person_is_alive_sd_jwt"})) - .await; - nonce.assert_status_ok(); - let c_nonce = nonce.json::()["c_nonce"] - .as_str() - .expect("nonce returned") - .to_string(); - let proof = sign_oid4vci_proof(NOTARY_ISSUER, &c_nonce); + let proof = sign_oid4vci_proof(NOTARY_ISSUER, "unbound-legacy-nonce"); let credential = server .post("/oid4vci/credential") @@ -1558,8 +1581,8 @@ pub(super) async fn preauth_notary_access_token_with_empty_authorization_details })) .await; - credential.assert_status(StatusCode::FORBIDDEN); - assert_eq!(credential.json::()["error"], json!("access_denied")); + credential.assert_status(StatusCode::UNAUTHORIZED); + assert_eq!(credential.json::()["error"], json!("invalid_token")); idp.stop().await; } @@ -1608,18 +1631,21 @@ pub(super) async fn preauth_end_to_end_issues_sd_jwt_vc_bound_to_holder() { // The Notary-minted token is accepted at the credential endpoint and issues // an SD-JWT VC bound to the holder's did:jwk proof. let proof = sign_oid4vci_proof(NOTARY_ISSUER, &c_nonce); + let credential_request = json!({ + "format": "dc+sd-jwt", + "credential_configuration_id": "person_is_alive_sd_jwt", + "proof": { "proof_type": "jwt", "jwt": proof } + }); let credential = server .post("/oid4vci/credential") .add_header("authorization", format!("Bearer {access_token}")) - .json(&json!({ - "format": "dc+sd-jwt", - "credential_configuration_id": "person_is_alive_sd_jwt", - "proof": { "proof_type": "jwt", "jwt": proof } - })) + .json(&credential_request) .await; credential.assert_status_ok(); let credential_body: Value = credential.json(); assert_eq!(credential_body["format"], json!("dc+sd-jwt")); + assert!(credential_body.get("c_nonce").is_none()); + assert!(credential_body.get("c_nonce_expires_in").is_none()); let sd_jwt = credential_body["credential"] .as_str() .expect("credential issued"); @@ -1633,6 +1659,17 @@ pub(super) async fn preauth_end_to_end_issues_sd_jwt_vc_bound_to_holder() { payload["expirationDate"].as_str().is_some(), "wallet-compatible expiration date alias is present" ); + let retry = server + .post("/oid4vci/credential") + .add_header("authorization", format!("Bearer {access_token}")) + .json(&credential_request) + .await; + retry.assert_status_ok(); + assert_eq!( + retry.json::(), + credential_body, + "an exact retry receives the verbatim cached credential response" + ); idp.stop().await; } @@ -1674,20 +1711,6 @@ pub(super) fn decode_disclosed_claim(sd_jwt: &str, claim_name: &str) -> Value { .unwrap_or_else(|| panic!("disclosure for {claim_name} is present")) } -/// The evaluated-claim fields that must be stable across issuance paths. The -/// `issued_at` timestamp legitimately differs between two evaluations, so it is -/// excluded from the parity comparison. -#[cfg(feature = "registry-notary-cel")] -pub(super) fn semantic_claim_fields(disclosure_value: &Value) -> Value { - json!({ - "claim_id": disclosure_value["claim_id"], - "version": disclosure_value["version"], - "value": disclosure_value["value"], - "satisfied": disclosure_value["satisfied"], - "subject_type": disclosure_value["subject_type"], - }) -} - /// Find the single `credential_issued` audit record for the OID4VCI credential /// endpoint. Its `target_ref_hash`/`requester_ref_hash` are HMACs over the /// bound subject reference, deterministic for a fixed audit secret, so two paths @@ -1705,25 +1728,11 @@ pub(super) fn credential_issued_audit(audit_path: &std::path::Path) -> Value { .expect("credential_issued audit record exists") } -/// The semantic capstone. Drive the full pre-authorized-code path and compare -/// the issued credential to the one the existing eSignet-token path produces for -/// the same eSignet-authenticated subject and the same configuration. -/// -/// It asserts two properties that a shape check cannot: -/// -/// 1. Subject equality: both paths bind the same eSignet `subject_binding` value -/// (the civil id), proven by identical, secret-keyed `target_ref_hash` / -/// `requester_ref_hash` audit hashes. The raw civil id is never logged, so the -/// hash is the only observable subject handle, and matching it proves the -/// pre-auth credential is bound to the eSignet subject, not the holder key -/// alone. -/// 2. Evaluation parity: the disclosed `person-is-alive` claim result is -/// byte-identical across the two paths (claim_id, version, value, satisfied, -/// subject_type), proving the pre-auth path yields an equivalent credential, -/// not merely a well-shaped one. +/// A wallet-issued eSignet bearer token cannot bypass the issuer-initiated +/// transaction, while the approved pre-authorized-code path binds and issues. #[tokio::test] #[cfg(feature = "registry-notary-cel")] -pub(super) async fn preauth_credential_subject_and_evaluation_match_esignet_token_path() { +pub(super) async fn preauth_transaction_cannot_be_bypassed_with_esignet_wallet_token() { set_preauth_env(); // The eSignet-token (auth-code) baseline: an eSignet token whose @@ -1755,16 +1764,7 @@ pub(super) async fn preauth_credential_subject_and_evaluation_match_esignet_toke "exp": now + 300, "nbf": now, })); - let baseline_nonce = baseline_server - .post("/oid4vci/nonce") - .json(&json!({"credential_configuration_id": "person_is_alive_sd_jwt"})) - .await; - baseline_nonce.assert_status_ok(); - let baseline_nonce = baseline_nonce.json::()["c_nonce"] - .as_str() - .expect("nonce returned") - .to_string(); - let baseline_proof = sign_oid4vci_proof(NOTARY_ISSUER, &baseline_nonce); + let baseline_proof = sign_oid4vci_proof(NOTARY_ISSUER, "unbound-wallet-nonce"); let baseline_credential = baseline_server .post("/oid4vci/credential") .add_header("authorization", format!("Bearer {esignet_token}")) @@ -1774,15 +1774,10 @@ pub(super) async fn preauth_credential_subject_and_evaluation_match_esignet_toke "proof": { "proof_type": "jwt", "jwt": baseline_proof } })) .await; - baseline_credential.assert_status_ok(); - let baseline_sd_jwt = baseline_credential.json::()["credential"] - .as_str() - .expect("baseline credential issued") - .to_string(); - let baseline_audit = credential_issued_audit(&baseline_audit_path); + baseline_credential.assert_status(StatusCode::UNAUTHORIZED); assert_eq!( - baseline_audit["purposes"], - json!(["citizen_subject_access"]) + baseline_credential.json::()["error"], + json!("invalid_token") ); baseline_idp.stop().await; @@ -1846,47 +1841,18 @@ pub(super) async fn preauth_credential_subject_and_evaluation_match_esignet_toke assert_eq!(preauth_audit["purposes"], json!(["citizen_subject_access"])); preauth_idp.stop().await; - // Subject equality: the pre-auth credential is bound to the eSignet subject, - // not the holder key alone. The secret-keyed audit hash over the bound - // subject reference is identical to the eSignet-token path, which it can be - // only if both bound the same civil id. assert!( - baseline_audit["target_ref_hash"].as_str().is_some(), - "baseline credential audit hashes the bound subject" + preauth_audit["target_ref_hash"] + .as_str() + .is_some_and(|hash| hash.starts_with("hmac-sha256:")), + "the transaction audit uses only a keyed subject handle" ); - assert_eq!( - preauth_audit["target_ref_hash"], baseline_audit["target_ref_hash"], - "pre-auth credential subject must equal the eSignet subject_binding value" - ); - assert_eq!( - preauth_audit["requester_ref_hash"], baseline_audit["requester_ref_hash"], - "pre-auth requester must equal the eSignet-token path requester" - ); - assert_eq!(preauth_audit["target_type"], baseline_audit["target_type"]); - - // The holder binding is independent of the access token: both credentials are - // bound to the same holder did:jwk proof key via `cnf`/`sub`. - let baseline_payload = decode_sd_jwt_payload(&baseline_sd_jwt); let preauth_payload = decode_sd_jwt_payload(&preauth_sd_jwt); - assert_eq!( - preauth_payload["cnf"], baseline_payload["cnf"], - "holder binding comes from the did:jwk proof, identical across paths" - ); - assert_eq!(preauth_payload["vct"], baseline_payload["vct"]); - // The registry subject ref is deliberately never exposed in the payload. assert!( !preauth_payload.to_string().contains("person-1"), "the raw civil id must not appear in the credential payload" ); - - // Evaluation parity: the disclosed person-is-alive result is identical. - let baseline_claim = decode_disclosed_claim(&baseline_sd_jwt, "person-is-alive"); let preauth_claim = decode_disclosed_claim(&preauth_sd_jwt, "person-is-alive"); - assert_eq!( - semantic_claim_fields(&preauth_claim), - semantic_claim_fields(&baseline_claim), - "the evaluated claim result must be identical to the eSignet-token path" - ); assert_eq!(preauth_claim["claim_id"], json!("person-is-alive")); assert_eq!(preauth_claim["value"], json!(true)); assert_eq!(preauth_claim["satisfied"], json!(true)); diff --git a/crates/registry-notary-server/tests/standalone_http/preauth_support.rs b/crates/registry-notary-server/tests/standalone_http/preauth_support.rs index 5c89a0d94..a91b892dc 100644 --- a/crates/registry-notary-server/tests/standalone_http/preauth_support.rs +++ b/crates/registry-notary-server/tests/standalone_http/preauth_support.rs @@ -59,6 +59,8 @@ pub(super) fn subject_access_preauth_config( // credential endpoint still accepts eSignet tokens on the unchanged path. let mut config = subject_access_oid4vci_config(base_url, audit_path, esignet_issuer, esignet_jwks_uri); + config.oid4vci.offer_endpoint.clear(); + config.oid4vci.nonce_endpoint = None; config.state.storage = registry_notary_core::STATE_STORAGE_IN_MEMORY.to_string(); // The credential endpoint must be allowed to issue credentials for the // pre-auth happy path. diff --git a/crates/registry-notary-server/tests/standalone_http/support.rs b/crates/registry-notary-server/tests/standalone_http/support.rs index 739ccb24f..761d53b3e 100644 --- a/crates/registry-notary-server/tests/standalone_http/support.rs +++ b/crates/registry-notary-server/tests/standalone_http/support.rs @@ -30,12 +30,14 @@ pub(super) use registry_platform_audit::{ pub(super) use registry_platform_authcommon::{ CredentialFingerprintProvider, CredentialFingerprintRef, }; -pub(super) use registry_platform_crypto::{canonicalize_json, sign, PrivateJwk}; #[cfg(feature = "registry-notary-cel")] -pub(super) use registry_platform_crypto::{did_jwk_from_public_jwk, verify}; +pub(super) use registry_platform_crypto::verify; +pub(super) use registry_platform_crypto::{ + canonicalize_json, did_jwk_from_public_jwk, sign, PrivateJwk, +}; pub(super) use registry_platform_testing::{ - fixtures, jwks_from_private_jwk, sign_ed25519_compact_jwt, sign_openid4vci_proof_jwt, - MockHttpUpstream, MockIdp, FEDERATION_PROTOCOL, FEDERATION_REQUEST_JWT_TYPE, + fixtures, jwks_from_private_jwk, sign_ed25519_compact_jwt, MockHttpUpstream, MockIdp, + FEDERATION_PROTOCOL, FEDERATION_REQUEST_JWT_TYPE, }; pub(super) use serde::Deserialize; pub(super) use serde_json::{json, Value}; @@ -284,12 +286,40 @@ pub(super) fn set_audit_secret() { } pub(super) fn sign_oid4vci_proof(audience: &str, nonce: &str) -> String { + sign_oid4vci_did_jwk_proof(audience, Some(nonce), true) +} + +fn sign_oid4vci_did_jwk_proof(audience: &str, nonce: Option<&str>, include_iss: bool) -> String { + let holder = PrivateJwk::parse(TEST_HOLDER_JWK).expect("holder JWK parses"); + let holder_id = did_jwk_from_public_jwk(&holder.public()).expect("holder did:jwk encodes"); let now = OffsetDateTime::now_utc().unix_timestamp(); - sign_openid4vci_proof_jwt(TEST_HOLDER_JWK, audience, Some(nonce), now) + let header_b64 = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "alg": "EdDSA", + "typ": "openid4vci-proof+jwt", + "kid": holder_id, + })) + .expect("header serializes"), + ); + let mut payload = serde_json::Map::from_iter([ + ("aud".to_string(), json!(audience)), + ("iat".to_string(), json!(now)), + ("exp".to_string(), json!(now + 60)), + ]); + if include_iss { + payload.insert("iss".to_string(), json!(holder_id)); + } + if let Some(nonce) = nonce { + payload.insert("nonce".to_string(), json!(nonce)); + } + let payload_b64 = URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&Value::Object(payload)).expect("payload serializes")); + let signing_input = format!("{header_b64}.{payload_b64}"); + let signature = sign(signing_input.as_bytes(), &holder).expect("holder signs proof"); + format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) } -#[cfg(feature = "registry-notary-cel")] -pub(super) fn sign_oid4vci_proof_without_iss(audience: &str, nonce: &str) -> String { +pub(super) fn sign_oid4vci_inline_jwk_proof(audience: &str, nonce: &str) -> String { let holder = PrivateJwk::parse(TEST_HOLDER_JWK).expect("holder JWK parses"); let now = OffsetDateTime::now_utc().unix_timestamp(); let header_b64 = URL_SAFE_NO_PAD.encode( @@ -314,6 +344,11 @@ pub(super) fn sign_oid4vci_proof_without_iss(audience: &str, nonce: &str) -> Str format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) } +#[cfg(feature = "registry-notary-cel")] +pub(super) fn sign_oid4vci_proof_without_iss(audience: &str, nonce: &str) -> String { + sign_oid4vci_did_jwk_proof(audience, Some(nonce), false) +} + #[cfg(feature = "registry-notary-cel")] pub(super) fn sign_direct_holder_proof(holder_id: &str, evaluation_id: &str, jti: &str) -> String { let holder = PrivateJwk::parse(TEST_HOLDER_JWK).expect("holder JWK parses"); diff --git a/crates/registry-notary-server/tests/subject_access_guard_test.rs b/crates/registry-notary-server/tests/subject_access_guard_test.rs index ff2ef8138..5d633e167 100644 --- a/crates/registry-notary-server/tests/subject_access_guard_test.rs +++ b/crates/registry-notary-server/tests/subject_access_guard_test.rs @@ -104,6 +104,9 @@ fn subject_access_principal() -> EvidencePrincipal { audiences: vec![bounded("registry-notary-citizen")], client_id: Some(bounded("azp:citizen-portal")), token_type: Some(bounded("JWT")), + credential_configuration_id: None, + issuance_transaction_id: None, + issuance_transaction_commitment: None, scopes: vec![bounded("subject_access")], subject: Some(bounded(RAW_PRINCIPAL_ID)), subject_binding_claim: Some( diff --git a/crates/registryctl/src/project_authoring/compiler/notary.rs b/crates/registryctl/src/project_authoring/compiler/notary.rs index 9b46f0e1b..347c501f2 100644 --- a/crates/registryctl/src/project_authoring/compiler/notary.rs +++ b/crates/registryctl/src/project_authoring/compiler/notary.rs @@ -452,8 +452,6 @@ fn add_oid4vci_config( "authorization_servers": [binding.authorization_server.issuer], "accepted_token_audiences": [public_base_url, binding.client.id], "credential_endpoint": format!("{public_base_url}/oid4vci/credential"), - "offer_endpoint": format!("{public_base_url}/oid4vci/credential-offer"), - "nonce_endpoint": format!("{public_base_url}/oid4vci/nonce"), "nonce": { "enabled": true, "ttl_seconds": 300 }, "authorization": { "require_pkce_method": "S256" }, "proof": { "max_age_seconds": 300, "max_clock_skew_seconds": 30 }, diff --git a/products/notary/openapi/registry-notary.openapi.json b/products/notary/openapi/registry-notary.openapi.json index 946af16fd..578b7a381 100644 --- a/products/notary/openapi/registry-notary.openapi.json +++ b/products/notary/openapi/registry-notary.openapi.json @@ -768,29 +768,6 @@ ], "type": "object" }, - "CredentialOffer": { - "properties": { - "credential_configuration_ids": { - "items": { - "type": "string" - }, - "type": "array" - }, - "credential_issuer": { - "format": "uri", - "type": "string" - }, - "grants": { - "additionalProperties": true, - "type": "object" - } - }, - "required": [ - "credential_issuer", - "credential_configuration_ids" - ], - "type": "object" - }, "CredentialRequest": { "additionalProperties": false, "properties": { @@ -843,13 +820,6 @@ }, "CredentialResponse": { "properties": { - "c_nonce": { - "type": "string" - }, - "c_nonce_expires_in": { - "format": "uint64", - "type": "integer" - }, "credential": { "type": "string" }, @@ -1234,31 +1204,6 @@ ], "type": "object" }, - "NonceRequest": { - "additionalProperties": false, - "properties": { - "credential_configuration_id": { - "type": "string" - } - }, - "type": "object" - }, - "NonceResponse": { - "properties": { - "c_nonce": { - "type": "string" - }, - "c_nonce_expires_in": { - "format": "uint64", - "type": "integer" - } - }, - "required": [ - "c_nonce", - "c_nonce_expires_in" - ], - "type": "object" - }, "Oid4vciError": { "properties": { "error": { @@ -2071,8 +2016,7 @@ "locale": "en-US", "name": "Civil Registry Notary" } - ], - "nonce_endpoint": "https://issuer.example.gov/oid4vci/nonce" + ] }, "schema": { "$ref": "#/components/schemas/CredentialIssuerMetadata" @@ -2925,7 +2869,7 @@ }, "/oid4vci/credential": { "post": { - "description": "Issues a dc+sd-jwt credential for an authenticated direct subject-access principal only after a fresh non-delegated registry-backed evaluation records exact compiler pins and normalized unique Relay executions for every selected root's dependency closure. Source-free, delegated, and legacy evaluations are not issuable. Error responses use the OpenID4VCI error envelope, not RFC 9457 Problem Details.", + "description": "Materializes at most one dc+sd-jwt credential from the immutable registry-backed evaluation transaction authorized before the issuer-initiated offer. The access token supplies the transaction nonce, holder proof must use EdDSA with did:jwk, and an exact retry returns the cached response without another Relay call or signature. Source-free, delegated, and legacy evaluations are not issuable. Error responses use the OpenID4VCI error envelope, not RFC 9457 Problem Details.", "operationId": "issueOid4vciCredential", "requestBody": { "content": { @@ -2942,8 +2886,6 @@ "content": { "application/json": { "example": { - "c_nonce": "next-b64url-nonce", - "c_nonce_expires_in": 300, "credential": "eyJhbGciOiJFZERTQSIsInR5cCI6ImRjK3NkLWp3dCJ9.payload.signature~disclosure~", "format": "dc+sd-jwt" }, @@ -3033,161 +2975,9 @@ "summary": "Issue a credential through OpenID4VCI" } }, - "/oid4vci/credential-offer": { - "get": { - "description": "Returns an authorization-code credential offer. Error responses use the OpenID4VCI error envelope, not RFC 9457 Problem Details.", - "operationId": "getOid4vciCredentialOffer", - "parameters": [ - { - "in": "query", - "name": "credential_configuration_id", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "example": { - "credential_configuration_ids": [ - "person_is_alive_sd_jwt" - ], - "credential_issuer": "https://issuer.example.gov", - "grants": { - "authorization_code": { - "authorization_server": "https://id.example.gov", - "issuer_state": "issuer-state" - } - } - }, - "schema": { - "$ref": "#/components/schemas/CredentialOffer" - } - } - }, - "description": "Credential offer" - }, - "400": { - "content": { - "application/json": { - "example": { - "error": "invalid_request", - "error_description": "credential request is invalid" - }, - "schema": { - "$ref": "#/components/schemas/Oid4vciError" - } - } - }, - "description": "Invalid credential offer request" - }, - "404": { - "description": "OpenID4VCI issuer is disabled" - }, - "500": { - "content": { - "application/json": { - "example": { - "error": "server_error", - "error_description": "credential issuer failed" - }, - "schema": { - "$ref": "#/components/schemas/Oid4vciError" - } - } - }, - "description": "OpenID4VCI issuer failed" - } - }, - "security": [], - "summary": "Create an OpenID4VCI credential offer" - } - }, - "/oid4vci/nonce": { - "post": { - "description": "Returns a c_nonce for proof-of-possession. Error responses use the OpenID4VCI error envelope, not RFC 9457 Problem Details.", - "operationId": "createOid4vciNonce", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NonceRequest" - } - } - }, - "required": false - }, - "responses": { - "200": { - "content": { - "application/json": { - "example": { - "c_nonce": "b64url-nonce", - "c_nonce_expires_in": 300 - }, - "schema": { - "$ref": "#/components/schemas/NonceResponse" - } - } - }, - "description": "Nonce response" - }, - "400": { - "content": { - "application/json": { - "example": { - "error": "invalid_request", - "error_description": "credential request is invalid" - }, - "schema": { - "$ref": "#/components/schemas/Oid4vciError" - } - } - }, - "description": "Invalid nonce request" - }, - "404": { - "description": "OpenID4VCI nonce endpoint is disabled" - }, - "429": { - "content": { - "application/json": { - "example": { - "error": "temporarily_unavailable", - "error_description": "credential request is rate limited" - }, - "schema": { - "$ref": "#/components/schemas/Oid4vciError" - } - } - }, - "description": "Nonce store is rate limited" - }, - "500": { - "content": { - "application/json": { - "example": { - "error": "server_error", - "error_description": "credential issuer failed" - }, - "schema": { - "$ref": "#/components/schemas/Oid4vciError" - } - } - }, - "description": "OpenID4VCI issuer failed" - } - }, - "security": [], - "summary": "Create an OpenID4VCI credential nonce" - } - }, "/oid4vci/offer/callback": { "get": { - "description": "Public and unauthenticated. Consumes the login state, exchanges the eSignet code with private_key_jwt, validates the id_token, and mints one single-use pre-authorized_code. When configured, the offer also includes one numeric tx_code (PIN) shown out-of-band from the QR. Returns 404 when the pre-authorized-code flow is disabled.", + "description": "Public and unauthenticated. Consumes the login state, exchanges the eSignet code with private_key_jwt, validates the id_token, completes the exact registry-backed Relay evaluation, and only then mints one single-use pre-authorized_code bound to that immutable transaction. Denied, unavailable, malformed, source-free, or provenance-invalid evaluations produce no offer. When configured, the offer also includes one numeric tx_code (PIN) shown out-of-band from the QR. Returns 404 when the pre-authorized-code flow is disabled.", "operationId": "completeOid4vciOffer", "parameters": [ { @@ -3228,6 +3018,16 @@ }, "description": "Login state, eSignet code, or id_token is invalid" }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Oid4vciError" + } + } + }, + "description": "Registry-backed evaluation or issuance policy denied the transaction" + }, "404": { "description": "Pre-authorized-code flow is disabled" }, diff --git a/products/notary/security/auth-none-allowlist.yml b/products/notary/security/auth-none-allowlist.yml index ffae25868..32c44778f 100644 --- a/products/notary/security/auth-none-allowlist.yml +++ b/products/notary/security/auth-none-allowlist.yml @@ -24,10 +24,6 @@ allowed: method: GET path: /.well-known/openid-credential-issuer reason: Public OID4VCI issuer metadata. - - listener: public - method: GET - path: /oid4vci/credential-offer - reason: Public OID4VCI credential offer endpoint. - listener: public method: GET path: /oid4vci/offer/start @@ -40,10 +36,6 @@ allowed: method: POST path: /oid4vci/token reason: Public OID4VCI pre-authorized-code token endpoint with code, PIN, and route-local replay controls. - - listener: public - method: POST - path: /oid4vci/nonce - reason: Public OID4VCI nonce endpoint. - listener: public method: GET path: /credentials/{*vct_path} diff --git a/products/notary/security/exposure-manifest.json b/products/notary/security/exposure-manifest.json index c0a38570c..c6bfe38b8 100644 --- a/products/notary/security/exposure-manifest.json +++ b/products/notary/security/exposure-manifest.json @@ -275,25 +275,6 @@ "enforcement_tests": [], "waiver": null }, - { - "service": "registry-notary", - "listener": "public", - "method": "GET", - "path": "/oid4vci/credential-offer", - "feature": null, - "audience": "external", - "auth": "none", - "scopes": [], - "rate_limit": null, - "audit": "not_applicable", - "openapi": true, - "stability": "beta", - "data_classification": "credential", - "notes": "Intentionally unauthenticated public route; auth/audit middleware bypass is exact-match allowlisted and route-local controls apply where relevant.", - "source": "manual", - "enforcement_tests": [], - "waiver": null - }, { "service": "registry-notary", "listener": "public", @@ -313,7 +294,7 @@ "enforcement_tests": [ "crates/registry-notary-server/tests/standalone_http/preauth.rs::preauth_offer_start_redirects_to_esignet_and_mints_nothing", "crates/registry-notary-server/tests/standalone_http/preauth.rs::preauth_offer_start_returns_429_when_login_state_store_is_full", - "crates/registry-notary-server/tests/standalone_http/preauth.rs::preauth_disabled_returns_404_and_offer_is_authorization_code" + "crates/registry-notary-server/tests/standalone_http/preauth.rs::preauth_disabled_exposes_no_wallet_issuance_grant" ], "waiver": null }, @@ -331,7 +312,7 @@ "openapi": true, "stability": "beta", "data_classification": "secret-adjacent", - "notes": "Intentionally unauthenticated public OID4VCI pre-authorized-code callback route. Route-local state, nonce, IdP token exchange, subject binding, and hashed-only audit controls apply.", + "notes": "Intentionally unauthenticated public OID4VCI pre-authorized-code callback route. Route-local state, nonce, IdP token exchange, subject binding, and hashed-only audit controls apply. The exact registry-backed Relay evaluation and provenance checks must succeed before any offer is minted.", "source": "manual", "enforcement_tests": [ "crates/registry-notary-server/tests/standalone_http/preauth.rs::preauth_callback_mints_pre_authorized_offer_with_tx_code", @@ -363,28 +344,6 @@ ], "waiver": null }, - { - "service": "registry-notary", - "listener": "public", - "method": "POST", - "path": "/oid4vci/nonce", - "feature": null, - "audience": "external", - "auth": "none", - "scopes": [], - "rate_limit": "subject_access", - "audit": "not_applicable", - "openapi": true, - "stability": "beta", - "data_classification": "credential", - "notes": "Intentionally unauthenticated public route; auth/audit middleware bypass is exact-match allowlisted and route-local controls apply where relevant.", - "source": "manual", - "enforcement_tests": [ - "crates/registry-notary-server/tests/standalone_http/oid4vci.rs::oid4vci_metadata_offer_and_nonce_are_public", - "crates/registry-notary-server/tests/standalone_http/oid4vci.rs::oid4vci_nonce_is_rate_limited_before_reservation" - ], - "waiver": null - }, { "service": "registry-notary", "listener": "public", @@ -399,11 +358,11 @@ "openapi": true, "stability": "beta", "data_classification": "credential", - "notes": "Protected OID4VCI credential endpoint. The proof precheck validates holder proof shape before bearer-token subject-bound auth, and issuance requires the selected credential configuration's exact scope.", + "notes": "Protected OID4VCI credential endpoint. The proof precheck requires an EdDSA did:jwk holder proof before bearer-token auth. Issuance is bound to the immutable registry-backed offer transaction, performs no Relay call, materializes at most one credential, and returns the encrypted cached response for an exact retry.", "source": "manual", "enforcement_tests": [ "crates/registry-notary-server/tests/standalone_http/oid4vci.rs::oid4vci_malformed_proof_is_rejected_before_oidc_auth", - "crates/registry-notary-server/tests/standalone_http/oid4vci.rs::oid4vci_credential_route_issues_holder_bound_sd_jwt" + "crates/registry-notary-server/tests/standalone_http/preauth.rs::preauth_end_to_end_issues_sd_jwt_vc_bound_to_holder" ], "waiver": null }, diff --git a/products/notary/security/route-inventory.json b/products/notary/security/route-inventory.json index 24f7cda3e..2c8e5eec7 100644 --- a/products/notary/security/route-inventory.json +++ b/products/notary/security/route-inventory.json @@ -107,14 +107,6 @@ ], "source": "crates/registry-notary-server/src/api.rs" }, - { - "listener": "public", - "path": "/oid4vci/credential-offer", - "methods": [ - "GET" - ], - "source": "crates/registry-notary-server/src/api.rs" - }, { "listener": "public", "path": "/oid4vci/offer/start", @@ -139,14 +131,6 @@ ], "source": "crates/registry-notary-server/src/api.rs" }, - { - "listener": "public", - "path": "/oid4vci/nonce", - "methods": [ - "POST" - ], - "source": "crates/registry-notary-server/src/api.rs" - }, { "listener": "public", "path": "/oid4vci/credential", diff --git a/schemas/registry-notary.config.schema.json b/schemas/registry-notary.config.schema.json index 070d9c177..e7705e411 100644 --- a/schemas/registry-notary.config.schema.json +++ b/schemas/registry-notary.config.schema.json @@ -1637,7 +1637,7 @@ "required": true } }, - "description": "Pre-authorized-code flow settings. Disabled by default; offers fall\nback to the `authorization_code` grant unless this is enabled." + "description": "Issuer-initiated pre-authorized-code flow settings. This is the only\nwallet-facing issuance grant in the 1.0 profile." }, "proof": { "$ref": "#/$defs/Oid4vciProofConfig", From ca1acb50aed5f4b1263fbade31661d22235d9816 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 18:22:07 +0700 Subject: [PATCH 31/65] fix(notary): preserve audit on OID4VCI retries Signed-off-by: Jeremi Joslin --- .../src/api/oid4vci/credential.rs | 70 ++- .../src/api/tests/oid4vci.rs | 469 +++++++++--------- .../src/api/tests/support.rs | 115 ++++- .../src/standalone/preauth.rs | 76 +++ .../tests/standalone_http/federation.rs | 65 ++- .../tests/standalone_http/oid4vci.rs | 275 +++------- .../tests/standalone_http/preauth.rs | 103 +++- .../tests/standalone_http/preauth_support.rs | 37 -- .../tests/standalone_http/support.rs | 21 + 9 files changed, 692 insertions(+), 539 deletions(-) diff --git a/crates/registry-notary-server/src/api/oid4vci/credential.rs b/crates/registry-notary-server/src/api/oid4vci/credential.rs index 5953c0b55..ef68aff9a 100644 --- a/crates/registry-notary-server/src/api/oid4vci/credential.rs +++ b/crates/registry-notary-server/src/api/oid4vci/credential.rs @@ -143,10 +143,41 @@ pub(in crate::api) async fn oid4vci_credential( .await { Ok(CredentialMaterialization::Cached(response)) => { + let transaction = match preauth + .preauthorization_state() + .transaction(transaction_id) + .await + { + Ok(Some(transaction)) => transaction, + _ => return oid4vci_error_response(Oid4vciWireError::ServerError), + }; + let evaluation = match state + .store + .get( + &transaction.evaluation_id, + &transaction.evaluation_client_id, + ) + .await + { + Ok(Some(evaluation)) => evaluation, + _ => return oid4vci_error_response(Oid4vciWireError::ServerError), + }; + let response = match oid4vci_credential_response_with_audit( + &state, + evidence, + configuration_id, + configuration, + &transaction, + &evaluation, + response, + ) { + Ok(response) => response, + Err(error) => return oid4vci_error_response(error), + }; state .metrics .record_credential("openid4vci", "retry_cached"); - return Json(response).into_response(); + return response; } Ok(CredentialMaterialization::Acquired(transaction)) => transaction, Ok(CredentialMaterialization::Busy) => { @@ -195,15 +226,36 @@ pub(in crate::api) async fn oid4vci_credential( .await; return oid4vci_error_response(Oid4vciWireError::ServerError); } - let profile = match evidence + let response = match oid4vci_credential_response_with_audit( + &state, + evidence, + configuration_id, + configuration, + &transaction, + &evaluation, + response_body, + ) { + Ok(response) => response, + Err(error) => return oid4vci_error_response(error), + }; + state.metrics.record_credential("openid4vci", "issued"); + response +} + +pub(in crate::api) fn oid4vci_credential_response_with_audit( + state: &RegistryNotaryApiState, + evidence: &EvidenceConfig, + configuration_id: &str, + configuration: &Oid4vciCredentialConfigurationConfig, + transaction: &IssuanceTransaction, + evaluation: ®istry_notary_core::StoredEvaluation, + response_body: Value, +) -> Result { + let profile = evidence .credential_profiles .get(&configuration.credential_profile) - { - Some(profile) => profile, - None => return oid4vci_error_response(Oid4vciWireError::ServerError), - }; + .ok_or(Oid4vciWireError::ServerError)?; let mut response = Json(response_body).into_response(); - state.metrics.record_credential("openid4vci", "issued"); if attach_subject_access_credential_audit( &mut response, &state.subject_access_rate_keys, @@ -225,9 +277,9 @@ pub(in crate::api) async fn oid4vci_credential( ) .is_err() { - return oid4vci_error_response(Oid4vciWireError::ServerError); + return Err(Oid4vciWireError::ServerError); } - response + Ok(response) } pub(in crate::api) async fn materialize_oid4vci_transaction( diff --git a/crates/registry-notary-server/src/api/tests/oid4vci.rs b/crates/registry-notary-server/src/api/tests/oid4vci.rs index dec76dc7a..b29134728 100644 --- a/crates/registry-notary-server/src/api/tests/oid4vci.rs +++ b/crates/registry-notary-server/src/api/tests/oid4vci.rs @@ -120,10 +120,7 @@ fn oid4vci_metadata_is_public_but_not_operationally_leaky() { metadata["credential_endpoint"], "http://127.0.0.1:4325/oid4vci/credential" ); - assert_eq!( - metadata["nonce_endpoint"], - "http://127.0.0.1:4325/oid4vci/nonce" - ); + assert!(metadata.get("nonce_endpoint").is_none()); assert_eq!( metadata["credential_configurations_supported"]["person_is_alive_sd_jwt"]["display"][0] ["name"], @@ -169,12 +166,6 @@ fn oid4vci_metadata_is_public_but_not_operationally_leaky() { ["proof_types_supported"]["jwt"]["proof_signing_alg_values_supported"][0], "EdDSA" ); - let mut without_nonce = oid4vci_config(); - without_nonce.nonce.enabled = false; - let without_nonce = - serde_json::to_value(oid4vci_metadata(&without_nonce, &evidence).expect("metadata builds")) - .expect("metadata serializes"); - assert!(without_nonce.get("nonce_endpoint").is_none()); let text = metadata.to_string(); assert!(!text.contains("token_env")); assert!(!text.contains("source_connections")); @@ -212,14 +203,19 @@ async fn oid4vci_credential_rejects_delegated_transaction_token() { let store = Arc::new(EvidenceStore::default()); let mut oid4vci = oid4vci_config(); oid4vci.accepted_token_audiences = vec!["registry-notary-citizen".to_string()]; - let state = Arc::new(RegistryNotaryApiState::new_with_subject_access_and_oid4vci( - Arc::new(oid4vci_evidence_config()), - Arc::new(delegated_subject_access_config()), - Arc::new(oid4vci), - AuditKeyHasher::unkeyed_dev_only(), - Arc::clone(&store), - Arc::new(TestIssuerResolver), - )); + let state = Arc::new( + RegistryNotaryApiState::new_with_subject_access_and_oid4vci( + Arc::new(oid4vci_evidence_config()), + Arc::new(delegated_subject_access_config()), + Arc::new(oid4vci), + AuditKeyHasher::unkeyed_dev_only(), + Arc::clone(&store), + Arc::new(TestIssuerResolver), + ) + .with_preauth_runtime(Some(oid4vci_test_preauth_runtime( + registry_notary_core::tokens::NOTARY_ACCESS_TOKEN_JWT_TYP, + ))), + ); let mut principal = fresh_oidc_principal(Some("client_id:citizen-portal"), &["subject_access"]); principal.authorization_details = Some(delegated_authorization_details(&delegated_evidence_config())); @@ -256,7 +252,7 @@ async fn oid4vci_credential_rejects_delegated_transaction_token() { } #[tokio::test] -async fn oid4vci_source_free_bypass_denies_before_nonce_or_signer_access() { +async fn oid4vci_source_free_bypass_denies_before_offer_or_signer_access() { let store = Arc::new(EvidenceStore::default()); let evidence = Arc::new(oid4vci_evidence_config()); assert!(evidence.claims[0].evidence_mode.is_self_attested()); @@ -265,60 +261,49 @@ async fn oid4vci_source_free_bypass_denies_before_nonce_or_signer_access() { oid4vci.accepted_token_audiences = vec!["registry-notary-citizen".to_string()]; let oid4vci = Arc::new(oid4vci); let sign_count = Arc::new(AtomicUsize::new(0)); - let state = Arc::new(RegistryNotaryApiState::new_with_subject_access_and_oid4vci( - Arc::clone(&evidence), - Arc::clone(&subject_access), - Arc::clone(&oid4vci), - AuditKeyHasher::unkeyed_dev_only(), - Arc::clone(&store), - Arc::new(CountingIssuerResolver { - sign_count: Arc::clone(&sign_count), - }), - )); - let nonce = "source-free-bypass-nonce"; - let (nonce_scope, nonce_key) = - reserve_oid4vci_test_nonce(&state, "person_is_alive_sd_jwt", nonce).await; - let proof = sign_oid4vci_proof(&state.oid4vci.credential_issuer, nonce); - - let response = oid4vci_credential( - Some(Extension(Arc::clone(&state))), - Some(Extension(oid4vci_authorized_principal( - &evidence, - &subject_access, - &oid4vci, - "person_is_alive_sd_jwt", - &["subject_access", "person_is_alive"], - ))), - Some(Extension(validated_oid4vci_proof( - &state, - &proof, - Some(nonce), - ))), - Json(Oid4vciCredentialRequest { - format: SD_JWT_VC_FORMAT.to_string(), - credential_identifier: Some("person_is_alive_sd_jwt".to_string()), - credential_configuration_id: None, - vct: None, - proof: registry_platform_oid4vci::CredentialRequestProof { - proof_type: PROOF_TYPE_JWT.to_string(), - jwt: proof, - }, - proofs: registry_platform_oid4vci::CredentialRequestProofs::default(), - }), + let preauth = + oid4vci_test_preauth_runtime(registry_notary_core::tokens::NOTARY_ACCESS_TOKEN_JWT_TYP); + let state = Arc::new( + RegistryNotaryApiState::new_with_subject_access_and_oid4vci( + Arc::clone(&evidence), + Arc::clone(&subject_access), + Arc::clone(&oid4vci), + oid4vci_test_audit_hasher(), + Arc::clone(&store), + Arc::new(CountingIssuerResolver { + sign_count: Arc::clone(&sign_count), + }), + ) + .with_preauth_runtime(Some(Arc::clone(&preauth))), + ); + let now = OffsetDateTime::now_utc().unix_timestamp(); + let transaction_id = ulid::Ulid::new().to_string(); + let err = prepare_registry_backed_issuance_transaction( + &state, + &preauth, + &BoundSubject { + subject: "citizen-subject".to_string(), + subject_binding_claim: SUBJECT_BINDING_CLAIM.to_string(), + subject_binding_value: "NAT-123".to_string(), + client_id: "citizen-portal".to_string(), + scopes: vec!["subject_access".to_string()], + acr: Some("urn:example:loa:substantial".to_string()), + auth_time: Some(now), + }, + "person_is_alive_sd_jwt", + &transaction_id, ) - .await; + .await + .expect_err("source-free configuration is credential-ineligible"); - assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(matches!(err, EvidenceError::EvaluationBindingMismatch)); assert_eq!(sign_count.load(Ordering::SeqCst), 0); - assert!(matches!( - state - .replay - .nonce_store() - .consume_nonce(&nonce_scope, &nonce_key) - .await - .expect("nonce store is available"), - ReplayInsertOutcome::Inserted - )); + assert!(preauth + .preauthorization_state() + .transaction(&transaction_id) + .await + .expect("transaction state is available") + .is_none()); } #[tokio::test] @@ -346,14 +331,19 @@ async fn oid4vci_credential_scope_prevents_cross_configuration_issuance_before_n &["subject_access", "person_is_alive"], ); let oid4vci = Arc::new(oid4vci); - let state = Arc::new(RegistryNotaryApiState::new_with_subject_access_and_oid4vci( - Arc::clone(&evidence), - Arc::clone(&subject_access), - Arc::clone(&oid4vci), - AuditKeyHasher::unkeyed_dev_only(), - Arc::clone(&store), - Arc::new(TestIssuerResolver), - )); + let state = Arc::new( + RegistryNotaryApiState::new_with_subject_access_and_oid4vci( + Arc::clone(&evidence), + Arc::clone(&subject_access), + Arc::clone(&oid4vci), + AuditKeyHasher::unkeyed_dev_only(), + Arc::clone(&store), + Arc::new(TestIssuerResolver), + ) + .with_preauth_runtime(Some(oid4vci_test_preauth_runtime( + registry_notary_core::tokens::NOTARY_ACCESS_TOKEN_JWT_TYP, + ))), + ); let nonce = "cross-configuration-nonce"; let (nonce_scope, nonce_key) = reserve_oid4vci_test_nonce(&state, "date_of_birth_sd_jwt", nonce).await; @@ -406,14 +396,19 @@ async fn oid4vci_credential_requires_authorization_details_before_nonce_consume( let mut oid4vci = oid4vci_config(); oid4vci.accepted_token_audiences = vec!["registry-notary-citizen".to_string()]; let oid4vci = Arc::new(oid4vci); - let state = Arc::new(RegistryNotaryApiState::new_with_subject_access_and_oid4vci( - Arc::clone(&evidence), - Arc::clone(&subject_access), - Arc::clone(&oid4vci), - AuditKeyHasher::unkeyed_dev_only(), - Arc::clone(&store), - Arc::new(TestIssuerResolver), - )); + let state = Arc::new( + RegistryNotaryApiState::new_with_subject_access_and_oid4vci( + Arc::clone(&evidence), + Arc::clone(&subject_access), + Arc::clone(&oid4vci), + AuditKeyHasher::unkeyed_dev_only(), + Arc::clone(&store), + Arc::new(TestIssuerResolver), + ) + .with_preauth_runtime(Some(oid4vci_test_preauth_runtime( + registry_notary_core::tokens::NOTARY_ACCESS_TOKEN_JWT_TYP, + ))), + ); let nonce = "missing-authz-nonce"; let (nonce_scope, nonce_key) = reserve_oid4vci_test_nonce(&state, "person_is_alive_sd_jwt", nonce).await; @@ -487,7 +482,10 @@ async fn oid4vci_credential_requires_custom_notary_typ_details_before_nonce_cons Arc::clone(&store), Arc::new(TestIssuerResolver), ) - .with_runtime_config(runtime_config), + .with_runtime_config(runtime_config) + .with_preauth_runtime(Some(oid4vci_test_preauth_runtime( + "custom-notary-access+jwt", + ))), ); let nonce = "custom-typ-missing-authz-nonce"; let (nonce_scope, nonce_key) = @@ -693,7 +691,7 @@ async fn oid4vci_token_error_fails_closed_when_denial_audit_fails() { #[cfg(feature = "registry-notary-cel")] #[tokio::test] -async fn oid4vci_projected_registry_credential_issues_and_rejects_nonce_replay() { +async fn oid4vci_projected_registry_credential_issues_and_caches_exact_retry() { let store = Arc::new(EvidenceStore::default()); let mut subject_access = subject_access_config(); subject_access @@ -743,14 +741,22 @@ async fn oid4vci_projected_registry_credential_issues_and_rejects_nonce_replay() .credential_claim_ids(), ) .expect("positive fixture has registry-backed credential roots and dependency"); - let state = Arc::new(RegistryNotaryApiState::new_with_subject_access_and_oid4vci( - Arc::clone(&evidence), - Arc::clone(&subject_access), - Arc::clone(&oid4vci), - AuditKeyHasher::unkeyed_dev_only(), - Arc::clone(&store), - Arc::new(StaticIssuerResolver), - )); + let sign_count = Arc::new(AtomicUsize::new(0)); + let preauth = + oid4vci_test_preauth_runtime(registry_notary_core::tokens::NOTARY_ACCESS_TOKEN_JWT_TYP); + let state = Arc::new( + RegistryNotaryApiState::new_with_subject_access_and_oid4vci( + Arc::clone(&evidence), + Arc::clone(&subject_access), + Arc::clone(&oid4vci), + oid4vci_test_audit_hasher(), + Arc::clone(&store), + Arc::new(CountingIssuerResolver { + sign_count: Arc::clone(&sign_count), + }), + ) + .with_preauth_runtime(Some(Arc::clone(&preauth))), + ); let relay = Arc::new(RegistryCredentialRelay::default()); state .install_activated_relay(relay.clone()) @@ -783,16 +789,20 @@ async fn oid4vci_projected_registry_credential_issues_and_rejects_nonce_replay() serde_json::from_slice(&missing_nonce_body).expect("error body parses"); assert_eq!(missing_nonce_body["error"], "invalid_proof"); + let nonce = "nonce-1"; + let test_transaction = reserve_registry_backed_oid4vci_test_transaction( + &state, + &preauth, + "person_is_alive_sd_jwt", + nonce, + ) + .await; + assert_eq!(relay.calls.load(Ordering::SeqCst), 2); + let proof_without_nonce = sign_oid4vci_proof_without_nonce(&state.oid4vci.credential_issuer); let missing_validated_nonce = oid4vci_credential( Some(Extension(Arc::clone(&state))), - Some(Extension(oid4vci_authorized_principal( - &evidence, - &subject_access, - &oid4vci, - "person_is_alive_sd_jwt", - &["subject_access", "person_is_alive"], - ))), + Some(Extension(test_transaction.principal.clone())), Some(Extension(validated_oid4vci_proof( &state, &proof_without_nonce, @@ -820,28 +830,6 @@ async fn oid4vci_projected_registry_credential_issues_and_rejects_nonce_replay() serde_json::from_slice(&missing_validated_nonce_body).expect("error body parses"); assert_eq!(missing_validated_nonce_body["error"], "invalid_proof"); - let nonce = "nonce-1"; - let nonce_key = state - .subject_access_rate_keys - .oid4vci_nonce( - &state.oid4vci.credential_issuer, - "person_is_alive_sd_jwt", - nonce, - ) - .expect("nonce hashes"); - let nonce_scope = - oid4vci_nonce_replay_scope(&state, "person_is_alive_sd_jwt").expect("nonce scope"); - let nonce_key = ReplayKey::new(nonce_key).expect("nonce replay key"); - state - .replay - .nonce_store() - .reserve_nonce( - &nonce_scope, - &nonce_key, - OffsetDateTime::now_utc() + time::Duration::seconds(60), - ) - .await - .expect("nonce reserves"); let proof = sign_oid4vci_proof(&state.oid4vci.credential_issuer, nonce); let request = Oid4vciCredentialRequest { format: SD_JWT_VC_FORMAT.to_string(), @@ -855,22 +843,9 @@ async fn oid4vci_projected_registry_credential_issues_and_rejects_nonce_replay() proofs: registry_platform_oid4vci::CredentialRequestProofs::default(), }; let validated_proof = validated_oid4vci_proof(&state, &proof, Some(nonce)); - let authorized_principal = oid4vci_authorized_principal( - &evidence, - &subject_access, - &oid4vci, - "person_is_alive_sd_jwt", - &["subject_access", "person_is_alive"], - ); - let classified_principal = - classify_subject_access_principal(&subject_access, &authorized_principal) - .expect("OIDC principal classifies as subject access"); - let stored_client_id = - stored_evaluation_client_id(&state, &classified_principal).expect("stored owner resolves"); - let response = oid4vci_credential( Some(Extension(Arc::clone(&state))), - Some(Extension(authorized_principal.clone())), + Some(Extension(test_transaction.principal.clone())), Some(Extension(validated_proof.clone())), Json(request.clone()), ) @@ -899,15 +874,11 @@ async fn oid4vci_projected_registry_credential_issues_and_rejects_nonce_replay() .is_some_and(|credential| credential.contains('~')), "expected compact SD-JWT credential: {body}" ); - let evaluation_id = relay - .evaluation_ids - .lock() - .expect("evaluation id lock is not poisoned") - .first() - .cloned() - .expect("Relay observed the projected evaluation id"); let stored = store - .get(&evaluation_id, &stored_client_id) + .get( + &test_transaction.transaction.evaluation_id, + &test_transaction.transaction.evaluation_client_id, + ) .await .expect("stored evaluation read succeeds") .expect("projected registry evaluation is stored"); @@ -957,19 +928,32 @@ async fn oid4vci_projected_registry_credential_issues_and_rejects_nonce_replay() .iter() .all(|result| { result.provenance.used.relay_consultation_count == 2 })); - let replay = oid4vci_credential( + assert_eq!(sign_count.load(Ordering::SeqCst), 1); + assert!(matches!( + state + .replay + .nonce_store() + .consume_nonce(&test_transaction.nonce_scope, &test_transaction.nonce_key,) + .await + .expect("nonce store is available"), + ReplayInsertOutcome::AlreadySeen + )); + + let retry = oid4vci_credential( Some(Extension(Arc::clone(&state))), - Some(Extension(authorized_principal)), + Some(Extension(test_transaction.principal)), Some(Extension(validated_proof)), Json(request), ) .await; - assert_eq!(replay.status(), StatusCode::BAD_REQUEST); - let replay_body = axum::body::to_bytes(replay.into_body(), usize::MAX) + assert_eq!(retry.status(), StatusCode::OK); + let retry_body = axum::body::to_bytes(retry.into_body(), usize::MAX) .await .expect("body reads"); - let replay_body: Value = serde_json::from_slice(&replay_body).expect("error body parses"); - assert_eq!(replay_body["error"], "invalid_proof"); + let retry_body: Value = serde_json::from_slice(&retry_body).expect("credential body parses"); + assert_eq!(retry_body, body); + assert_eq!(relay.calls.load(Ordering::SeqCst), 2); + assert_eq!(sign_count.load(Ordering::SeqCst), 1); } #[cfg(feature = "registry-notary-cel")] @@ -988,49 +972,47 @@ async fn oid4vci_rejects_tampered_dependency_catalog_before_signing() { let evidence = Arc::new(evidence); let mut oid4vci = oid4vci_config(); oid4vci.accepted_token_audiences = vec!["registry-notary-citizen".to_string()]; - oid4vci.nonce.enabled = false; let oid4vci = Arc::new(oid4vci); let sign_count = Arc::new(AtomicUsize::new(0)); - let state = Arc::new(RegistryNotaryApiState::new_with_subject_access_and_oid4vci( - Arc::clone(&evidence), - Arc::clone(&subject_access), - Arc::clone(&oid4vci), - AuditKeyHasher::unkeyed_dev_only(), - store, - Arc::new(CountingIssuerResolver { - sign_count: Arc::clone(&sign_count), - }), - )); + let preauth = + oid4vci_test_preauth_runtime(registry_notary_core::tokens::NOTARY_ACCESS_TOKEN_JWT_TYP); + let state = Arc::new( + RegistryNotaryApiState::new_with_subject_access_and_oid4vci( + Arc::clone(&evidence), + Arc::clone(&subject_access), + Arc::clone(&oid4vci), + AuditKeyHasher::unkeyed_dev_only(), + store, + Arc::new(CountingIssuerResolver { + sign_count: Arc::clone(&sign_count), + }), + ) + .with_preauth_runtime(Some(Arc::clone(&preauth))), + ); let relay = Arc::new(RegistryCredentialRelay::default()); state .install_activated_relay(relay.clone()) .expect("registry credential Relay activates once"); - let proof = sign_oid4vci_proof_without_nonce(&state.oid4vci.credential_issuer); - let response = oid4vci_credential( - Some(Extension(Arc::clone(&state))), - Some(Extension(oid4vci_authorized_principal( - &evidence, - &subject_access, - &oid4vci, - "person_is_alive_sd_jwt", - &["subject_access", "person_is_alive"], - ))), - Some(Extension(validated_oid4vci_proof(&state, &proof, None))), - Json(Oid4vciCredentialRequest { - format: SD_JWT_VC_FORMAT.to_string(), - credential_identifier: Some("person_is_alive_sd_jwt".to_string()), - credential_configuration_id: None, - vct: None, - proof: registry_platform_oid4vci::CredentialRequestProof { - proof_type: PROOF_TYPE_JWT.to_string(), - jwt: proof, - }, - proofs: registry_platform_oid4vci::CredentialRequestProofs::default(), - }), + let now = OffsetDateTime::now_utc().unix_timestamp(); + let err = prepare_registry_backed_issuance_transaction( + &state, + &preauth, + &BoundSubject { + subject: "citizen-subject".to_string(), + subject_binding_claim: SUBJECT_BINDING_CLAIM.to_string(), + subject_binding_value: "NAT-123".to_string(), + client_id: "citizen-portal".to_string(), + scopes: vec!["subject_access".to_string()], + acr: Some("urn:example:loa:substantial".to_string()), + auth_time: Some(now), + }, + "person_is_alive_sd_jwt", + &ulid::Ulid::new().to_string(), ) - .await; + .await + .expect_err("duplicate dependency catalog is rejected before an offer"); - assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(matches!(err, EvidenceError::EvaluationBindingMismatch)); assert_eq!(relay.calls.load(Ordering::SeqCst), 0); assert_eq!(sign_count.load(Ordering::SeqCst), 0); } @@ -1044,23 +1026,39 @@ async fn oid4vci_dependency_execution_tampering_is_denied_before_signing() { let evidence = Arc::new(registry_backed_oid4vci_evidence_with_dependency()); let mut oid4vci = oid4vci_config(); oid4vci.accepted_token_audiences = vec!["registry-notary-citizen".to_string()]; - oid4vci.nonce.enabled = false; let oid4vci = Arc::new(oid4vci); let sign_count = Arc::new(AtomicUsize::new(0)); - let state = Arc::new(RegistryNotaryApiState::new_with_subject_access_and_oid4vci( - Arc::clone(&evidence), - Arc::clone(&subject_access), - Arc::clone(&oid4vci), - AuditKeyHasher::unkeyed_dev_only(), - Arc::clone(&store), - Arc::new(CountingIssuerResolver { - sign_count: Arc::clone(&sign_count), - }), - )); + let preauth = + oid4vci_test_preauth_runtime(registry_notary_core::tokens::NOTARY_ACCESS_TOKEN_JWT_TYP); + let state = Arc::new( + RegistryNotaryApiState::new_with_subject_access_and_oid4vci( + Arc::clone(&evidence), + Arc::clone(&subject_access), + Arc::clone(&oid4vci), + oid4vci_test_audit_hasher(), + Arc::clone(&store), + Arc::new(CountingIssuerResolver { + sign_count: Arc::clone(&sign_count), + }), + ) + .with_preauth_runtime(Some(Arc::clone(&preauth))), + ); let relay = Arc::new(RegistryCredentialRelay::default()); state .install_activated_relay(relay.clone()) .expect("registry credential Relay activates once"); + let nonce = if tamper_acquired_at { + "tampered-acquired-at-nonce" + } else { + "tampered-consultation-binding-nonce" + }; + let test_transaction = reserve_registry_backed_oid4vci_test_transaction( + &state, + &preauth, + "person_is_alive_sd_jwt", + nonce, + ) + .await; store.tamper_next_read(move |evaluation| { let issuance = evaluation .issuance_provenance @@ -1097,17 +1095,15 @@ async fn oid4vci_dependency_execution_tampering_is_denied_before_signing() { issuance.claims[root_index].consultation_id = dependency_id; } }); - let proof = sign_oid4vci_proof_without_nonce(&state.oid4vci.credential_issuer); + let proof = sign_oid4vci_proof(&state.oid4vci.credential_issuer, nonce); let response = oid4vci_credential( Some(Extension(Arc::clone(&state))), - Some(Extension(oid4vci_authorized_principal( - &evidence, - &subject_access, - &oid4vci, - "person_is_alive_sd_jwt", - &["subject_access", "person_is_alive"], + Some(Extension(test_transaction.principal)), + Some(Extension(validated_oid4vci_proof( + &state, + &proof, + Some(nonce), ))), - Some(Extension(validated_oid4vci_proof(&state, &proof, None))), Json(Oid4vciCredentialRequest { format: SD_JWT_VC_FORMAT.to_string(), credential_identifier: Some("person_is_alive_sd_jwt".to_string()), @@ -1138,51 +1134,36 @@ async fn oid4vci_rejects_holder_key_equal_to_issuer_key_after_registry_evaluatio let evidence = Arc::new(evidence); let subject_access = Arc::new(subject_access); let oid4vci = Arc::new(oid4vci); - let state = Arc::new(RegistryNotaryApiState::new_with_subject_access_and_oid4vci( - Arc::clone(&evidence), - Arc::clone(&subject_access), - Arc::clone(&oid4vci), - AuditKeyHasher::unkeyed_dev_only(), - Arc::clone(&store), - Arc::new(HolderIssuerResolver), - )); + let preauth = + oid4vci_test_preauth_runtime(registry_notary_core::tokens::NOTARY_ACCESS_TOKEN_JWT_TYP); + let state = Arc::new( + RegistryNotaryApiState::new_with_subject_access_and_oid4vci( + Arc::clone(&evidence), + Arc::clone(&subject_access), + Arc::clone(&oid4vci), + oid4vci_test_audit_hasher(), + Arc::clone(&store), + Arc::new(HolderIssuerResolver), + ) + .with_preauth_runtime(Some(Arc::clone(&preauth))), + ); let relay = Arc::new(RegistryCredentialRelay::default()); state .install_activated_relay(relay.clone()) .expect("registry credential Relay activates once"); let nonce = "nonce-equal-key"; - let nonce_key = state - .subject_access_rate_keys - .oid4vci_nonce( - &state.oid4vci.credential_issuer, - "person_is_alive_sd_jwt", - nonce, - ) - .expect("nonce hashes"); - let nonce_scope = - oid4vci_nonce_replay_scope(&state, "person_is_alive_sd_jwt").expect("nonce scope"); - let nonce_key = ReplayKey::new(nonce_key).expect("nonce replay key"); - state - .replay - .nonce_store() - .reserve_nonce( - &nonce_scope, - &nonce_key, - OffsetDateTime::now_utc() + time::Duration::seconds(60), - ) - .await - .expect("nonce reserves"); + let test_transaction = reserve_registry_backed_oid4vci_test_transaction( + &state, + &preauth, + "person_is_alive_sd_jwt", + nonce, + ) + .await; let proof = sign_oid4vci_proof(&state.oid4vci.credential_issuer, nonce); let response = oid4vci_credential( Some(Extension(Arc::clone(&state))), - Some(Extension(oid4vci_authorized_principal( - &evidence, - &subject_access, - &oid4vci, - "person_is_alive_sd_jwt", - &["subject_access", "person_is_alive"], - ))), + Some(Extension(test_transaction.principal)), Some(Extension(validated_oid4vci_proof( &state, &proof, @@ -1218,7 +1199,7 @@ async fn oid4vci_rejects_holder_key_equal_to_issuer_key_after_registry_evaluatio state .replay .nonce_store() - .consume_nonce(&nonce_scope, &nonce_key) + .consume_nonce(&test_transaction.nonce_scope, &test_transaction.nonce_key,) .await .expect("nonce store is available"), ReplayInsertOutcome::AlreadySeen diff --git a/crates/registry-notary-server/src/api/tests/support.rs b/crates/registry-notary-server/src/api/tests/support.rs index 79c3312db..bc01a8c75 100644 --- a/crates/registry-notary-server/src/api/tests/support.rs +++ b/crates/registry-notary-server/src/api/tests/support.rs @@ -310,8 +310,6 @@ "authorization_servers": ["http://localhost:8088/v1/esignet"], "accepted_token_audiences": ["http://127.0.0.1:4325"], "credential_endpoint": "http://127.0.0.1:4325/oid4vci/credential", - "offer_endpoint": "http://127.0.0.1:4325/oid4vci/credential-offer", - "nonce_endpoint": "http://127.0.0.1:4325/oid4vci/nonce", "nonce": { "enabled": true, "ttl_seconds": 300 }, "display": [{ "name": "Civil Registry Notary", @@ -552,6 +550,96 @@ principal } + fn oid4vci_test_preauth_runtime(access_token_typ: &str) -> Arc { + Arc::new(PreAuthRuntime::for_api_tests(access_token_typ)) + } + + fn oid4vci_test_audit_hasher() -> AuditKeyHasher { + const ENV: &str = "TEST_OID4VCI_AUDIT_HASH_SECRET"; + std::env::set_var(ENV, "0123456789abcdef0123456789abcdef"); + AuditKeyHasher::from_env(ENV).expect("OID4VCI test audit hasher loads") + } + + struct Oid4vciTestTransaction { + principal: EvidencePrincipal, + transaction: IssuanceTransaction, + nonce_scope: ReplayScope, + nonce_key: ReplayKey, + } + + async fn reserve_registry_backed_oid4vci_test_transaction( + state: &RegistryNotaryApiState, + preauth: &PreAuthRuntime, + configuration_id: &str, + nonce: &str, + ) -> Oid4vciTestTransaction { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let transaction_id = ulid::Ulid::new().to_string(); + let bound_subject = BoundSubject { + subject: "citizen-subject".to_string(), + subject_binding_claim: SUBJECT_BINDING_CLAIM.to_string(), + subject_binding_value: "NAT-123".to_string(), + client_id: "citizen-portal".to_string(), + scopes: vec!["subject_access".to_string()], + acr: Some("urn:example:loa:substantial".to_string()), + auth_time: Some(now), + }; + let transaction = prepare_registry_backed_issuance_transaction( + state, + preauth, + &bound_subject, + configuration_id, + &transaction_id, + ) + .await + .expect("registry-backed issuance transaction prepares"); + preauth + .preauthorization_state() + .reserve_issuance_transaction( + &transaction_id, + transaction.clone(), + OffsetDateTime::now_utc() + time::Duration::minutes(10), + ) + .await + .expect("issuance transaction reserves"); + assert!(preauth + .preauthorization_state() + .bind_transaction_nonce(&transaction_id, &transaction.commitment, nonce.to_string()) + .await + .expect("transaction nonce binds")); + let (nonce_scope, nonce_key) = + reserve_oid4vci_test_nonce(state, configuration_id, nonce).await; + + let mut principal = oid4vci_authorized_principal( + &state.evidence, + &state.subject_access, + &state.oid4vci, + configuration_id, + &["subject_access", state.oid4vci.credential_configurations[configuration_id].scope.as_str()], + ); + let claims = principal + .verified_claims + .as_mut() + .expect("test principal has claims"); + claims.issuer = bounded(preauth.notary_issuer()); + claims.audiences = preauth + .notary_audiences() + .iter() + .map(|audience| bounded(audience)) + .collect(); + claims.token_type = Some(bounded(preauth.access_token_typ())); + claims.credential_configuration_id = Some(bounded(configuration_id)); + claims.issuance_transaction_id = Some(bounded(&transaction_id)); + claims.issuance_transaction_commitment = Some(bounded(&transaction.commitment)); + + Oid4vciTestTransaction { + principal, + transaction, + nonce_scope, + nonce_key, + } + } + async fn reserve_oid4vci_test_nonce( state: &RegistryNotaryApiState, configuration_id: &str, @@ -712,29 +800,6 @@ } } - #[cfg(feature = "registry-notary-cel")] - struct StaticIssuerResolver; - - #[cfg(feature = "registry-notary-cel")] - impl EvidenceIssuerResolver for StaticIssuerResolver { - fn issuer( - &self, - _profile_id: &str, - ) -> Result { - registry_notary_core::sd_jwt::EvidenceIssuer::from_jwk_str( - &json!({ - "kty": "OKP", - "crv": "Ed25519", - "d": ISSUER_PRIV_D_B64, - "x": ISSUER_PUB_X_B64, - "alg": "EdDSA" - }) - .to_string(), - "did:web:issuer.example#key-1".to_string(), - ) - } - } - struct HolderIssuerResolver; impl EvidenceIssuerResolver for HolderIssuerResolver { diff --git a/crates/registry-notary-server/src/standalone/preauth.rs b/crates/registry-notary-server/src/standalone/preauth.rs index b6eb713c6..80082ed09 100644 --- a/crates/registry-notary-server/src/standalone/preauth.rs +++ b/crates/registry-notary-server/src/standalone/preauth.rs @@ -257,6 +257,82 @@ impl PreAuthRuntime { })) } + #[cfg(test)] + pub(crate) fn for_api_tests(access_token_typ: &str) -> Self { + let signer = Arc::new( + LocalJwkSigner::new( + PrivateJwk::parse( + &json!({ + "kty": "OKP", + "crv": "Ed25519", + "d": "2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw", + "x": "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc", + "alg": "EdDSA", + "kid": "did:web:notary.example#access" + }) + .to_string(), + ) + .expect("test access-token JWK parses"), + ) + .expect("test access-token signer builds"), + ); + let public_jwk = signer.public_jwk(); + let fetch_url_policy = FetchUrlPolicy::strict(); + let esignet_issuer = "https://id.example.gov".to_string(); + let esignet_client_id = "citizen-portal".to_string(); + let esignet_id_token_verifier = Arc::new(TokenVerifier::new( + esignet_token_verifier_config(&esignet_issuer, &esignet_client_id), + Arc::new(JwksFetcher::new_with_fetch_url_policy( + "https://id.example.gov/jwks".to_string(), + JwksFetcherConfig::defaults(), + fetch_url_policy.clone(), + )), + )); + let state_plane = Arc::new( + NotaryStatePlaneHandle::from_config( + ®istry_notary_core::StateConfig { + storage: registry_notary_core::STATE_STORAGE_IN_MEMORY.to_string(), + ..registry_notary_core::StateConfig::default() + }, + true, + ) + .expect("test in-memory state plane builds"), + ); + + Self { + access_token_signer: signer.clone(), + access_token_verification_keys: vec![AccessTokenVerificationKey { + public_jwk, + publish_until_unix_seconds: None, + }], + notary_issuer: "http://127.0.0.1:4325".to_string(), + notary_audiences: vec!["registry-notary-citizen".to_string()], + access_token_typ: access_token_typ.to_string(), + pre_authorized_code_ttl_seconds: 300, + access_token_ttl_seconds: 300, + tx_code_required: true, + tx_code_length: 6, + esignet_client_id, + esignet_client_signer: signer, + esignet_authorize_url: "https://id.example.gov/authorize".to_string(), + esignet_token_url: "https://id.example.gov/token".to_string(), + esignet_redirect_uri: "http://127.0.0.1:4325/oid4vci/offer/callback".to_string(), + esignet_scopes: vec!["openid".to_string()], + esignet_issuer, + esignet_userinfo_url: None, + subject_binding_claim_source: SubjectAccessClaimSource::AccessToken, + subject_binding_claim: "https://id.example.gov/claims/national_id".to_string(), + esignet_id_token_verifier, + fetch_url_policy, + login_state_ttl_seconds: 300, + preauthorization_state: Arc::new( + crate::preauth_state::PreauthorizationState::from_state_plane(state_plane) + .expect("test preauthorization state builds"), + ), + audit: AuditPipeline::for_sink_dev_only(Arc::new(JsonlStdoutSink::new())), + } + } + /// Emit a hashed pre-auth audit event. Returns an error if emission fails so /// callers fail closed rather than silently dropping the audit trail. pub(crate) async fn emit_audit(&self, event: &EvidenceAuditEvent) -> Result<(), AuditError> { diff --git a/crates/registry-notary-server/tests/standalone_http/federation.rs b/crates/registry-notary-server/tests/standalone_http/federation.rs index 90a1c655c..ed78c1331 100644 --- a/crates/registry-notary-server/tests/standalone_http/federation.rs +++ b/crates/registry-notary-server/tests/standalone_http/federation.rs @@ -404,13 +404,39 @@ pub(super) fn subject_access_oid4vci_config( jwks_uri: &str, ) -> StandaloneRegistryNotaryConfig { let mut config = subject_access_oidc_config(base_url, audit_path, issuer, jwks_uri); + std::env::set_var("TEST_ACCESS_TOKEN_JWK", TEST_ACCESS_TOKEN_JWK); + std::env::set_var("TEST_ESIGNET_RP_JWK", TEST_ESIGNET_RP_JWK); + config + .subject_access + .rate_limits + .tx_code_attempts_per_code_per_minute = 3; + config + .subject_access + .citizen_clients + .allowed_client_ids + .push("registry-lab-live-client".to_string()); + if let Some(oidc) = config.auth.oidc.as_mut() { + oidc.allowed_clients + .push("registry-lab-live-client".to_string()); + } + config.evidence.signing_keys.insert( + "access-token-key".to_string(), + local_jwk_signing_key( + "TEST_ACCESS_TOKEN_JWK", + "did:web:issuer.example#access-token-key", + ), + ); + config.evidence.signing_keys.insert( + "esignet-rp-key".to_string(), + local_jwk_signing_key("TEST_ESIGNET_RP_JWK", "did:web:rp.example#esignet-rp-key"), + ); config .evidence .credential_profiles .get_mut("civil_status_sd_jwt") .expect("civil status credential profile exists") .vct = "http://127.0.0.1:4325/credentials/civil-status".to_string(); - config.oid4vci = serde_norway::from_str::( + config.oid4vci = serde_norway::from_str::(&format!( r#" enabled: true credential_issuer: http://127.0.0.1:4325 @@ -419,8 +445,6 @@ authorization_servers: accepted_token_audiences: - registry-notary-citizen credential_endpoint: http://127.0.0.1:4325/oid4vci/credential -offer_endpoint: http://127.0.0.1:4325/oid4vci/credential-offer -nonce_endpoint: http://127.0.0.1:4325/oid4vci/nonce nonce: enabled: true ttl_seconds: 300 @@ -437,9 +461,42 @@ credential_configurations: scope: person-is-alive vct: http://127.0.0.1:4325/credentials/civil-status display_name: Person is alive +pre_authorized_code: + enabled: true + tx_code: + required: true + input_mode: numeric + length: 6 + esignet: + client_id: registry-lab-live-client + client_signing_key_id: esignet-rp-key + redirect_uri: http://127.0.0.1:4325/oid4vci/offer/callback + authorize_url: {issuer}/authorize + token_url: {issuer}/token + issuer: {issuer} + jwks_uri: {jwks_uri} + scopes: + - openid + login_state_ttl_seconds: 300 + allow_insecure_localhost: true + pre_authorized_code_ttl_seconds: 300 +"# + )) + .expect("oid4vci config deserializes"); + config.auth.access_token_signing = serde_norway::from_str( + r#" +enabled: true +issuer: http://127.0.0.1:4325 +audiences: + - registry-notary-citizen +allowed_algorithms: + - EdDSA +token_typ: registry-notary-access+jwt +signing_key_id: access-token-key +access_token_ttl_seconds: 300 "#, ) - .expect("oid4vci config deserializes"); + .expect("access-token signing config parses"); config } diff --git a/crates/registry-notary-server/tests/standalone_http/oid4vci.rs b/crates/registry-notary-server/tests/standalone_http/oid4vci.rs index 328804d53..0c1c0fce8 100644 --- a/crates/registry-notary-server/tests/standalone_http/oid4vci.rs +++ b/crates/registry-notary-server/tests/standalone_http/oid4vci.rs @@ -8,7 +8,7 @@ use super::{ }; #[tokio::test] -pub(super) async fn oid4vci_metadata_offer_and_nonce_are_public() { +pub(super) async fn oid4vci_metadata_is_public_but_legacy_offer_and_nonce_are_not() { set_audit_secret(); std::env::set_var("TEST_SELF_ATTESTATION_ISSUER_JWK", TEST_ISSUER_JWK); @@ -36,94 +36,38 @@ pub(super) async fn oid4vci_metadata_offer_and_nonce_are_public() { let metadata_text = metadata_body.to_string(); assert!(!metadata_text.contains("source_connections")); - let offer = server.get("/oid4vci/credential-offer").await; - offer.assert_status_ok(); - let offer_body: Value = offer.json(); - assert_eq!( - offer_body["credential_configuration_ids"][0], - json!("person_is_alive_sd_jwt") - ); - let filtered_offer = server - .get("/oid4vci/credential-offer?credential_configuration_id=person_is_alive_sd_jwt") - .await; - filtered_offer.assert_status_ok(); - let filtered_offer_body: Value = filtered_offer.json(); - assert_eq!( - filtered_offer_body["credential_configuration_ids"], - json!(["person_is_alive_sd_jwt"]) - ); - let unknown_offer = server - .get("/oid4vci/credential-offer?credential_configuration_id=unknown") - .await; - unknown_offer.assert_status(StatusCode::BAD_REQUEST); - let unknown_offer_body: Value = unknown_offer.json(); - assert_eq!(unknown_offer_body["error"], json!("invalid_request")); - - let nonce = server.post("/oid4vci/nonce").json(&json!({})).await; - nonce.assert_status_ok(); - let nonce_body: Value = nonce.json(); - assert!(nonce_body["c_nonce"] - .as_str() - .is_some_and(|value| !value.is_empty())); - assert_eq!(nonce_body["c_nonce_expires_in"], json!(300)); - - let bad_nonce = server - .post("/oid4vci/nonce") - .json(&json!({"subject": "person-2"})) - .await; - bad_nonce.assert_status(StatusCode::BAD_REQUEST); - let bad_nonce_body: Value = bad_nonce.json(); - assert_eq!(bad_nonce_body["error"], json!("invalid_request")); - - idp.stop().await; -} - -#[tokio::test] -pub(super) async fn oid4vci_nonce_is_rate_limited_before_reservation() { - set_audit_secret(); - std::env::set_var("TEST_SELF_ATTESTATION_ISSUER_JWK", TEST_ISSUER_JWK); - - let idp = MockIdp::start().await; - let tmp = TempDir::new().expect("tempdir"); - let audit_path = tmp.path().join("audit.jsonl"); - let mut config = subject_access_oid4vci_config( - "http://127.0.0.1:1", - audit_path.to_str().expect("audit path is UTF-8"), - &idp.issuer(), - &idp.jwks_uri(), - ); - config - .subject_access - .rate_limits - .invalid_token_per_client_address_per_minute = 2; - let app = standalone_router(config) + server + .get("/oid4vci/credential-offer") .await - .expect("standalone router builds"); - let server = TestServer::builder().http_transport().build(app); - + .assert_status(StatusCode::UNAUTHORIZED); server .post("/oid4vci/nonce") - .add_header("x-forwarded-for", "203.0.113.10") .json(&json!({})) .await - .assert_status_ok(); + .assert_status(StatusCode::UNAUTHORIZED); + let now = OffsetDateTime::now_utc().unix_timestamp(); + let token = idp.mint_token(json!({ + "sub": "citizen-subject", + "aud": "registry-notary-citizen", + "azp": "citizen-portal", + "scope": "subject_access", + "national_id": "person-1", + "auth_time": now, + "iat": now, + "exp": now + 300, + "nbf": now, + })); server - .post("/oid4vci/nonce") - .add_header("x-forwarded-for", "203.0.113.11") - .json(&json!({})) + .get("/oid4vci/credential-offer") + .add_header("authorization", format!("Bearer {token}")) .await - .assert_status_ok(); - - let limited = server + .assert_status(StatusCode::NOT_FOUND); + server .post("/oid4vci/nonce") - .add_header("x-forwarded-for", "203.0.113.12") + .add_header("authorization", format!("Bearer {token}")) .json(&json!({})) - .await; - limited.assert_status(StatusCode::TOO_MANY_REQUESTS); - assert_eq!( - limited.json::()["error"], - json!("temporarily_unavailable") - ); + .await + .assert_status(StatusCode::NOT_FOUND); idp.stop().await; } @@ -237,9 +181,8 @@ pub(super) async fn oid4vci_type_metadata_normalizes_forwarded_scheme_and_host_c config.oid4vci.credential_issuer = "https://issuer.example.test".to_string(); config.oid4vci.credential_endpoint = "https://issuer.example.test/oid4vci/credential".to_string(); - config.oid4vci.offer_endpoint = - "https://issuer.example.test/oid4vci/credential-offer".to_string(); - config.oid4vci.nonce_endpoint = Some("https://issuer.example.test/oid4vci/nonce".to_string()); + config.oid4vci.offer_endpoint.clear(); + config.oid4vci.nonce_endpoint = None; config .evidence .credential_profiles @@ -350,9 +293,8 @@ pub(super) async fn oid4vci_type_metadata_supports_path_prefixed_issuer_behind_s config.oid4vci.credential_issuer = "http://127.0.0.1:4325/notary".to_string(); config.oid4vci.credential_endpoint = "http://127.0.0.1:4325/notary/oid4vci/credential".to_string(); - config.oid4vci.offer_endpoint = - "http://127.0.0.1:4325/notary/oid4vci/credential-offer".to_string(); - config.oid4vci.nonce_endpoint = Some("http://127.0.0.1:4325/notary/oid4vci/nonce".to_string()); + config.oid4vci.offer_endpoint.clear(); + config.oid4vci.nonce_endpoint = None; config .evidence .credential_profiles @@ -409,6 +351,7 @@ pub(super) async fn oid4vci_type_metadata_is_not_served_when_oid4vci_is_disabled &idp.jwks_uri(), ); config.oid4vci.enabled = false; + config.oid4vci.pre_authorized_code = Default::default(); let app = standalone_router(config) .await .expect("standalone router builds"); @@ -554,6 +497,7 @@ pub(super) async fn oid4vci_type_metadata_well_known_is_not_served_when_oid4vci_ &idp.jwks_uri(), ); config.oid4vci.enabled = false; + config.oid4vci.pre_authorized_code = Default::default(); let app = standalone_router(config) .await .expect("standalone router builds"); @@ -700,12 +644,12 @@ pub(super) async fn public_probe_routes_remain_public_except_metrics() { server .get("/oid4vci/credential-offer") .await - .assert_status_ok(); + .assert_status(StatusCode::UNAUTHORIZED); server .post("/oid4vci/nonce") .json(&json!({})) .await - .assert_status_ok(); + .assert_status(StatusCode::UNAUTHORIZED); server .get("/v1/credentials/urn:ulid:01HX0000000000000000000000/status") .await @@ -1115,13 +1059,16 @@ pub(super) async fn oid4vci_credential_route_issues_holder_bound_sd_jwt() { std::env::set_var("TEST_SELF_ATTESTATION_ISSUER_JWK", TEST_ISSUER_JWK); let idp = MockIdp::start().await; + let token_upstream = MockHttpUpstream::start().await; let tmp = TempDir::new().expect("tempdir"); let audit_path = tmp.path().join("audit.jsonl"); - let mut config = subject_access_oid4vci_config( + let mut config = subject_access_preauth_config( "http://127.0.0.1:1", audit_path.to_str().expect("audit path is UTF-8"), &idp.issuer(), &idp.jwks_uri(), + &format!("{}/authorize", idp.issuer()), + &format!("{}/token", token_upstream.url()), ); enable_shared_admin_listener(&mut config); enable_credential_status(&mut config); @@ -1151,41 +1098,24 @@ pub(super) async fn oid4vci_credential_route_issues_holder_bound_sd_jwt() { json!("credential_status.not_found") ); - let nonce = server - .post("/oid4vci/nonce") - .json(&json!({"credential_configuration_id": "person_is_alive_sd_jwt"})) - .await; - nonce.assert_status_ok(); - let nonce_body: Value = nonce.json(); - let nonce = nonce_body["c_nonce"] + let (code, pin) = drive_offer_to_code(&server, &token_upstream, &idp, "person-1").await; + let token = redeem_token(&server, &code, &pin).await; + token.assert_status_ok(); + let token_body: Value = token.json(); + let access_token = token_body["access_token"] + .as_str() + .expect("access token is returned") + .to_string(); + let nonce = token_body["c_nonce"] .as_str() .expect("nonce is returned") .to_string(); let proof = sign_oid4vci_proof("http://127.0.0.1:4325", &nonce); let now = OffsetDateTime::now_utc().unix_timestamp(); - let token = idp.mint_token(json!({ - "sub": "citizen-subject", - "aud": "registry-notary-citizen", - "azp": "citizen-portal", - "scope": "subject_access person-is-alive", - "national_id": "person-1", - "authorization_details": [{ - "type": registry_notary_core::tokens::NOTARY_AUTHORIZATION_DETAILS_TYPE, - "schema_version": registry_notary_core::tokens::NOTARY_AUTHORIZATION_DETAILS_SCHEMA_VERSION, - "legal_basis_ref": "wallet-compat-context", - "consent_ref": "wallet-compat-consent", - "jurisdiction": "ZZ", - "assurance_level": "substantial" - }], - "auth_time": now, - "iat": now, - "exp": now + 300, - "nbf": now, - })); let response = server .post("/oid4vci/credential") - .add_header("authorization", format!("Bearer {token}")) + .add_header("authorization", format!("Bearer {access_token}")) .json(&json!({ "format": "dc+sd-jwt", "credential_configuration_id": "person_is_alive_sd_jwt", @@ -1233,9 +1163,7 @@ pub(super) async fn oid4vci_credential_route_issues_holder_bound_sd_jwt() { } }) ); - assert!(body["c_nonce"] - .as_str() - .is_some_and(|value| !value.is_empty())); + assert!(body.get("c_nonce").is_none()); let status = server .get(&format!("/v1/credentials/{credential_id}/status")) @@ -1376,13 +1304,16 @@ pub(super) async fn oid4vci_field_projection_issues_separate_disclosures() { std::env::set_var("TEST_SELF_ATTESTATION_ISSUER_JWK", TEST_ISSUER_JWK); let idp = MockIdp::start().await; + let token_upstream = MockHttpUpstream::start().await; let tmp = TempDir::new().expect("tempdir"); let audit_path = tmp.path().join("audit.jsonl"); - let mut config = subject_access_oid4vci_config( + let mut config = subject_access_preauth_config( "http://127.0.0.1:1", audit_path.to_str().expect("audit path is UTF-8"), &idp.issuer(), &idp.jwks_uri(), + &format!("{}/authorize", idp.issuer()), + &format!("{}/token", token_upstream.url()), ); enable_oid4vci_field_projection(&mut config); let app = standalone_router(config) @@ -1411,32 +1342,23 @@ pub(super) async fn oid4vci_field_projection_issues_separate_disclosures() { ); assert_eq!(metadata_body["claims"][1]["mandatory"], json!(true)); - let nonce = server - .post("/oid4vci/nonce") - .json(&json!({"credential_configuration_id": "person_is_alive_sd_jwt"})) - .await; - nonce.assert_status_ok(); - let nonce = nonce.json::()["c_nonce"] + let (code, pin) = drive_offer_to_code(&server, &token_upstream, &idp, "person-1").await; + let token = redeem_token(&server, &code, &pin).await; + token.assert_status_ok(); + let token_body: Value = token.json(); + let access_token = token_body["access_token"] + .as_str() + .expect("access token is returned") + .to_string(); + let nonce = token_body["c_nonce"] .as_str() .expect("nonce is returned") .to_string(); let proof = sign_oid4vci_proof("http://127.0.0.1:4325", &nonce); - let now = OffsetDateTime::now_utc().unix_timestamp(); - let token = idp.mint_token(json!({ - "sub": "citizen-subject", - "aud": "registry-notary-citizen", - "azp": "citizen-portal", - "scope": "subject_access person-is-alive", - "national_id": "person-1", - "auth_time": now, - "iat": now, - "exp": now + 300, - "nbf": now, - })); let response = server .post("/oid4vci/credential") - .add_header("authorization", format!("Bearer {token}")) + .add_header("authorization", format!("Bearer {access_token}")) .json(&json!({ "format": "dc+sd-jwt", "credential_configuration_id": "person_is_alive_sd_jwt", @@ -1477,78 +1399,6 @@ pub(super) async fn oid4vci_field_projection_issues_separate_disclosures() { idp.stop().await; } -#[tokio::test] -#[cfg(feature = "registry-notary-cel")] -pub(super) async fn oid4vci_credential_route_rejects_replayed_nonce() { - set_audit_secret(); - std::env::set_var("TEST_SELF_ATTESTATION_ISSUER_JWK", TEST_ISSUER_JWK); - - let idp = MockIdp::start().await; - let tmp = TempDir::new().expect("tempdir"); - let audit_path = tmp.path().join("audit.jsonl"); - let mut config = subject_access_oid4vci_config( - "http://127.0.0.1:1", - audit_path.to_str().expect("audit path is UTF-8"), - &idp.issuer(), - &idp.jwks_uri(), - ); - config.subject_access.allowed_operations.issue_credential = true; - let app = standalone_router(config) - .await - .expect("standalone router builds"); - let server = TestServer::builder().http_transport().build(app); - - let nonce = server - .post("/oid4vci/nonce") - .json(&json!({"credential_configuration_id": "person_is_alive_sd_jwt"})) - .await; - nonce.assert_status_ok(); - let nonce_body: Value = nonce.json(); - let nonce = nonce_body["c_nonce"] - .as_str() - .expect("nonce is returned") - .to_string(); - let proof = sign_oid4vci_proof("http://127.0.0.1:4325", &nonce); - let now = OffsetDateTime::now_utc().unix_timestamp(); - let token = idp.mint_token(json!({ - "sub": "citizen-subject", - "aud": "registry-notary-citizen", - "azp": "citizen-portal", - "scope": "subject_access person-is-alive", - "national_id": "person-1", - "auth_time": now, - "iat": now, - "exp": now + 300, - "nbf": now, - })); - let credential_request = json!({ - "format": "dc+sd-jwt", - "credential_configuration_id": "person_is_alive_sd_jwt", - "proof": { - "proof_type": "jwt", - "jwt": proof - } - }); - - let first = server - .post("/oid4vci/credential") - .add_header("authorization", format!("Bearer {token}")) - .json(&credential_request) - .await; - first.assert_status_ok(); - - let replay = server - .post("/oid4vci/credential") - .add_header("authorization", format!("Bearer {token}")) - .json(&credential_request) - .await; - replay.assert_status(StatusCode::BAD_REQUEST); - let body: Value = replay.json(); - assert_eq!(body["error"], json!("invalid_proof")); - - idp.stop().await; -} - #[tokio::test] pub(super) async fn oid4vci_malformed_proof_is_rejected_before_oidc_auth() { set_audit_secret(); @@ -1586,7 +1436,8 @@ pub(super) async fn oid4vci_malformed_proof_is_rejected_before_oidc_auth() { .oidc .as_mut() .expect("oidc config exists") - .userinfo_endpoint = Some(userinfo_endpoint); + .userinfo_endpoint = Some(userinfo_endpoint.clone()); + config.oid4vci.pre_authorized_code.esignet.userinfo_url = userinfo_endpoint; let app = standalone_router(config) .await .expect("standalone router builds"); diff --git a/crates/registry-notary-server/tests/standalone_http/preauth.rs b/crates/registry-notary-server/tests/standalone_http/preauth.rs index ce96c4368..573d4be84 100644 --- a/crates/registry-notary-server/tests/standalone_http/preauth.rs +++ b/crates/registry-notary-server/tests/standalone_http/preauth.rs @@ -938,7 +938,7 @@ pub(super) async fn preauth_signed_required_policy_and_lockout_survive_optional_ } #[tokio::test] -pub(super) async fn preauth_signed_optional_policy_survives_required_runtime_config() { +pub(super) async fn preauth_forged_optional_code_without_reserved_transaction_is_rejected() { set_preauth_env(); let idp = MockIdp::start().await; let token_upstream = MockHttpUpstream::start().await; @@ -962,9 +962,9 @@ pub(super) async fn preauth_signed_optional_policy_survives_required_runtime_con payload["tx_code_required"] = json!(false); let signed_optional_code = sign_test_preauthorized_code(payload); - redeem_token_without_pin(&server, &signed_optional_code) - .await - .assert_status_ok(); + let response = redeem_token_without_pin(&server, &signed_optional_code).await; + response.assert_status(StatusCode::BAD_REQUEST); + assert_eq!(response.json::()["error"], json!("invalid_grant")); idp.stop().await; } @@ -1153,6 +1153,7 @@ pub(super) async fn preauth_disabled_exposes_no_wallet_issuance_grant() { &idp.jwks_uri(), ); config.oid4vci.enabled = false; + config.oid4vci.pre_authorized_code = Default::default(); let app = standalone_router(config) .await .expect("standalone router builds"); @@ -1176,11 +1177,11 @@ pub(super) async fn preauth_disabled_exposes_no_wallet_issuance_grant() { server .get("/oid4vci/credential-offer") .await - .assert_status(StatusCode::NOT_FOUND); + .assert_status(StatusCode::UNAUTHORIZED); server .post("/oid4vci/nonce") .await - .assert_status(StatusCode::NOT_FOUND); + .assert_status(StatusCode::UNAUTHORIZED); // Disabling the only supported issuance grant disables issuer metadata. let metadata = server.get("/.well-known/openid-credential-issuer").await; @@ -1581,8 +1582,8 @@ pub(super) async fn preauth_notary_access_token_with_empty_authorization_details })) .await; - credential.assert_status(StatusCode::UNAUTHORIZED); - assert_eq!(credential.json::()["error"], json!("invalid_token")); + credential.assert_status(StatusCode::FORBIDDEN); + assert_eq!(credential.json::()["error"], json!("access_denied")); idp.stop().await; } @@ -1673,6 +1674,92 @@ pub(super) async fn preauth_end_to_end_issues_sd_jwt_vc_bound_to_holder() { idp.stop().await; } +#[tokio::test] +#[cfg(feature = "registry-notary-cel")] +pub(super) async fn preauth_cached_retry_cannot_escape_failed_credential_audit() { + set_preauth_env(); + let idp = MockIdp::start().await; + let token_upstream = MockHttpUpstream::start().await; + let tmp = TempDir::new().expect("tempdir"); + let audit_path = tmp.path().join("audit.jsonl"); + let audit_backup_path = tmp.path().join("audit.backup.jsonl"); + let app = standalone_router(preauth_test_config( + "http://127.0.0.1:1", + audit_path.to_str().expect("audit path is UTF-8"), + &idp, + &token_upstream, + )) + .await + .expect("standalone router builds"); + let server = TestServer::builder().http_transport().build(app); + + let (code, pin) = drive_offer_to_code(&server, &token_upstream, &idp, "person-1").await; + let token = redeem_token(&server, &code, &pin).await; + token.assert_status_ok(); + let token_body: Value = token.json(); + let access_token = token_body["access_token"] + .as_str() + .expect("access token issued") + .to_string(); + let nonce = token_body["c_nonce"] + .as_str() + .expect("transaction nonce issued"); + let credential_request = json!({ + "format": "dc+sd-jwt", + "credential_configuration_id": "person_is_alive_sd_jwt", + "proof": { + "proof_type": "jwt", + "jwt": sign_oid4vci_proof(NOTARY_ISSUER, nonce), + } + }); + + // Preserve the valid retained chain, but temporarily replace its active + // path with a directory so the first credential-issued append fails. + std::fs::rename(&audit_path, &audit_backup_path).expect("audit file moves aside"); + std::fs::create_dir(&audit_path).expect("audit path becomes unwritable"); + let failed = server + .post("/oid4vci/credential") + .add_header("authorization", format!("Bearer {access_token}")) + .json(&credential_request) + .await; + failed.assert_status(StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(failed.json::()["code"], json!("audit.write_failed")); + + std::fs::remove_dir(&audit_path).expect("temporary audit directory is removed"); + std::fs::rename(&audit_backup_path, &audit_path).expect("audit chain is restored"); + let recovered = server + .post("/oid4vci/credential") + .add_header("authorization", format!("Bearer {access_token}")) + .json(&credential_request) + .await; + recovered.assert_status_ok(); + let recovered_body: Value = recovered.json(); + let exact_retry = server + .post("/oid4vci/credential") + .add_header("authorization", format!("Bearer {access_token}")) + .json(&credential_request) + .await; + exact_retry.assert_status_ok(); + assert_eq!( + exact_retry.json::(), + recovered_body, + "recovery and later exact retries use the one cached signature" + ); + let issued_audits = audit_envelopes(&audit_path) + .into_iter() + .filter(|envelope| { + envelope.record["path"] == json!("/oid4vci/credential") + && envelope.record["decision"] == json!("credential_issued") + && envelope.record["status"] == json!(200) + }) + .count(); + assert_eq!( + issued_audits, 2, + "each released cached response must pass credential-issued audit emission" + ); + idp.stop().await; +} + /// Decode the SD-JWT VC issuer JWS payload (the segment before the first `~`). #[cfg(feature = "registry-notary-cel")] pub(super) fn decode_sd_jwt_payload(sd_jwt: &str) -> Value { diff --git a/crates/registry-notary-server/tests/standalone_http/preauth_support.rs b/crates/registry-notary-server/tests/standalone_http/preauth_support.rs index a91b892dc..9990482aa 100644 --- a/crates/registry-notary-server/tests/standalone_http/preauth_support.rs +++ b/crates/registry-notary-server/tests/standalone_http/preauth_support.rs @@ -3,11 +3,6 @@ use super::federation::subject_access_oid4vci_config; use super::support::*; -// Dedicated access-token signing key, distinct from the credential key -// (TEST_ISSUER_JWK). Config validation rejects reusing a credential key. -pub(super) const TEST_ACCESS_TOKEN_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"8jFBgUJxaaQimd4NjzxhvPYyNbcOnnZsqOntZbpP3Xk","x":"XvW-aWwJCWSYoYudTB9OZqNHURKElnnyGNa6DQNjzZk","alg":"EdDSA"}"#; -// eSignet RP client signing key (signs the private_key_jwt client assertion). -pub(super) const TEST_ESIGNET_RP_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"EOLPz23yGd5Ju5e-PYybLE-YyvjgXLhGzS6XgmszzXs","x":"3v5jZ5rAf7KGvcC3zuKh6-ujgtA0ABa4jqmAWXq-S_c","alg":"EdDSA"}"#; // Test-only 2048-bit RSA private JWK (kty=RSA, alg=RS256) for the eSignet RP // client when the lab registers the Notary's RP client with an RSA key. // Generated once with openssl and converted to a JWK; not a production key. @@ -25,25 +20,6 @@ pub(super) fn set_preauth_env() { std::env::set_var("TEST_ESIGNET_RP_JWK", TEST_ESIGNET_RP_JWK); } -pub(super) fn local_jwk_signing_key(private_jwk_env: &str, kid: &str) -> SigningKeyConfig { - SigningKeyConfig { - provider: SigningKeyProviderConfig::LocalJwkEnv, - alg: SD_JWT_VC_SIGNING_ALG.to_string(), - kid: kid.to_string(), - status: SigningKeyStatus::Active, - publish_until_unix_seconds: None, - private_jwk_env: private_jwk_env.to_string(), - public_jwk_env: String::new(), - module_path: String::new(), - token_label: String::new(), - pin_env: String::new(), - key_label: String::new(), - key_id_hex: String::new(), - path: String::new(), - password_env: String::new(), - } -} - /// A pre-auth-enabled config. eSignet `issuer`/`jwks_uri` point at the MockIdp; /// the token endpoint points at `token_url` (a wiremock upstream). The /// access-token signing key is dedicated (distinct from the credential key). @@ -75,19 +51,6 @@ pub(super) fn subject_access_preauth_config( .invalid_token_per_client_address_per_minute = 50; // The Notary RP client id must be an accepted citizen client + audience so a // Notary-minted token classifies as subject-access. - config - .subject_access - .citizen_clients - .allowed_client_ids - .push(ESIGNET_RP_CLIENT_ID.to_string()); - config - .oid4vci - .accepted_token_audiences - .push(NOTARY_AUDIENCE.to_string()); - if let Some(oidc) = config.auth.oidc.as_mut() { - oidc.allowed_clients.push(ESIGNET_RP_CLIENT_ID.to_string()); - } - // Dedicated access-token signing key. config.evidence.signing_keys.insert( "access-token-key".to_string(), diff --git a/crates/registry-notary-server/tests/standalone_http/support.rs b/crates/registry-notary-server/tests/standalone_http/support.rs index 761d53b3e..092823b5e 100644 --- a/crates/registry-notary-server/tests/standalone_http/support.rs +++ b/crates/registry-notary-server/tests/standalone_http/support.rs @@ -55,10 +55,31 @@ pub(super) use ulid::Ulid; pub(super) const TEST_AUDIT_SECRET: &str = "0123456789abcdef0123456789abcdef"; pub(super) const TEST_ISSUER_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA"}"#; +pub(super) const TEST_ACCESS_TOKEN_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"8jFBgUJxaaQimd4NjzxhvPYyNbcOnnZsqOntZbpP3Xk","x":"XvW-aWwJCWSYoYudTB9OZqNHURKElnnyGNa6DQNjzZk","alg":"EdDSA"}"#; +pub(super) const TEST_ESIGNET_RP_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"EOLPz23yGd5Ju5e-PYybLE-YyvjgXLhGzS6XgmszzXs","x":"3v5jZ5rAf7KGvcC3zuKh6-ujgtA0ABa4jqmAWXq-S_c","alg":"EdDSA"}"#; pub(super) const TEST_HOLDER_JWK: &str = r#"{"crv":"Ed25519","d":"f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys","x":"pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec","kty":"OKP","alg":"EdDSA"}"#; pub(super) const TEST_RELAY_PROFILE_ID: &str = "example.person-status.exact"; pub(super) const TEST_RELAY_CONTRACT_DOMAIN: &[u8] = b"registry.relay.consultation-contract.v1\0"; +pub(super) fn local_jwk_signing_key(private_jwk_env: &str, kid: &str) -> SigningKeyConfig { + SigningKeyConfig { + provider: SigningKeyProviderConfig::LocalJwkEnv, + alg: SD_JWT_VC_SIGNING_ALG.to_string(), + kid: kid.to_string(), + status: SigningKeyStatus::Active, + publish_until_unix_seconds: None, + private_jwk_env: private_jwk_env.to_string(), + public_jwk_env: String::new(), + module_path: String::new(), + token_label: String::new(), + pin_env: String::new(), + key_label: String::new(), + key_id_hex: String::new(), + path: String::new(), + password_env: String::new(), + } +} + #[derive(Clone)] struct TestRelayState { contract: Value, From c87bfcb43b1027e5911d81cfa24ae65cc99df1d9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 18:24:01 +0700 Subject: [PATCH 32/65] test(notary): keep OID4VCI fixtures warning-free Signed-off-by: Jeremi Joslin --- crates/registry-notary-server/src/api/tests/support.rs | 2 ++ crates/registry-notary-server/tests/standalone_http/oid4vci.rs | 1 + 2 files changed, 3 insertions(+) diff --git a/crates/registry-notary-server/src/api/tests/support.rs b/crates/registry-notary-server/src/api/tests/support.rs index bc01a8c75..e5ab4d163 100644 --- a/crates/registry-notary-server/src/api/tests/support.rs +++ b/crates/registry-notary-server/src/api/tests/support.rs @@ -562,6 +562,7 @@ struct Oid4vciTestTransaction { principal: EvidencePrincipal, + #[cfg(feature = "registry-notary-cel")] transaction: IssuanceTransaction, nonce_scope: ReplayScope, nonce_key: ReplayKey, @@ -634,6 +635,7 @@ Oid4vciTestTransaction { principal, + #[cfg(feature = "registry-notary-cel")] transaction, nonce_scope, nonce_key, diff --git a/crates/registry-notary-server/tests/standalone_http/oid4vci.rs b/crates/registry-notary-server/tests/standalone_http/oid4vci.rs index 0c1c0fce8..442f077fb 100644 --- a/crates/registry-notary-server/tests/standalone_http/oid4vci.rs +++ b/crates/registry-notary-server/tests/standalone_http/oid4vci.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 +#[cfg(feature = "registry-notary-cel")] use super::preauth_support::*; use super::support::*; #[allow(unused_imports)] From f91c6e1c6145f46dd366ede422e31dc18e63e855 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 17:31:49 +0700 Subject: [PATCH 33/65] feat(notary): verify credential status fail closed Signed-off-by: Jeremi Joslin --- Cargo.lock | 1 + crates/registry-notary-client/Cargo.toml | 3 +- crates/registry-notary-client/src/client.rs | 38 +- crates/registry-notary-client/src/lib.rs | 5 +- crates/registry-notary-client/src/verifier.rs | 698 +++++++++++++++++- .../tests/status_verifier_contract.rs | 527 +++++++++++++ .../registry-notary-server/src/api/status.rs | 4 +- .../src/api/tests/status.rs | 5 + 8 files changed, 1262 insertions(+), 19 deletions(-) create mode 100644 crates/registry-notary-client/tests/status_verifier_contract.rs diff --git a/Cargo.lock b/Cargo.lock index 5d7cbb118..6122e70a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5283,6 +5283,7 @@ dependencies = [ "axum", "axum-test", "base64", + "flate2", "registry-notary-core", "registry-notary-server", "registry-platform-crypto", diff --git a/crates/registry-notary-client/Cargo.toml b/crates/registry-notary-client/Cargo.toml index 4bda88882..eda1ce25b 100644 --- a/crates/registry-notary-client/Cargo.toml +++ b/crates/registry-notary-client/Cargo.toml @@ -19,11 +19,12 @@ oid4vci = ["dep:registry-platform-oid4vci"] federation = [] json-facade = [] test-support = [] -verifier = ["dep:base64", "dep:registry-platform-crypto", "dep:sha2"] +verifier = ["dep:base64", "dep:flate2", "dep:registry-platform-crypto", "dep:sha2"] [dependencies] async-trait.workspace = true base64 = { workspace = true, optional = true } +flate2 = { version = "1", optional = true } registry-notary-core.workspace = true registry-platform-crypto = { workspace = true, optional = true } registry-platform-httputil.workspace = true diff --git a/crates/registry-notary-client/src/client.rs b/crates/registry-notary-client/src/client.rs index 9b195a571..bb216abc8 100644 --- a/crates/registry-notary-client/src/client.rs +++ b/crates/registry-notary-client/src/client.rs @@ -304,28 +304,54 @@ impl RegistryNotaryClient { /// This method is opt-in. Transport methods continue to decode response /// bodies without performing verification. Verification reuses the /// `issuer_jwks` TTL cache, forces one `refresh_jwks` on `key.unknown`, - /// and never performs an unbounded refresh loop. + /// and never performs an unbounded refresh loop. A status-bearing + /// credential additionally requires [`crate::StatusListPolicy`] in the + /// options. Its signed status list is fetched through a DNS-pinned, + /// no-redirect, no-proxy, bounded HTTPS request and fails closed. pub async fn verify_sd_jwt_vc( &self, compact: &str, options: VerifyOptions, ) -> Result { - let jwks = self + let mut jwks = self .issuer_jwks(RequestOptions::default()) .await .map_err(|_| VerificationError::jwks_unavailable())? .body; - match crate::verifier::verify_sd_jwt_vc(compact, &jwks, &options) { + let pending = match crate::verifier::verify_sd_jwt_vc_pending(compact, &jwks, &options) { Err(error) if error.is_unknown_key() => { - let refreshed = self + jwks = self .refresh_jwks(RequestOptions::default()) .await .map_err(|_| VerificationError::jwks_unavailable())? .body; - crate::verifier::verify_sd_jwt_vc(compact, &refreshed, &options) + crate::verifier::verify_sd_jwt_vc_pending(compact, &jwks, &options)? + } + Err(error) => return Err(error), + Ok(pending) => pending, + }; + if let Some(status) = &pending.status { + let status_token = crate::verifier::fetch_status_list_token(status, &options).await?; + match crate::verifier::verify_status_list_token(&status_token, status, &jwks, &options) + { + Err(error) if error.is_status_unknown_key() => { + jwks = self + .refresh_jwks(RequestOptions::default()) + .await + .map_err(|_| VerificationError::jwks_unavailable())? + .body; + crate::verifier::verify_status_list_token( + &status_token, + status, + &jwks, + &options, + )?; + } + Err(error) => return Err(error), + Ok(()) => {} } - result => result, } + Ok(pending.credential) } #[cfg(feature = "verifier")] diff --git a/crates/registry-notary-client/src/lib.rs b/crates/registry-notary-client/src/lib.rs index cf53897f2..587dcc958 100644 --- a/crates/registry-notary-client/src/lib.rs +++ b/crates/registry-notary-client/src/lib.rs @@ -88,4 +88,7 @@ pub use responses::{ SigningProviderReadinessChecks, }; #[cfg(feature = "verifier")] -pub use verifier::{HolderBindingPolicy, VerificationError, VerifiedCredential, VerifyOptions}; +pub use verifier::{ + HolderBindingPolicy, StatusListPolicy, StatusListPolicyError, VerificationError, + VerifiedCredential, VerifyOptions, +}; diff --git a/crates/registry-notary-client/src/verifier.rs b/crates/registry-notary-client/src/verifier.rs index 3a3f2f8f9..b446f8b16 100644 --- a/crates/registry-notary-client/src/verifier.rs +++ b/crates/registry-notary-client/src/verifier.rs @@ -3,17 +3,145 @@ use std::collections::BTreeSet; use std::fmt; +use std::io::Read; use std::time::Duration; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; +use flate2::read::ZlibDecoder; use registry_notary_core::SD_JWT_VC_JWT_TYP; use registry_platform_crypto::{verify, PublicJwk, SigningAlgorithm}; +use registry_platform_httputil::{read_bounded, BoundedReadError, FetchUrlError, FetchUrlPolicy}; +use reqwest::header::{ACCEPT, ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_TYPE}; +use reqwest::{StatusCode, Url}; use serde_json::Value; use sha2::{Digest, Sha256}; use thiserror::Error; use time::OffsetDateTime; +const STATUS_LIST_MEDIA_TYPE: &str = "application/statuslist+jwt"; +const STATUS_LIST_FETCH_TIMEOUT: Duration = Duration::from_secs(10); +const STATUS_LIST_DNS_TIMEOUT: Duration = Duration::from_secs(3); +const MAX_STATUS_LIST_RESPONSE_BYTES: u64 = 256 * 1024; +const MAX_STATUS_LIST_COMPRESSED_BYTES: usize = 128 * 1024; +const MAX_STATUS_LIST_DECOMPRESSED_BYTES: usize = 128 * 1024; +const MAX_STATUS_LIST_URI_BYTES: usize = 4_096; +const MAX_STATUS_LIST_ORIGINS: usize = 16; +const MAX_STATUS_LIST_TOKEN_LIFETIME_SECONDS: i64 = 300; +const MAX_STATUS_LIST_CLOCK_SKEW_SECONDS: u64 = 60; + +/// Trusted status-list origins associated with exactly one credential issuer. +/// +/// The primary origin is required. Additional origins are accepted only after +/// the caller adds each exact HTTPS origin explicitly. Paths, queries, +/// fragments, URL credentials, localhost, private networks, and unsafe DNS +/// answers are still rejected by the fetch path. +#[derive(Clone, PartialEq, Eq)] +pub struct StatusListPolicy { + issuer: String, + origins: BTreeSet, + #[cfg(any(test, feature = "test-support"))] + allow_loopback_http: bool, +} + +impl fmt::Debug for StatusListPolicy { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("StatusListPolicy") + .field("issuer", &"") + .field("trusted_origin_count", &self.origins.len()) + .finish() + } +} + +impl StatusListPolicy { + /// Bind an issuer to its primary trusted HTTPS status origin. + pub fn new( + issuer: impl Into, + trusted_origin: impl AsRef, + ) -> Result { + let issuer = issuer.into(); + if issuer.trim().is_empty() { + return Err(StatusListPolicyError::InvalidIssuer); + } + let origin = parse_status_origin(trusted_origin.as_ref(), false)?; + Ok(Self { + issuer, + origins: BTreeSet::from([origin]), + #[cfg(any(test, feature = "test-support"))] + allow_loopback_http: false, + }) + } + + /// Add one exact HTTPS origin that is trusted for the same issuer. + pub fn allow_origin(mut self, origin: impl AsRef) -> Result { + if self.origins.len() >= MAX_STATUS_LIST_ORIGINS { + return Err(StatusListPolicyError::TooManyOrigins); + } + let origin = parse_status_origin(origin.as_ref(), false)?; + self.origins.insert(origin); + Ok(self) + } + + /// Construct a loopback-only HTTP policy for local test harnesses. + /// + /// This escape hatch is absent from production release builds. It does not + /// admit non-loopback HTTP origins. + #[cfg(any(test, feature = "test-support"))] + pub fn loopback_for_testing( + issuer: impl Into, + trusted_origin: impl AsRef, + ) -> Result { + let issuer = issuer.into(); + if issuer.trim().is_empty() { + return Err(StatusListPolicyError::InvalidIssuer); + } + let origin = parse_status_origin(trusted_origin.as_ref(), true)?; + Ok(Self { + issuer, + origins: BTreeSet::from([origin]), + allow_loopback_http: true, + }) + } + + fn permits(&self, issuer: &str, url: &Url) -> Result<(), VerificationError> { + if self.issuer != issuer { + return Err(VerificationError::StatusPolicy { + code: "status.policy_issuer_mismatch", + }); + } + let origin = url.origin().ascii_serialization(); + if !self.origins.contains(&origin) { + return Err(VerificationError::StatusPolicy { + code: "status.origin_untrusted", + }); + } + Ok(()) + } + + fn fetch_url_policy(&self) -> FetchUrlPolicy { + #[cfg(any(test, feature = "test-support"))] + if self.allow_loopback_http { + return FetchUrlPolicy::dev(); + } + FetchUrlPolicy::strict() + } +} + +/// Invalid caller-owned status trust configuration. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum StatusListPolicyError { + #[error("status-list issuer must not be empty")] + InvalidIssuer, + #[error("status-list origin must be an exact HTTPS origin")] + InvalidOrigin, + #[error("status-list origin contains URL credentials or resource components")] + OriginHasResourceComponents, + #[error("status-list origin allow-list exceeds the supported bound")] + TooManyOrigins, +} + /// Caller-owned policy for explicit SD-JWT VC verification. #[derive(Clone, Debug, PartialEq, Eq)] pub struct VerifyOptions { @@ -31,6 +159,8 @@ pub struct VerifyOptions { pub expected_key_binding_audience: Option, /// Expected key-binding JWT nonce for verifier-controlled challenges. pub expected_key_binding_nonce: Option, + /// Mandatory trust policy when the credential carries a status reference. + pub status_list: Option, /// Test hook for deterministic time checks. Production callers should leave /// this unset. pub now: Option, @@ -47,6 +177,7 @@ impl VerifyOptions { holder_binding: HolderBindingPolicy::NotRequired, expected_key_binding_audience: None, expected_key_binding_nonce: None, + status_list: None, now: None, } } @@ -89,6 +220,13 @@ impl VerifyOptions { self } + /// Configure fail-closed verification for status-bearing credentials. + #[must_use] + pub fn status_list(mut self, policy: StatusListPolicy) -> Self { + self.status_list = Some(policy); + self + } + #[must_use] pub fn now(mut self, now: OffsetDateTime) -> Self { self.now = Some(now); @@ -175,6 +313,18 @@ pub enum VerificationError { UnsupportedCredentialShape { code: &'static str }, #[error("issuer JWKS could not be loaded")] JwksUnavailable { code: &'static str }, + #[error("credential status trust policy is missing or does not match")] + StatusPolicy { code: &'static str }, + #[error("status-bearing credentials require the asynchronous client verifier")] + StatusVerificationRequired { code: &'static str }, + #[error("credential status endpoint could not be reached safely")] + StatusFetch { code: &'static str }, + #[error("credential status response does not satisfy transport policy")] + StatusResponse { code: &'static str }, + #[error("credential status-list token is invalid")] + StatusToken { code: &'static str }, + #[error("credential status is not valid")] + CredentialStatus { code: &'static str }, } impl VerificationError { @@ -195,7 +345,13 @@ impl VerificationError { | Self::DisclosureDigestMismatch { code } | Self::HolderBinding { code } | Self::UnsupportedCredentialShape { code } - | Self::JwksUnavailable { code } => code, + | Self::JwksUnavailable { code } + | Self::StatusPolicy { code } + | Self::StatusVerificationRequired { code } + | Self::StatusFetch { code } + | Self::StatusResponse { code } + | Self::StatusToken { code } + | Self::CredentialStatus { code } => code, } } @@ -204,6 +360,11 @@ impl VerificationError { matches!(self, Self::UnknownKey { .. }) } + #[must_use] + pub(crate) fn is_status_unknown_key(&self) -> bool { + matches!(self, Self::StatusToken { code } if *code == "status.key.unknown") + } + pub(crate) const fn jwks_unavailable() -> Self { Self::JwksUnavailable { code: "jwks.unavailable", @@ -211,12 +372,47 @@ impl VerificationError { } } -/// Verify one SD-JWT VC compact credential against caller-supplied trusted JWKS. +/// Verify one status-free SD-JWT VC against caller-supplied trusted JWKS. +/// +/// A status-bearing credential always fails with `status.policy_required` or +/// `status.fetch_required`. Use [`crate::RegistryNotaryClient::verify_sd_jwt_vc`] +/// so the status material is fetched and verified under [`StatusListPolicy`]. pub fn verify_sd_jwt_vc( compact: &str, jwks: &Value, options: &VerifyOptions, ) -> Result { + let pending = verify_sd_jwt_vc_pending(compact, jwks, options)?; + if pending.status.is_some() { + return if options.status_list.is_some() { + Err(VerificationError::StatusVerificationRequired { + code: "status.fetch_required", + }) + } else { + Err(VerificationError::StatusPolicy { + code: "status.policy_required", + }) + }; + } + Ok(pending.credential) +} + +pub(crate) struct PendingCredentialVerification { + pub(crate) credential: VerifiedCredential, + pub(crate) status: Option, +} + +#[derive(Clone)] +pub(crate) struct StatusListReference { + uri: Url, + index: u64, +} + +pub(crate) fn verify_sd_jwt_vc_pending( + compact: &str, + jwks: &Value, + options: &VerifyOptions, +) -> Result { let parsed = ParsedSdJwt::parse(compact)?; let header = decode_segment(parsed.header_b64)?; let payload = decode_segment(parsed.payload_b64)?; @@ -263,7 +459,7 @@ fn verify_claims( options: &VerifyOptions, alg: &str, kid: &str, -) -> Result { +) -> Result { let issuer = required_string(payload, "iss")?; if issuer != options.expected_issuer { return Err(VerificationError::IssuerMismatch { @@ -302,7 +498,7 @@ fn verify_claims( } } - verify_disclosures(payload, &parsed.disclosures)?; + let disclosed_status = verify_disclosures(payload, &parsed.disclosures)?; let holder_key_id = verify_holder_binding(HolderBindingContext { payload, policy: &options.holder_binding, @@ -313,7 +509,8 @@ fn verify_claims( now, skew, })?; - Ok(VerifiedCredential { + let status = parse_status_reference(payload, disclosed_status.as_ref())?; + let credential = VerifiedCredential { issuer: issuer.to_string(), subject: payload .get("sub") @@ -332,12 +529,485 @@ fn verify_claims( issued_at: iat, disclosure_count: parsed.disclosures.len(), holder_key_id, + }; + Ok(PendingCredentialVerification { credential, status }) +} + +fn parse_status_reference( + payload: &Value, + disclosed_status: Option<&Value>, +) -> Result, VerificationError> { + let status = match (payload.get("status"), disclosed_status) { + (None, None) => return Ok(None), + (Some(status), None) | (None, Some(status)) => status, + (Some(_), Some(_)) => { + return Err(VerificationError::StatusToken { + code: "status.reference_malformed", + }) + } + }; + let status_list = status + .as_object() + .and_then(|status| status.get("status_list")) + .and_then(Value::as_object) + .ok_or(VerificationError::StatusToken { + code: "status.reference_malformed", + })?; + let index = + status_list + .get("idx") + .and_then(Value::as_u64) + .ok_or(VerificationError::StatusToken { + code: "status.index.invalid", + })?; + let raw_uri = status_list + .get("uri") + .and_then(Value::as_str) + .filter(|uri| !uri.is_empty() && uri.len() <= MAX_STATUS_LIST_URI_BYTES) + .ok_or(VerificationError::StatusToken { + code: "status.reference_malformed", + })?; + let uri = Url::parse(raw_uri).map_err(|_| VerificationError::StatusToken { + code: "status.reference_malformed", + })?; + if uri.host().is_none() + || !uri.username().is_empty() + || uri.password().is_some() + || uri.fragment().is_some() + { + return Err(VerificationError::StatusToken { + code: "status.reference_malformed", + }); + } + Ok(Some(StatusListReference { uri, index })) +} + +pub(crate) async fn fetch_status_list_token( + reference: &StatusListReference, + options: &VerifyOptions, +) -> Result { + let policy = options + .status_list + .as_ref() + .ok_or(VerificationError::StatusPolicy { + code: "status.policy_required", + })?; + policy.permits(&options.expected_issuer, &reference.uri)?; + let validated = policy + .fetch_url_policy() + .validate_for_immediate_fetch_with_timeout(&reference.uri, STATUS_LIST_DNS_TIMEOUT) + .await + .map_err(map_status_fetch_url_error)?; + let response = validated + .immediate_get_with_timeout(STATUS_LIST_FETCH_TIMEOUT) + .map_err(map_status_fetch_url_error)? + .header(ACCEPT, STATUS_LIST_MEDIA_TYPE) + .header(ACCEPT_ENCODING, "identity") + .send() + .await + .map_err(|_| VerificationError::StatusFetch { + code: "status.unreachable", + })?; + + if response.status().is_redirection() { + return Err(VerificationError::StatusFetch { + code: "status.redirect_denied", + }); + } + if response.status() != StatusCode::OK { + return Err(VerificationError::StatusResponse { + code: "status.http_status_invalid", + }); + } + if !has_exact_status_list_media_type(response.headers()) { + return Err(VerificationError::StatusResponse { + code: "status.media_type_invalid", + }); + } + if !has_identity_content_encoding(response.headers()) { + return Err(VerificationError::StatusResponse { + code: "status.content_encoding_denied", + }); + } + let body = read_bounded(response, MAX_STATUS_LIST_RESPONSE_BYTES) + .await + .map_err(map_status_body_error)?; + let compact = String::from_utf8(body).map_err(|_| VerificationError::StatusToken { + code: "status.token_malformed", + })?; + if compact.trim() != compact || !is_compact_jws(&compact) { + return Err(VerificationError::StatusToken { + code: "status.token_malformed", + }); + } + Ok(compact) +} + +pub(crate) fn verify_status_list_token( + compact: &str, + reference: &StatusListReference, + jwks: &Value, + options: &VerifyOptions, +) -> Result<(), VerificationError> { + let mut parts = compact.split('.'); + let header_b64 = + parts + .next() + .filter(|part| !part.is_empty()) + .ok_or(VerificationError::StatusToken { + code: "status.token_malformed", + })?; + let payload_b64 = + parts + .next() + .filter(|part| !part.is_empty()) + .ok_or(VerificationError::StatusToken { + code: "status.token_malformed", + })?; + let signature_b64 = + parts + .next() + .filter(|part| !part.is_empty()) + .ok_or(VerificationError::StatusToken { + code: "status.token_malformed", + })?; + if parts.next().is_some() { + return Err(VerificationError::StatusToken { + code: "status.token_malformed", + }); + } + + let header = decode_status_segment(header_b64)?; + if ["crit", "jku", "jwk", "x5u", "x5c"] + .iter() + .any(|forbidden| header.get(forbidden).is_some()) + { + return Err(VerificationError::StatusToken { + code: "status.header.untrusted_key_reference", + }); + } + if header.get("typ").and_then(Value::as_str) != Some("statuslist+jwt") { + return Err(VerificationError::StatusToken { + code: "status.header.typ_mismatch", + }); + } + let algorithm = status_required_string(&header, "alg")?; + if !options.accepted_algorithms.contains(algorithm) { + return Err(VerificationError::StatusToken { + code: "status.algorithm.disallowed", + }); + } + let kid = status_required_string(&header, "kid")?; + let jwk = find_jwk(jwks, kid).map_err(|_| VerificationError::StatusToken { + code: "status.key.unknown", + })?; + if jwk_alg(&jwk) != Some(algorithm) + || jwk + .alg + .as_deref() + .is_some_and(|jwk_algorithm| jwk_algorithm != algorithm) + { + return Err(VerificationError::StatusToken { + code: "status.algorithm.key_mismatch", + }); + } + let signature = + URL_SAFE_NO_PAD + .decode(signature_b64) + .map_err(|_| VerificationError::StatusToken { + code: "status.token_malformed", + })?; + let signing_input = format!("{header_b64}.{payload_b64}"); + verify(signing_input.as_bytes(), &signature, &jwk).map_err(|_| { + VerificationError::StatusToken { + code: "status.signature.invalid", + } + })?; + + let payload = decode_status_segment(payload_b64)?; + let status_uri = reference.uri.as_str(); + if status_required_string(&payload, "iss")? != options.expected_issuer { + return Err(VerificationError::StatusToken { + code: "status.claim.issuer_mismatch", + }); + } + if status_required_string(&payload, "sub")? != status_uri { + return Err(VerificationError::StatusToken { + code: "status.claim.uri_mismatch", + }); + } + if !exact_audience_matches(&payload, status_uri) { + return Err(VerificationError::StatusToken { + code: "status.claim.audience_mismatch", + }); + } + verify_status_time_claims(&payload, options)?; + let status = indexed_status(&payload, reference.index)?; + match status { + 0 => Ok(()), + 1 => Err(VerificationError::CredentialStatus { + code: "status.revoked", + }), + 2 => Err(VerificationError::CredentialStatus { + code: "status.suspended", + }), + _ => Err(VerificationError::CredentialStatus { + code: "status.unknown", + }), + } +} + +fn verify_status_time_claims( + payload: &Value, + options: &VerifyOptions, +) -> Result<(), VerificationError> { + let now = options + .now + .unwrap_or_else(OffsetDateTime::now_utc) + .unix_timestamp(); + let skew = i64::try_from( + options + .clock_skew + .as_secs() + .min(MAX_STATUS_LIST_CLOCK_SKEW_SECONDS), + ) + .expect("bounded status clock skew fits i64"); + let iat = payload + .get("iat") + .and_then(Value::as_i64) + .ok_or(VerificationError::StatusToken { + code: "status.claim.time_invalid", + })?; + let exp = payload + .get("exp") + .and_then(Value::as_i64) + .ok_or(VerificationError::StatusToken { + code: "status.claim.time_invalid", + })?; + let ttl = payload + .get("ttl") + .and_then(Value::as_i64) + .filter(|ttl| *ttl > 0 && *ttl <= MAX_STATUS_LIST_TOKEN_LIFETIME_SECONDS) + .ok_or(VerificationError::StatusToken { + code: "status.claim.time_invalid", + })?; + let nbf = match payload.get("nbf") { + None => None, + Some(value) => Some(value.as_i64().ok_or(VerificationError::StatusToken { + code: "status.claim.time_invalid", + })?), + }; + let lifetime = exp.checked_sub(iat).ok_or(VerificationError::StatusToken { + code: "status.claim.time_invalid", + })?; + if lifetime <= 0 + || lifetime > ttl + || exp <= now.saturating_sub(skew) + || iat > now.saturating_add(skew) + || nbf.is_some_and(|not_before| not_before > now.saturating_add(skew)) + || nbf.is_some_and(|not_before| not_before >= exp) + { + return Err(VerificationError::StatusToken { + code: "status.claim.time_invalid", + }); + } + Ok(()) +} + +fn indexed_status(payload: &Value, index: u64) -> Result { + let status_list = payload + .get("status_list") + .and_then(Value::as_object) + .ok_or(VerificationError::StatusToken { + code: "status.list.malformed", + })?; + let bits = status_list + .get("bits") + .and_then(Value::as_u64) + .filter(|bits| matches!(bits, 1 | 2 | 4 | 8)) + .ok_or(VerificationError::StatusToken { + code: "status.list.malformed", + })? as u8; + let encoded = status_list + .get("lst") + .and_then(Value::as_str) + .filter(|encoded| !encoded.is_empty()) + .ok_or(VerificationError::StatusToken { + code: "status.list.malformed", + })?; + let compressed = + URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| VerificationError::StatusToken { + code: "status.list.malformed", + })?; + if compressed.len() > MAX_STATUS_LIST_COMPRESSED_BYTES { + return Err(VerificationError::StatusToken { + code: "status.list.compressed_too_large", + }); + } + let list = decompress_status_list(&compressed)?; + let entries_per_byte = u64::from(8 / bits); + let byte_index = index / entries_per_byte; + let byte_index = usize::try_from(byte_index).map_err(|_| VerificationError::StatusToken { + code: "status.index.invalid", + })?; + let byte = list + .get(byte_index) + .copied() + .ok_or(VerificationError::StatusToken { + code: "status.index.invalid", + })?; + let shift = u8::try_from((index % entries_per_byte) * u64::from(bits)).map_err(|_| { + VerificationError::StatusToken { + code: "status.index.invalid", + } + })?; + let mask = if bits == 8 { + u8::MAX + } else { + (1_u8 << bits) - 1 + }; + Ok((byte >> shift) & mask) +} + +fn decompress_status_list(compressed: &[u8]) -> Result, VerificationError> { + let mut decoder = ZlibDecoder::new(compressed); + let mut decompressed = Vec::with_capacity(MAX_STATUS_LIST_DECOMPRESSED_BYTES.min(8_192)); + (&mut decoder) + .take((MAX_STATUS_LIST_DECOMPRESSED_BYTES + 1) as u64) + .read_to_end(&mut decompressed) + .map_err(|_| VerificationError::StatusToken { + code: "status.list.malformed", + })?; + if decompressed.len() > MAX_STATUS_LIST_DECOMPRESSED_BYTES + || decoder.total_in() != compressed.len() as u64 + { + return Err(VerificationError::StatusToken { + code: "status.list.decompression_limit", + }); + } + Ok(decompressed) +} + +fn decode_status_segment(segment: &str) -> Result { + let decoded = URL_SAFE_NO_PAD + .decode(segment) + .map_err(|_| VerificationError::StatusToken { + code: "status.token_malformed", + })?; + serde_json::from_slice(&decoded).map_err(|_| VerificationError::StatusToken { + code: "status.token_malformed", }) } -fn verify_disclosures(payload: &Value, disclosures: &[&str]) -> Result<(), VerificationError> { +fn status_required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str, VerificationError> { + value + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or(VerificationError::StatusToken { + code: "status.token_malformed", + }) +} + +fn exact_audience_matches(payload: &Value, expected: &str) -> bool { + match payload.get("aud") { + Some(Value::String(audience)) => audience == expected, + Some(Value::Array(audiences)) if audiences.len() == 1 => { + audiences[0].as_str() == Some(expected) + } + _ => false, + } +} + +fn has_exact_status_list_media_type(headers: &reqwest::header::HeaderMap) -> bool { + let mut values = headers.get_all(CONTENT_TYPE).iter(); + matches!( + (values.next(), values.next()), + (Some(value), None) + if value + .to_str() + .is_ok_and(|value| value.eq_ignore_ascii_case(STATUS_LIST_MEDIA_TYPE)) + ) +} + +fn has_identity_content_encoding(headers: &reqwest::header::HeaderMap) -> bool { + let mut values = headers.get_all(CONTENT_ENCODING).iter(); + match (values.next(), values.next()) { + (None, None) => true, + (Some(value), None) => value + .to_str() + .is_ok_and(|value| value.eq_ignore_ascii_case("identity")), + _ => false, + } +} + +fn map_status_fetch_url_error(error: FetchUrlError) -> VerificationError { + match error { + FetchUrlError::Dns { .. } + | FetchUrlError::NoAddresses + | FetchUrlError::ValidationTimeout { .. } + | FetchUrlError::ValidationTask(_) => VerificationError::StatusFetch { + code: "status.unreachable", + }, + _ => VerificationError::StatusFetch { + code: "status.destination_unsafe", + }, + } +} + +fn map_status_body_error(error: BoundedReadError) -> VerificationError { + match error { + BoundedReadError::ContentLengthExceeded { .. } + | BoundedReadError::BodyTooLarge { .. } + | BoundedReadError::LengthOverflow => VerificationError::StatusResponse { + code: "status.response_too_large", + }, + BoundedReadError::Transport(_) => VerificationError::StatusFetch { + code: "status.unreachable", + }, + _ => VerificationError::StatusResponse { + code: "status.response_invalid", + }, + } +} + +fn parse_status_origin( + raw_origin: &str, + allow_loopback_http: bool, +) -> Result { + if raw_origin.len() > MAX_STATUS_LIST_URI_BYTES { + return Err(StatusListPolicyError::InvalidOrigin); + } + let origin = Url::parse(raw_origin).map_err(|_| StatusListPolicyError::InvalidOrigin)?; + if origin.host().is_none() || origin.port() == Some(0) { + return Err(StatusListPolicyError::InvalidOrigin); + } + if !origin.username().is_empty() + || origin.password().is_some() + || origin.path() != "/" + || origin.query().is_some() + || origin.fragment().is_some() + { + return Err(StatusListPolicyError::OriginHasResourceComponents); + } + let valid_scheme = origin.scheme() == "https" + || (allow_loopback_http + && origin.scheme() == "http" + && matches!(origin.host_str(), Some("127.0.0.1" | "localhost" | "::1"))); + if !valid_scheme { + return Err(StatusListPolicyError::InvalidOrigin); + } + Ok(origin.origin().ascii_serialization()) +} + +fn verify_disclosures( + payload: &Value, + disclosures: &[&str], +) -> Result, VerificationError> { if disclosures.is_empty() && payload.get("_sd").is_none() { - return Ok(()); + return Ok(None); } if payload.get("_sd_alg").and_then(Value::as_str) != Some("sha-256") { return Err(VerificationError::DisclosureDigestMismatch { @@ -361,6 +1031,7 @@ fn verify_disclosures(payload: &Value, disclosures: &[&str]) -> Result<(), Verif .collect::, _>>()?; let mut actual = BTreeSet::new(); + let mut disclosed_status = None; for disclosure in disclosures { let decoded = URL_SAFE_NO_PAD.decode(disclosure).map_err(|_| { VerificationError::DisclosureDigestMismatch { @@ -372,19 +1043,26 @@ fn verify_disclosures(payload: &Value, disclosures: &[&str]) -> Result<(), Verif code: "disclosure.digest_mismatch", } })?; - if value.as_array().is_none_or(|items| items.len() < 3) { + let Some(items) = value.as_array().filter(|items| items.len() >= 3) else { return Err(VerificationError::DisclosureDigestMismatch { code: "disclosure.digest_mismatch", }); - } + }; let digest = URL_SAFE_NO_PAD.encode(Sha256::digest(disclosure.as_bytes())); if !expected.contains(&digest) || !actual.insert(digest) { return Err(VerificationError::DisclosureDigestMismatch { code: "disclosure.digest_mismatch", }); } + if items.get(1).and_then(Value::as_str) == Some("status") + && disclosed_status.replace(items[2].clone()).is_some() + { + return Err(VerificationError::StatusToken { + code: "status.reference_malformed", + }); + } } - Ok(()) + Ok(disclosed_status) } struct HolderBindingContext<'a> { diff --git a/crates/registry-notary-client/tests/status_verifier_contract.rs b/crates/registry-notary-client/tests/status_verifier_contract.rs new file mode 100644 index 000000000..83c53e94a --- /dev/null +++ b/crates/registry-notary-client/tests/status_verifier_contract.rs @@ -0,0 +1,527 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(all(feature = "verifier", feature = "test-support"))] + +use std::collections::BTreeMap; +use std::io::Write; +use std::sync::{Arc, Mutex}; + +use axum::body::Body; +use axum::extract::State; +use axum::http::{header, HeaderValue, Response, StatusCode}; +use axum::routing::get; +use axum::{Json, Router}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use flate2::write::ZlibEncoder; +use flate2::Compression; +use registry_notary_client::verifier; +use registry_notary_client::{ + RegistryNotaryClient, StatusListPolicy, VerificationError, VerifyOptions, +}; +use registry_platform_crypto::PrivateJwk; +use registry_platform_sdjwt::{Disclosure, SdJwtIssuanceInput, SdJwtIssuer}; +use serde_json::{json, Value}; +use time::OffsetDateTime; +use tokio::net::TcpListener; + +const ISSUER: &str = "did:web:issuer.test"; +const VCT: &str = "https://vct.example/test"; +const NOW: i64 = 1_700_000_010; +const ISSUER_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"}"#; + +#[derive(Clone)] +struct HarnessState { + jwks: Value, + status: Arc>, +} + +#[derive(Clone)] +struct StatusHttpResponse { + status: StatusCode, + content_type: Option<&'static str>, + content_encoding: Option<&'static str>, + location: Option<&'static str>, + body: String, +} + +impl Default for StatusHttpResponse { + fn default() -> Self { + Self { + status: StatusCode::SERVICE_UNAVAILABLE, + content_type: Some("application/statuslist+jwt"), + content_encoding: None, + location: None, + body: String::new(), + } + } +} + +struct StatusHarness { + base_url: String, + status_uri: String, + state: HarnessState, + client: RegistryNotaryClient, +} + +impl StatusHarness { + async fn start() -> Self { + let state = HarnessState { + jwks: jwks(), + status: Arc::new(Mutex::new(StatusHttpResponse::default())), + }; + let app = Router::new() + .route("/.well-known/evidence/jwks.json", get(jwks_handler)) + .route("/status", get(status_handler)) + .with_state(state.clone()); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener binds"); + let address = listener.local_addr().expect("test listener has address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("test server remains available"); + }); + let base_url = format!("http://{address}"); + let status_uri = format!("{base_url}/status"); + let client = RegistryNotaryClient::builder(&base_url) + .build() + .expect("test client builds"); + Self { + base_url, + status_uri, + state, + client, + } + } + + fn options(&self) -> VerifyOptions { + VerifyOptions::new(ISSUER) + .now(OffsetDateTime::from_unix_timestamp(NOW).expect("test time is valid")) + .status_list( + StatusListPolicy::loopback_for_testing(ISSUER, &self.base_url) + .expect("loopback test policy is valid"), + ) + } + + async fn credential(&self, status_uri: &str, index: u64) -> String { + issuer() + .issue(SdJwtIssuanceInput { + iss: ISSUER.to_string(), + sub_ref: "subject-ref".to_string(), + credential_id: Some("urn:ulid:01HG0000000000000000000000".to_string()), + iat: NOW, + exp: NOW + 600, + vct: VCT.to_string(), + status: Some(json!({ + "status_list": { + "idx": index, + "uri": status_uri, + } + })), + public_claims: BTreeMap::new(), + cnf: None, + disclosures: vec![Disclosure { + name: "claim-a".to_string(), + value: json!({"satisfied": true}), + }], + }) + .await + .expect("status-bearing credential issues") + .jwt + } + + async fn signed_status(&self, payload: Value) -> String { + issuer() + .sign_compact_jwt("statuslist+jwt", payload) + .await + .expect("status token signs") + } + + fn valid_payload(&self, encoded_list: &str) -> Value { + json!({ + "iss": ISSUER, + "sub": self.status_uri, + "aud": self.status_uri, + "iat": NOW, + "exp": NOW + 100, + "ttl": 100, + "status_list": { + "bits": 8, + "lst": encoded_list, + } + }) + } + + fn respond(&self, response: StatusHttpResponse) { + *self.state.status.lock().expect("status response lock") = response; + } + + async fn respond_with_token(&self, token: String) { + self.respond(StatusHttpResponse { + status: StatusCode::OK, + body: token, + ..StatusHttpResponse::default() + }); + } +} + +#[tokio::test] +async fn async_verifier_accepts_signed_valid_status() { + let harness = StatusHarness::start().await; + let credential = harness.credential(&harness.status_uri, 0).await; + let token = harness + .signed_status(harness.valid_payload("eJxjAAAAAQAB")) + .await; + harness.respond_with_token(token).await; + + let verified = harness + .client + .verify_sd_jwt_vc(&credential, harness.options()) + .await + .expect("valid status-bearing credential verifies"); + + assert_eq!(verified.issuer, ISSUER); +} + +#[tokio::test] +async fn synchronous_verifier_never_skips_status() { + let harness = StatusHarness::start().await; + let credential = harness.credential(&harness.status_uri, 0).await; + + let missing_policy = verifier::verify_sd_jwt_vc( + &credential, + &jwks(), + &VerifyOptions::new(ISSUER) + .now(OffsetDateTime::from_unix_timestamp(NOW).expect("test time is valid")), + ) + .expect_err("status cannot be skipped without policy"); + assert_code(missing_policy, "status.policy_required"); + + let requires_fetch = verifier::verify_sd_jwt_vc(&credential, &jwks(), &harness.options()) + .expect_err("synchronous verification cannot skip the fetch"); + assert_code(requires_fetch, "status.fetch_required"); +} + +#[tokio::test] +async fn revoked_suspended_and_unknown_status_fail_closed() { + for (encoded, expected_code) in [ + ("eJxjBAAAAgAC", "status.revoked"), + ("eJxjAgAAAwAD", "status.suspended"), + (&encoded_status_list(&[3]), "status.unknown"), + ] { + let harness = StatusHarness::start().await; + let credential = harness.credential(&harness.status_uri, 0).await; + let token = harness.signed_status(harness.valid_payload(encoded)).await; + harness.respond_with_token(token).await; + + let error = harness + .client + .verify_sd_jwt_vc(&credential, harness.options()) + .await + .expect_err("non-valid status is rejected"); + assert_code(error, expected_code); + } +} + +#[tokio::test] +async fn invalid_status_claims_signature_index_and_compression_fail_closed() { + let harness = StatusHarness::start().await; + let mut cases = Vec::new(); + + let mut wrong_issuer = harness.valid_payload("eJxjAAAAAQAB"); + wrong_issuer["iss"] = json!("did:web:other.example"); + cases.push((wrong_issuer, 0, "status.claim.issuer_mismatch")); + + let mut wrong_uri = harness.valid_payload("eJxjAAAAAQAB"); + wrong_uri["sub"] = json!(format!("{}/other", harness.base_url)); + cases.push((wrong_uri, 0, "status.claim.uri_mismatch")); + + let mut wrong_audience = harness.valid_payload("eJxjAAAAAQAB"); + wrong_audience["aud"] = json!("https://verifier.example"); + cases.push((wrong_audience, 0, "status.claim.audience_mismatch")); + + let mut stale = harness.valid_payload("eJxjAAAAAQAB"); + stale["iat"] = json!(NOW - 400); + stale["exp"] = json!(NOW - 300); + cases.push((stale, 0, "status.claim.time_invalid")); + + let mut excessive_lifetime = harness.valid_payload("eJxjAAAAAQAB"); + excessive_lifetime["exp"] = json!(NOW + 301); + excessive_lifetime["ttl"] = json!(301); + cases.push((excessive_lifetime, 0, "status.claim.time_invalid")); + + cases.push(( + harness.valid_payload("eJxjAAAAAQAB"), + 1, + "status.index.invalid", + )); + + let decompression_bomb = encoded_status_list(&vec![0; 128 * 1024 + 1]); + cases.push(( + harness.valid_payload(&decompression_bomb), + 0, + "status.list.decompression_limit", + )); + + for (payload, index, expected_code) in cases { + let credential = harness.credential(&harness.status_uri, index).await; + let token = harness.signed_status(payload).await; + harness.respond_with_token(token).await; + let error = harness + .client + .verify_sd_jwt_vc(&credential, harness.options()) + .await + .expect_err("invalid status material is rejected"); + assert_code(error, expected_code); + } + + let credential = harness.credential(&harness.status_uri, 0).await; + let token = harness + .signed_status(harness.valid_payload("eJxjAAAAAQAB")) + .await; + harness.respond_with_token(tamper_signature(&token)).await; + let error = harness + .client + .verify_sd_jwt_vc(&credential, harness.options()) + .await + .expect_err("invalid status signature is rejected"); + assert_code(error, "status.signature.invalid"); + + let valid_token = harness + .signed_status(harness.valid_payload("eJxjAAAAAQAB")) + .await; + for (token, expected_code) in [ + ( + rewrite_status_header(&valid_token, |header| { + header["kid"] = json!("did:web:issuer.test#unknown") + }), + "status.key.unknown", + ), + ( + rewrite_status_header(&valid_token, |header| header["alg"] = json!("RS256")), + "status.algorithm.disallowed", + ), + ( + rewrite_status_header(&valid_token, |header| { + header["jku"] = json!("https://attacker.example/jwks.json") + }), + "status.header.untrusted_key_reference", + ), + ] { + harness.respond_with_token(token).await; + let error = harness + .client + .verify_sd_jwt_vc(&credential, harness.options()) + .await + .expect_err("untrusted status signing metadata is rejected"); + assert_code(error, expected_code); + } +} + +#[tokio::test] +async fn status_transport_rejects_redirect_media_type_encoding_and_size() { + let harness = StatusHarness::start().await; + let credential = harness.credential(&harness.status_uri, 0).await; + + let cases = [ + ( + StatusHttpResponse { + status: StatusCode::SERVICE_UNAVAILABLE, + ..StatusHttpResponse::default() + }, + "status.http_status_invalid", + ), + ( + StatusHttpResponse { + status: StatusCode::FOUND, + location: Some("https://other.example/status"), + ..StatusHttpResponse::default() + }, + "status.redirect_denied", + ), + ( + StatusHttpResponse { + status: StatusCode::OK, + content_type: Some("application/json"), + body: "not-a-token".to_string(), + ..StatusHttpResponse::default() + }, + "status.media_type_invalid", + ), + ( + StatusHttpResponse { + status: StatusCode::OK, + content_encoding: Some("gzip"), + body: "not-a-token".to_string(), + ..StatusHttpResponse::default() + }, + "status.content_encoding_denied", + ), + ( + StatusHttpResponse { + status: StatusCode::OK, + body: "not-a-compact-jwt".to_string(), + ..StatusHttpResponse::default() + }, + "status.token_malformed", + ), + ( + StatusHttpResponse { + status: StatusCode::OK, + body: "x".repeat(256 * 1024 + 1), + ..StatusHttpResponse::default() + }, + "status.response_too_large", + ), + ]; + + for (response, expected_code) in cases { + harness.respond(response); + let error = harness + .client + .verify_sd_jwt_vc(&credential, harness.options()) + .await + .expect_err("unsafe status response is rejected"); + assert_code(error, expected_code); + } +} + +#[tokio::test] +async fn status_origin_and_destination_must_be_explicit_and_safe() { + let harness = StatusHarness::start().await; + let untrusted_uri = "https://status.other.example/status"; + let credential = harness.credential(untrusted_uri, 0).await; + let error = harness + .client + .verify_sd_jwt_vc(&credential, harness.options()) + .await + .expect_err("unlisted status origin is rejected before fetch"); + assert_code(error, "status.origin_untrusted"); + + let unsafe_uri = "https://127.0.0.1/status"; + let credential = harness.credential(unsafe_uri, 0).await; + let options = VerifyOptions::new(ISSUER) + .now(OffsetDateTime::from_unix_timestamp(NOW).expect("test time is valid")) + .status_list( + StatusListPolicy::new(ISSUER, "https://127.0.0.1") + .expect("structurally valid HTTPS origin"), + ); + let error = harness + .client + .verify_sd_jwt_vc(&credential, options) + .await + .expect_err("private destination is rejected"); + assert_code(error, "status.destination_unsafe"); + + let closed_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("closed-port probe binds"); + let closed_address = closed_listener + .local_addr() + .expect("closed-port probe has address"); + drop(closed_listener); + let unreachable_origin = format!("http://{closed_address}"); + let unreachable_uri = format!("{unreachable_origin}/status"); + let credential = harness.credential(&unreachable_uri, 0).await; + let options = VerifyOptions::new(ISSUER) + .now(OffsetDateTime::from_unix_timestamp(NOW).expect("test time is valid")) + .status_list( + StatusListPolicy::loopback_for_testing(ISSUER, unreachable_origin) + .expect("loopback test policy is valid"), + ); + let error = harness + .client + .verify_sd_jwt_vc(&credential, options) + .await + .expect_err("unreachable status endpoint is rejected"); + assert_code(error, "status.unreachable"); +} + +#[test] +fn status_policy_requires_exact_https_origins() { + assert!(StatusListPolicy::new(ISSUER, "http://status.example").is_err()); + assert!(StatusListPolicy::new(ISSUER, "https://status.example/path").is_err()); + assert!(StatusListPolicy::new(ISSUER, "https://user@status.example").is_err()); + assert!(StatusListPolicy::new(ISSUER, "https://status.example") + .expect("primary origin is accepted") + .allow_origin("https://status-backup.example:8443") + .is_ok()); +} + +fn issuer() -> SdJwtIssuer { + SdJwtIssuer::from_jwk(PrivateJwk::parse(ISSUER_JWK).expect("issuer JWK parses")) + .expect("issuer builds") +} + +fn jwks() -> Value { + let public = PrivateJwk::parse(ISSUER_JWK) + .expect("issuer JWK parses") + .public(); + json!({"keys": [serde_json::to_value(public).expect("public JWK serializes")]}) +} + +fn encoded_status_list(bytes: &[u8]) -> String { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(bytes).expect("status list compresses"); + URL_SAFE_NO_PAD.encode(encoder.finish().expect("status compression finishes")) +} + +fn rewrite_status_header(token: &str, mutate: impl FnOnce(&mut Value)) -> String { + let mut parts = token.split('.'); + let encoded_header = parts.next().expect("status token has header"); + let payload = parts.next().expect("status token has payload"); + let signature = parts.next().expect("status token has signature"); + assert!(parts.next().is_none()); + let mut header: Value = serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(encoded_header) + .expect("status header decodes"), + ) + .expect("status header is JSON"); + mutate(&mut header); + let header = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header serializes")); + format!("{header}.{payload}.{signature}") +} + +fn tamper_signature(token: &str) -> String { + let mut parts = token.split('.'); + let header = parts.next().expect("status token has header"); + let payload = parts.next().expect("status token has payload"); + let encoded_signature = parts.next().expect("status token has signature"); + assert!(parts.next().is_none()); + let mut signature = URL_SAFE_NO_PAD + .decode(encoded_signature) + .expect("status signature decodes"); + signature[0] ^= 1; + format!("{header}.{payload}.{}", URL_SAFE_NO_PAD.encode(signature)) +} + +fn assert_code(error: VerificationError, expected: &str) { + assert_eq!(error.code(), expected, "unexpected verifier error: {error}"); +} + +async fn jwks_handler(State(state): State) -> Json { + Json(state.jwks) +} + +async fn status_handler(State(state): State) -> Response { + let response = state.status.lock().expect("status response lock").clone(); + let mut builder = Response::builder().status(response.status); + if let Some(content_type) = response.content_type { + builder = builder.header(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + } + if let Some(content_encoding) = response.content_encoding { + builder = builder.header( + header::CONTENT_ENCODING, + HeaderValue::from_static(content_encoding), + ); + } + if let Some(location) = response.location { + builder = builder.header(header::LOCATION, HeaderValue::from_static(location)); + } + builder + .body(Body::from(response.body)) + .expect("test response builds") +} diff --git a/crates/registry-notary-server/src/api/status.rs b/crates/registry-notary-server/src/api/status.rs index 774c0b21c..0fdd17732 100644 --- a/crates/registry-notary-server/src/api/status.rs +++ b/crates/registry-notary-server/src/api/status.rs @@ -104,7 +104,9 @@ pub(super) async fn credential_status_list_response( let cache_expires_at = status_list_jwt_cache_expires_at(record, &effective_status, token_expires_at); let payload = json!({ + "iss": record.issuer, "sub": status_url, + "aud": status_url, "iat": now.unix_timestamp(), "exp": token_expires_at.unix_timestamp(), "ttl": ttl_seconds, @@ -207,7 +209,7 @@ pub(super) fn status_list_jwt_cache_key( "typ": "statuslist+jwt", "issuer": record.issuer, "issuer_public_jwk_sha256": public_jwk_hash, - "audience": Value::Null, + "audience": status_url, "credential_id": record.credential_id, "credential_profile": record.credential_profile, "status_url": status_url, diff --git a/crates/registry-notary-server/src/api/tests/status.rs b/crates/registry-notary-server/src/api/tests/status.rs index a898e6aec..ae7ffa7f0 100644 --- a/crates/registry-notary-server/src/api/tests/status.rs +++ b/crates/registry-notary-server/src/api/tests/status.rs @@ -59,10 +59,15 @@ async fn credential_status_list_response_is_signed_status_list_jwt() { let payload = decode_jwt_payload(jwt); assert_eq!(header["typ"], json!("statuslist+jwt")); assert_eq!(header["kid"], json!("did:web:issuer.example#key-1")); + assert_eq!(payload["iss"], json!("did:web:issuer.example")); assert_eq!( payload["sub"], json!("https://issuer.example/v1/credentials/credential-1/status") ); + assert_eq!( + payload["aud"], + json!("https://issuer.example/v1/credentials/credential-1/status") + ); assert_eq!(payload["ttl"], json!(300)); assert_eq!(payload["status_list"]["bits"], json!(8)); assert_eq!(payload["status_list"]["lst"], json!("eJxjAAAAAQAB")); From 0fc6531b1bbaf0c01f5e26b44222271b50547255 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 17:20:54 +0700 Subject: [PATCH 34/65] test(notary): prove OID4VCI algorithm profile Signed-off-by: Jeremi Joslin --- .../tests/sd_jwt_vc_verifier_compat.rs | 167 +++++++++++++++++- .../fixtures/sd_jwt_vc/algorithm-profile.json | 44 +++++ .../holder-eddsa-private.test.jwk.json | 8 + .../fixtures/sd_jwt_vc/holder-proof-eddsa.jwt | 1 + .../holder-proof-es256-unsupported.jwt | 1 + .../issuer-eddsa-private.test.jwk.json | 8 + .../issuer-es256-private.test.jwk.json | 9 + .../tests/fixtures/sd_jwt_vc/issuer-jwks.json | 10 +- .../notary/tests/fixtures/sd_jwt_vc/meta.json | 3 +- .../fixtures/sd_jwt_vc/valid-es256.sd-jwt | 1 + products/notary/xtask/src/main.rs | 143 ++++++++++++++- 11 files changed, 390 insertions(+), 5 deletions(-) create mode 100644 products/notary/tests/fixtures/sd_jwt_vc/algorithm-profile.json create mode 100644 products/notary/tests/fixtures/sd_jwt_vc/holder-eddsa-private.test.jwk.json create mode 100644 products/notary/tests/fixtures/sd_jwt_vc/holder-proof-eddsa.jwt create mode 100644 products/notary/tests/fixtures/sd_jwt_vc/holder-proof-es256-unsupported.jwt create mode 100644 products/notary/tests/fixtures/sd_jwt_vc/issuer-eddsa-private.test.jwk.json create mode 100644 products/notary/tests/fixtures/sd_jwt_vc/issuer-es256-private.test.jwk.json create mode 100644 products/notary/tests/fixtures/sd_jwt_vc/valid-es256.sd-jwt diff --git a/crates/registry-notary-server/tests/sd_jwt_vc_verifier_compat.rs b/crates/registry-notary-server/tests/sd_jwt_vc_verifier_compat.rs index 39f4beb62..acdc02d71 100644 --- a/crates/registry-notary-server/tests/sd_jwt_vc_verifier_compat.rs +++ b/crates/registry-notary-server/tests/sd_jwt_vc_verifier_compat.rs @@ -9,16 +9,23 @@ //! Run the harness with: //! cargo nextest run -p registry-notary-server sd_jwt_vc_verifier_compat //! or: -//! cargo test -p registry-notary-server sd_jwt_vc_verifier_compat +//! cargo test -p registry-notary-server --test sd_jwt_vc_verifier_compat //! //! The harness requires no secret material and no network access. //! Fixtures are pre-generated synthetic material. Regenerate them with: //! cargo run -p xtask -- gen-sd-jwt-vc-fixtures +//! or regenerate only the deterministic 1.0 algorithm fixtures with: +//! cargo run -p xtask -- gen-oid4vci-algorithm-fixtures use std::path::Path; +use std::time::Duration; use registry_notary_client::verifier::verify_sd_jwt_vc; use registry_notary_client::{HolderBindingPolicy, VerifyOptions}; +use registry_platform_crypto::{did_jwk_from_public_jwk, PrivateJwk}; +use registry_platform_oid4vci::{ + validate_proof_jwt, ProofError, ProofIssuerPolicy, ProofValidationPolicy, +}; use serde_json::Value; // --------------------------------------------------------------------------- @@ -125,6 +132,164 @@ fn valid_holder_bound_credential_verifies_with_required_kid_policy() { ); } +#[test] +fn es256_credential_verifies_end_to_end_with_explicit_algorithm_policy() { + let compact = read_fixture("valid-es256.sd-jwt"); + let meta = meta(); + let holder_did = meta["holder_did"] + .as_str() + .expect("holder_did in meta") + .to_string(); + let options = base_options() + .accepted_algorithms(["ES256"]) + .holder_binding(HolderBindingPolicy::RequiredKid(holder_did.clone())); + + let result = verify_sd_jwt_vc(&compact, &jwks(), &options) + .expect("ES256 issuer fixture must verify when explicitly accepted"); + + assert_eq!(result.algorithm, "ES256"); + assert_eq!( + result.key_id, + meta["es256_key_id"].as_str().expect("es256_key_id in meta") + ); + assert_eq!(result.holder_key_id.as_deref(), Some(holder_did.as_str())); + assert_eq!(result.disclosure_count, 0); +} + +#[test] +fn es256_credential_is_not_silently_enabled_by_the_default_verifier_policy() { + let err = verify_sd_jwt_vc( + &read_fixture("valid-es256.sd-jwt"), + &jwks(), + &base_options(), + ) + .expect_err("ES256 must require an explicit verifier allow-list"); + + assert_eq!(err.code(), "algorithm.disallowed"); +} + +#[test] +fn oid4vci_algorithm_profile_is_exact_and_narrow() { + let profile = read_fixture_json("algorithm-profile.json"); + let configurations = &profile["credential_configurations"]; + + assert_eq!( + configurations["fixture_eddsa"], + serde_json::json!({ + "credential_signing_alg_values_supported": ["EdDSA"], + "proof_signing_alg_values_supported": ["EdDSA"], + "cryptographic_binding_methods_supported": ["did:jwk"], + "fixture": "valid-holder-bound.sd-jwt", + "private_jwk_fixture": "issuer-eddsa-private.test.jwk.json" + }) + ); + assert_eq!( + configurations["fixture_es256"], + serde_json::json!({ + "credential_signing_alg_values_supported": ["ES256"], + "proof_signing_alg_values_supported": ["EdDSA"], + "cryptographic_binding_methods_supported": ["did:jwk"], + "fixture": "valid-es256.sd-jwt", + "private_jwk_fixture": "issuer-es256-private.test.jwk.json" + }) + ); + assert_eq!( + profile["holder_proof"], + serde_json::json!({ + "signing_alg_values_supported": ["EdDSA"], + "cryptographic_binding_methods_supported": ["did:jwk"], + "fixture": "holder-proof-eddsa.jwt", + "private_jwk_fixture": "holder-eddsa-private.test.jwk.json", + "unsupported_fixture": "holder-proof-es256-unsupported.jwt" + }) + ); + assert_eq!( + configurations + .as_object() + .expect("credential_configurations is an object") + .len(), + 2, + "fixture profile must not imply additional credential algorithms" + ); +} + +#[test] +fn algorithm_private_key_fixtures_match_public_metadata() { + let profile = read_fixture_json("algorithm-profile.json"); + let jwks = jwks(); + let public_keys = jwks["keys"].as_array().expect("JWKS keys is an array"); + + for configuration_id in ["fixture_eddsa", "fixture_es256"] { + let configuration = &profile["credential_configurations"][configuration_id]; + let private_jwk = PrivateJwk::parse(&read_fixture( + configuration["private_jwk_fixture"] + .as_str() + .expect("private_jwk_fixture is configured"), + )) + .expect("private issuer fixture parses"); + let public_jwk = serde_json::to_value(private_jwk.public()).expect("public JWK serialises"); + let key_id = public_jwk["kid"].as_str().expect("public JWK has kid"); + let published = public_keys + .iter() + .find(|key| key["kid"] == key_id) + .expect("issuer public JWK is published"); + + assert_eq!(&public_jwk, published); + assert!( + published.get("d").is_none(), + "JWKS must not expose private key material" + ); + } + + let holder_private = PrivateJwk::parse(&read_fixture( + profile["holder_proof"]["private_jwk_fixture"] + .as_str() + .expect("holder private_jwk_fixture is configured"), + )) + .expect("holder private fixture parses"); + assert_eq!( + did_jwk_from_public_jwk(&holder_private.public()).expect("holder did:jwk encodes"), + meta()["holder_did"].as_str().expect("holder_did in meta") + ); +} + +#[test] +fn eddsa_did_jwk_holder_proof_validates_and_es256_holder_proof_is_rejected() { + let profile = read_fixture_json("algorithm-profile.json"); + let audience = profile["proof_audience"] + .as_str() + .expect("proof_audience in profile"); + let nonce = profile["proof_nonce"] + .as_str() + .expect("proof_nonce in profile"); + let policy = ProofValidationPolicy { + audience, + expected_nonce: Some(nonce), + issuer: ProofIssuerPolicy::Optional, + max_lifetime: Duration::from_secs(300), + future_skew: Duration::from_secs(30), + forbidden_holder_keys: &[], + }; + + let proof = validate_proof_jwt(&read_fixture("holder-proof-eddsa.jwt"), &policy, NOW_UNIX) + .expect("EdDSA did:jwk holder proof must validate"); + assert_eq!( + proof.holder_id, + meta()["holder_did"].as_str().expect("holder_did in meta") + ); + assert_eq!(proof.nonce.as_deref(), Some(nonce)); + + assert_eq!( + validate_proof_jwt( + &read_fixture("holder-proof-es256-unsupported.jwt"), + &policy, + NOW_UNIX, + ), + Err(ProofError::InvalidHeader), + "Registry Stack 1.0 must not accept ES256 holder proof" + ); +} + // --------------------------------------------------------------------------- // Negative fixtures: each must fail with the exact error code documented in // the conformance profile and verifier API. diff --git a/products/notary/tests/fixtures/sd_jwt_vc/algorithm-profile.json b/products/notary/tests/fixtures/sd_jwt_vc/algorithm-profile.json new file mode 100644 index 000000000..1d9111975 --- /dev/null +++ b/products/notary/tests/fixtures/sd_jwt_vc/algorithm-profile.json @@ -0,0 +1,44 @@ +{ + "credential_configurations": { + "fixture_eddsa": { + "credential_signing_alg_values_supported": [ + "EdDSA" + ], + "cryptographic_binding_methods_supported": [ + "did:jwk" + ], + "fixture": "valid-holder-bound.sd-jwt", + "private_jwk_fixture": "issuer-eddsa-private.test.jwk.json", + "proof_signing_alg_values_supported": [ + "EdDSA" + ] + }, + "fixture_es256": { + "credential_signing_alg_values_supported": [ + "ES256" + ], + "cryptographic_binding_methods_supported": [ + "did:jwk" + ], + "fixture": "valid-es256.sd-jwt", + "private_jwk_fixture": "issuer-es256-private.test.jwk.json", + "proof_signing_alg_values_supported": [ + "EdDSA" + ] + } + }, + "holder_proof": { + "cryptographic_binding_methods_supported": [ + "did:jwk" + ], + "fixture": "holder-proof-eddsa.jwt", + "private_jwk_fixture": "holder-eddsa-private.test.jwk.json", + "signing_alg_values_supported": [ + "EdDSA" + ], + "unsupported_fixture": "holder-proof-es256-unsupported.jwt" + }, + "note": "All key and token material is synthetic test-only material. Do not use outside tests.", + "proof_audience": "https://fixture.test/oid4vci", + "proof_nonce": "fixture-transaction-nonce" +} \ No newline at end of file diff --git a/products/notary/tests/fixtures/sd_jwt_vc/holder-eddsa-private.test.jwk.json b/products/notary/tests/fixtures/sd_jwt_vc/holder-eddsa-private.test.jwk.json new file mode 100644 index 000000000..753c2c8cc --- /dev/null +++ b/products/notary/tests/fixtures/sd_jwt_vc/holder-eddsa-private.test.jwk.json @@ -0,0 +1,8 @@ +{ + "alg": "EdDSA", + "crv": "Ed25519", + "d": "f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys", + "kid": "did:web:fixture.test#holder-key-1", + "kty": "OKP", + "x": "pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec" +} \ No newline at end of file diff --git a/products/notary/tests/fixtures/sd_jwt_vc/holder-proof-eddsa.jwt b/products/notary/tests/fixtures/sd_jwt_vc/holder-proof-eddsa.jwt new file mode 100644 index 000000000..8337c8a53 --- /dev/null +++ b/products/notary/tests/fixtures/sd_jwt_vc/holder-proof-eddsa.jwt @@ -0,0 +1 @@ +eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDpqd2s6ZXlKamNuWWlPaUpGWkRJMU5URTVJaXdpYTNSNUlqb2lUMHRRSWl3aWVDSTZJbkIyTkdWZmFGaElRa3hPTWpkeVkzTTJWa1JHVmpGRlJEQlVhVlU0VFRONGVUbDJjM1ZYUmtWelpXTWlmUSMwIiwidHlwIjoib3BlbmlkNHZjaS1wcm9vZitqd3QifQ.eyJhdWQiOiJodHRwczovL2ZpeHR1cmUudGVzdC9vaWQ0dmNpIiwiZXhwIjoxNzA1Mjc3MTAwLCJpYXQiOjE3MDUyNzY4MDAsIm5vbmNlIjoiZml4dHVyZS10cmFuc2FjdGlvbi1ub25jZSJ9.ZxPLxfPZTlWPMssHaKrNWLiaAfAYmIiGVp88J-N6PKE-0YXWB8AKUcLFA1_vKfftvkAugtl9cw2yBaupIeA9DA \ No newline at end of file diff --git a/products/notary/tests/fixtures/sd_jwt_vc/holder-proof-es256-unsupported.jwt b/products/notary/tests/fixtures/sd_jwt_vc/holder-proof-es256-unsupported.jwt new file mode 100644 index 000000000..d5b43d131 --- /dev/null +++ b/products/notary/tests/fixtures/sd_jwt_vc/holder-proof-es256-unsupported.jwt @@ -0,0 +1 @@ +eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDpqd2s6ZXlKcmRIa2lPaUpGUXlJc0ltdHBaQ0k2SW1ScFpEcDNaV0k2Wm1sNGRIVnlaUzUwWlhOMEkzQXlOVFl0YTJWNUxURWlMQ0poYkdjaU9pSkZVekkxTmlJc0ltTnlkaUk2SWxBdE1qVTJJaXdpZUNJNklqTnJjSHBCU3pabVN6WjRlV1p4WW1Sd01FaDJabHBEY1dabmVqZE5ZV3BOZG1sTGVVMDJZbk5PUlRRaUxDSjVJam9pUjJ0VFpGTnVPSGh4WjJVMU1uSndPVk4yTFRSeFVHRjNNVkU1VkVveVpVMVZlVmt5TW1ac1lYWk1WU0o5IzAiLCJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCJ9.eyJhdWQiOiJodHRwczovL2ZpeHR1cmUudGVzdC9vaWQ0dmNpIiwiZXhwIjoxNzA1Mjc3MTAwLCJpYXQiOjE3MDUyNzY4MDAsIm5vbmNlIjoiZml4dHVyZS10cmFuc2FjdGlvbi1ub25jZSJ9.Pt61ZPAazRYMjsdk8sLx6v7xoP2VT9CqPNzPggy7Bj_KbyJvA7feV35U99NQnVAmml22NRIuonsOkYHRaPgQXA \ No newline at end of file diff --git a/products/notary/tests/fixtures/sd_jwt_vc/issuer-eddsa-private.test.jwk.json b/products/notary/tests/fixtures/sd_jwt_vc/issuer-eddsa-private.test.jwk.json new file mode 100644 index 000000000..0a9500160 --- /dev/null +++ b/products/notary/tests/fixtures/sd_jwt_vc/issuer-eddsa-private.test.jwk.json @@ -0,0 +1,8 @@ +{ + "alg": "EdDSA", + "crv": "Ed25519", + "d": "2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw", + "kid": "did:web:fixture.test#key-1", + "kty": "OKP", + "x": "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc" +} \ No newline at end of file diff --git a/products/notary/tests/fixtures/sd_jwt_vc/issuer-es256-private.test.jwk.json b/products/notary/tests/fixtures/sd_jwt_vc/issuer-es256-private.test.jwk.json new file mode 100644 index 000000000..d938912e9 --- /dev/null +++ b/products/notary/tests/fixtures/sd_jwt_vc/issuer-es256-private.test.jwk.json @@ -0,0 +1,9 @@ +{ + "alg": "ES256", + "crv": "P-256", + "d": "MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4", + "kid": "did:web:fixture.test#p256-key-1", + "kty": "EC", + "x": "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4", + "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU" +} \ No newline at end of file diff --git a/products/notary/tests/fixtures/sd_jwt_vc/issuer-jwks.json b/products/notary/tests/fixtures/sd_jwt_vc/issuer-jwks.json index c3bf0ba95..2fcc6c370 100644 --- a/products/notary/tests/fixtures/sd_jwt_vc/issuer-jwks.json +++ b/products/notary/tests/fixtures/sd_jwt_vc/issuer-jwks.json @@ -6,6 +6,14 @@ "kid": "did:web:fixture.test#key-1", "kty": "OKP", "x": "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc" + }, + { + "alg": "ES256", + "crv": "P-256", + "kid": "did:web:fixture.test#p256-key-1", + "kty": "EC", + "x": "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4", + "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU" } ] -} \ No newline at end of file +} diff --git a/products/notary/tests/fixtures/sd_jwt_vc/meta.json b/products/notary/tests/fixtures/sd_jwt_vc/meta.json index 5433e14c4..bfebdff49 100644 --- a/products/notary/tests/fixtures/sd_jwt_vc/meta.json +++ b/products/notary/tests/fixtures/sd_jwt_vc/meta.json @@ -1,9 +1,10 @@ { "exp": 1705280400, + "es256_key_id": "did:web:fixture.test#p256-key-1", "holder_did": "did:jwk:eyJjcnYiOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwieCI6InB2NGVfaFhIQkxOMjdyY3M2VkRGVjFFRDBUaVU4TTN4eTl2c3VXRkVzZWMifQ", "iat": 1705276800, "issuer": "did:web:fixture.test", "key_id": "did:web:fixture.test#key-1", "note": "All key material in this directory is synthetic test-only material. Do not use outside tests.", "vct": "https://fixture.test/credentials/registry-witness/v1" -} \ No newline at end of file +} diff --git a/products/notary/tests/fixtures/sd_jwt_vc/valid-es256.sd-jwt b/products/notary/tests/fixtures/sd_jwt_vc/valid-es256.sd-jwt new file mode 100644 index 000000000..47ca918ff --- /dev/null +++ b/products/notary/tests/fixtures/sd_jwt_vc/valid-es256.sd-jwt @@ -0,0 +1 @@ +eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDp3ZWI6Zml4dHVyZS50ZXN0I3AyNTYta2V5LTEiLCJ0eXAiOiJkYytzZC1qd3QifQ.eyJfc2QiOltdLCJfc2RfYWxnIjoic2hhLTI1NiIsImNuZiI6eyJqd2siOnsiYWxnIjoiRWREU0EiLCJjcnYiOiJFZDI1NTE5Iiwia2lkIjoiZGlkOndlYjpmaXh0dXJlLnRlc3QjaG9sZGVyLWtleS0xIiwia3R5IjoiT0tQIiwieCI6InB2NGVfaFhIQkxOMjdyY3M2VkRGVjFFRDBUaVU4TTN4eTl2c3VXRkVzZWMifSwia2lkIjoiZGlkOmp3azpleUpqY25ZaU9pSkZaREkxTlRFNUlpd2lhM1I1SWpvaVQwdFFJaXdpZUNJNkluQjJOR1ZmYUZoSVFreE9NamR5WTNNMlZrUkdWakZGUkRCVWFWVTRUVE40ZVRsMmMzVlhSa1Z6WldNaWZRIn0sImV4cCI6MTcwNTI4MDQwMCwiaWF0IjoxNzA1Mjc2ODAwLCJpZCI6InVybjp1bGlkOjAxSEcwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwiaXNzIjoiZGlkOndlYjpmaXh0dXJlLnRlc3QiLCJqdGkiOiJ1cm46dWxpZDowMUhHMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCIsInN1YiI6ImRpZDpqd2s6ZXlKamNuWWlPaUpGWkRJMU5URTVJaXdpYTNSNUlqb2lUMHRRSWl3aWVDSTZJbkIyTkdWZmFGaElRa3hPTWpkeVkzTTJWa1JHVmpGRlJEQlVhVlU0VFRONGVUbDJjM1ZYUmtWelpXTWlmUSIsInZjdCI6Imh0dHBzOi8vZml4dHVyZS50ZXN0L2NyZWRlbnRpYWxzL3JlZ2lzdHJ5LXdpdG5lc3MvdjEifQ.p-YA4EwxXa1LLBQLm7vUGF8Y2jBHPpneRtOg8ZgC_zq1myfQUa6cPP9aQwActI3zeURcFZBnNuMiAm0bCDHZnA~~ \ No newline at end of file diff --git a/products/notary/xtask/src/main.rs b/products/notary/xtask/src/main.rs index 0c60370a7..12d231db3 100644 --- a/products/notary/xtask/src/main.rs +++ b/products/notary/xtask/src/main.rs @@ -3,6 +3,7 @@ //! //! Run with: //! cargo run -p xtask -- gen-sd-jwt-vc-fixtures +//! cargo run -p xtask -- gen-oid4vci-algorithm-fixtures //! //! Writes synthetic fixtures to tests/fixtures/sd_jwt_vc/. //! Keys and timestamps are fixed so the JWTs are reproducible, but @@ -26,6 +27,7 @@ use serde_json::{json, Value}; // are fully reproducible without a random source at generation time. These keys // are test-only material and carry no trust outside of the fixture set. const ISSUER_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"did:web:fixture.test#key-1"}"#; +const ES256_ISSUER_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"did:web:fixture.test#p256-key-1"}"#; const HOLDER_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys","x":"pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec","alg":"EdDSA","kid":"did:web:fixture.test#holder-key-1"}"#; const ISSUER: &str = "did:web:fixture.test"; @@ -34,6 +36,8 @@ const VCT: &str = "https://fixture.test/credentials/registry-witness/v1"; // harness can override the verifier clock to match. const IAT: i64 = 1_705_276_800; const EXP: i64 = 1_705_276_800 + 3600; +const PROOF_AUDIENCE: &str = "https://fixture.test/oid4vci"; +const PROOF_NONCE: &str = "fixture-transaction-nonce"; #[tokio::main] async fn main() { @@ -41,8 +45,12 @@ async fn main() { let cmd = args.get(1).map(String::as_str).unwrap_or(""); match cmd { "gen-sd-jwt-vc-fixtures" => generate_fixtures().await, + "gen-oid4vci-algorithm-fixtures" => generate_algorithm_fixtures().await, _ => { - eprintln!("usage: cargo run -p xtask -- gen-sd-jwt-vc-fixtures"); + eprintln!( + "usage: cargo run -p xtask -- \ + " + ); std::process::exit(1); } } @@ -54,6 +62,7 @@ async fn generate_fixtures() { std::fs::create_dir_all(&fixture_dir).expect("fixture dir created"); let issuer_jwk = PrivateJwk::parse(ISSUER_JWK).expect("issuer jwk parses"); + let es256_issuer_jwk = PrivateJwk::parse(ES256_ISSUER_JWK).expect("ES256 issuer jwk parses"); let holder_jwk = PrivateJwk::parse(HOLDER_JWK).expect("holder jwk parses"); let issuer = SdJwtIssuer::from_jwk(issuer_jwk.clone()).expect("issuer builds"); let holder_did = did_jwk_from_public_jwk(&holder_jwk.public()).expect("holder did encodes"); @@ -61,7 +70,9 @@ async fn generate_fixtures() { // Write the public issuer JWKS so the harness can load it without // embedding raw key material. let public_jwk = serde_json::to_value(issuer_jwk.public()).expect("public jwk serialises"); - let jwks = json!({ "keys": [public_jwk] }); + let es256_public_jwk = + serde_json::to_value(es256_issuer_jwk.public()).expect("ES256 public jwk serialises"); + let jwks = json!({ "keys": [public_jwk, es256_public_jwk] }); write_fixture( &fixture_dir, "issuer-jwks.json", @@ -77,6 +88,7 @@ async fn generate_fixtures() { "exp": EXP, "holder_did": holder_did, "key_id": "did:web:fixture.test#key-1", + "es256_key_id": "did:web:fixture.test#p256-key-1", "note": "All key material in this directory is synthetic test-only material. Do not use outside tests." }); write_fixture( @@ -199,9 +211,113 @@ async fn generate_fixtures() { ); write_fixture(&fixture_dir, "holder-proof-mismatch.sd-jwt", &bad_kb); + generate_algorithm_fixtures().await; println!("fixtures written to {}", fixture_dir.display()); } +/// Generate only the deterministic fixtures for the narrowed Registry Stack +/// 1.0 algorithm profile. Keeping this command separate avoids rewriting the +/// older disclosure fixtures, whose random salts are intentionally not stable. +async fn generate_algorithm_fixtures() { + let fixture_dir = workspace_root().join("tests/fixtures/sd_jwt_vc"); + std::fs::create_dir_all(&fixture_dir).expect("fixture dir created"); + + let es256_issuer_jwk = PrivateJwk::parse(ES256_ISSUER_JWK).expect("ES256 issuer jwk parses"); + let es256_issuer = + SdJwtIssuer::from_jwk(es256_issuer_jwk.clone()).expect("ES256 issuer builds"); + let holder_jwk = PrivateJwk::parse(HOLDER_JWK).expect("holder jwk parses"); + let holder_did = did_jwk_from_public_jwk(&holder_jwk.public()).expect("holder did encodes"); + + // These private keys are synthetic test material already embedded in this + // generator. Publishing them as fixtures lets the transaction harness + // exercise the same exact key pairs without duplicating constants. + for (name, raw) in [ + ("issuer-eddsa-private.test.jwk.json", ISSUER_JWK), + ("issuer-es256-private.test.jwk.json", ES256_ISSUER_JWK), + ("holder-eddsa-private.test.jwk.json", HOLDER_JWK), + ] { + let key: Value = serde_json::from_str(raw).expect("private fixture JWK parses as JSON"); + write_fixture( + &fixture_dir, + name, + &serde_json::to_string_pretty(&key).expect("private fixture JWK serialises"), + ); + } + + // No selective disclosures are needed for this fixture. That keeps the + // compact credential byte-for-byte reproducible while still exercising + // issuer signing, cnf holder binding, and verifier algorithm dispatch. + let valid_es256 = issue( + &es256_issuer, + ISSUER, + IAT, + EXP, + Some((&holder_did, &holder_jwk)), + &[], + None, + ) + .await; + write_fixture(&fixture_dir, "valid-es256.sd-jwt", &valid_es256); + + let holder_proof = oid4vci_proof_jwt(&holder_jwk, "EdDSA", &holder_did); + write_fixture(&fixture_dir, "holder-proof-eddsa.jwt", &holder_proof); + + let es256_holder_jwk = + PrivateJwk::parse(ES256_ISSUER_JWK).expect("ES256 holder fixture key parses"); + // The platform intentionally refuses to construct an ES256 holder DID. + // Encode the synthetic public key directly so the negative fixture proves + // that the credential-endpoint validator rejects this out-of-profile proof. + let es256_holder_did = format!( + "did:jwk:{}", + URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&es256_holder_jwk.public()) + .expect("ES256 holder public JWK serialises") + ) + ); + let unsupported_holder_proof = oid4vci_proof_jwt(&es256_holder_jwk, "ES256", &es256_holder_did); + write_fixture( + &fixture_dir, + "holder-proof-es256-unsupported.jwt", + &unsupported_holder_proof, + ); + + let profile = json!({ + "credential_configurations": { + "fixture_eddsa": { + "credential_signing_alg_values_supported": ["EdDSA"], + "proof_signing_alg_values_supported": ["EdDSA"], + "cryptographic_binding_methods_supported": ["did:jwk"], + "fixture": "valid-holder-bound.sd-jwt", + "private_jwk_fixture": "issuer-eddsa-private.test.jwk.json" + }, + "fixture_es256": { + "credential_signing_alg_values_supported": ["ES256"], + "proof_signing_alg_values_supported": ["EdDSA"], + "cryptographic_binding_methods_supported": ["did:jwk"], + "fixture": "valid-es256.sd-jwt", + "private_jwk_fixture": "issuer-es256-private.test.jwk.json" + } + }, + "holder_proof": { + "signing_alg_values_supported": ["EdDSA"], + "cryptographic_binding_methods_supported": ["did:jwk"], + "fixture": "holder-proof-eddsa.jwt", + "private_jwk_fixture": "holder-eddsa-private.test.jwk.json", + "unsupported_fixture": "holder-proof-es256-unsupported.jwt" + }, + "proof_audience": PROOF_AUDIENCE, + "proof_nonce": PROOF_NONCE, + "note": "All key and token material is synthetic test-only material. Do not use outside tests." + }); + write_fixture( + &fixture_dir, + "algorithm-profile.json", + &serde_json::to_string_pretty(&profile).expect("algorithm profile serialises"), + ); + + println!("algorithm fixtures written to {}", fixture_dir.display()); +} + async fn issue( issuer: &SdJwtIssuer, iss: &str, @@ -327,6 +443,29 @@ fn bad_key_binding_jwt(wrong_key: &PrivateJwk, iat: i64) -> String { format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) } +fn oid4vci_proof_jwt(key: &PrivateJwk, alg: &str, holder_did: &str) -> String { + let header = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "alg": alg, + "typ": "openid4vci-proof+jwt", + "kid": format!("{holder_did}#0") + })) + .expect("proof header serialises"), + ); + let payload = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "aud": PROOF_AUDIENCE, + "iat": IAT, + "exp": IAT + 300, + "nonce": PROOF_NONCE + })) + .expect("proof payload serialises"), + ); + let signing_input = format!("{header}.{payload}"); + let signature = sign(signing_input.as_bytes(), key).expect("proof signs"); + format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) +} + fn write_fixture(dir: &std::path::Path, name: &str, content: &str) { let path = dir.join(name); std::fs::write(&path, content).expect("fixture write succeeds"); From e9ef6b74859f85cee74c2260ce7918035c8f1fc6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 17:25:31 +0700 Subject: [PATCH 35/65] test(notary): make algorithm fixtures self-contained Signed-off-by: Jeremi Joslin --- .../tests/fixtures/sd_jwt_vc/issuer-jwks.json | 2 +- .../notary/tests/fixtures/sd_jwt_vc/meta.json | 4 +- products/notary/xtask/src/main.rs | 71 +++++++++++-------- 3 files changed, 45 insertions(+), 32 deletions(-) diff --git a/products/notary/tests/fixtures/sd_jwt_vc/issuer-jwks.json b/products/notary/tests/fixtures/sd_jwt_vc/issuer-jwks.json index 2fcc6c370..6a29499dc 100644 --- a/products/notary/tests/fixtures/sd_jwt_vc/issuer-jwks.json +++ b/products/notary/tests/fixtures/sd_jwt_vc/issuer-jwks.json @@ -16,4 +16,4 @@ "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU" } ] -} +} \ No newline at end of file diff --git a/products/notary/tests/fixtures/sd_jwt_vc/meta.json b/products/notary/tests/fixtures/sd_jwt_vc/meta.json index bfebdff49..7996a33aa 100644 --- a/products/notary/tests/fixtures/sd_jwt_vc/meta.json +++ b/products/notary/tests/fixtures/sd_jwt_vc/meta.json @@ -1,10 +1,10 @@ { - "exp": 1705280400, "es256_key_id": "did:web:fixture.test#p256-key-1", + "exp": 1705280400, "holder_did": "did:jwk:eyJjcnYiOiJFZDI1NTE5Iiwia3R5IjoiT0tQIiwieCI6InB2NGVfaFhIQkxOMjdyY3M2VkRGVjFFRDBUaVU4TTN4eTl2c3VXRkVzZWMifQ", "iat": 1705276800, "issuer": "did:web:fixture.test", "key_id": "did:web:fixture.test#key-1", "note": "All key material in this directory is synthetic test-only material. Do not use outside tests.", "vct": "https://fixture.test/credentials/registry-witness/v1" -} +} \ No newline at end of file diff --git a/products/notary/xtask/src/main.rs b/products/notary/xtask/src/main.rs index 12d231db3..4d0f9298c 100644 --- a/products/notary/xtask/src/main.rs +++ b/products/notary/xtask/src/main.rs @@ -67,35 +67,7 @@ async fn generate_fixtures() { let issuer = SdJwtIssuer::from_jwk(issuer_jwk.clone()).expect("issuer builds"); let holder_did = did_jwk_from_public_jwk(&holder_jwk.public()).expect("holder did encodes"); - // Write the public issuer JWKS so the harness can load it without - // embedding raw key material. - let public_jwk = serde_json::to_value(issuer_jwk.public()).expect("public jwk serialises"); - let es256_public_jwk = - serde_json::to_value(es256_issuer_jwk.public()).expect("ES256 public jwk serialises"); - let jwks = json!({ "keys": [public_jwk, es256_public_jwk] }); - write_fixture( - &fixture_dir, - "issuer-jwks.json", - &serde_json::to_string_pretty(&jwks).expect("jwks serialises"), - ); - - // Write fixture metadata so the harness knows constants without embedding - // them as Rust literals. - let meta = json!({ - "issuer": ISSUER, - "vct": VCT, - "iat": IAT, - "exp": EXP, - "holder_did": holder_did, - "key_id": "did:web:fixture.test#key-1", - "es256_key_id": "did:web:fixture.test#p256-key-1", - "note": "All key material in this directory is synthetic test-only material. Do not use outside tests." - }); - write_fixture( - &fixture_dir, - "meta.json", - &serde_json::to_string_pretty(&meta).expect("meta serialises"), - ); + write_algorithm_public_metadata(&fixture_dir, &issuer_jwk, &es256_issuer_jwk, &holder_jwk); // 1. Valid credential (positive fixture). let valid = issue( @@ -225,9 +197,12 @@ async fn generate_algorithm_fixtures() { let es256_issuer_jwk = PrivateJwk::parse(ES256_ISSUER_JWK).expect("ES256 issuer jwk parses"); let es256_issuer = SdJwtIssuer::from_jwk(es256_issuer_jwk.clone()).expect("ES256 issuer builds"); + let issuer_jwk = PrivateJwk::parse(ISSUER_JWK).expect("EdDSA issuer jwk parses"); let holder_jwk = PrivateJwk::parse(HOLDER_JWK).expect("holder jwk parses"); let holder_did = did_jwk_from_public_jwk(&holder_jwk.public()).expect("holder did encodes"); + write_algorithm_public_metadata(&fixture_dir, &issuer_jwk, &es256_issuer_jwk, &holder_jwk); + // These private keys are synthetic test material already embedded in this // generator. Publishing them as fixtures lets the transaction harness // exercise the same exact key pairs without duplicating constants. @@ -466,6 +441,44 @@ fn oid4vci_proof_jwt(key: &PrivateJwk, alg: &str, holder_did: &str) -> String { format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) } +fn write_algorithm_public_metadata( + fixture_dir: &std::path::Path, + issuer_jwk: &PrivateJwk, + es256_issuer_jwk: &PrivateJwk, + holder_jwk: &PrivateJwk, +) { + // Write the public issuer JWKS so the harness can load it without + // embedding raw key material. + let public_jwk = serde_json::to_value(issuer_jwk.public()).expect("public jwk serialises"); + let es256_public_jwk = + serde_json::to_value(es256_issuer_jwk.public()).expect("ES256 public jwk serialises"); + let jwks = json!({ "keys": [public_jwk, es256_public_jwk] }); + write_fixture( + fixture_dir, + "issuer-jwks.json", + &serde_json::to_string_pretty(&jwks).expect("jwks serialises"), + ); + + // Write fixture metadata so the harness knows constants without embedding + // them as Rust literals. + let holder_did = did_jwk_from_public_jwk(&holder_jwk.public()).expect("holder did encodes"); + let meta = json!({ + "issuer": ISSUER, + "vct": VCT, + "iat": IAT, + "exp": EXP, + "holder_did": holder_did, + "key_id": "did:web:fixture.test#key-1", + "es256_key_id": "did:web:fixture.test#p256-key-1", + "note": "All key material in this directory is synthetic test-only material. Do not use outside tests." + }); + write_fixture( + fixture_dir, + "meta.json", + &serde_json::to_string_pretty(&meta).expect("meta serialises"), + ); +} + fn write_fixture(dir: &std::path::Path, name: &str, content: &str) { let path = dir.join(name); std::fs::write(&path, content).expect("fixture write succeeds"); From 128761848edbf5bdcd0225f67bbc5ba41808315a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 18:54:15 +0700 Subject: [PATCH 36/65] test(notary): verify registry-backed issuer profiles end to end Signed-off-by: Jeremi Joslin --- .../tests/standalone_http/preauth.rs | 130 ++++++++++++++++++ .../tests/standalone_http/support.rs | 1 + 2 files changed, 131 insertions(+) diff --git a/crates/registry-notary-server/tests/standalone_http/preauth.rs b/crates/registry-notary-server/tests/standalone_http/preauth.rs index 573d4be84..2f6013520 100644 --- a/crates/registry-notary-server/tests/standalone_http/preauth.rs +++ b/crates/registry-notary-server/tests/standalone_http/preauth.rs @@ -6,6 +6,10 @@ use super::support::*; use super::{ admin::*, audit::*, auth::*, credentials::*, federation::*, http_contracts::*, oid4vci::*, }; +#[cfg(feature = "registry-notary-cel")] +use registry_notary_client::verifier::verify_sd_jwt_vc; +#[cfg(feature = "registry-notary-cel")] +use registry_notary_client::{HolderBindingPolicy, VerifyOptions}; #[tokio::test] pub(super) async fn preauth_offer_start_redirects_to_esignet_and_mints_nothing() { @@ -1674,6 +1678,132 @@ pub(super) async fn preauth_end_to_end_issues_sd_jwt_vc_bound_to_holder() { idp.stop().await; } +#[tokio::test] +#[cfg(feature = "registry-notary-cel")] +pub(super) async fn preauth_end_to_end_issuer_algorithms_match_metadata_and_client_verification() { + set_preauth_env(); + std::env::set_var("TEST_ISSUER_ES256_JWK", TEST_ISSUER_ES256_JWK); + + for (algorithm, key_env, key_id, key_material) in [ + ( + "EdDSA", + "TEST_SELF_ATTESTATION_ISSUER_JWK", + "issuer-key", + TEST_ISSUER_JWK, + ), + ( + "ES256", + "TEST_ISSUER_ES256_JWK", + "issuer-es256-key", + TEST_ISSUER_ES256_JWK, + ), + ] { + let idp = MockIdp::start().await; + let token_upstream = MockHttpUpstream::start().await; + let tmp = TempDir::new().expect("tempdir"); + let audit_path = tmp.path().join("audit.jsonl"); + let mut config = preauth_test_config( + "http://127.0.0.1:1", + audit_path.to_str().expect("audit path is UTF-8"), + &idp, + &token_upstream, + ); + let configured_kid = if algorithm == "EdDSA" { + "did:web:issuer.example#key-1" + } else { + "did:web:issuer.example#p256-key-1" + }; + if algorithm == "ES256" { + let mut key = local_jwk_signing_key(key_env, configured_kid); + key.alg = algorithm.to_string(); + config.evidence.signing_keys.insert(key_id.to_string(), key); + config + .evidence + .credential_profiles + .get_mut("civil_status_sd_jwt") + .expect("credential profile exists") + .signing_key = key_id.to_string(); + } + let app = standalone_router(config) + .await + .expect("standalone router builds"); + let server = TestServer::builder().http_transport().build(app); + + let metadata = server.get("/.well-known/openid-credential-issuer").await; + metadata.assert_status_ok(); + let metadata: Value = metadata.json(); + let advertised = &metadata["credential_configurations_supported"]["person_is_alive_sd_jwt"]; + assert_eq!( + advertised["credential_signing_alg_values_supported"], + json!([algorithm]), + "issuer metadata advertises exactly the configured credential algorithm" + ); + assert_eq!( + advertised["proof_types_supported"]["jwt"]["proof_signing_alg_values_supported"], + json!(["EdDSA"]), + "holder proof stays EdDSA for both issuer algorithms" + ); + assert_eq!( + advertised["cryptographic_binding_methods_supported"], + json!(["did:jwk"]) + ); + assert!(metadata.get("nonce_endpoint").is_none()); + + let page = drive_offer_to_page(&server, &token_upstream, &idp, "person-1").await; + let grants = page.offer["grants"] + .as_object() + .expect("offer grants are an object"); + assert_eq!( + grants.keys().map(String::as_str).collect::>(), + vec!["urn:ietf:params:oauth:grant-type:pre-authorized_code"], + "the wallet-facing offer has only the pre-authorized-code grant" + ); + let pin = page.pin.expect("secure-default offer includes tx_code"); + let token = redeem_token(&server, &page.code, &pin).await; + token.assert_status_ok(); + let token_body: Value = token.json(); + let access_token = token_body["access_token"] + .as_str() + .expect("access token issued"); + let c_nonce = token_body["c_nonce"] + .as_str() + .expect("transaction-scoped nonce issued"); + let proof = sign_oid4vci_proof(NOTARY_ISSUER, c_nonce); + let credential = server + .post("/oid4vci/credential") + .add_header("authorization", format!("Bearer {access_token}")) + .json(&json!({ + "format": "dc+sd-jwt", + "credential_configuration_id": "person_is_alive_sd_jwt", + "proof": { "proof_type": "jwt", "jwt": proof } + })) + .await; + credential.assert_status_ok(); + let credential_body: Value = credential.json(); + assert!(credential_body.get("c_nonce").is_none()); + assert!(credential_body.get("c_nonce_expires_in").is_none()); + let compact = credential_body["credential"] + .as_str() + .expect("credential issued"); + + let mut private = PrivateJwk::parse(key_material).expect("issuer key parses"); + private.alg = Some(algorithm.to_string()); + private.kid = Some(configured_kid.to_string()); + let holder_id = holder_did_jwk(); + let options = VerifyOptions::new("did:web:issuer.example") + .expected_vct("http://127.0.0.1:4325/credentials/civil-status") + .accepted_algorithms([algorithm]) + .holder_binding(HolderBindingPolicy::RequiredKid(holder_id.clone())); + let verified = verify_sd_jwt_vc(compact, &jwks_from_private_jwk(&private), &options) + .expect("the Registry Notary client verifies the issued credential"); + assert_eq!(verified.algorithm, algorithm); + assert_eq!(verified.key_id, configured_kid); + assert_eq!(verified.holder_key_id.as_deref(), Some(holder_id.as_str())); + + idp.stop().await; + } +} + #[tokio::test] #[cfg(feature = "registry-notary-cel")] pub(super) async fn preauth_cached_retry_cannot_escape_failed_credential_audit() { diff --git a/crates/registry-notary-server/tests/standalone_http/support.rs b/crates/registry-notary-server/tests/standalone_http/support.rs index 092823b5e..874f22e1c 100644 --- a/crates/registry-notary-server/tests/standalone_http/support.rs +++ b/crates/registry-notary-server/tests/standalone_http/support.rs @@ -55,6 +55,7 @@ pub(super) use ulid::Ulid; pub(super) const TEST_AUDIT_SECRET: &str = "0123456789abcdef0123456789abcdef"; pub(super) const TEST_ISSUER_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA"}"#; +pub(super) const TEST_ISSUER_ES256_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256"}"#; pub(super) const TEST_ACCESS_TOKEN_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"8jFBgUJxaaQimd4NjzxhvPYyNbcOnnZsqOntZbpP3Xk","x":"XvW-aWwJCWSYoYudTB9OZqNHURKElnnyGNa6DQNjzZk","alg":"EdDSA"}"#; pub(super) const TEST_ESIGNET_RP_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"EOLPz23yGd5Ju5e-PYybLE-YyvjgXLhGzS6XgmszzXs","x":"3v5jZ5rAf7KGvcC3zuKh6-ujgtA0ABa4jqmAWXq-S_c","alg":"EdDSA"}"#; pub(super) const TEST_HOLDER_JWK: &str = r#"{"crv":"Ed25519","d":"f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys","x":"pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec","kty":"OKP","alg":"EdDSA"}"#; From 543a1842422d867df4592872af6db4dc4ae1e4ef Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 18:54:25 +0700 Subject: [PATCH 37/65] feat(registryctl): author OID4VCI issuer and PIN profiles Signed-off-by: Jeremi Joslin --- .../project-authoring/environment.schema.json | 9 +++- .../src/project_authoring/commands.rs | 1 + .../src/project_authoring/compiler/notary.rs | 8 ++- .../src/project_authoring/model.rs | 41 ++++++++++++++ crates/registryctl/tests/project_authoring.rs | 54 ++++++++++++++++++- 5 files changed, 108 insertions(+), 5 deletions(-) diff --git a/crates/registryctl/schemas/project-authoring/environment.schema.json b/crates/registryctl/schemas/project-authoring/environment.schema.json index 80ec11a28..33aa6de9d 100644 --- a/crates/registryctl/schemas/project-authoring/environment.schema.json +++ b/crates/registryctl/schemas/project-authoring/environment.schema.json @@ -13,7 +13,7 @@ "issuance": { "description": "Notary issuer and signing-key bindings for credential issuance.", "type": "object", "additionalProperties": false, "required": ["issuer", "signing_key", "signing_kid", "generation"], - "properties": { "issuer": { "type": "string", "minLength": 1 }, "signing_key": { "$ref": "#/$defs/secret" }, "signing_kid": { "$ref": "#/$defs/token2048" }, "generation": { "type": "integer", "minimum": 1 } } + "properties": { "issuer": { "type": "string", "minLength": 1 }, "signing_key": { "$ref": "#/$defs/secret" }, "signing_kid": { "$ref": "#/$defs/token2048" }, "algorithm": { "enum": ["EdDSA", "ES256"], "default": "EdDSA", "description": "Credential issuer signing algorithm. Holder proof remains EdDSA with did:jwk." }, "generation": { "type": "integer", "minimum": 1 } } }, "callers": { "description": "Notary API-key caller identities and the scopes each identity receives.", @@ -62,7 +62,7 @@ } }, "oid4vci": { - "description": "Explicit citizen-facing OID4VCI binding for one authored Notary credential profile.", + "description": "Explicit registry-backed holder-wallet OID4VCI binding for one authored Notary credential profile.", "$ref": "#/$defs/oid4vci" }, "deployment": { @@ -211,6 +211,11 @@ "description": "Exact HTTPS browser origins admitted to the wallet-facing Notary routes.", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": { "$ref": "#/$defs/origin" } + }, + "tx_code": { + "description": "Transaction-code policy. Omit for the secure required-PIN default. Set required=false only for a bounded bearer-offer interoperability profile; the compiler fixes the offer lifetime at 300 seconds.", + "type": "object", "additionalProperties": false, + "properties": { "required": { "type": "boolean", "default": true } } } } }, diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index c4c33625b..4e6e6a35a 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -278,6 +278,7 @@ fn offline_fixture_environment(loaded: &LoadedRegistryProject) -> Result &'static str { + match self { + Self::EdDsa => "EdDSA", + Self::Es256 => "ES256", + } + } +} + #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct CallerBinding { @@ -1487,6 +1507,27 @@ struct Oid4vciBinding { subject: Oid4vciSubjectBinding, redirect_uri: String, allowed_wallet_origins: Vec, + #[serde(default)] + tx_code: Oid4vciTxCodeBinding, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Oid4vciTxCodeBinding { + #[serde(default = "default_oid4vci_tx_code_required")] + required: bool, +} + +impl Default for Oid4vciTxCodeBinding { + fn default() -> Self { + Self { + required: default_oid4vci_tx_code_required(), + } + } +} + +const fn default_oid4vci_tx_code_required() -> bool { + true } #[derive(Debug, Deserialize, Serialize)] diff --git a/crates/registryctl/tests/project_authoring.rs b/crates/registryctl/tests/project_authoring.rs index 6f5a37cc4..af1120bc8 100644 --- a/crates/registryctl/tests/project_authoring.rs +++ b/crates/registryctl/tests/project_authoring.rs @@ -4308,7 +4308,7 @@ fn check_and_build_produce_deterministic_product_inputs() { assert_eq!(first_closure, directory_closure(&output)); assert_eq!( closure_digest(&first_closure), - "d15890b0c4e693d7ef1f2a03f5ca1c0451fcab6dcb0241a2464dacf935d69e7f", + "2a85af0238a9a3606e34bc66cad4d5e21b93832809ee2fd2c0101f1bcfffc76b", "project inputs must match the cross-machine golden digest" ); } @@ -5122,6 +5122,10 @@ fn authored_oid4vci_binding_generates_the_complete_notary_owned_issuer() { notary["evidence"]["signing_keys"]["oid4vci-access-token"]["private_jwk_env"].as_str(), Some("OID4VCI_ACCESS_TOKEN_JWK") ); + assert_eq!( + notary["evidence"]["signing_keys"]["project-issuer"]["alg"].as_str(), + Some("EdDSA") + ); assert_eq!( notary["evidence"]["signing_keys"]["oid4vci-esignet-client"]["alg"].as_str(), Some("RS256") @@ -5180,6 +5184,10 @@ fn authored_oid4vci_binding_generates_the_complete_notary_owned_issuer() { notary["oid4vci"]["pre_authorized_code"]["esignet"]["redirect_uri"].as_str(), Some("https://notary.example.invalid/oid4vci/offer/callback") ); + assert_eq!( + notary["oid4vci"]["pre_authorized_code"]["tx_code"]["required"].as_bool(), + Some(true) + ); assert_eq!( notary["oid4vci"]["credential_configurations"] ["household-eligibility.household-eligibility"]["vct"] @@ -5214,6 +5222,50 @@ fn authored_oid4vci_binding_generates_the_complete_notary_owned_issuer() { assert!(notary.get("self_attestation").is_none()); } +#[test] +fn authored_oid4vci_walt_profile_is_explicit_and_keeps_the_bearer_window_bounded() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let project = copy_project("custom-system", temporary.path()); + let project_path = project.join("registry-stack.yaml"); + let mut document = read_yaml(&project_path); + document["services"]["household-eligibility"]["credential_profiles"]["household-eligibility"] + ["claims"] = + serde_yaml::from_str("[household-record-exists]").expect("single registry-backed claim"); + write_yaml(&project_path, &document); + author_oid4vci_binding( + &project, + "household-eligibility", + "household-eligibility", + "household_reference", + ); + merge_environment_yaml( + &project.join("environments/local.yaml"), + "issuance:\n algorithm: ES256\noid4vci:\n tx_code:\n required: false\n", + ); + + let build = build_registry_project(&ProjectBuildOptions { + project_directory: project, + environment: "local".to_string(), + against: None, + anchor: None, + }) + .expect("explicit Walt-compatible binding builds"); + let output = PathBuf::from(build.output.expect("build output")); + let notary = read_yaml(&output.join("private/notary/config/notary.yaml")); + assert_eq!( + notary["evidence"]["signing_keys"]["project-issuer"]["alg"].as_str(), + Some("ES256") + ); + assert_eq!( + notary["oid4vci"]["pre_authorized_code"]["tx_code"]["required"].as_bool(), + Some(false) + ); + assert_eq!( + notary["oid4vci"]["pre_authorized_code"]["pre_authorized_code_ttl_seconds"].as_u64(), + Some(300) + ); +} + #[test] fn authored_oid4vci_binding_rejects_open_or_incoherent_trust_topologies() { for (name, mutate, expected) in [ From 4795cb0661ccf974b7235c4eca567253a31edcf3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 18:54:32 +0700 Subject: [PATCH 38/65] ci(notary): bound the 1.0 OpenAPI exception Signed-off-by: Jeremi Joslin --- .../notary/openapi/oasdiff-1.0-err-ignore.txt | 8 ++++ .../notary/scripts/check-openapi-contract.sh | 44 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 products/notary/openapi/oasdiff-1.0-err-ignore.txt diff --git a/products/notary/openapi/oasdiff-1.0-err-ignore.txt b/products/notary/openapi/oasdiff-1.0-err-ignore.txt new file mode 100644 index 000000000..2af6b2688 --- /dev/null +++ b/products/notary/openapi/oasdiff-1.0-err-ignore.txt @@ -0,0 +1,8 @@ +# One-time accepted Registry Stack 1.0 break. The wallet facade now exposes +# registry-backed, issuer-initiated pre-authorized issuance only. The contract +# check also requires every line below to remain present in the raw diff, so this +# file must be removed after the comparison baseline includes the new contract. +GET /oid4vci/credential-offer api path removed without deprecation +POST /oid4vci/nonce api path removed without deprecation +POST /oid4vci/credential removed the optional property 'c_nonce' from the response with the '200' status +POST /oid4vci/credential removed the optional property 'c_nonce_expires_in' from the response with the '200' status diff --git a/products/notary/scripts/check-openapi-contract.sh b/products/notary/scripts/check-openapi-contract.sh index 832b8a6b4..0d08a12a3 100755 --- a/products/notary/scripts/check-openapi-contract.sh +++ b/products/notary/scripts/check-openapi-contract.sh @@ -9,6 +9,8 @@ BASE_REF="${1:-${OPENAPI_CONTRACT_BASE_REF:-}}" WORK_DIR="target/openapi-contract" GENERATED="$WORK_DIR/generated.openapi.json" BASELINE="$WORK_DIR/base.openapi.json" +RAW_BREAKING_DIFF="$WORK_DIR/breaking.singleline.txt" +BREAKING_IGNORE="openapi/oasdiff-1.0-err-ignore.txt" mkdir -p "$WORK_DIR" @@ -60,4 +62,44 @@ if ! git cat-file -e "$BASE_REF:$SPEC_PATH_FROM_ROOT" 2>/dev/null; then fi git show "$BASE_REF:$SPEC_PATH_FROM_ROOT" > "$BASELINE" -oasdiff breaking --fail-on ERR "$BASELINE" "$GENERATED" + +# Keep the one-time 1.0 exception exact and self-expiring. An added allowlist +# line fails this check, and an accepted line that disappears from the raw diff +# also fails so the exception cannot silently survive a baseline advance. +oasdiff breaking --format singleline "$BASELINE" "$GENERATED" > "$RAW_BREAKING_DIFF" +python3 - "$BREAKING_IGNORE" "$RAW_BREAKING_DIFF" <<'PY' +import sys +from pathlib import Path + +expected = { + "GET /oid4vci/credential-offer api path removed without deprecation", + "POST /oid4vci/nonce api path removed without deprecation", + "POST /oid4vci/credential removed the optional property 'c_nonce' from the response with the '200' status", + "POST /oid4vci/credential removed the optional property 'c_nonce_expires_in' from the response with the '200' status", +} + +ignore_path = Path(sys.argv[1]) +raw_path = Path(sys.argv[2]) +allowed = { + line.strip() + for line in ignore_path.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") +} +if allowed != expected: + missing = sorted(expected - allowed) + extra = sorted(allowed - expected) + raise SystemExit( + f"Notary OpenAPI 1.0 allowlist is not exact; missing={missing}, extra={extra}" + ) + +raw_diff = raw_path.read_text(encoding="utf-8") +stale = sorted(line for line in allowed if line not in raw_diff) +if stale: + raise SystemExit( + "Notary OpenAPI 1.0 allowlist contains stale entries: " + "; ".join(stale) + ) +PY + +oasdiff breaking --fail-on ERR --err-ignore "$BREAKING_IGNORE" \ + --warn-ignore "$BREAKING_IGNORE" \ + "$BASELINE" "$GENERATED" From 37ce044567f05f8b3088b54f5bd4dbf389fd2ddb Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 18:54:41 +0700 Subject: [PATCH 39/65] docs(notary): define the registry-backed OID4VCI contract Signed-off-by: Jeremi Joslin --- crates/registry-platform-oid4vci/README.md | 72 +- products/notary/CHANGELOG.md | 24 + products/notary/docs/client-sdk-guide.md | 18 +- .../docs/credential-issuance-migration.md | 20 +- .../docs/credential-lifecycle-status.md | 16 +- .../notary/docs/notary-capability-matrix.md | 14 +- .../notary/docs/oid4vci-wallet-interop.md | 581 +++------ .../notary/docs/operator-config-reference.md | 22 +- products/notary/docs/release-notes.md | 24 + .../docs/sd-jwt-vc-conformance-profile.md | 22 +- .../specs/citizen-self-attestation-spec.md | 9 +- .../notary-api-v1-route-cleanup-proposal.md | 4 +- .../specs/openid4vci-wallet-facade-spec.md | 1105 +++-------------- 13 files changed, 523 insertions(+), 1408 deletions(-) diff --git a/crates/registry-platform-oid4vci/README.md b/crates/registry-platform-oid4vci/README.md index 31f26da87..c02642db9 100644 --- a/crates/registry-platform-oid4vci/README.md +++ b/crates/registry-platform-oid4vci/README.md @@ -1,47 +1,51 @@ # registry-platform-oid4vci -OID4VCI protocol constants, metadata structs, and proof validation helpers for -registry services acting as credential issuers. - -## What It Provides - -- Protocol constants (`PROOF_JWT_TYPE`, `SD_JWT_VC_FORMAT`, `AUTHORIZATION_CODE_GRANT_TYPE`, etc.). -- `CredentialIssuerMetadata` and `CredentialConfigurationMetadata` for - `.well-known/openid-credential-issuer` responses. -- `CredentialConfigurationMetadata::sd_jwt_vc()` for constructing an SD-JWT VC - configuration entry matching the OID4VCI draft spec. -- `CredentialOffer::authorization_code()` for constructing credential offer objects. -- `validate_proof_jwt` for verifying a holder-bound proof JWT presented at the - credential endpoint. Validates structure, `typ`, signature, audience, nonce, - and time bounds. Returns a `ValidatedProof` carrying the holder JWK and - verified claims. -- `ProofValidationPolicy::credential_endpoint` for configuring credential - endpoint validation without hand-filling policy fields. -- `consume_validated_proof_nonce_once` for binding a validated proof nonce to a - required replay store consume operation. -- Wire types for nonce, credential, and error responses. - -## Spec References - -- [OpenID for Verifiable Credential Issuance (OID4VCI)](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html) +OID4VCI protocol constants, metadata types, offer and token wire types, and +holder-proof validation helpers for Registry Stack issuers. + +## What it provides + +- `CredentialIssuerMetadata` and `CredentialConfigurationMetadata` for issuer + discovery. +- `CredentialConfigurationMetadata::sd_jwt_vc()` for `dc+sd-jwt` metadata. +- `CredentialOffer::pre_authorized_code()` and the pre-authorized token request + wire types. +- `validate_proof_jwt` for validating structure, `typ`, signature, audience, + nonce, and time bounds. +- `ProofValidationPolicy::credential_endpoint` for configuring proof checks. +- `consume_validated_proof_nonce_once` for binding a validated proof to a + required replay-store consume operation. +- Credential and protocol error response types. + +The crate supplies protocol primitives. Registry Notary defines the public 1.0 +profile: registry-backed issuer-initiated pre-authorized code, `dc+sd-jwt`, +EdDSA or ES256 issuer signing, and EdDSA `did:jwk` holder proof. It has no public +nonce route and returns no next nonce from the credential endpoint. + +## Specification reference + +- [OpenID for Verifiable Credential Issuance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html) - Proof JWT type: `openid4vci-proof+jwt` -- Grant type: `authorization_code` +- Wallet grant: + `urn:ietf:params:oauth:grant-type:pre-authorized_code` - Credential format: `dc+sd-jwt` -## Security Notes +## Security notes -- **Nonce replay is a caller responsibility.** `validate_proof_jwt` validates - that the nonce in the proof matches `policy.expected_nonce`, but it does not - track nonce usage across calls. Use `consume_validated_proof_nonce_once` with - a `registry-platform-replay` store to reject reused nonces after validation. -- Proof JWTs must use EdDSA with `did:jwk` holder binding. RS\*/PS\*/ES\* keys - and `jku`/`x5u`/`x5c`/`crit` headers are rejected. -- Proof audience is validated against `policy.audience` before any JWKS lookup. +- Proof nonce consumption is a caller responsibility. `validate_proof_jwt` + checks the expected nonce but does not mutate replay state. Call + `consume_validated_proof_nonce_once` with a correctness-state replay store. +- Registry Notary accepts only EdDSA proof JWTs with `did:jwk` holder binding. + ES256 holder keys and `jku`, `x5u`, `x5c`, or `crit` headers are rejected. +- Proof audience is validated before issuer or holder key use. +- A transaction code is required by default. If a caller deliberately builds a + bearer offer without one, its lifetime must be no more than 300 seconds and + redemption must remain single-use and rate limited. ## Testing ```sh -cargo test -p registry-platform-oid4vci +cargo test --locked -p registry-platform-oid4vci ``` ## License diff --git a/products/notary/CHANGELOG.md b/products/notary/CHANGELOG.md index 069c53aa9..f7fe19ddb 100644 --- a/products/notary/CHANGELOG.md +++ b/products/notary/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added a complete source-tested registry-backed OID4VCI journey for EdDSA and + ES256 issuer profiles with EdDSA `did:jwk` holder proof, exact generated + metadata assertions, client verification, replay and provenance checks, and + unsupported-profile denial. +- Added registryctl authoring for the issuer signing algorithm and transaction + code mode. EdDSA and a required transaction code are secure defaults. An + explicit no-PIN profile retains the 300-second compiler ceiling. - Added the bounded batch evaluation v1 contract. Batch evaluation has an immutable 100-member platform ceiling with lower-only global and per-claim configuration, client and OpenAPI bounds, and pre-side-effect HTTP 413 @@ -19,6 +26,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the production `StandaloneRegistryNotaryConfig` deserialization graph. The operator reference now has a bidirectional key-path contract check. +### Changed + +- BREAKING: the Registry Notary 1.0 wallet facade now supports only + issuer-initiated pre-authorized code backed by a stored registry transaction. + `GET /oid4vci/credential-offer` and `POST /oid4vci/nonce` are removed, and + credential responses no longer expose `c_nonce` or `c_nonce_expires_in`. + Start at `GET /oid4vci/offer/start`, complete the identity-provider callback, + redeem the rendered offer at `POST /oid4vci/token`, and use that token + response's transaction-bound proof nonce. The identity provider's + authorization code is internal to Notary and is not a wallet grant. +- Status-bearing credentials now require fail-closed client verification from + the configured exact HTTPS status origin. The reserved top-level `status` + claim cannot be selectively disclosable. +- OID4VCI remains limited to EdDSA or ES256 issuer signing and EdDSA `did:jwk` + holder proof. No EUDI, HAIP, PAR, DPoP, wallet-attestation, ES256-holder, or + external conformance claim is made without frozen candidate evidence. + ## [0.11.0] - 2026-07-18 ### Added diff --git a/products/notary/docs/client-sdk-guide.md b/products/notary/docs/client-sdk-guide.md index a26d12884..21726635d 100644 --- a/products/notary/docs/client-sdk-guide.md +++ b/products/notary/docs/client-sdk-guide.md @@ -864,13 +864,15 @@ let updated = client ### OID4VCI -The client wraps endpoints only. It does not generate holder proofs or manage -holder keys. +The client wraps issuer metadata and the credential endpoint. It does not start +the browser login, redeem a pre-authorized code, generate holder proofs, or +manage holder keys. Start the issuer-initiated journey at +`/oid4vci/offer/start` and let the wallet redeem the rendered offer. Call the +credential helper only after the wallet has a Notary access token and the +transaction-bound proof nonce returned by the token response. ```python metadata = client.oid4vci_issuer_metadata() -offer = client.oid4vci_credential_offer("person_is_alive_sd_jwt") -nonce = client.oid4vci_nonce() credential = client.oid4vci_credential({ "credential_configuration_id": "person_is_alive_sd_jwt", "proof": {"proof_type": "jwt", "jwt": "eyJ..."}, @@ -879,8 +881,6 @@ credential = client.oid4vci_credential({ ```js const metadata = await client.oid4vciIssuerMetadata(); -const offer = await client.oid4vciCredentialOffer("person_is_alive_sd_jwt"); -const nonce = await client.oid4vciNonce(); const credential = await client.oid4vciCredential({ credential_configuration_id: "person_is_alive_sd_jwt", proof: { proof_type: "jwt", jwt: "eyJ..." }, @@ -901,10 +901,6 @@ registry-notary-client = { let metadata = client .oid4vci_issuer_metadata(RequestOptions::default()) .await?; - -let offer = client - .oid4vci_credential_offer(Some("person_is_alive_sd_jwt"), RequestOptions::default()) - .await?; ``` ### Federation @@ -1174,8 +1170,6 @@ const key = await client.getJwk("key-1"); const status = await client.credentialStatus("credential-1"); const metadata = await client.oid4vciIssuerMetadata(); -const offer = await client.oid4vciCredentialOffer("person_is_alive_sd_jwt"); -const nonce = await client.oid4vciNonce(); const responseJws = await client.federationEvaluateJws("eyJ..."); ``` diff --git a/products/notary/docs/credential-issuance-migration.md b/products/notary/docs/credential-issuance-migration.md index 23f971e1b..9272ea15f 100644 --- a/products/notary/docs/credential-issuance-migration.md +++ b/products/notary/docs/credential-issuance-migration.md @@ -7,6 +7,14 @@ selected claims were produced by fresh, exact compiler-pinned Registry Relay consultations. This applies to `POST /v1/credentials` and `POST /oid4vci/credential`. +The OID4VCI surface also changes to issuer-initiated pre-authorized code only. +Remove integrations that call the former credential-offer or public nonce +routes, or that treat an identity-provider authorization code as a wallet +grant. Start the browser journey at `GET /oid4vci/offer/start`, import the offer +rendered after the callback, redeem its pre-authorized code at +`POST /oid4vci/token`, and use the proof nonce from that token response. The +credential response no longer returns a next nonce. + ## Configuration changes Before upgrading, inspect every credential profile, every @@ -58,9 +66,11 @@ that root's closure. Missing, duplicate, extra, stale, or modified claim pins or execution records are denied before signer access, signing, credential identifiers, or status writes. Direct issuance performs this check before holder-proof replay mutation. The -OID4VCI path rejects a source-free credential configuration before nonce -consumption, then preserves its nonce-before-evaluation ordering and verifies -the newly stored evaluation before signer access. +OID4VCI callback creates the registry-backed transaction and completes the +Relay evaluation before it renders an offer. The credential endpoint rejects a +source-free configuration, consumes the transaction-bound proof nonce, reloads +the stored transaction and evaluation, and verifies exact provenance before +signer access. The execution binding detects partial stored-record mutation, including a changed acquisition time or consultation ids swapped between claims. It is not @@ -82,7 +92,9 @@ change. 3. Deploy compatible Relay and Notary configuration from one project generation. 4. Re-evaluate claims used by in-progress credential journeys. -5. Exercise both direct and OID4VCI issuance and confirm the Relay receives the +5. Replace wallet authorization-code, public nonce, and next-nonce assumptions + with the pre-authorized transaction flow. +6. Exercise both direct and OID4VCI issuance and confirm the Relay receives the exact configured profile, purpose, and contract hash. Do not copy provenance from an old evaluation or retry with an edited stored diff --git a/products/notary/docs/credential-lifecycle-status.md b/products/notary/docs/credential-lifecycle-status.md index 9f3b4d482..58db69ff6 100644 --- a/products/notary/docs/credential-lifecycle-status.md +++ b/products/notary/docs/credential-lifecycle-status.md @@ -85,6 +85,10 @@ same URL returns a signed `application/statuslist+jwt` token when requested with `Accept: application/statuslist+jwt`, and retains the JSON lifecycle response for operational compatibility. +`status` is a reserved top-level credential claim. It cannot be configured as a +selectively disclosable OID4VCI projection, so a holder cannot remove the live +status requirement while presenting the credential. + ## Status values The public status response can report: @@ -204,11 +208,13 @@ Make verifier policy explicit: - Accept status-free credentials only from profiles that are expected to be status-free. - For status-bearing credentials, fetch the `status.status_list.uri` with - `Accept: application/statuslist+jwt` and require the indexed status value to - be `0x00` (`VALID`). -- Treat `0x01` (`INVALID`), `0x02` (`SUSPENDED`), missing status, malformed - status, or network failure according to the relying party's risk policy. - Fail closed for high-assurance flows. + `Accept: application/statuslist+jwt` only when its origin exactly matches the + verifier's configured trusted HTTPS status origin. Validate the signed token, + issuer, type, lifetime, index, and require the indexed value to be `0x00` + (`VALID`). +- Fail closed on `0x01` (`INVALID`), `0x02` (`SUSPENDED`), missing status, + malformed status, an untrusted origin, or network failure. A status-bearing + credential is not valid when its live status cannot be verified. - Apply credential expiry even when status returns `valid`. Registry Notary does not currently publish aggregated status lists or external diff --git a/products/notary/docs/notary-capability-matrix.md b/products/notary/docs/notary-capability-matrix.md index 303e8cd54..b150d0ed5 100644 --- a/products/notary/docs/notary-capability-matrix.md +++ b/products/notary/docs/notary-capability-matrix.md @@ -40,7 +40,7 @@ scenario patterns](notary-scenario-patterns.md). | --- | --- | | Source registry | Operational system of record. It is not exposed directly to consumers | | Registry Relay | Read-only gateway and metadata publisher for source registry data | -| Registry Notary | Evaluates Relay-backed or source-free claims, signs results, issues credentials only from exact Relay-backed evaluation provenance, enforces evidence policy, and emits audit | +| Registry Notary | Evaluates Relay-backed or source-free claims, signs results, issues credentials only from exact Relay-backed evaluation provenance, enforces evidence policy, and emits audit. Source-free results cannot authorize issuance | | Registry Manifest | Public metadata and discovery artifact for capabilities, profiles, and evidence offerings | | Registry Platform | Shared crypto, HTTP, OIDC, SD-JWT, DID/JWK, replay, and audit primitives | | Service portal or case system | Starts a service workflow and consumes evidence or decisions | @@ -67,10 +67,18 @@ scenario patterns](notary-scenario-patterns.md). | 13 | Health worker presents professional credential for service eligibility | User-presented proof | Planned | No proof profiles or issuer trust policy ship yet; you cannot accept a presented professional credential for this eligibility check | | 14 | Parent or guardian requests a service for a child or dependent | Delegated self-attestation plus proof | Supported | Evaluation and rendering require the configured Relay-backed relationship proof; delegated credential issuance is intentionally unavailable in 1.0 | | 15 | Household or group representative requests a service | Representation plus proof | Planned | No collective subject model or representative authority policy ships yet; you cannot let a household or group representative request this service | -| 16 | Civil Notary issues date-of-birth or alive credential | Credential issuance | Supported | Requires a fresh compiler-pinned Relay-backed evaluation; local wallet ceremony is still demo-grade | -| 17 | Agriculture Notary issues voucher eligibility credential | Credential issuance | Supported | Requires a fresh compiler-pinned Relay-backed evaluation; local wallet ceremony is still demo-grade | +| 16 | Civil Notary issues date-of-birth or alive credential | Credential issuance | Supported | Full EdDSA and ES256 source-tested pre-authorized flow exists; external wallet evidence remains candidate-only | +| 17 | Agriculture Notary issues voucher eligibility credential | Credential issuance | Supported | Full EdDSA and ES256 source-tested pre-authorized flow exists; external wallet evidence remains candidate-only | | 18 | Shared Eligibility Notary issues combined-support credential | Credential issuance plus composition | Partial | Relay-backed credential issuance exists, but peer-result composition is missing | | 19 | Consuming service helps holder obtain credential from remote Notary | Federated credential issuance | Planned | No holder-binding ceremony, nonce ownership, or relay rules ship yet; you cannot help a holder obtain a credential from a remote Notary through this service | | 20 | Replay and emergency peer/key denial | Governance | Supported | Active-active deployments require the typed Notary-owned PostgreSQL state schema | | 21 | Auditor verifies minimized decision evidence | Governance | Partial | Signed results and audit exist, checkpoints are planned | | 22 | Peer audit checkpoint monitoring | Governance | Planned | No checkpoint publisher, Merkle builder, or peer monitor ships yet; you cannot independently verify peer audit checkpoints | + +Each Relay authority uses one Notary authority, with Notary-owned PostgreSQL +correctness state for production and multi-instance deployment. Wallet-facing +issuance supports only issuer-initiated pre-authorized code, EdDSA `did:jwk` +holder proof, and EdDSA or ES256 issuer signing. This matrix makes no EUDI, +HAIP, PAR, DPoP, wallet-attestation, ES256-holder, or external wallet/verifier +conformance claim. Those claims require frozen candidate artifacts and recorded +external evidence. diff --git a/products/notary/docs/oid4vci-wallet-interop.md b/products/notary/docs/oid4vci-wallet-interop.md index 619b17cbe..772f6a242 100644 --- a/products/notary/docs/oid4vci-wallet-interop.md +++ b/products/notary/docs/oid4vci-wallet-interop.md @@ -1,91 +1,85 @@ -# OID4VCI wallet interop guide +# OID4VCI wallet interoperability > **Page type:** How-to · **Product:** Registry Notary · **Layer:** credential · **Audience:** operator, integrator -This guide describes the implemented OpenID4VCI wallet facade for Registry -Notary adopters. It focuses on what wallet and platform teams need to configure -and test. - -The wallet facade issues only registry-backed claims. Every configured claim -must execute its exact compiler-pinned Relay consultation, and the stored -evaluation provenance must match the active claim, profile, purpose, and -contract hash before signing. A deterministic execution binding also -cross-checks each claim pin against its Relay ULID, acquisition time, and exact -claim provenance. Source-free `self_attested` claims are -evaluation-only and cannot appear in an OID4VCI credential configuration. - -## Use case - -Use the OID4VCI facade when a citizen wallet should request a Registry Notary -SD-JWT VC directly. The wallet holds an access token from an authorization -server, proves control of a holder key, and receives a short-lived credential -for a configured claim. - -The facade is intentionally narrow: - -- Credential format is `dc+sd-jwt`. -- Issued VC media type is `application/dc+sd-jwt`. -- Proof type is JWT. -- Supported proof algorithm is `EdDSA`. -- Supported holder binding method is `did:jwk`. -- Subject access is backed by self-attestation policy, while credential evidence - is backed by compiler-pinned Relay consultations. -- Delegated attestation transaction tokens are rejected. Delegated wallet - issuance is not part of this OID4VCI facade version. - -It is not a full OpenID4VCI issuer product. It is an interoperability facade for -Registry Notary's current SD-JWT VC issuance path. - -The execution binding detects partial mutation of the stored record. It is an -unkeyed digest, not an authenticity anchor against an operator who can rewrite -all committed fields and recompute it. - -## Prerequisites - -The wallet facade requires server-side OID4VCI, subject-access, Relay, and -registry-backed claim configuration before any wallet can request a -credential. Self-attestation is the policy gate that prevents a wallet from -using any valid token to request another person's credential. The compiled -Relay binding supplies the evidence provenance. The operator who runs Notary -owns these settings; this guide assumes they are already in place. - -For the full configuration, including the `auth.oidc`, `subject_access`, and -`oid4vci` blocks and their constraints, see the -[operator configuration reference](operator-config-reference.md). For the policy -gate that binds a request to the token subject, see the -[self-attestation operator guide](self-attestation-operator-guide.md). - -## Integration quick check - -For a public holder wallet, the normal human-facing entry point is: - -```text -GET /oid4vci/offer/start?credential_configuration_id= -``` +Registry Notary exposes an issuer-initiated pre-authorized code flow for +issuing registry-backed `dc+sd-jwt` credentials to holder wallets. Source-free +`self_attested` claims remain evaluation-only and cannot appear in an OID4VCI +credential configuration. + +## Supported 1.0 profile + +The wallet facade supports: + +- registry-backed claims whose exact compiler-pinned Relay execution is stored + in a Notary transaction; +- `dc+sd-jwt` credentials; +- EdDSA or ES256 issuer signing, selected by the credential profile; +- EdDSA JWT holder proof with `did:jwk` binding; +- an issuer-initiated pre-authorized code grant; +- one credential per immediate response. + +It does not support wallet-facing authorization-code grants, a public nonce +route, response next nonces, ES256 holder proof, source-free issuance, EUDI or +HAIP profiles, PAR, DPoP, or wallet attestation. + +The eSignet authorization code used during the browser callback is internal to +Notary's identity-provider login. The wallet never receives or redeems it. + +## Topology and prerequisites -Open that URL in the citizen browser, complete the configured identity-provider -login, then use the rendered offer page: - -- scan or paste the `openid-credential-offer://` URI into the wallet; -- enter the displayed numeric PIN when the wallet asks for the `tx_code`; -- let the wallet redeem the offer at the Notary `token_endpoint`; -- confirm the wallet stores a `dc+sd-jwt` credential. - -A successful wallet run leaves these externally visible results: - -- issuer metadata is reachable at `/.well-known/openid-credential-issuer`; -- the configured Type Metadata is reachable at - `/.well-known/vct/{vct_path}` without authentication; -- issuer metadata advertises `token_endpoint` only when the authenticated - pre-authorized-code flow is enabled; -- the offer contains the - `urn:ietf:params:oauth:grant-type:pre-authorized_code` grant and a `tx_code` - object; -- the issued SD-JWT VC payload has `vct` equal to the requested configuration, - `iss` equal to the Notary issuer DID, and holder binding in `cnf.jwk` / - `cnf.kid` for the wallet's `did:jwk`; -- the only selectively disclosable claim disclosures are represented through - `_sd` and `_sd_alg: "sha-256"` in the payload. +Deploy one Notary for each Relay authority. Notary owns its transaction, +pre-authorized code, proof replay, evaluation, audit, and credential-status +state. Use the Notary-owned PostgreSQL schema for production or multiple +instances. In-memory state is for explicit local single-process use only. + +Before enabling the wallet facade: + +- configure the Relay connection and compile the claim consultation pins; +- configure `subject_access` for the identity-provider binding claim; +- bind every OID4VCI configuration to one mutually valid registry-backed claim + and credential profile; +- configure separate signing keys for identity-provider client assertions, + Notary access tokens, and credentials; +- expose HTTPS issuer, callback, token, credential, and Type Metadata URLs; +- set the Notary state backend and rate limits. + +See the [operator configuration reference](operator-config-reference.md) and +[credential issuance migration](credential-issuance-migration.md). + +## End-to-end developer check + +1. Open this URL in the citizen's browser: + + ```text + https://notary.example.gov/oid4vci/offer/start?credential_configuration_id= + ``` + +2. Complete the configured identity-provider login. +3. After the callback succeeds, scan or paste the rendered + `openid-credential-offer://` URI into the wallet. +4. Enter the separately displayed numeric PIN when the wallet asks for the + `tx_code`. +5. Confirm that the wallet redeems the offer at `/oid4vci/token` and calls + `/oid4vci/credential` with an EdDSA `did:jwk` proof. +6. Verify the returned credential with the issuer JWKS and the expected holder + binding. + +A successful check establishes all of these observations: + +- issuer metadata is reachable at + `/.well-known/openid-credential-issuer`; +- Type Metadata is reachable at `/.well-known/vct/{vct_path}`; +- metadata advertises `dc+sd-jwt`, `did:jwk`, EdDSA holder proof, and the + configured EdDSA or ES256 issuer algorithm; +- metadata has a `/oid4vci/token` endpoint and no nonce endpoint; +- the offer contains exactly the + `urn:ietf:params:oauth:grant-type:pre-authorized_code` grant; +- the default offer contains a numeric `tx_code` description; +- the token response supplies the proof nonce bound to that transaction; +- the credential response has no next-nonce fields; +- the SD-JWT VC has the expected `iss`, `vct`, EdDSA `did:jwk` holder binding, + and registry-backed disclosures. ```mermaid sequenceDiagram @@ -94,336 +88,135 @@ sequenceDiagram participant Browser participant Notary as Registry Notary participant IdP as Identity provider + participant Relay as Registry Relay participant Wallet - Citizen->>Browser: Open /oid4vci/offer/start - Browser->>Notary: Start offer for credential configuration + Citizen->>Browser: Open offer start URL + Browser->>Notary: Start configured credential transaction Notary-->>Browser: Redirect to identity provider Browser->>IdP: Authenticate citizen - IdP-->>Browser: Redirect to /oid4vci/offer/callback - Browser->>Notary: Send code and state + IdP-->>Browser: Return internal authorization code + Browser->>Notary: Callback with code and state Notary->>IdP: Exchange code and validate subject - Notary-->>Browser: Render offer URI plus PIN - Citizen->>Wallet: Scan or paste offer, enter PIN - Wallet->>Notary: POST /oid4vci/token with code plus tx_code - Notary-->>Wallet: Access token plus c_nonce - Wallet->>Notary: POST /oid4vci/credential with holder proof - Notary-->>Wallet: Holder-bound dc+sd-jwt credential + Notary->>Relay: Execute compiler-pinned consultation + Relay-->>Notary: Typed registry evidence + Notary-->>Browser: Render pre-authorized offer and PIN + Citizen->>Wallet: Transfer offer and enter PIN + Wallet->>Notary: Redeem pre-authorized code and PIN + Notary-->>Wallet: Access token and proof nonce + Wallet->>Notary: Submit EdDSA did:jwk proof + Notary-->>Wallet: Holder-bound dc+sd-jwt ``` -## Wallet flow - -The current wallet-facing flow is: - -1. Wallet discovers issuer metadata. -2. Wallet obtains or receives a credential offer for a configured credential. -3. Wallet obtains an OIDC access token from the configured authorization server. -4. Wallet requests a nonce when nonce support is enabled. -5. Wallet sends a credential request with `format: "dc+sd-jwt"` and a JWT proof. -6. Notary validates the access token, subject binding, self-attestation policy, - nonce and proof, executes the exact compiler-pinned Relay consultation, and - issues the SD-JWT VC only when the stored execution provenance matches. - -The credential request should not carry a raw subject id as a free-form wallet -choice. The subject comes from the OIDC token claim configured in -`subject_access.subject_binding` and must match the Notary request context. -If the access token's scoped authorization details select -`access_mode: delegated_attestation`, the credential endpoint rejects the -request rather than issuing for a dependent target. - -## Authenticated pre-authorized-code flow - -A public holder wallet (for example walt.id `wallet-api`) is a PKCE client and -cannot authenticate to a confidential authorization server such as eSignet (an -OpenID Connect identity service). For -those wallets the Notary additionally supports an authenticated -pre-authorized-code flow. The citizen still authenticates at eSignet; the wallet -never authenticates to eSignet and only ever talks to the Notary. - -This flow is disabled by default. When `oid4vci.pre_authorized_code.enabled` is -false (or unset), this flow's three endpoints (`/oid4vci/offer/start`, -`/oid4vci/offer/callback`, and `/oid4vci/token`) return `404`, issuer metadata -advertises no `token_endpoint`, and credential offers stay -`authorization_code`. The confidential-client `authorization_code` path -described in Wallet flow is unchanged regardless of this setting. - -When enabled, the flow is: - -1. The citizen browser opens `GET /oid4vci/offer/start`, optionally with - `?credential_configuration_id=`. This endpoint is public and - unauthenticated. It begins the eSignet authorization-code login as the - configured confidential RP (PKCE S256), stores short-lived state, and - `302`-redirects the browser to eSignet. It mints no `pre-authorized_code` and - no credential material. -2. The citizen authenticates at eSignet. eSignet redirects back to - `GET /oid4vci/offer/callback?code=...&state=...` (public, unauthenticated). - The Notary exchanges the code with eSignet using `private_key_jwt`, validates - the returned `id_token`, captures the exact `subject_access.subject_binding` - claim value (the civil id), and only then mints one single-use - `pre-authorized_code`. By default it also mints one numeric `tx_code` (a PIN). - It renders an HTML offer page carrying the `openid-credential-offer://` URI - (the QR payload) and, when enabled, the PIN shown out-of-band from the QR. -3. The wallet reads the offer. Its `grants` carry - `urn:ietf:params:oauth:grant-type:pre-authorized_code` with the - `pre-authorized_code`. When the offer includes a `tx_code` object, the citizen - enters the PIN. -4. The wallet redeems the offer at `POST /oid4vci/token` (public, - unauthenticated). A `tx_code` is required when the offer includes a `tx_code` - object; the token endpoint rejects a missing or wrong PIN in that mode. The - request is form-encoded or JSON: - - ```sh - curl -fsS -X POST https://notary.example.gov/oid4vci/token \ - -H 'content-type: application/x-www-form-urlencoded' \ - --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code' \ - --data-urlencode "pre-authorized_code=$CODE" \ - --data-urlencode "tx_code=$PIN" - ``` +## Transaction code modes - The response carries a short-TTL Notary-signed `access_token`, `token_type`, - `expires_in`, and a `c_nonce`. -5. The wallet calls `POST /oid4vci/credential` with the Notary-issued - `access_token` and a `did:jwk` holder proof, exactly as in the Wallet flow - credential request step. The issued SD-JWT VC is bound to the - eSignet-authenticated subject (the civil id determines whose claim is - evaluated) and to the holder's `did:jwk` key. - -The browser callback URL itself is not the wallet input. The callback renders an -HTML offer page after identity-provider authentication succeeds; the wallet -receives the `openid-credential-offer://` URI from that page and the citizen -enters the displayed PIN separately. - -The `pre-authorized_code` is single-use and short-lived: a second redemption -fails, and the code expires after `pre_authorized_code_ttl_seconds`. When -`tx_code.required` is true (the default), repeated wrong-PIN attempts on one code -hit `subject_access.rate_limits.tx_code_attempts_per_code_per_minute` and lock -the code. A flood of random codes from one client address is throttled by the -existing per-address invalid-attempt limiter. - -Each signed pre-authorized code records whether its offer requires a -transaction code. Changing `tx_code.required` affects newly issued offers only; -it cannot weaken or add the PIN requirement for an unexpired offer. - -Operators may set `oid4vci.pre_authorized_code.tx_code.required: false` for -wallets that cannot present a transaction code. Registry Notary reports this as -`bearer_offer` mode in the admin posture document. In this mode, the -pre-authorized code is bearer credential material until it is redeemed, so -validation requires `pre_authorized_code_ttl_seconds <= 300`. Use it only for -controlled demos or compatibility deployments where the wallet cannot send a -`tx_code`. - -This bearer-offer window is separate from the issued VC lifetime. Wallet-held -credentials use the requested credential profile's `validity_seconds`, so demo -profiles can issue credentials valid for days while keeping offer codes and -Notary access tokens short-lived. - -The Notary mints its access token with a dedicated signing key separate from the -SD-JWT VC credential key, with its own issuer, audience, and a distinct header -`typ`. It is verified by a second, separately-keyed trust anchor: an eSignet token -and a Notary token each pass only their own verifier. - -During governed key rotation, keep the outgoing access-token key as -`publish_only` and list it in `auth.access_token_signing.verification_key_ids` -until existing Notary access tokens and pre-authorized codes have expired. The -active `signing_key_id` signs new codes and access tokens; `verification_key_ids` -only accepts older public keys for verification. - -## Metadata and offers - -Issuer metadata is derived from `oid4vci` and the configured credential -configurations. Wallets should verify that metadata advertises: - -- `credential_issuer` equal to the public issuer URL. -- Authorization servers matching the wallet's token issuer. -- Credential endpoint matching the configured HTTPS endpoint. -- Credential configurations for the expected credential ids. -- `format: dc+sd-jwt`. -- `proof_signing_alg_values_supported: [EdDSA]`. -- `cryptographic_binding_methods_supported: [did:jwk]`. -- `vct` equal to a public HTTPS URL served by the Notary. -- `token_endpoint` equal to the Notary's `/oid4vci/token` endpoint, present only - when the authenticated pre-authorized-code flow is enabled. Its presence is the - metadata signal that the Notary is its own authorization server for the - pre-authorized-code grant; the grant itself is advertised per-offer in the - credential offer's `grants`. When the flow is disabled there is no - `token_endpoint`. - -For SD-JWT VC wallet interoperability, the Notary serves public Type Metadata for -each configured `vct`. Per the SD-JWT VC Type Metadata convention, a consumer -dereferences an HTTPS `vct` by inserting `/.well-known/vct` between the host and -the path: for `vct = https://{host}/credentials/citizen-civil-status/v1` it -fetches `https://{host}/.well-known/vct/credentials/citizen-civil-status/v1`. -walt.id (wallet-api `0.20.2`) does exactly this during offer setup and aborts the -flow if it does not get a `200`. The Notary serves the metadata at that -well-known location, `GET /.well-known/vct/{vct_path}`, and a wallet can fetch it -without authentication. The route uses a trailing-wildcard capture, so nested -configured paths such as `/.well-known/vct/credentials/dhis2/health-status/v1` -resolve. The bare `GET /credentials/{vct_path}` route is also served for -spec-compliant consumers that dereference the `vct` directly, but it is not the -path walt.id fetches. The response is `application/json`, returns `404` when OID4VCI -is disabled or no configured `vct` matches, and includes: - -- `vct`: the configured `vct` identifier (`https://{host}/{vct_path}`), not the - requested well-known URL. -- `name` and `display[].locale`/`display[].name`. -- `claims[].path` using the configured OID4VCI `claim_id`. -- `claims[].display[].locale`/`claims[].display[].label`. -- `claims[].sd: "always"`, because Notary emits evaluated claim results as - selectively disclosable SD-JWT disclosures. -- `claims[].registry_notary_semantics` when the underlying claim config - declares semantic bindings such as PublicSchema concept, property, - vocabulary, predicate, or `derived_from` terms. - -Browser-based wallets from configured self-attestation wallet origins receive -CORS headers on the `/.well-known/vct/...` metadata surface (the response echoes -the request `Origin` in `access-control-allow-origin`, and `OPTIONS` preflights -for an allowed method return `204` with `access-control-allow-origin` and -`access-control-allow-methods`). - -Credential offers are intentionally lightweight. They tell the wallet which -credential configuration to request and which issuer metadata to use. If more -than one credential configuration is enabled, wallet tests should explicitly -select the intended configuration. - -## Nonce policy - -Enable nonce support for real wallet interop: +The secure default is: ```yaml oid4vci: - nonce: + pre_authorized_code: enabled: true - ttl_seconds: 300 - nonce_endpoint: https://notary.example.gov/oid4vci/nonce + pre_authorized_code_ttl_seconds: 300 + tx_code: + required: true + input_mode: numeric + length: 6 ``` -Nonce TTL must be between 1 and 600 seconds. When nonce is enabled, -`nonce_endpoint` is required. - -For multiple credential configurations, the nonce request should identify the -credential configuration. That keeps a nonce from being reused across a -different credential configuration. - -Use PostgreSQL correctness state for nonce-backed wallet traffic when more -than one process can receive requests. +Codes are short-lived and single-use. Wrong PIN attempts are bounded per offer, +and invalid-code attempts are rate limited per client address. -## Credential request - -The wallet credential request can use the legacy single-proof shape: - -```json -{ - "format": "dc+sd-jwt", - "credential_configuration_id": "birth_record_sd_jwt", - "proof": { - "proof_type": "jwt", - "jwt": "" - } -} -``` +Some wallet versions, including the Walt compatibility profile, cannot present +a transaction code. Make that weaker mode explicit: -OID4VCI 1.0 wallets can instead send the final-spec proof array shape: - -```json -{ - "credential_configuration_id": "birth_record_sd_jwt", - "proofs": { - "jwt": [""] - } -} -``` - -Send either `proof` or `proofs`, not both. Notary accepts one JWT proof for a -credential request and rejects requests with multiple proof JWTs. - -The proof JWT should demonstrate holder control of a `did:jwk` key and be fresh -within `oid4vci.proof.max_age_seconds`, allowing only -`max_clock_skew_seconds` of clock difference. - -Notary rejects unsupported formats, unsupported proof algorithms, stale proofs, -replayed nonces, subject-binding mismatches, claims outside the allow-list, and -credential profiles outside the allow-list. - -## Credential response - -Successful responses contain the issued SD-JWT VC: - -```json -{ - "format": "dc+sd-jwt", - "credential": "", - "credentials": [ - { - "credential": "" - } - ], - "c_nonce": "", - "c_nonce_expires_in": 300 -} +```yaml +oid4vci: + pre_authorized_code: + enabled: true + pre_authorized_code_ttl_seconds: 300 + tx_code: + required: false ``` -Wallets should store the credential as SD-JWT VC and verify: - -- Issuer key resolves from Notary JWKS. -- `vct` matches the requested credential configuration. -- Holder binding is the wallet's `did:jwk`. -- Expiry is short and within deployment policy. -- Optional status URL is handled according to verifier policy. - -The response does not need to echo every request field. Wallet tests should -assert the credential content, not only the response envelope. - -## Compatibility checklist - -For each wallet product or SDK: - -- Can it parse issuer metadata with `dc+sd-jwt` credential configurations? -- Can it request or accept a credential offer for a specific configuration id? -- Can it obtain an access token with the configured audience and scopes? -- Does the access token carry the subject-binding claim Notary expects? -- For public wallets, can it redeem `pre-authorized_code` offers with a numeric - `tx_code` at the Notary `token_endpoint`? -- Can it generate a JWT proof using EdDSA and a `did:jwk` holder key? -- Does it include and refresh nonces according to issuer responses? -- Does it accept short-lived credentials? -- Does it preserve SD-JWT disclosures without logging them? -- Can it display status-free credentials and status-bearing credentials? -- Does it surface a distinct, actionable error for `invalid_token`, proof - failure, nonce replay, and subject mismatch? - -Record the wallet name, version, supported draft/profile behavior, and any -configuration overrides in your deployment notes. - -## Security and privacy notes - -- Every issued root retains exact compiler pins for its complete - registry-backed dependency closure and one normalized record per unique, - freshly executed Relay consultation. -- `self_attested` claims perform no Relay consultation and cannot be issued. -- Delegated self-attestation is evaluation-only and cannot be issued. -- Subject binding is exact; do not use normalization that could join different - civil identifiers. -- A holder DID can become a correlation handle if reused widely. Wallets should - follow their privacy model for pairwise or purpose-specific keys. - -For wallet-origin restrictions, secret handling, rate-limit layering, and -logging boundaries, see the -[deployment hardening runbook](deployment-hardening-runbook.md). +The TTL must be no more than 300 seconds in this mode. An offer without a PIN is +bearer credential material until redemption. A person who steals the +unredeemed offer can use it during that window. Keep the offer out of logs, +analytics, screenshots, browser synchronization, and support messages. The +code remains single-use and redemption remains rate limited. + +## Metadata and Type Metadata + +Issuer metadata is derived from active Notary configuration. Wallets should +assert exact values rather than infer capability from permissive schema fields: + +- `credential_issuer` equals the configured public issuer; +- `token_endpoint` equals the Notary token endpoint; +- every credential configuration has `format: dc+sd-jwt`; +- `cryptographic_binding_methods_supported` is exactly `[did:jwk]`; +- JWT `proof_signing_alg_values_supported` is exactly `[EdDSA]`; +- the issuer algorithm is exactly the active profile algorithm; +- `vct` is the configured public HTTPS identifier; +- no nonce endpoint is advertised. + +Notary serves Type Metadata at both the configured `vct` URL and the +`/.well-known/vct/{vct_path}` form used by wallets. It describes each projected +claim and its selective-disclosure behavior. `status` is a reserved top-level +claim and cannot be projected as a selectively disclosable value. + +## Credential request and response + +The wallet sends one proof using either the supported single-proof shape or the +single-entry proof-array shape. Multiple proofs and mixed shapes are rejected. +The proof must be fresh, have the Notary issuer as audience, contain the +transaction-bound nonce from the token response, use EdDSA, and identify the +holder as `did:jwk`. + +The response contains the immediate credential in its compatibility envelope. +It does not return a new proof nonce. The wallet or verifier should check: + +- issuer signature and configured issuer algorithm; +- expected `vct` and credential lifetime; +- exact EdDSA `did:jwk` holder binding; +- SD-JWT disclosure hashes; +- live status when the credential contains a status claim. + +For a status-bearing credential, verification fails closed on an unavailable, +untrusted, malformed, expired, suspended, revoked, or otherwise invalid status +response. Status retrieval is restricted to the configured exact HTTPS trusted +origin. + +## Security invariants + +- Notary creates the offer only after the identity binding and registry-backed + evaluation succeed. +- The credential endpoint reloads the stored transaction and verifies the + active claim, profile, purpose, contract hash, Relay ULID, acquisition time, + and claim provenance before signer access. +- Wallet input cannot select a free-form subject or replace stored evidence. +- Pre-authorized codes, access tokens, proof nonces, and transaction bindings + are time bounded and replay protected. +- Identity-provider codes, wallet grants, access tokens, proof JWTs, subjects, + registry rows, and disclosures must not appear in logs. + +## Compatibility evidence + +Record the wallet and verifier product, exact version, configuration override, +artifact digest, and observed result for every external run. Local source tests +cover EdDSA and ES256 issuer variants with an EdDSA `did:jwk` holder. External +wallet, verifier, OIDF, EUDI, or HAIP conformance remains candidate-only until a +frozen candidate artifact and immutable evidence are published. ## Troubleshooting | Symptom | Likely cause | Check | | --- | --- | --- | -| Metadata route is unavailable | `oid4vci.enabled` is false or subject access is disabled | Expanded config and startup logs | -| Config fails validation | OID4VCI references a source-free or delegated claim, a mixed profile, or inconsistent claim/profile bindings | `credential_configurations`, `subject_access.allowed_claims`, `subject_access.credential_profiles`, claim evidence modes | -| Credential denied after an evaluation | Evaluation is legacy, source-free, delegated, or its dependency-closure Relay execution provenance no longer matches active configuration | Re-evaluate the exact non-delegated registry-backed claims and retry with a fresh nonce and holder proof | -| Delegated token is rejected | The credential endpoint only accepts direct self-attestation access tokens | Token authorization details, `subject_access.delegation` | -| Wallet token rejected | Audience, issuer, client id, scope, or algorithm mismatch | `auth.oidc`, `oid4vci.accepted_token_audiences`, wallet token header and claims | -| Wallet never asks for PIN | Offer is still `authorization_code`, pre-authorized-code flow is disabled, or wallet ignored the grant | Issuer metadata `token_endpoint`, offer `grants`, `oid4vci.pre_authorized_code.enabled` | -| PIN is rejected | Wrong `tx_code`, expired code, reused code, or rate-limit lockout | Offer age, token endpoint response, `tx_code_attempts_per_code_per_minute` | -| Wallet aborts before login or PIN | Wallet cannot resolve SD-JWT VC Type Metadata | `GET /.well-known/vct/{vct_path}` returns `200` JSON without auth | -| Subject mismatch | Token claim does not exactly match the requested subject context | `subject_access.subject_binding` and identity-provider claims | -| Nonce rejected | Nonce expired, reused, or from another configuration | Nonce TTL, replay store, credential configuration id | -| Proof rejected | Unsupported alg, wrong holder binding, stale proof, or clock skew | Wallet proof JWT and `oid4vci.proof` | -| Credential issued but wallet cannot verify | JWKS, issuer DID, `kid`, or `vct` mismatch | Signing key config and credential profile | -| Works with one process but fails in active-active | `state.storage: in_memory` | Install and use PostgreSQL correctness state | +| OID4VCI routes are unavailable | The facade or pre-authorized flow is disabled | Expanded config and startup diagnostics | +| Configuration is rejected | A source-free, delegated, mixed, or one-sided credential binding remains | Claim evidence mode, profile bindings, and OID4VCI projections | +| Offer is not rendered | Identity binding, Relay execution, or stored transaction creation failed | Sanitized audit records and Relay availability | +| Wallet asks for a different grant | Wallet does not support issuer-initiated pre-authorized code | Wallet version and imported offer | +| PIN is rejected | Wrong, expired, replayed, or locked offer | Offer age and rate-limit diagnostics | +| Wallet cannot send a PIN | Compatibility profile needs explicit bearer-offer mode | `tx_code.required: false` and TTL no more than 300 seconds | +| Proof is rejected | Unsupported algorithm or binding, stale proof, wrong audience, or nonce mismatch | Wallet proof header and claims | +| Credential is denied after token redemption | Stored registry transaction is missing, stale, or does not match active compiler pins | Re-run the browser journey under one project generation | +| Wallet cannot verify | Issuer JWKS, `kid`, algorithm, `vct`, holder binding, or status mismatch | Active signing profile and verifier diagnostics | +| Multiple instances disagree | In-memory correctness state is in use | Install and select Notary-owned PostgreSQL state | diff --git a/products/notary/docs/operator-config-reference.md b/products/notary/docs/operator-config-reference.md index 56e311c2d..14b113f70 100644 --- a/products/notary/docs/operator-config-reference.md +++ b/products/notary/docs/operator-config-reference.md @@ -38,6 +38,21 @@ the default is 30 seconds. These controls compose with the 1 MiB inbound body, 64 KiB per-Relay-result, and 256 consultation-group limits. They do not replace the 100-member ceiling. +## OID4VCI 1.0 profile + +Enabled OID4VCI configuration requires +`oid4vci.pre_authorized_code.enabled: true`. The only wallet-facing grant is +issuer-initiated pre-authorized code. `oid4vci.nonce.enabled` controls the +transaction-bound proof nonce returned by the token response; it does not mount +a public nonce route. `oid4vci.nonce_endpoint` must be omitted, and +`oid4vci.offer_endpoint` has no route effect in the 1.0 profile. + +`oid4vci.pre_authorized_code.tx_code.required` defaults to `true`. An explicit +`false` setting, including the Walt compatibility profile, requires +`pre_authorized_code_ttl_seconds` of no more than 300. The no-PIN offer is +single-use bearer credential material until redemption and must remain covered +by rate limits and disclosure controls. + {/* registry-notary-config-key-paths:start */} ```text audit @@ -692,9 +707,10 @@ SHA-256 execution binding cross-binds each claim pin, execution, and exact claim provenance. Missing, duplicated, extra, legacy, or modified provenance is denied before signer, credential-id, or status side effects. Direct issuance checks before holder-proof replay -mutation. OID4VCI rejects a source-free credential configuration before nonce -consumption, preserves the existing nonce-before-evaluation ordering, and -checks stored provenance before signer access. Delegated self-attestation is +mutation. OID4VCI rejects a source-free credential configuration, creates and +evaluates the registry transaction before rendering an offer, consumes the +transaction-bound proof nonce at the credential endpoint, and checks stored +provenance before signer access. Delegated self-attestation is evaluation-only; delegated relationship and claim credential-profile bindings are rejected at configuration load. diff --git a/products/notary/docs/release-notes.md b/products/notary/docs/release-notes.md index 01a716cb4..0826723be 100644 --- a/products/notary/docs/release-notes.md +++ b/products/notary/docs/release-notes.md @@ -2,6 +2,30 @@ ## Unreleased +- BREAKING: The 1.0 wallet facade supports only issuer-initiated + pre-authorized code backed by a stored registry transaction. The former + credential-offer and public nonce routes are removed, and the credential + response has no next nonce. Start with `/oid4vci/offer/start`, complete the + identity-provider callback, redeem the rendered offer at `/oid4vci/token`, + and use its transaction-bound proof nonce. The identity provider's + authorization code is never a wallet grant. +- OID4VCI source tests now cover complete issuance and client verification for + EdDSA and ES256 issuer keys with an EdDSA `did:jwk` holder. Metadata + advertises the exact configured issuer algorithm and only the supported + holder profile. +- `tx_code` remains enabled by default. A no-PIN wallet profile must opt out + explicitly and use a pre-authorized-code TTL no longer than 300 seconds. The + offer is bearer credential material until its single use, so operators must + prevent disclosure and retain redemption rate limits. +- Source-free claims remain evaluation-only. OID4VCI issuance reloads the + registry transaction and exact Relay-backed evaluation provenance before + signer access. +- Status-bearing credentials are verified fail closed from the configured + exact HTTPS status origin. The reserved top-level status claim cannot be + selectively disclosable. +- External wallet, verifier, OIDF, EUDI, or HAIP evidence remains + candidate-only until recorded against a frozen release artifact. + ## 0.11.0 - BREAKING: Direct and OID4VCI credential issuance now require a fresh, diff --git a/products/notary/docs/sd-jwt-vc-conformance-profile.md b/products/notary/docs/sd-jwt-vc-conformance-profile.md index 4954c19e1..3d3b00cb0 100644 --- a/products/notary/docs/sd-jwt-vc-conformance-profile.md +++ b/products/notary/docs/sd-jwt-vc-conformance-profile.md @@ -3,8 +3,8 @@ > **Page type:** Standards conformance · **Product:** Registry Notary · **Layer:** credential · **Audience:** integrator Registry Notary issues one constrained SD-JWT VC credential format -(`application/dc+sd-jwt`, EdDSA over Ed25519, `did:jwk` holder binding); it -does not claim full SD-JWT VC or OpenID4VCI conformance. This page documents +(`application/dc+sd-jwt`, EdDSA or ES256 issuer signing, and EdDSA `did:jwk` +holder proof for OID4VCI); it does not claim full SD-JWT VC or OpenID4VCI conformance. This page documents that credential contract and names adjacent ecosystem features that are not yet part of the product surface. @@ -28,13 +28,13 @@ wire media type `application/dc+sd-jwt`. Issued credentials use a compact issuer-signed JWT as the first component of the SD-JWT value. The protected header has these Registry Notary invariants: -- `alg` is `EdDSA`. +- `alg` is the credential profile's configured `EdDSA` or `ES256` algorithm. - `typ` is `dc+sd-jwt`. - `kid` is the `kid` on the credential profile's configured signing key. -Only Ed25519 EdDSA signing keys are supported. Local JWK keys are supported for -development and tests; PKCS#11 keys are available behind the optional server -feature. +Ed25519 keys are used with EdDSA and P-256 keys are used with ES256. Local JWK +keys are supported for development and tests; supported production signing +providers are documented separately. Signing key configuration examples are documented in [signing-key-provider.md](signing-key-provider.md). @@ -136,6 +136,12 @@ Type Metadata convention, a consumer dereferences an HTTPS `vct` by inserting semantic bindings, the claim metadata also includes the Notary extension `registry_notary_semantics`; this labels the claim with external terms such as PublicSchema URIs but does not change the Notary claim-result payload shape. + +The top-level `status` claim is reserved and cannot be projected as a +selectively disclosable value. A client verifier of a status-bearing credential +retrieves the signed status token only from its configured exact HTTPS trusted +origin and fails closed when the status is missing, malformed, untrusted, +unavailable, suspended, revoked, expired, or otherwise invalid. - **CORS.** Browser-based wallets from configured self-attestation wallet origins receive CORS headers on the `/.well-known/vct/...` metadata surface. @@ -180,7 +186,9 @@ The following features are out of scope for the current profile: - external status-list or revocation-list profiles; - mDoc/mDL; - CWT proof binding; -- full OpenID4VCI issuer behavior. +- full OpenID4VCI issuer behavior, wallet-facing authorization code, a public + nonce endpoint, response next nonce, ES256 holder proof, EUDI, HAIP, PAR, + DPoP, or wallet attestation. These features require separate compatibility, lifecycle, and security design before implementation. diff --git a/products/notary/specs/citizen-self-attestation-spec.md b/products/notary/specs/citizen-self-attestation-spec.md index 5a171d272..3c14260ac 100644 --- a/products/notary/specs/citizen-self-attestation-spec.md +++ b/products/notary/specs/citizen-self-attestation-spec.md @@ -18,10 +18,11 @@ > self-attestation is evaluation-only in 1.0. > See `docs/credential-issuance-migration.md` for current operator guidance. -Current status: implemented for evaluation, render, credential issuance, batch -denial, rate-limit guard, and OpenID4VCI facade integration in 0.3.0. External -wallet and lab smoke coverage remains separate from the in-repo implementation -status. +Historical implementation status: the original design covered evaluation, +rendering, credential issuance, batch denial, rate limits, and OID4VCI. The +credential portions below are superseded and must not be implemented or copied. +Current source-free and delegated self-attestation is evaluation-only. Current +credential issuance is registry-backed as described in the supersession note. ## Goal diff --git a/products/notary/specs/notary-api-v1-route-cleanup-proposal.md b/products/notary/specs/notary-api-v1-route-cleanup-proposal.md index 32730578f..259fa1818 100644 --- a/products/notary/specs/notary-api-v1-route-cleanup-proposal.md +++ b/products/notary/specs/notary-api-v1-route-cleanup-proposal.md @@ -72,8 +72,8 @@ operational: | `GET /.well-known/evidence-service` | keep | Discovery convention. | | `GET /.well-known/evidence/jwks.json` | keep | JWKS discovery convention. | | `GET /.well-known/openid-credential-issuer` | keep | OID4VCI metadata convention. | -| `GET /oid4vci/credential-offer` | keep | OID4VCI wallet-facing endpoint. | -| `POST /oid4vci/nonce` | keep | OID4VCI wallet-facing endpoint. | +| `GET /oid4vci/credential-offer` | removed for 1.0 | Superseded by issuer-initiated offers rendered after the authenticated callback. | +| `POST /oid4vci/nonce` | removed for 1.0 | Proof nonces are transaction-bound and returned by the token response. | | `POST /oid4vci/credential` | keep | OID4VCI wallet-facing endpoint. | ### Versioned Application API diff --git a/products/notary/specs/openid4vci-wallet-facade-spec.md b/products/notary/specs/openid4vci-wallet-facade-spec.md index 0c498a1ef..6cb81243e 100644 --- a/products/notary/specs/openid4vci-wallet-facade-spec.md +++ b/products/notary/specs/openid4vci-wallet-facade-spec.md @@ -1,950 +1,175 @@ -# OpenID4VCI Wallet Facade Spec +# OpenID4VCI wallet facade specification -> **Status: Archived (2026-05-31).** The OpenID4VCI wallet facade described here has shipped; the four facade endpoints are implemented. This file is kept as a -> design record and is not the source of truth. For current behavior see the code -> and docs/oid4vci-wallet-interop.md and docs/sd-jwt-vc-conformance-profile.md. -> -> **Credential supersession note (2026-07-17).** The facade now accepts only -> registry-backed claims with exact compiler-pinned Relay execution provenance. -> Source-free self-attested claims are evaluation-only. Descriptions below that -> imply source-free credential issuance are historical. +Status: normative Registry Stack 1.0 profile. -Adoption mode: profiled (OID4VCI Draft 13 subset). +Adoption mode: profiled OpenID4VCI subset. -## Goal +## Purpose -Add an optional OpenID4VCI issuer facade for Registry Notary so citizen -self-attestation credentials can be downloaded by standards-oriented wallets -such as Inji Wallet and Walt Wallet. +Registry Notary exposes a narrow wallet facade for issuing holder-bound +`dc+sd-jwt` credentials from registry-backed evidence. The facade adapts the +Notary transaction and issuance model to wallets without creating a second +source-free credential trust surface. -The facade must be wallet-neutral. Inji and Walt are validation clients, not -special cases. eSignet remains the citizen identity provider. Registry Notary -remains the authorization, subject-binding, Relay-consultation, audit, and credential -issuance authority. +Source-free `self_attested` claims remain available for evaluation. They cannot +authorize or produce credentials through either the Notary API or OID4VCI. -```text -Wallet - -> OpenID4VCI issuer metadata and credential offer - -> eSignet citizen authentication - -> Notary OpenID4VCI credential endpoint - -> Notary self-attestation guard - -> Registry Relay consultation - -> Notary SD-JWT VC issuance -``` +## Trust and topology -## Background - -Registry Notary already supports citizen self-attestation through its custom -API surface: - -1. `POST /v1/evaluations` -2. `POST /v1/credentials` - -That shape is practical for scripts and portals, but wallets generally expect -OpenID4VCI: - -1. Fetch issuer metadata from `/.well-known/openid-credential-issuer`. -2. Authenticate the citizen through an authorization server. -3. Submit an access token and wallet proof JWT to a credential endpoint. -4. Receive a credential response in the advertised format. - -Notary can already issue `application/dc+sd-jwt` credentials, but its holder -proof is currently stricter and more Notary-specific than the standard -OpenID4VCI proof JWT most wallets generate. The facade therefore needs to adapt -the protocol without weakening the existing Notary `/v1/credentials` -contract. - -## Actors +The actors and trust boundaries are: | Actor | Responsibility | | --- | --- | -| Citizen wallet | Discovers issuer metadata, launches citizen authentication, signs holder proof, stores the credential | -| eSignet | Authenticates the citizen and provides the verified subject-binding claim | -| Registry Notary | Publishes OpenID4VCI facade, validates token and proof, enforces self-attestation policy, issues credential | -| Registry Relay | Supplies typed outputs through compiler-pinned consultations after Notary authorization | -| Registry Platform | Provides reusable cryptographic, OpenID4VCI, SD-JWT, OIDC, and test-fixture primitives | -| External adopter demo | Optionally orchestrates scripts, artifacts, and wallet smoke checks outside Registry Stack; Solmara Lab is the current adopter repository | - -## User Story - -A citizen opens a wallet, scans or follows a credential offer for -`person_is_alive_sd_jwt`, authenticates with eSignet, and receives a -holder-bound SD-JWT VC proving `person-is-alive` for their own bound civil -identifier. - -The citizen must not be able to request another person's claim, choose an -arbitrary subject in the credential request, bypass self-attestation policy, or -start a Relay consultation before subject binding succeeds. - -## Non-Goals - -- Full OpenID4VCI feature coverage. -- Pre-authorized code flow in V1. -- Deferred credential issuance. -- Batch credential issuance. -- mDoc, JSON-LD, `vc+sd-jwt`, or CWT proof support. -- Wallet-specific custom APIs. -- Delegation, guardianship, representative access, or multi-subject flows. -- Proving that the wallet holder DID is the same identifier as the civil - subject. -- Changing the existing `/v1/evaluations` and `/v1/credentials` protocol. - -## Protocol Surface - -The facade is disabled by default and enabled by explicit Notary config. - -### `GET /.well-known/openid-credential-issuer` - -Returns issuer metadata for configured citizen self-attestation credentials. - -Required V1 fields: - -- `credential_issuer` -- `authorization_servers` -- `credential_endpoint` -- `nonce_endpoint` -- `credential_configurations_supported` - -The metadata must not expose internal registry source names, source URLs, -source credentials, raw civil identifiers, or operator-only policy details. - -Example: - -```json -{ - "credential_issuer": "http://127.0.0.1:4325", - "authorization_servers": ["http://localhost:8088/v1/esignet"], - "credential_endpoint": "http://127.0.0.1:4325/oid4vci/credential", - "nonce_endpoint": "http://127.0.0.1:4325/oid4vci/nonce", - "credential_configurations_supported": { - "person_is_alive_sd_jwt": { - "format": "dc+sd-jwt", - "scope": "person-is-alive", - "vct": "https://registry.example.gov/credentials/person-is-alive", - "cryptographic_binding_methods_supported": ["did:jwk"], - "proof_types_supported": { - "jwt": { - "proof_signing_alg_values_supported": ["EdDSA"] - } - }, - "claims": { - "person-is-alive": { - "display": [ - { - "name": "Person is alive", - "locale": "en" - } - ] - } - }, - "display": [ - { - "name": "Civil status attestation", - "locale": "en" - } - ] - } - } -} -``` - -`format` uses the OpenID4VCI credential format identifier expected by wallets. -Notary still issues the wire credential media type documented in the SD-JWT VC -profile. - -### `GET /oid4vci/credential-offer` - -Returns a credential offer for a configured credential configuration id. - -V1 supports: - -- query parameter `credential_configuration_id`; -- JSON response containing the credential offer object directly. - -The offer must not include a subject id. The subject is derived only from the -verified eSignet token at credential issuance time. - -Example response: - -```json -{ - "credential_issuer": "http://127.0.0.1:4325", - "credential_configuration_ids": ["person_is_alive_sd_jwt"], - "grants": { - "authorization_code": { - "issuer_state": "...", - "authorization_server": "https://id.example.gov" - } - } -} -``` - -### `POST /oid4vci/nonce` - -Returns a nonce for wallet proof JWT replay protection. - -Example response: - -```json -{ - "c_nonce": "...", - "c_nonce_expires_in": 300 -} -``` - -The endpoint reserves replay-protected nonces. Deployments should apply gateway -or service-level rate limiting if public exposure requires it. The request may -omit `credential_configuration_id` only when exactly one credential -configuration is configured; otherwise clients must supply it. The endpoint does -not accept or return a subject id. The nonce is later consumed by -`POST /oid4vci/credential`. - -### `POST /oid4vci/credential` - -Accepts a wallet credential request and returns an OpenID4VCI credential -response. - -Example request: - -```json -{ - "format": "dc+sd-jwt", - "credential_configuration_id": "person_is_alive_sd_jwt", - "proof": { - "proof_type": "jwt", - "jwt": "eyJ..." - } -} -``` - -Processing order is security-sensitive: - -1. Parse and validate bearer token without logging token material. -2. Verify the token using the existing OIDC/eSignet policy. -3. Classify the request as citizen self-attestation. -4. Validate OpenID4VCI credential configuration id. -5. Validate the wallet proof JWT and consume nonce when required. -6. Extract the configured subject-binding claim from verified claims or - verified UserInfo. UserInfo is a network call and must happen only after the - proof is valid. -7. Construct the internal subject from the verified binding claim. -8. Run the same self-attestation subject-binding guard used by - `/v1/evaluations`. -9. Only then read the configured registry source. -10. Issue the configured SD-JWT VC profile. -11. Return the credential response. - -The request body must not contain a subject id in V1. If a wallet or malicious -client supplies one through an extension field, the request is denied. - -Example response: - -```json -{ - "credential": "eyJ...~WyI...~", - "format": "dc+sd-jwt", - "c_nonce": "...", - "c_nonce_expires_in": 300 -} -``` - -V1 targets a pragmatic compatibility profile: OpenID4VCI Draft 13 response -fields for Inji-style clients plus a dedicated nonce endpoint for Final-style -clients. If a wallet rejects this profile, the compatibility smoke must record -the exact field mismatch before any wallet-specific workaround is added. -Each live wallet smoke must record the wallet product, version or commit, and -the OpenID4VCI draft/profile behavior observed during the run. - -## Configuration - -Add an optional top-level Notary block: - -```yaml -oid4vci: - enabled: true - credential_issuer: http://127.0.0.1:4325 - authorization_servers: - - http://localhost:8088/v1/esignet - accepted_token_audiences: - - http://127.0.0.1:4325 - credential_endpoint: http://127.0.0.1:4325/oid4vci/credential - offer_endpoint: http://127.0.0.1:4325/oid4vci/credential-offer - nonce_endpoint: http://127.0.0.1:4325/oid4vci/nonce - nonce: - enabled: true - ttl_seconds: 300 - authorization: - require_pkce_method: S256 - credential_configurations: - person_is_alive_sd_jwt: - claim_id: person-is-alive - credential_profile: civil_status_sd_jwt - format: dc+sd-jwt - scope: person-is-alive - vct: https://registry.example.gov/credentials/person-is-alive - display_name: Person is alive - proof_signing_alg_values_supported: - - EdDSA - cryptographic_binding_methods_supported: - - did:jwk -``` - -Validation rules: - -- `oid4vci.enabled = true` requires `self_attestation.enabled = true`. -- Each credential configuration must reference an allowed self-attestation - claim. -- Each credential configuration must reference an allowed self-attestation - credential profile. -- `format` must be `dc+sd-jwt` in V1. -- `proof_signing_alg_values_supported` must contain only supported algorithms. -- `cryptographic_binding_methods_supported` must contain only `did:jwk` in V1. -- `credential_issuer`, `credential_endpoint`, and `offer_endpoint` must be - absolute URLs. -- `nonce_endpoint` must be configured when `nonce.enabled = true`. -- `accepted_token_audiences` must be non-empty when `oid4vci.enabled = true`; - the lab default is `credential_issuer`. -- `accepted_token_audiences` must be matched against verified token claims, not - against runtime `Host` or `X-Forwarded-Host` headers. -- Public metadata URLs must be consistent with the configured listener or - operator-supplied external base URL. -- HTTPS is required outside loopback/dev configurations. Plain HTTP is allowed - only for loopback lab URLs. -- The `oid4vci` config block must deserialize with `#[serde(default)]` so - existing configs continue to load when the facade is absent. -- Cross-block validation must be implemented as a dedicated - `validate_oid4vci_cross_block()` path or equivalent, not scattered across - route handlers. - -## Security Invariants - -- The facade is disabled by default. -- The facade requires OIDC/eSignet authentication. -- The subject used for evaluation is derived only from verified token claims or - verified UserInfo. -- The wallet request cannot choose a subject. -- Subject binding succeeds before any Relay consultation. -- A request for another subject is denied before any Relay consultation. -- A credential configuration id maps to exactly one configured claim and one - configured credential profile. -- The wallet proof binds the credential to a wallet-controlled key. -- Holder binding does not prove holder-equals-civil-subject. -- No raw token, raw civil id, holder private key, source credential, or source - row appears in logs, audit events, script artifacts, or error bodies. -- Existing `/v1/evaluations` and `/v1/credentials` behavior remains - unchanged unless self-attestation config already changes it. - -## V1 Trust And Privacy Boundaries - -V1 is a practical wallet interoperability feature. It must not overclaim -production wallet-ecosystem trust. - -V1 validates: - -- the citizen's current OIDC/eSignet token; -- the configured citizen subject-binding claim or verified UserInfo claim; -- the token audience against configured `oid4vci.accepted_token_audiences`; -- the requested credential configuration id; -- the wallet's proof of possession for the key used as the credential holder; -- the configured self-attestation claim and credential profile policy. - -V1 does not validate: - -- that the wallet application is certified by any external scheme; -- that the holder key is hardware-backed or stored in a secure element; -- that a wallet instance has not been revoked by a wallet provider; -- issuer access certificates, trusted lists, or external ecosystem trust - anchors; -- external status-list or revocation-list validation after issuance; -- delegated authority between the citizen and another civil subject. - -Privacy boundaries: - -- Offers and metadata must not contain civil identifiers. -- The credential request must not contain a civil subject id. -- Audit events and lab artifacts must contain only hashed or bounded - identifiers. -- Holder-bound credentials should use the holder DID as the credential subject - in V1, not the raw civil subject id. -- Stable civil identifiers must not appear in credential payloads unless a - credential profile explicitly requires them and the privacy impact is - reviewed. -- V1 should prefer short-lived credentials and re-issuance over long-lived - linkable credentials. - -## Holder Proof Compatibility - -OpenID4VCI wallets normally produce a standard proof JWT. Notary currently -requires a custom holder proof on `/v1/credentials` that binds additional -internal values such as `evaluation_id`, `credential_profile`, disclosure hash, -and claim set. - -V1 must keep the existing strict proof for `/v1/credentials` and add a -separate OpenID4VCI proof validator for `/oid4vci/credential`. - -The OpenID4VCI validator must: - -- accept `proof_type = jwt`; -- require `typ = openid4vci-proof+jwt`; -- reject `alg = none` and any algorithm not explicitly configured; -- accept holder public key material from either an embedded public JWK or a - `kid` that is a `did:jwk` reference; -- reject private key material such as JWK `d`; -- reject remote or ambiguous key references such as `jku`, `x5u`, `x5c`, or - unsupported `kid` values; -- reject unrecognized `crit` headers; -- require `alg = EdDSA` in V1; -- require `aud = oid4vci.credential_issuer`; -- require `iat` within configured clock skew; -- when `exp` is present, reject stale or overlong proofs; -- when `exp` is absent, enforce a maximum proof age from `iat` using the same - configured proof lifetime ceiling; -- require a nonce when nonce enforcement is enabled; -- reject nonce replay; -- derive one canonical holder DID from either accepted key representation; -- produce a holder id suitable for the existing SD-JWT issuance profile; -- produce bounded audit metadata without raw proof JWT material. - -The facade may translate the validated OpenID4VCI proof into the internal -holder request used by Notary issuance. Wallets must not be asked to include -Notary-specific proof claims in V1. - -## Nonce Lifecycle - -Nonce handling is mandatory when `oid4vci.nonce.enabled = true`. - -- Notary is the nonce minting authority. -- `POST /oid4vci/nonce` returns `c_nonce` and `c_nonce_expires_in`. -- `POST /oid4vci/credential` may also return `c_nonce` and - `c_nonce_expires_in` for Draft 13 clients that expect the next nonce in the - credential response. -- A nonce is bound to the configured credential issuer and credential - configuration id. It may additionally be bound to a hashed token subject when - the subject is already available. -- The nonce store persists only keyed hashes, not raw nonce values. -- Nonce consume is atomic: if the nonce is missing, expired, already consumed, - bound to a different issuer, or bound to a different credential - configuration, the request is denied without issuing a credential. -- If nonce enforcement is enabled and the nonce store is unavailable, the - request fails closed. -- If nonce enforcement is disabled for a dev-only profile, proof replay tests - must be skipped with an explicit reason and the metadata must not advertise a - nonce endpoint. - -## Access Token Policy - -The OpenID4VCI facade must not accept any valid citizen token merely because it -was signed by eSignet. - -- The access token issuer, signature, algorithm, expiry, not-before, and clock - skew are validated by the configured OIDC policy. -- The access token audience must match one of - `oid4vci.accepted_token_audiences`. -- Audience validation uses configured values only. Runtime `Host`, - `X-Forwarded-Host`, and forwarded scheme headers are not trusted for this - decision. -- The token must authorize the requested credential configuration through the - configured self-attestation scope policy or an equivalent configured - authorization detail. -- A token valid for another client, relying party, or resource server is denied - even if the subject-binding claim is present. -- Access-token and subject-binding failures may have distinct internal audit - denial codes, but they must not expose a subject-existence oracle on the wire. - -## UserInfo Binding Policy - -When the subject-binding claim is sourced from UserInfo: - -- UserInfo must be fetched only from the configured issuer's UserInfo endpoint; -- the request must use the same verified access token; -- the response must be signed when configured by the issuer profile; -- `userinfo.sub` must equal the verified access-token `sub`; -- the required binding claim must be present exactly once; -- missing, unreachable, unsigned-when-required, ambiguous, or mismatched - UserInfo fails closed; -- the implementation must never fall back to a weaker claim source after - UserInfo failure. - -## What Belongs In Registry Platform - -The wallet facade creates reusable standards and crypto surface. These pieces -belong in `registry-platform` rather than being implemented only inside -Notary: - -### `registry-platform-oid4vci` - -New crate or module for OpenID4VCI primitives: - -- issuer metadata structs; -- credential offer structs and URL encoding; -- credential request and response structs for the supported V1 subset; -- proof JWT parser and validator; -- proof validation policy type; -- validated holder proof output type; -- nonce claim validation helpers; -- negative test vectors for malformed proof, wrong audience, stale proof, - unsupported algorithm, missing key, and replayed nonce. - -The crate must not know about Notary claim ids, registry subjects, Relay -sources, or self-attestation policy. It validates protocol and cryptographic -facts only. - -The proof validator should reuse existing signature parsing, audience, lifetime, -and holder-confirmation helpers from `registry-platform-sdjwt` where practical -instead of reimplementing them. - -### `registry-platform-sdjwt` - -Extend or reuse existing SD-JWT utilities for: - -- `did:jwk` holder key extraction; -- holder confirmation construction; -- issuer media type and compact JWT `typ` constants where reusable; -- wallet-proof test fixture generation. - -The existing Notary-specific holder proof validator remains available. The -OpenID4VCI proof validator must be a separate API so callers cannot -accidentally relax `/v1/credentials`. - -### `registry-platform-crypto` - -Use or add shared helpers for: - -- JWK parsing and thumbprint-safe validation; -- base64url encoding/decoding; -- Ed25519 signature verification; -- DID method validation for `did:jwk`; -- a standalone `parse_did_jwk()` helper that returns public key material and - rejects private, malformed, or unsupported DID documents. - -### `registry-platform-oidc` - -Use the shared OIDC verifier for eSignet access-token validation when it is -available in the target branch. If Notary keeps its current verifier in V1, -that is explicit V1 debt and must be listed in the implementation note. -Signed UserInfo validation should reuse the platform OIDC UserInfo verifier -when available rather than introducing a third verifier. - -### `registry-platform-testing` - -Add reusable test fixtures: - -- mock OpenID4VCI wallet proof signer; -- mock issuer metadata assertions; -- mock eSignet/OIDC token helper if not already covered; -- golden metadata and credential-offer examples. - -## What Belongs In Registry Notary - -Notary owns product policy and runtime behavior: - -- `oid4vci` config schema and validation against `self_attestation`; -- axum routes; -- mapping from credential configuration id to claim id and credential profile; -- eSignet subject-binding extraction; -- UserInfo fetch integration when configured; -- self-attestation guard reuse; -- internal evaluate and issue orchestration; -- audit event shape and redaction; -- nonce storage for V1 if no shared platform storage abstraction exists; -- error mapping to safe client responses. - -Notary must not move registry domain types into platform only to support this -facade. - -## What Belongs In An External Adopter Demo - -An external adopter repository, currently Solmara Lab, owns optional demo -orchestration: - -- generated Notary config with `oid4vci.enabled = true`; -- `just oid4vci-offer`; -- `just oid4vci-smoke`; -- optional `just wallet-walt` once a Walt Wallet API target is available; -- optional `just wallet-inji` once Mimoto/Inji local config is stable; -- narrated artifacts under `output/oid4vci-citizen-attestation/`; -- docs that explain what happened without printing secrets. - -The default `just quick` path must not require Inji, Walt, eSignet, or the -OpenID4VCI facade. - -## Auditability - -Every allow and deny decision on the facade must emit bounded audit context. - -Required audit fields: - -- `protocol = openid4vci`; -- `access_mode = self_attestation`; -- credential configuration id; -- credential profile; -- claim id; -- result status; -- denial code when denied; -- hashed principal; -- hashed subject binding; -- hashed holder id when available; -- correlation id; -- Relay consultation count; -- proof validation result class, without raw proof values. - -The audit record must allow an operator to prove: - -- metadata was served for a configured credential; -- the credential endpoint used a citizen token; -- subject binding happened before Relay consultation; -- successful issuance was tied to self-attestation mode; -- another-subject attempts were denied before Relay consultation; -- proof failures did not issue credentials; -- identifiers were redacted or hashed. - -## Error Model - -V1 uses separate internal audit denial codes and external wire errors. Error -bodies must be generic enough to avoid subject probing. - -Required internal denial codes: - -- `oid4vci.disabled`; -- `oid4vci.unknown_credential_configuration`; -- `oid4vci.invalid_request`; -- `oid4vci.invalid_token`; -- `oid4vci.subject_binding_denied`; -- `oid4vci.proof_required`; -- `oid4vci.proof_invalid`; -- `oid4vci.proof_replay`; -- `oid4vci.unsupported_format`; -- `oid4vci.policy_denied`; -- `oid4vci.rate_limited`; - -Required wire mapping: - -| Internal denial code | HTTP status | Client-visible error | Notes | -| --- | --- | --- | --- | -| `oid4vci.disabled` | 404 or 403 | `invalid_request` | Do not reveal hidden routes in hardened deployments | -| `oid4vci.unknown_credential_configuration` | 400 | `unsupported_credential_type` | No Relay consultation | -| `oid4vci.invalid_request` | 400 | `invalid_request` | Includes malformed JSON and extension fields that try to supply subject | -| `oid4vci.invalid_token` | 401 | `invalid_token` | Includes expired, wrong issuer, wrong audience, and missing token | -| `oid4vci.subject_binding_denied` | 401 or 403 | `invalid_token` or `access_denied` | Must collapse with generic auth denial on the wire to avoid subject probing | -| `oid4vci.proof_required` | 400 | `invalid_proof` | Include `c_nonce` when appropriate | -| `oid4vci.proof_invalid` | 400 | `invalid_proof` | Include `c_nonce` when appropriate | -| `oid4vci.proof_replay` | 400 | `invalid_proof` | Never reveal whether the nonce was seen before | -| `oid4vci.unsupported_format` | 400 | `unsupported_credential_type` | No Relay consultation | -| `oid4vci.policy_denied` | 403 | `access_denied` | Generic denial body | -| `oid4vci.rate_limited` | 429 | `temporarily_unavailable` | Include `Retry-After` when practical | - -The exact enum names must be implemented from the selected OpenID4VCI -compatibility profile. Internal `oid4vci.*` codes are for audit and tests, not -the public wire contract. - -## Wallet Compatibility Targets - -### Inji - -Inji Wallet should use the same facade through issuer metadata and the -credential endpoint. Inji/Mimoto configuration may need to add this issuer and -point its authorization server to eSignet. - -No Inji Wallet code change should be required for V1. If one is required, that -is a compatibility finding and should be documented before patching. - -### Walt - -Walt should be tested through credential offer ingestion. The preferred smoke -target is a Walt Wallet API call that consumes the offer JSON directly, or a -lab-constructed `openid-credential-offer://` URL derived from that JSON, and -receives the credential. - -No Walt code change should be required for V1. - -The external adopter repository keeps the operational wallet test procedure. -That guide defines the Walt API curl shape, the Inji/Mimoto configuration path, -required topology, and evidence to capture for passed or blocked wallet runs. - -## Later Version Candidates - -The following items are intentionally out of V1. They should be considered only -after the wallet-neutral facade is working end to end with at least one real -wallet or wallet API. - -### Production Issuer Trust - -- issuer access certificates; -- trusted issuer lists or trust anchors; -- issuer metadata signing if required by the target ecosystem; -- operational key rotation and trust-rollover procedures; -- conformance checks for the selected wallet ecosystem profile. - -### Wallet Instance Trust - -- wallet instance attestation; -- certified wallet/provider allow-lists; -- hardware-backed key or secure-element attestation; -- wallet instance revocation checks; -- richer holder-binding methods beyond `did:jwk`. - -### Credential Lifecycle - -- credential status and revocation; -- re-issue and refresh flows; -- update notification flows; -- deletion and withdrawal semantics; -- longer-lived credentials with lifecycle controls. - -### Protocol Coverage - -- pre-authorized code flow; -- deferred credential issuance; -- batch issuance; -- additional credential formats such as mDoc or JSON-LD; -- additional proof types such as CWT proof; -- high-assurance interoperability profile testing. - -### Disclosure Policy - -- embedded disclosure policy in issuer metadata; -- relying-party class restrictions; -- verifier trust roots for future presentation flows; -- wallet-side policy hints for when the credential is presented. - -## Definition Of Done - -The feature is complete only when every item below is true: - -- `oid4vci.enabled` is disabled by default. -- With `oid4vci.enabled = false`, the new well-known, offer, and credential - routes are unavailable or return the configured disabled response. -- With `oid4vci.enabled = true`, `GET /.well-known/openid-credential-issuer` - returns metadata containing `credential_issuer`, `authorization_servers`, - `credential_endpoint`, `nonce_endpoint`, and - `credential_configurations_supported`. -- Metadata contains `person_is_alive_sd_jwt` mapped to `dc+sd-jwt`, - `person-is-alive`, and the configured credential profile. -- Metadata contains credential-level display data and a snapshot/allow-list test - proves it contains no source URL, source credential name, internal route, raw - subject id, or operator-only field. -- `GET /oid4vci/credential-offer` returns a valid offer for - `person_is_alive_sd_jwt`. -- The offer includes `grants.authorization_code` with bounded `issuer_state`. -- The offer does not contain a subject id, raw civil identifier, source URL, or - operator-only field. -- Demo authorization requests use PKCE `S256`. If a target IdP or wallet cannot - exercise PKCE in the automated smoke, the gap is documented with the exact - command and observed behavior. -- `POST /oid4vci/nonce` returns `c_nonce` and `c_nonce_expires_in` when nonce - enforcement is enabled. -- Nonces are stored only as keyed hashes or one-way hashes with a deployment - secret. -- Nonces are bound to credential issuer and credential configuration id. -- Nonces are consumed atomically. -- Nonce-store unavailability fails closed when nonce enforcement is enabled. -- `POST /oid4vci/credential` accepts a standard OpenID4VCI JWT proof. -- `POST /oid4vci/credential` rejects the existing Notary-specific proof when - it is not a valid OpenID4VCI proof. -- `POST /oid4vci/credential` validates a current eSignet/OIDC bearer token. -- Access tokens with wrong audience are denied. -- Audience validation uses configured `accepted_token_audiences`, not runtime - `Host` or `X-Forwarded-Host`. -- Tokens valid for another relying party or resource server are denied. -- The credential endpoint derives the subject only from the verified eSignet - binding claim or verified UserInfo. -- When UserInfo is used, `userinfo.sub == access_token.sub` is required. -- UserInfo failures fail closed and do not fall back to weaker subject-binding - claim sources. -- The proof JWT is validated before any UserInfo network call. -- The credential request body cannot supply, override, or influence the subject. -- Subject override attempts through JSON body, nested JSON body, arrays, query - parameters, headers, duplicated parameters, and unknown extension fields are - denied. -- The facade denies unknown credential configuration ids. -- The facade denies unsupported formats. -- Credential responses do not echo `credential_configuration_id`; that value is - retained in audit metadata. -- The facade denies missing proof. -- The facade denies wrong proof audience. -- The facade denies stale or overlong proof lifetime. -- The facade denies replayed nonce or proof when nonce enforcement is enabled. -- The facade denies unsupported proof algorithms. -- The facade denies missing `typ`, unexpected `typ`, `alg = none`, unsupported - `crit`, private JWK `d`, `jku`, `x5u`, `x5c`, and remote key lookup. -- The facade denies missing or unsupported holder key material. -- The issued credential's `cnf.jwk` is the public key derived from the submitted - proof JWT. -- Reusing a holder DID across credentials is documented as a linkability risk. -- A valid request for the bound citizen succeeds and returns a `dc+sd-jwt` - credential response. -- A malicious request cannot fetch a claim for `NID-1002` when the token binds - to `NID-1001`. -- Another-subject attempts are denied before any Relay consultation. -- All Relay consultations happen only after OIDC validation, credential configuration - validation, proof validation, and self-attestation subject binding. -- Zero-consultation claims are proven by a mock Relay client counter or structured - audit field assertion, not by log text. -- The issued credential remains compatible with the existing Notary SD-JWT VC - profile. -- The existing `/v1/evaluations` and `/v1/credentials` tests still pass. -- The facade does not relax the existing strict `/v1/credentials` holder - proof validator. -- A boundary test posts an OpenID4VCI-shaped proof to `/v1/credentials` and - receives the strict endpoint's holder-proof error. -- Audit events include `protocol = openid4vci`, `access_mode = - self_attestation`, credential configuration id, credential profile, denial - code when applicable, and hashed identifiers only. -- Civil subject hashes use the existing keyed audit hasher, not a raw digest. -- Offers and metadata contain no civil subject identifier. -- The issued V1 credential does not expose the raw civil subject id unless the - credential profile explicitly requires it. -- Any credential profile that exposes a raw civil subject id requires an - explicit config flag and a documented privacy-impact note. -- Documentation states that V1 validates wallet key possession, not certified - wallet authenticity or wallet revocation state. -- No raw access token, ID token, UserInfo response, proof JWT, civil id, holder - private key, source credential, or source row appears in logs, audit events, - or lab artifacts. -- Redaction tests use a structured allow-list of permitted audit/log fields and - deny unknown sensitive fields; they do not rely only on substring searches. -- Config validation tests cover `serde(default)` for absent `oid4vci`, missing - `self_attestation`, unknown claims, unknown credential profiles, bad URLs, - non-loopback HTTP, missing accepted audiences, missing nonce endpoint, bad - algorithm lists, and bad binding methods. -- Focused unit tests cover metadata generation, offer generation, valid proof, - invalid proof, wrong audience, stale proof, replay, unsupported algorithm, - unknown credential configuration, disabled facade, wire error mapping, every - internal denial code, and subject override rejection. -- Integration tests cover a successful citizen issuance and an attempted - other-subject request with zero Relay consultations. -- The external adopter demo has an optional narrated smoke command that writes metadata, - offer, successful credential response, denied response, audit excerpt, and - transcript artifacts. -- Existing external adopter default commands still work without enabling the - OpenID4VCI facade. -- A Walt-compatible offer smoke has either passed against a live Walt Wallet API - or is documented as blocked with the exact command, missing dependency, and a - scripted partial-path test that still passed. The artifact records the Walt - version or image tag. -- An Inji/Mimoto compatibility smoke has either passed or is documented with - the exact command, incompatible request/response field, and a scripted - partial-path test that still passed. The artifact records the Inji Wallet, - Mimoto, and Certify versions or commits used. -- The implementation has passed code review for each wave before the next wave - begins. - -## Verification Commands - -The final implementation must run the closest practical set of checks: - -```sh -# registry-platform -cargo fmt --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace - -# registry-notary -cargo fmt --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace - -# external adopter repository (currently solmara-lab) -just generate -just build -just up -just smoke -just quick -just citizen-self-attestation -just oid4vci-smoke -OID4VCI_ENABLED=false just smoke -``` - -If a live wallet check is available: - -```sh -just wallet-walt -just wallet-inji -``` - -Any skipped command must be reported with the exact blocker. - -## Implementation Waves And Parallel Work Plan - -Each wave ends with review, focused tests, and a short integration note before -the next wave begins. The parent agent remains responsible for coordination, -conflict resolution, final integration, and verification. - -### Wave 1: Platform Protocol Primitives - -Parallel workers: - -- Worker A: implement `registry-platform-oid4vci` metadata, offer, nonce, - request, response, and wire-error structs with serialization tests. -- Worker C: add or extend SD-JWT, crypto, and testing helpers for `did:jwk`, - `parse_did_jwk()`, EdDSA verification reuse, and wallet proof signing. -- Worker B starts after Worker A and Worker C have landed their APIs: - implement OpenID4VCI proof JWT validation, nonce policy checks, and negative - fixtures. -- Reviewer: check standards alignment, API boundaries, and that Notary domain - policy did not leak into platform. - -Exit criteria: - -- platform unit tests pass; -- metadata, offer, nonce, wire-error, and proof negative tests pass; -- public APIs are documented; -- reviewer signs off before Notary integration starts. - -### Wave 2: Notary Facade Routes - -Parallel workers: - -- Worker A: add `oid4vci` config schema and cross-block validation. -- Worker B: add metadata and offer routes. -- Worker C: add credential endpoint orchestration using existing - self-attestation guards. -- Worker D: own audit schema/type changes and denial-code definitions only; - Worker C wires them into `api.rs` to avoid overlapping route edits. -- Reviewer: check auth order, subject-binding order, audit redaction, and that - `/v1/credentials` proof validation was not weakened. - -Exit criteria: - -- focused Notary route and config tests pass; -- successful issuance integration test passes; -- other-subject denial proves zero Relay consultations; -- wire errors map from internal denial codes without leaking subject-binding - detail; -- reviewer signs off before lab work starts. - -### Wave 3: External Adopter Demo And Artifacts - -Parallel workers: - -- Worker A: add generated optional Notary config and `just oid4vci-smoke`. -- Worker B: add narrated smoke script and artifact report. -- Worker C: add Walt offer smoke if local Walt API is available. -- Worker D: add Inji/Mimoto compatibility config notes and smoke if stable. -- Reviewer: run the demo from a clean adopter state and check that output teaches - the flow without leaking secrets. - -Exit criteria: - -- the external adopter's default path still works with the facade disabled; -- new smoke writes all required artifacts; -- at least one wallet-neutral client smoke passes; -- live wallet gaps are documented with exact blockers. - -### Wave 4: Final Hardening Review - -Parallel workers: - -- Security reviewer: threat-model the facade against subject probing, token - confusion, proof replay, Relay consultation ordering, and audit leakage. -- Interop reviewer: compare metadata, offer, and credential responses against - Inji and Walt expectations. -- Test reviewer: inspect unit and integration coverage against the Definition - Of Done. - -Exit criteria: - -- every Definition Of Done item is checked off or marked blocked with evidence; -- all required verification commands pass or have documented blockers; -- no partially implemented behavior remains hidden behind docs or scripts; -- release notes identify the facade as optional and disabled by default. +| Citizen browser | Completes the identity-provider login and receives the rendered offer | +| Holder wallet | Redeems the pre-authorized code, proves control of an Ed25519 `did:jwk`, and stores the credential | +| Identity provider | Authenticates the citizen and returns the configured subject-binding claim to Notary | +| Registry Notary | Authorizes the transaction, stores its state, validates wallet proof, and issues the credential | +| Registry Relay | Executes the compiler-pinned registry consultation that supplies issuance evidence | + +An authority deploys one Notary for each Relay authority. Notary owns its +correctness state, including transaction, pre-authorized code, replay, +evaluation, audit, and credential-status records. Production and multi-instance +deployments use the Notary-owned PostgreSQL schema. + +The identity provider's authorization code is an internal browser-to-Notary +authentication input. It is never a wallet grant. The only wallet-facing grant +is an issuer-initiated pre-authorized code. + +## Supported profile + +Registry Stack 1.0 supports: + +- credential format `dc+sd-jwt` and media type `application/dc+sd-jwt`; +- issuer signing with `EdDSA` or `ES256`, selected in the credential profile; +- JWT holder proof using `EdDSA` and the `did:jwk` binding method; +- issuer-initiated pre-authorized code offers; +- an optional numeric transaction code, enabled by default; +- immediate credential responses; +- registry-backed evidence with exact compiler-pinned Relay execution + provenance; +- status-bearing credentials whose status is verified fail closed. + +Registry Stack 1.0 does not claim: + +- wallet-facing OAuth authorization-code issuance; +- source-free or self-attested credential issuance; +- a public nonce endpoint or credential-response next nonce; +- ES256 holder proof; +- PAR, DPoP, wallet attestation, EUDI Wallet, or HAIP conformance; +- deferred or batch issuance; +- mDoc, JSON-LD, `vc+sd-jwt`, or CWT proof support; +- delegated or representative credential issuance. + +## Public routes + +When OID4VCI and the pre-authorized flow are enabled, Notary exposes: + +- `GET /.well-known/openid-credential-issuer` +- `GET /.well-known/vct/{vct_path}` +- `GET /credentials/{vct_path}` +- `GET /oid4vci/offer/start` +- `GET /oid4vci/offer/callback` +- `POST /oid4vci/token` +- `POST /oid4vci/credential` + +`GET /oid4vci/credential-offer` and `POST /oid4vci/nonce` are not part of the +1.0 contract. The credential response does not return `c_nonce` or +`c_nonce_expires_in`. + +## Issuance sequence + +1. The citizen opens `GET /oid4vci/offer/start` in a browser. +2. Notary starts an identity-provider authorization-code login using PKCE. +3. The provider returns its code to `GET /oid4vci/offer/callback`. +4. Notary exchanges and validates that code, derives the configured subject, + creates a registry transaction, executes the exact compiler-pinned Relay + consultation, and stores the resulting evaluation provenance. +5. Only after those checks succeed, Notary renders an + `openid-credential-offer://` URI containing a single-use pre-authorized code. +6. The wallet redeems the code at `POST /oid4vci/token`, including `tx_code` + when the offer requires one. +7. Notary returns a short-lived access token and one proof nonce. +8. The wallet calls `POST /oid4vci/credential` with that access token and an + EdDSA `did:jwk` proof. +9. Notary consumes the proof nonce, reloads the bound transaction and stored + evaluation, verifies their exact provenance against the active compiled + contract, and signs the credential. + +No wallet request can choose a free-form subject or substitute a different +claim, profile, purpose, contract hash, Relay ULID, acquisition time, or +provenance record. + +## Metadata + +Issuer metadata is generated from the active configuration. Each credential +configuration advertises exactly: + +- `format: dc+sd-jwt`; +- its configured HTTPS `vct`; +- `cryptographic_binding_methods_supported: [did:jwk]`; +- JWT proof with `proof_signing_alg_values_supported: [EdDSA]`; +- the issuer algorithm selected by its signing profile, `EdDSA` or `ES256`. + +Metadata includes Notary's `/oid4vci/token` endpoint while the flow is enabled. +Offers contain only +`urn:ietf:params:oauth:grant-type:pre-authorized_code`. Metadata does not +advertise a nonce endpoint. + +## Transaction code policy + +`oid4vci.pre_authorized_code.tx_code.required` defaults to `true`. A required +transaction code is displayed separately from the offer URI. Missing and wrong +codes fail, repeated wrong attempts lock the offer, and invalid-code attempts +are rate limited. + +Set `required: false` only when a wallet cannot present a transaction code. The +Walt compatibility profile uses this explicit setting. Without a transaction +code, the offer is bearer credential material until redemption, so: + +- `pre_authorized_code_ttl_seconds` must be no more than 300 seconds; +- codes remain single-use; +- invalid redemption attempts remain rate limited; +- the offer URI must be protected from logs, screenshots, browser history, + analytics, and unintended sharing; +- a stolen unredeemed offer can be redeemed by its holder within the bounded + lifetime. + +## Credential and status requirements + +The issued SD-JWT VC contains issuer, subject, type, lifetime, holder binding, +and registry-backed disclosures derived from the stored transaction. The +top-level `status` claim is reserved by Notary and cannot be configured as a +selectively disclosable claim. + +When a credential contains `status.status_list`, client verification must: + +- fetch the signed status-list JWT only from the configured exact HTTPS trusted + origin; +- validate its signature, issuer, type, index, and lifetime; +- require the indexed value to be valid; +- fail closed for missing, malformed, untrusted, unavailable, suspended, + revoked, or expired status. + +Status-free profiles remain explicit profile choices. + +## Error and replay behavior + +Pre-authorized codes, access tokens, proof nonces, and transaction bindings are +short-lived and single-use where applicable. A replay, expired artifact, wrong +transaction code, unsupported algorithm, unsupported binding, missing stored +evaluation, or provenance mismatch fails without credential issuance. + +Errors must not expose raw identity-provider codes, wallet grants, access +tokens, proof JWTs, subject identifiers, registry rows, disclosures, or signing +keys. + +## Evidence and conformance + +Source tests cover the complete browser callback, offer, token, credential, and +client-verification path for EdDSA and ES256 issuer keys with an EdDSA +`did:jwk` holder. Tests also cover replay, tampering, source-free denial, +unsupported holder profiles, route removal, and status failure behavior. + +External wallet, verifier, OIDF suite, or ecosystem conformance is claimed only +from a frozen candidate artifact with recorded product versions and immutable +evidence. Until that evidence is published, those rows remain candidate-only. From 322197080f5bb608632f3bea4774aa0d4c56ca3a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 18:56:17 +0700 Subject: [PATCH 40/65] docs(site): publish the Registry Notary 1.0 issuance profile Signed-off-by: Jeremi Joslin --- .../images/registry-evidence-transports.svg | 4 +- docs/site/scripts/fetch-openapi.mjs | 10 +- .../site/scripts/release-notes-drift.test.mjs | 9 +- ...ta-minimization-and-purpose-limitation.mdx | 2 +- .../docs/explanation/evidence-issuance.mdx | 37 +++--- .../docs/explanation/known-limitations.mdx | 26 +++-- .../content/docs/explanation/threat-model.mdx | 33 ++++-- .../docs/reference/apis/registry-notary.mdx | 9 +- .../src/content/docs/reference/glossary.mdx | 4 +- docs/site/src/content/docs/security/index.mdx | 3 +- docs/site/src/content/docs/spec/rs-arc-g.mdx | 4 +- .../src/content/docs/spec/rs-pr-notary.mdx | 109 +++++++++++++----- docs/site/src/content/docs/spec/rs-terms.mdx | 7 +- docs/site/src/data/contracts.yaml | 19 +-- docs/site/src/data/generated/contracts.json | 4 +- docs/site/src/data/generated/projects.json | 6 +- docs/site/src/data/generated/standards.json | 24 ++-- docs/site/src/data/projects.yaml | 6 +- docs/site/src/data/standards.yaml | 45 ++++---- 19 files changed, 228 insertions(+), 133 deletions(-) diff --git a/docs/site/public/images/registry-evidence-transports.svg b/docs/site/public/images/registry-evidence-transports.svg index 5f8b3b1d0..b916fbe19 100644 --- a/docs/site/public/images/registry-evidence-transports.svg +++ b/docs/site/public/images/registry-evidence-transports.svg @@ -2,7 +2,7 @@ font-family="'Public Sans', system-ui, -apple-system, BlinkMacSystemFont, sans-serif" text-rendering="geometricPrecision"> Evidence transports: four ways to consume a claim evaluation - One claim model, four transports a caller can use. Direct API: a backend calls /v1/evaluations, optionally pairs with /v1/credentials to materialize an SD-JWT VC. OID4VCI offer: a wallet discovers the issuer via /.well-known and completes a nonce-bound credential flow at /oid4vci/credential. Receipt only: a backend takes the audit-stamped evaluation result and stores it; no credential is issued. Federated delegated evaluation: another Registry Notary posts a signed JWT to /federation/v1/evaluations and receives a signed JWT response. + One claim model, four transports a caller can use. Direct API: a backend calls /v1/evaluations, optionally pairs with /v1/credentials to materialize an SD-JWT VC. OID4VCI offer: a wallet discovers the issuer, redeems a pre-authorized offer, and presents a transaction-bound holder proof at /oid4vci/credential. Receipt only: a backend takes the audit-stamped evaluation result and stores it; no credential is issued. Federated delegated evaluation: another Registry Notary posts a signed JWT to /federation/v1/evaluations and receives a signed JWT response. FOUR TRANSPORTS · ONE CLAIM MODEL @@ -36,7 +36,7 @@ Wallet caller. Discovers via /.well-known. - Nonce-bound flow. + Pre-authorized flow. Backend caller. Stores audit-stamped result. diff --git a/docs/site/scripts/fetch-openapi.mjs b/docs/site/scripts/fetch-openapi.mjs index 051a3c1de..752f7e7ec 100644 --- a/docs/site/scripts/fetch-openapi.mjs +++ b/docs/site/scripts/fetch-openapi.mjs @@ -129,16 +129,18 @@ async function main() { mode = 'clone'; } - // Parse to fail loudly on malformed JSON and to emit a stable, formatted file. - let parsed; + // Parse to fail loudly on malformed JSON, then preserve the product-owned + // artifact bytes. Re-serializing through JavaScript reorders response keys + // such as `4XX` after integer-like status codes and makes the docs copy look + // different even though it came from the same generated source. try { - parsed = JSON.parse(raw); + JSON.parse(raw); } catch (error) { fail(`${repoId}: spec at ${repo.ref}:${specPath} is not valid JSON: ${error.message}`); } const outFile = resolve(openapiDir, `${repoId}.openapi.json`); - await writeFile(outFile, `${JSON.stringify(parsed, null, 2)}\n`); + await writeFile(outFile, raw.endsWith('\n') ? raw : `${raw}\n`); written += 1; console.log( `Fetched ${repoId} OpenAPI spec at ${repo.ref.slice(0, 12)} (${mode}) -> ${relative(root, outFile)}`, diff --git a/docs/site/scripts/release-notes-drift.test.mjs b/docs/site/scripts/release-notes-drift.test.mjs index 816abc20c..cf5c35edc 100644 --- a/docs/site/scripts/release-notes-drift.test.mjs +++ b/docs/site/scripts/release-notes-drift.test.mjs @@ -108,7 +108,7 @@ test('registryctl changelog tracks the latest stack release', () => { ); }); -test('a hosted-held release does not claim completed current Solmara smoke evidence', () => { +test('a hosted-held release keeps external OID4VCI evidence candidate-only', () => { const manifest = latestStackManifest(); const hostedHeld = manifest.warnings?.some( (warning) => warning.code === 'hosted-publication-held', @@ -122,9 +122,12 @@ test('a hosted-held release does not claim completed current Solmara smoke evide readRepoFile('docs/site/src/data/standards.yaml'), ].join('\n'); - assert.doesNotMatch(publicEvidenceData, /Solmara Lab checks the (?:current )?hosted/); + assert.doesNotMatch( + publicEvidenceData, + /Solmara Lab[^\n]*(?:checks|passes|conformance evidence)/, + ); assert.match( publicEvidenceData, - /hosted evidence remains pending until the lab is[\s\S]*repinned to published v[0-9]+[.][0-9]+[.][0-9]+ digests/, + /External wallet[\s\S]*candidate-only[\s\S]*frozen (?:artifact|release artifact)/, ); }); diff --git a/docs/site/src/content/docs/explanation/data-minimization-and-purpose-limitation.mdx b/docs/site/src/content/docs/explanation/data-minimization-and-purpose-limitation.mdx index 4601657ec..9261a2835 100644 --- a/docs/site/src/content/docs/explanation/data-minimization-and-purpose-limitation.mdx +++ b/docs/site/src/content/docs/explanation/data-minimization-and-purpose-limitation.mdx @@ -104,7 +104,7 @@ Selective disclosure here is at the claim (or configured-projection) level: by d becomes one disclosure carrying its whole value, so an object-valued claim is revealed as a unit unless an explicit projection splits it into separately-disclosable fields. Holder binding (tying the credential to a holder key with `did:jwk`) is enabled by default per -profile, and the self-attestation (wallet) issuance path requires proof of possession; an operator +profile, and the registry-backed OID4VCI issuance path requires EdDSA proof of possession; an operator can explicitly configure `holder_binding.mode: none` for a bearer-style credential profile. The direct claim or evaluation result is not a credential and is never holder-bound. One caveat for a reviewer: the issuance surface is a profiled, partial subset, not a full diff --git a/docs/site/src/content/docs/explanation/evidence-issuance.mdx b/docs/site/src/content/docs/explanation/evidence-issuance.mdx index c93a60ce0..376473e04 100644 --- a/docs/site/src/content/docs/explanation/evidence-issuance.mdx +++ b/docs/site/src/content/docs/explanation/evidence-issuance.mdx @@ -93,7 +93,8 @@ evaluation, or receipt. alt="Four transports for a claim evaluation, sharing one claim model. (1) Direct API: a backend calls /v1/evaluations; pair with /v1/credentials to mint an SD-JWT VC. (2) OID4VCI offer: a wallet caller discovers the issuer via /.well-known and - completes a nonce-bound flow at /oid4vci/credential. (3) Receipt only: a backend + redeems an issuer-initiated pre-authorized offer, then presents a + transaction-bound holder proof at /oid4vci/credential. (3) Receipt only: a backend calls /v1/evaluations and stores the audit-stamped result; no credential is issued. (4) Federated: a peer Notary posts a signed JWT to /federation/v1/evaluations and receives a signed JWT response." /> @@ -104,22 +105,19 @@ evaluation, or receipt. is a backend (a program registry, a benefits portal) that holds a Registry Notary API key or an OIDC token. The credential ends up wherever the calling backend chooses to send it. 2. OID4VCI offer flow. The caller is a wallet, not a backend. Registry Notary publishes - `/.well-known/openid-credential-issuer` so the wallet learns the credential endpoint, the - nonce endpoint, and the supported credential configurations. The wallet (or a portal acting on - the wallet's behalf) fetches a `CredentialOffer` from `GET /oid4vci/credential-offer`, the - user authenticates at the configured authorization server, the wallet calls - `POST /oid4vci/nonce` to get a `c_nonce`, signs a proof-of-possession JWT with its `did:jwk` - key, and posts the bearer access token plus the proof to `POST /oid4vci/credential`. The - choreography is implemented in the Notary server's API module at - [`crates/registry-notary-server/src/api.rs`](https://github.com/registrystack/registry-stack/blob/v0.8.1/crates/registry-notary-server/src/api.rs). - The offer can carry an authorization-code grant (the user authenticates at the authorization - server, as in the authorization-code grant) or a pre-authorized-code grant; the hosted lab uses pre-authorized-code with - eSignet (an open-source identity and e-signature platform) as the authorization server. - Direct and OID4VCI issuance accept only newly stored, non-delegated - evaluations that retain exact claim pins and normalized unique Relay - execution records for every selected root's registry-backed dependency - closure. Source-free and delegated evaluations cannot enter either - credential path. + `/.well-known/openid-credential-issuer` so the wallet learns the token and + credential endpoints and supported configurations. The citizen opens + `/oid4vci/offer/start` in a browser and authenticates at the configured + identity provider. Notary consumes that internal authorization code, derives + the subject binding, creates the registry transaction, executes the + compiler-pinned Relay consultation, and only then renders an + issuer-initiated pre-authorized offer. The wallet redeems that offer, receives + a transaction-bound proof nonce from the token response, signs an EdDSA proof + with its `did:jwk` key, and posts the Notary access token plus proof to + `POST /oid4vci/credential`. The wallet-facing grant is pre-authorized code + only. Source-free and delegated evaluations cannot enter the credential + path. Transaction code is required by default; an explicit no-PIN wallet + profile has a maximum 300-second bearer-offer window. 3. Verifiable receipt only. The caller does not need a credential at all. It calls `POST /v1/evaluations`, accepts the disclosure mode the claim allows (often `predicate` or `redacted`), and stores the audit-stamped result. Every evaluation, credential or not, is @@ -155,6 +153,11 @@ Three mechanics make selective disclosure work. binds with `did:jwk`, and issuing an unbound, bearer-style credential requires an explicit `holder_binding.mode: none`. This has been the default since v0.8.4, and `registry-notary doctor` warns on a profile that opts out (`notary.credential_profile.unbound_holder_binding`). +- Live status outside selective disclosure. When a credential contains a + `status.status_list` claim, that top-level claim is always visible. A verifier + retrieves the signed status token only from its exact configured HTTPS trusted + origin and fails closed when status is unavailable, invalid, suspended, + revoked, or expired. Evidence: the [v0.8.4 Notary changelog](https://github.com/registrystack/registry-stack/blob/v0.8.4/products/notary/CHANGELOG.md#084---2026-07-04) records the default, and the [configuration loader](https://github.com/registrystack/registry-stack/blob/v0.8.4/crates/registry-notary-core/src/config.rs) applies it. diff --git a/docs/site/src/content/docs/explanation/known-limitations.mdx b/docs/site/src/content/docs/explanation/known-limitations.mdx index 8f16af713..0f8974fc4 100644 --- a/docs/site/src/content/docs/explanation/known-limitations.mdx +++ b/docs/site/src/content/docs/explanation/known-limitations.mdx @@ -90,8 +90,8 @@ For the rationale behind each of these, follow the linked specifications in each Registry Notary issues credentials, but the issuance surface is deliberately narrow. Read the full context in the [Registry Notary protocol](../../spec/rs-pr-notary/). -- One credential format, one binding method: Issued Registry Notary credentials are [SD-JWT - VC](../../reference/standards/) only. +- One credential format, one OID4VCI holder profile: Issued Registry Notary credentials are + [SD-JWT VC](../../reference/standards/) only. Issuer signing supports EdDSA and ES256. Holder binding is a per-credential-profile setting that defaults to `mode: did` with `did:jwk` as the supported holder DID method. Direct issuance requires a holder DID by default; it verifies a fresh holder proof only when @@ -99,17 +99,20 @@ Read the full context in the [Registry Notary protocol](../../spec/rs-pr-notary/ An operator can explicitly set `holder_binding.mode: none` for a bearer-style credential profile, in which case `registry-notary doctor` reports a warning and the issued credential is unbound. - The self-attestation (wallet) issuance path requires binding, so a credential issued down that - path is always `did:jwk`-bound with proof-of-possession. + The OID4VCI path requires EdDSA `did:jwk` binding. ES256 holder proof is not supported. - A profiled issuance subset, not a full issuer: The [OID4VCI](../../reference/standards/) surface - is a scoped self-attestation issuance profile (a profiled subset of Draft 13 using the - `dc+sd-jwt` format), not a full OID4VCI issuer and not a claim of general external-wallet - interoperability. + is a registry-backed issuer-initiated pre-authorized profile using the + `dc+sd-jwt` format, not a full OID4VCI issuer and not a claim of general + external-wallet interoperability. The identity provider's authorization code + is internal to Notary. There is no wallet-facing authorization-code grant, + public nonce endpoint, response next nonce, EUDI, HAIP, PAR, DPoP, or wallet + attestation profile. The capability-discovery document (`/.well-known/evidence-service`) declares `openid4vci.support: not_full_issuer` (announcing it is not a full issuer); this flag lives there rather than in the OID4VCI credential-issuer metadata. -- Delegated self-attestation is evaluation-only: Both `/v1/credentials` and the OID4VCI - credential endpoint reject delegated evaluations in 1.0. +- Source-free and delegated self-attestation are evaluation-only: Both + `/v1/credentials` and the OID4VCI credential endpoint reject source-free and + delegated evaluations in 1.0. Configuration load rejects delegated relationship `credential_profiles` and credential-profile bindings on delegated claims. Remove that capability to keep delegated evaluation, or issue a separately modeled registry-backed, non-delegated claim. @@ -121,6 +124,11 @@ Read the full context in the [Registry Notary protocol](../../spec/rs-pr-notary/ There is no `/.well-known/jwt-vc-issuer` endpoint and no built-in data-subject erasure workflow. A rotated-out signing key may remain published for verification, which is not revocation. These are documented pilot limitations, recorded in `SECURITY.md`. +- Status-bearing credentials fail closed: The top-level status claim is never + selectively disclosable. A client accepts a status-bearing credential only + after validating the signed status response from its exact configured HTTPS + trusted origin. Missing, invalid, unavailable, suspended, revoked, or expired + status denies the credential. - CCCEV output is a profiled shape, not conformant: [CCCEV](../../reference/standards/)-shaped output is a profiled subset and is not conformant to CCCEV 2.00. It is consumed by parsing the `@graph` for `cccev:Evidence` nodes. diff --git a/docs/site/src/content/docs/explanation/threat-model.mdx b/docs/site/src/content/docs/explanation/threat-model.mdx index 8646ce36c..6533dd863 100644 --- a/docs/site/src/content/docs/explanation/threat-model.mdx +++ b/docs/site/src/content/docs/explanation/threat-model.mdx @@ -105,7 +105,9 @@ peer-signed JWS, so it is not anonymous. **Notary to Relay.** A runtime boundary sits between Registry Notary and Registry Relay for every registry-backed claim. The compiler pins the consultation profile, contract hash, purpose, and typed input mapping. Notary authorizes the claim before calling Relay, and Relay applies its own -workload authorization before source work. Notary has no direct registry source connector. +workload authorization before source work. Notary has no direct registry source connector. One +Notary authority pairs with one Relay authority, and Notary owns its PostgreSQL transaction, +replay, evaluation, audit, and credential-status state. **Relay to registry sources.** Registry Relay owns the source boundary. Its compiled integration fixes the origin, credential interface, method and path authority, input placement, output @@ -164,8 +166,8 @@ leakage, and privacy regressions that expose raw subject identifiers. - Disclosure leakage: A redacted result carries neither the value nor the predicate outcome. Selective-disclosure credentials carry SHA-256 digests of unselected fields, so a holder cannot present an undisclosed field. Holder binding defaults to `did:jwk` for - credential profiles; where a profile keeps binding enabled, and on the self-attestation - issuance path, which requires it, the holder is bound by a fresh audience-bound + credential profiles; where a profile keeps binding enabled, and on the OID4VCI + issuance path, which requires EdDSA `did:jwk`, the holder is bound by a fresh audience-bound proof-of-possession. An operator can explicitly configure `holder_binding.mode: none` for a bearer-style credential profile, and `registry-notary doctor` warns on that choice. Binding applies only to an issued credential; the plain evaluation result is not a @@ -175,6 +177,15 @@ leakage, and privacy regressions that expose raw subject identifiers. principal and scoped authorization details; caller-supplied `requester`, `relationship`, and `on_behalf_of` fields are rejected before any Relay consultation or claim evaluation, and self-asserted target fields do not satisfy trusted-principal gates. +- Source-free evaluation promoted to credential evidence: Credential-capable profiles and + OID4VCI configurations accept only non-delegated registry-backed claims. Source-free, + delegated, legacy, missing, stale, or modified evaluation provenance fails before signer + access. +- Stolen issuer-initiated offer: A transaction code is required by default and is transferred + separately from the offer. Codes are single-use, wrong PIN attempts are bounded, and invalid + redemptions are rate limited. An explicit no-PIN compatibility profile has a maximum + 300-second lifetime, but the unredeemed offer remains bearer credential material that a thief + can use during that window. - Spoofed trust context and ungoverned reads (Relay side): Relay ignores trust-context headers unless the principal is scoped to assert that exact value, and a governed read must receive a PDP permit or fail closed with a stable `pdp.*` code rather than falling back to an @@ -187,7 +198,8 @@ leakage, and privacy regressions that expose raw subject identifiers. peers are admitted, and before any Relay consultation or claim evaluation the server verifies peer identity, request signature, freshness, single use, purpose, profile, and audience. The single-use and freshness checks are backed by a real replay-store primitive that tracks one-time JWT `jti` - and nonce values (federation request `jti`, OID4VCI `c_nonce`, and holder-proof `jti`). + and nonce values (federation request `jti`, transaction-bound OID4VCI proof nonce, and + holder-proof `jti`). **Audit as a control.** Every request touching person-level data can be recorded with the hashed principal, request id, the principal's granted scopes, and `Data-Purpose` context @@ -229,7 +241,9 @@ These are the risks the design does *not* close: - Known product gaps: No revocation flow is specified (though an optional, off-by-default credential-status surface can be enabled to mark a credential `revoked`), there is no `/.well-known/jwt-vc-issuer` endpoint, and no built-in data-subject erasure - workflow in this version. + workflow in this version. When a status-bearing credential is used, its top-level status is + not selectively disclosable and client verification fails closed unless the signed status is + valid at the exact trusted HTTPS origin. - Admin reload is non-functional standalone: Notary's admin reload route returns HTTP 501 (`registry.admin.capability.not_supported`) in the standalone router and performs no reload. Runtime configuration changes require deploying a signed local bundle and @@ -249,10 +263,11 @@ conformance claim: It is not full Evidence Gateway interoperability, not full OID4VCI issuer behavior, not dynamic external policy discovery, and not enforcement of ODRL terms outside the supported profile. Unsupported ODRL terms or invalid policy identity fail closed. -- A full credential issuer: The OID4VCI surface is a scoped self-attestation issuance - subset of OID4VCI Draft 13, not a full issuer or general external-wallet interoperability. - Notary advertises `openid4vci.support: not_full_issuer`, and delegated-attestation - transaction tokens are rejected at the credential endpoint. +- A full credential issuer: The OID4VCI surface is a registry-backed, issuer-initiated + pre-authorized subset, not a full issuer or general external-wallet interoperability. + Notary advertises `openid4vci.support: not_full_issuer`. It does not claim wallet-facing + authorization code, a public nonce endpoint, response next nonce, source-free issuance, + ES256 holder proof, EUDI, HAIP, PAR, DPoP, or wallet attestation. - Certified standards compliance: Aligning with a standard is not conforming to it: speaking the shape of OIDC, OAuth 2.0, SD-JWT VC, OID4VCI, CCCEV, W3C DID, or the rest does not certify conformance to any of them, and CCCEV-shaped output is not conformant to CCCEV diff --git a/docs/site/src/content/docs/reference/apis/registry-notary.mdx b/docs/site/src/content/docs/reference/apis/registry-notary.mdx index 19bc892f1..fde5ab9b1 100644 --- a/docs/site/src/content/docs/reference/apis/registry-notary.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-notary.mdx @@ -42,6 +42,13 @@ evaluation routes); it is not a public discovery endpoint. The issuer JWKS at `GET /.well-known/evidence/jwks.json` is public and requires no credentials. The evaluation, rendering, credential, and OID4VCI routes are documented in the generated API reference. +The OID4VCI profile is registry-backed and issuer initiated. Its only +wallet-facing grant is pre-authorized code. It uses EdDSA `did:jwk` holder +proof and EdDSA or ES256 issuer signing. The identity provider's authorization +code remains internal to Notary. There is no public nonce route, response next +nonce, source-free issuance, ES256 holder proof, EUDI, HAIP, PAR, DPoP, or +wallet-attestation profile. + ## Authentication Registry Notary runs in static-credential mode or OIDC mode: @@ -113,7 +120,7 @@ or execute direct registry source connectors. - Plugin rule type: The `Plugin` rule variant is declared in config but has no implementation at the pinned commit. -- Holder binding: `did:jwk` is the only supported proof-of-possession binding method. +- Holder binding: OID4VCI supports EdDSA `did:jwk` holder proof only. - Demo helpers: Generated workflow helpers are examples, not production replay-protection profiles. Add freshness, expiry, or nonce checks before copying a helper into a production workflow. diff --git a/docs/site/src/content/docs/reference/glossary.mdx b/docs/site/src/content/docs/reference/glossary.mdx index c825d8277..d39fedacb 100644 --- a/docs/site/src/content/docs/reference/glossary.mdx +++ b/docs/site/src/content/docs/reference/glossary.mdx @@ -172,7 +172,7 @@ Product names are always in English, including on future translated pages.
Open Geospatial Consortium API specification for Environmental Data Retrieval. Registry Relay exposes profiled area queries over configured spatial aggregates behind the `ogcapi-edr` feature flag.
OID4VCI
-
OpenID for Verifiable Credential Issuance. The protocol a wallet uses to obtain a credential from an issuer. Registry Notary emits an OID4VCI issuance flow: a credential offer, issuer metadata at `/.well-known/openid-credential-issuer`, a nonce, and a holder proof. The surface is a profiled subset of OID4VCI Draft 13 advertising the `dc+sd-jwt` format; full issuer conformance is not asserted (`openid4vci.support: not_full_issuer`).
+
OpenID for Verifiable Credential Issuance. Registry Notary implements a profiled registry-backed issuer subset using `dc+sd-jwt`, issuer-initiated pre-authorized code, a transaction-bound proof nonce returned by the token response, and EdDSA `did:jwk` holder proof. It has no wallet-facing authorization-code grant or public nonce endpoint. Full issuer, EUDI, HAIP, PAR, DPoP, wallet-attestation, and external-wallet conformance are not asserted (`openid4vci.support: not_full_issuer`).
PROV-O
W3C Provenance Ontology. Currently listed as design influence (`inspired_by`). Provenance-shaped concepts appear in audit fields and the claim provenance struct, but no PROV-O vocabulary terms are emitted as JSON-LD at reviewed commits.
@@ -256,7 +256,7 @@ Product names are always in English, including on future translated pages.
Commitment that every exchange the stack mediates is authorized, scoped, and audited. Concretely: authentication by API key or OIDC; scope-checked routes; per-claim disclosure control (value, predicate, or redacted); no source-registry data mutation through Relay; audit envelopes for every request that touches person-level data.
SD-JWT VC
-
Selective Disclosure JWT Verifiable Credential (IETF draft). Registry Notary issues SD-JWT VC credentials signed with EdDSA. Media type: `application/dc+sd-jwt`.
+
Selective Disclosure JWT Verifiable Credential (IETF draft). Registry Notary issues registry-backed SD-JWT VC credentials signed with EdDSA or ES256 and binds OID4VCI credentials to an EdDSA `did:jwk` holder. Media type: `application/dc+sd-jwt`.
self-attestation
Registry Notary access pattern where an authenticated OIDC principal is the protected requester for configured claim evaluation. In direct self-attestation, the subject is the authenticated requester. In delegated self-attestation, the requester acts for a configured dependent target only when the relationship proof passes. Source-free and delegated claims cannot be issued as credentials; subject access can authorize a credential only for a non-delegated registry-backed claim.
diff --git a/docs/site/src/content/docs/security/index.mdx b/docs/site/src/content/docs/security/index.mdx index 585cbe9e8..e2979a592 100644 --- a/docs/site/src/content/docs/security/index.mdx +++ b/docs/site/src/content/docs/security/index.mdx @@ -72,7 +72,8 @@ disclosable field rather than the values, so the holder reveals only what they c Holder binding, which cryptographically ties a credential to a key the holder controls, is enabled by default with `did:jwk`; an operator can set `holder_binding.mode: none` only for an explicit bearer-style credential profile, and `registry-notary doctor` warns on that choice. -The self-attestation (wallet) issuance path requires holder proof of possession. +The registry-backed OID4VCI issuance path requires EdDSA `did:jwk` holder proof +of possession. Source-free self-attestation remains evaluation-only. See [Evidence issuance, end to end](../explanation/evidence-issuance/) for the full credential lifecycle. diff --git a/docs/site/src/content/docs/spec/rs-arc-g.mdx b/docs/site/src/content/docs/spec/rs-arc-g.mdx index 9d97d1ebc..780bdb7d0 100644 --- a/docs/site/src/content/docs/spec/rs-arc-g.mdx +++ b/docs/site/src/content/docs/spec/rs-arc-g.mdx @@ -139,7 +139,7 @@ Registry Relay is not an open-data portal; it serves restricted consultation API ### Registry Notary -Registry Notary (`registry-notary`) is a standalone Rust service for claim evaluation, disclosure policy, credential issuance, and audit. A Notary-only deployment supports source-free and self-attested evaluation but cannot issue credentials. Registry-backed claims consume compiler-pinned Relay consultations whose profile, contract hash, purpose, and typed inputs are fixed before Relay source work. Notary does not own registry source origins, credentials, protocol adaptation, or snapshots. Registry Notary returns evaluation results as `application/vnd.registry-notary.claim-result+json` or CCCEV-shaped JSON-LD (`application/ld+json; profile="cccev"`). It materializes SD-JWT VC credentials (`application/dc+sd-jwt`) only from non-delegated stored evaluations with exact claim pins and unique Relay execution records for every selected root's registry-backed dependency closure, including through an OpenID for Verifiable Credential Issuance (OID4VCI) flow as a profiled subset of OID4VCI Draft 13. It supports static-peer delegated evaluation through `POST /federation/v1/evaluations`. Static-peer delegated evaluation is separate from delegated self-attestation, the local citizen/OIDC evaluation-only access mode defined in [RS-PR-NOTARY](../rs-pr-notary/). Registry Notary does not produce DCAT, BRegDCAT-AP, SHACL, or OGC Records artifacts. The current federation implementation is static-peer only; dynamic trust-chain discovery, replay storage shared across federation peers, and federated credential issuance are not part of this version. Each federation peer maintains its own replay scope; a peer does not share replay storage with the peers it federates with. +Registry Notary (`registry-notary`) is a standalone Rust service for claim evaluation, disclosure policy, credential issuance, and audit. A Notary-only deployment supports source-free and self-attested evaluation but cannot issue credentials. Registry-backed claims consume compiler-pinned Relay consultations whose profile, contract hash, purpose, and typed inputs are fixed before Relay source work. One Notary authority pairs with one Relay authority, and Notary owns its PostgreSQL correctness state. Notary does not own registry source origins, credentials, protocol adaptation, or snapshots. Registry Notary returns evaluation results as `application/vnd.registry-notary.claim-result+json` or CCCEV-shaped JSON-LD (`application/ld+json; profile="cccev"`). It materializes SD-JWT VC credentials (`application/dc+sd-jwt`) only from non-delegated stored evaluations with exact claim pins and unique Relay execution records for every selected root's registry-backed dependency closure. Its profiled OID4VCI surface uses issuer-initiated pre-authorized code, EdDSA `did:jwk` holder proof, and EdDSA or ES256 issuer signing. It supports static-peer delegated evaluation through `POST /federation/v1/evaluations`. Static-peer delegated evaluation is separate from delegated self-attestation, the local citizen/OIDC evaluation-only access mode defined in [RS-PR-NOTARY](../rs-pr-notary/). Registry Notary does not produce DCAT, BRegDCAT-AP, SHACL, or OGC Records artifacts. The current federation implementation is static-peer only; dynamic trust-chain discovery, replay storage shared across federation peers, and federated credential issuance are not part of this version. Each federation peer maintains its own replay scope; a peer does not share replay storage with the peers it federates with. ### Solmara Lab @@ -188,6 +188,8 @@ REQ-ARC-G-008: Registry Notary credentials MUST use SD-JWT VC format (`applicati REQ-ARC-G-012: Registry Notary MUST issue credentials only from non-delegated evaluations that retain exact compiler pins for every registry-backed claim in each selected root's dependency closure and one normalized execution record per unique Relay consultation ULID. Each pin MUST be deterministically cross-bound to its execution and claim provenance and checked before signing. Private Relay execution provenance MUST be retained only for credential-capable selections. Source-free, delegated, and registry-backed evaluation-only selections MUST remain nonissuable without retaining those private execution identifiers. +REQ-ARC-G-013: One Registry Notary authority MUST pair with one Registry Relay authority. Registry Notary MUST own its transaction, replay, evaluation, audit, and credential-status correctness state. Production and multi-instance deployments MUST use the Notary-owned PostgreSQL schema; explicit in-memory state is local and single-instance only. + REQ-ARC-G-009: Registry Notary's federation implementation is static-peer delegated evaluation. Peer lists are loaded from configuration at startup. Within a single deployment, replay storage MAY be shared across that deployment's own replicas through the typed Notary-owned PostgreSQL state schema; that sharing does not extend across a federation trust boundary. Each federation peer MUST maintain its own replay scope and MUST NOT share replay storage with the peers it federates with; that isolation is deployment topology, and Registry Notary enforces no runtime gate that prevents two peers from sharing a replay storage backend. Dynamic trust-chain discovery, replay storage shared across federation peers, audit checkpoint exchange, and federated credential issuance are not part of this version and MUST NOT be implied by conformance claims against this specification. REQ-ARC-G-010: The portable metadata layer describes; it does not authorize and it does not assert facts about live data. Publishing a dataset, schema, policy, evidence offering, ecosystem binding, or federation relationship in a discovery artifact MUST NOT be construed as granting access to it, as enforcing it, or as asserting that any particular record exists or satisfies it. Access control, governed PDP enforcement, and the existence or value of a record are determined only by an authorized runtime read under runtime configuration and trusted request or source context. A published ODRL policy is descriptive until a runtime service explicitly binds it into an enforced, supported profile. A conformance claim against a metadata artifact MUST NOT imply a runtime guarantee that the runtime layer has not made. diff --git a/docs/site/src/content/docs/spec/rs-pr-notary.mdx b/docs/site/src/content/docs/spec/rs-pr-notary.mdx index 940e3996c..06d6f4c01 100644 --- a/docs/site/src/content/docs/spec/rs-pr-notary.mdx +++ b/docs/site/src/content/docs/spec/rs-pr-notary.mdx @@ -45,6 +45,7 @@ The key words in this document are interpreted per [RS-DOC](../rs-doc/) Section | 0.1.4 | 2026-06-27 | draft | Clarified OIDC principal derivation, issuer-bound Notary transaction authorization details, and nonce expiry enforcement for OID4VCI holder proofs. | | 0.1.5 | 2026-07-07 | draft | Editorial: pointed the Relay-protocol reference at published RS-PR-RELAY and added explicit evidence citations for REQ-PR-NOTARY-034/035/036. | | 0.2.0 | 2026-07-13 | draft | Removed direct registry source connectors and sidecars. Registry-backed claims now consume compiler-pinned Relay consultations; Notary-only claims are source-free or self-attested. | +| 0.3.0 | 2026-07-19 | draft | Restricted OID4VCI to registry-backed issuer-initiated pre-authorized code, and defined algorithm, transaction-code, status, topology, and unsupported-profile boundaries. | ## 1. Scope and references @@ -159,13 +160,15 @@ Source-free claims are evaluation-only. Credential profiles, credential-capable subject-access allow-lists, and OID4VCI projections select only mutually bound `registry_backed` claims. -REQ-PR-NOTARY-013: An issued credential MUST be an SD-JWT VC with media type and `typ` header `dc+sd-jwt`, signed with EdDSA (Ed25519). No W3C Verifiable Credentials Data Model JSON-LD envelope or namespace is present in the issued credential. This is the protocol-level form of REQ-ARC-G-008. +REQ-PR-NOTARY-013: An issued credential MUST be an SD-JWT VC with media type and `typ` header `dc+sd-jwt`, signed with the credential profile's configured EdDSA (Ed25519) or ES256 (P-256) key. No W3C Verifiable Credentials Data Model JSON-LD envelope or namespace is present in the issued credential. This is the protocol-level form of REQ-ARC-G-008. REQ-PR-NOTARY-014: The signed credential body MUST carry the SHA-256 digest of each selectively disclosable field rather than the field value, so an unselected field stays hidden and a holder cannot present a disclosure that was not in the original credential. REQ-PR-NOTARY-015: A credential MUST bind its holder by naming the holder's public key as a `did:jwk` in the `cnf` claim. `did:jwk` is the only supported binding method. Presentation MUST require a fresh holder proof from that key, audience-bound to the verifier, so a credential without the matching private key is not presentable. -The issuer signing key is sourced from configuration as an OKP Ed25519 JWK; its public half is the key published at `GET /.well-known/evidence/jwks.json`. +The issuer signing key is sourced from configuration as an Ed25519 or P-256 JWK. Its public half is published at `GET /.well-known/evidence/jwks.json`. + +REQ-PR-NOTARY-038: A credential with a `status.status_list` claim MUST keep that top-level claim outside selective disclosure. A conforming client verifier MUST retrieve the signed status token only from the configured exact HTTPS trusted origin and MUST fail closed when status is missing, malformed, untrusted, unavailable, suspended, revoked, expired, or otherwise invalid. REQ-PR-NOTARY-037: Before direct or OID4VCI issuance, Registry Notary MUST reconstruct every selected root's executed registry-backed dependency closure. @@ -184,8 +187,9 @@ but MUST appear once in the normalized execution set. Legacy, source-free, stale, or modified provenance MUST be denied before signer access, signing, credential identifiers, or status writes. Direct issuance MUST perform this check before holder-proof replay mutation. OID4VCI MUST reject a source-free -credential configuration before nonce consumption, preserve nonce-before- -evaluation ordering, and verify the newly stored evaluation before signer +credential configuration, create and evaluate the registry transaction before +rendering an offer, consume the transaction-bound proof nonce at the credential +endpoint, and verify the stored transaction and evaluation before signer access. Existing stored evaluations without the private records MAY remain readable and renderable but MUST be re-evaluated before issuance. @@ -197,7 +201,7 @@ execution binding is an unkeyed partial-mutation check, not an authenticity guarantee against an operator able to rewrite all committed fields and recompute it. -Self-attestation is a constrained trust context, not a caller-provided claim value. It binds the authenticated citizen principal to configured claims, purposes, disclosures, formats, subject binding, assurance requirements, and token age limits before evaluation. Direct self-attestation additionally binds credential profiles before issuance; delegated self-attestation is evaluation-only. +Self-attestation is a constrained trust context, not a caller-provided claim value. It binds the authenticated citizen principal to configured claims, purposes, disclosures, formats, subject binding, assurance requirements, and token age limits before evaluation. Direct subject access can authorize issuance only for a non-delegated registry-backed claim with a mutually bound credential profile. Source-free and delegated self-attestation remain evaluation-only. REQ-PR-NOTARY-026: For self-attestation flows, Registry Notary MUST derive the subject and assurance context from the authenticated principal and configured subject binding. It MUST NOT let the caller choose a different subject, claim, purpose, disclosure, format, or credential profile outside the self-attestation policy, and MUST audit self-attestation decisions without raw subject identifiers or bearer tokens. @@ -220,23 +224,71 @@ REQ-PR-NOTARY-031: A delegated Relay capability MUST bind the requester subject ## 8. OID4VCI issuance flow -For a wallet caller rather than a backend, Registry Notary exposes a scoped OpenID for Verifiable Credential Issuance (OID4VCI) flow. The wallet learns the endpoints from the issuer metadata at `GET /.well-known/openid-credential-issuer`, obtains a credential offer, requests a nonce, signs a proof of possession with its `did:jwk` key, and posts the access token plus the proof to the credential endpoint. The flow uses self-attestation as its subject-access policy but issues only registry-backed claims with the provenance required by REQ-PR-NOTARY-037. It is not a general-purpose wallet interoperability claim. - -REQ-PR-NOTARY-016: The OID4VCI surface is a profiled subset of OID4VCI Draft 13 advertising the `dc+sd-jwt` format. Registry Notary MUST NOT advertise full OID4VCI issuer conformance; its capability-discovery document (`GET /.well-known/evidence-service`) declares `openid4vci.support: not_full_issuer`. - -REQ-PR-NOTARY-017: The credential endpoint (`POST /oid4vci/credential`) MUST require a valid access token and a holder proof of possession. -Registry Notary MUST NOT issue a credential without proof of possession. -Where `oid4vci.nonce.enabled` is set (off by default), the proof MUST additionally be bound to a fresh issuer nonce. -A nonce is fresh only when it is reserved for the credential issuer and configuration being requested, has not expired at consume time, and has not already been consumed. -The offer MAY carry an authorization-code grant or a pre-authorized-code grant. - -REQ-PR-NOTARY-027: OID4VCI credential issuance MUST be scoped to configured credential configurations and the authenticated self-attestation principal. The credential endpoint MUST evaluate the configured registry-backed claim under the self-attestation and source policies before issuance, MUST enforce the configured credential profile and REQ-PR-NOTARY-037 provenance boundary, and MUST NOT imply compatibility with arbitrary external wallets beyond the profiled endpoints and metadata this specification names. - -REQ-PR-NOTARY-036: When an OID4VCI credential request uses a Notary-issued access token, Registry Notary MUST require transaction-scoped authorization details that match the selected credential configuration, action, service, claims, disclosure, format, purpose, subject binding, and direct self-attestation access mode. -Missing, empty, or context-only authorization details MUST be denied before holder-proof nonce consumption. -Registry Notary MUST identify Notary-issued access tokens by the configured Notary signing issuer and supported Notary token types, so externally issued OIDC access tokens can continue to use scope-based authorization where the configured flow permits it. - -REQ-PR-NOTARY-032: The direct and OID4VCI credential endpoints MUST reject delegated-attestation evaluations or transaction tokens before signer access. Credential issuance is available only for non-delegated registry-backed claims in this version. +For a wallet caller, Registry Notary exposes a narrow OpenID for Verifiable +Credential Issuance (OID4VCI) profile. A citizen first authenticates in a +browser. Notary consumes the identity provider's authorization code, derives +the configured subject binding, creates and evaluates a registry transaction, +and then renders an issuer-initiated pre-authorized offer. The wallet redeems +that offer at Notary and proves control of an EdDSA `did:jwk` holder key. The +flow does not create a source-free credential trust surface. + +REQ-PR-NOTARY-016: The OID4VCI surface MUST advertise `dc+sd-jwt`, EdDSA +`did:jwk` holder proof, and the credential profile's exact EdDSA or ES256 issuer +algorithm. Registry Notary MUST NOT advertise full issuer, EUDI, HAIP, PAR, +DPoP, wallet-attestation, ES256-holder, or general external-wallet conformance. +Its capability-discovery document MUST declare +`openid4vci.support: not_full_issuer`. + +REQ-PR-NOTARY-039: The only wallet-facing grant MUST be +`urn:ietf:params:oauth:grant-type:pre-authorized_code`. The authorization code +returned by the configured identity provider MUST remain an internal +browser-to-Notary authentication input and MUST NOT be exposed or accepted as a +wallet grant. Registry Notary MUST NOT expose the former public credential-offer +or nonce routes, advertise a nonce endpoint, or return a next nonce from the +credential endpoint. + +REQ-PR-NOTARY-027: Before rendering an offer, Registry Notary MUST validate the +identity-provider response, derive the configured subject binding, create a +transaction for the selected credential configuration, execute the exact +compiler-pinned Relay consultation, and store the evaluation provenance. It +MUST NOT render an offer for a source-free, delegated, failed, ambiguous, +missing, or stale evaluation. + +REQ-PR-NOTARY-040: A pre-authorized offer MUST require a numeric `tx_code` by +default. The code and PIN requirement MUST be bound to the signed offer. Missing +or wrong PINs MUST fail, repeated wrong attempts MUST be bounded per offer, and +invalid redemption attempts MUST be rate limited. An explicit `required: false` +profile MAY support a wallet that cannot present a PIN, including the Walt +compatibility profile, only when the pre-authorized code lifetime is no more +than 300 seconds. In both modes the pre-authorized code MUST be single-use. The +no-PIN offer MUST be treated as bearer credential material that a thief can +redeem during its bounded lifetime. + +REQ-PR-NOTARY-017: The credential endpoint (`POST /oid4vci/credential`) MUST +require a valid Notary-issued access token and one holder proof of possession. +The holder proof MUST use EdDSA with `did:jwk`, MUST bind the credential issuer +as audience, and MUST contain the fresh transaction-bound nonce returned by the +token response. The nonce MUST be unexpired and single-use. ES256 holder proof +MUST be rejected. + +REQ-PR-NOTARY-036: A Notary-issued access token MUST carry transaction-scoped +authorization details that exactly match the stored transaction's credential +configuration, action, service, claims, disclosure, format, purpose, subject +binding, and direct subject-access mode. Missing, empty, context-only, or +mismatched details MUST be denied before signer access. The credential endpoint +MUST reload the stored transaction and evaluation and enforce +REQ-PR-NOTARY-037 before signing. + +REQ-PR-NOTARY-032: Direct and OID4VCI credential endpoints MUST reject +source-free, delegated-attestation, and registry-backed evaluation-only records +before signer access. Credential issuance is available only for non-delegated +registry-backed claims. + +REQ-PR-NOTARY-041: A deployment authority MUST pair one Registry Notary +authority with one Registry Relay authority. Notary MUST own the PostgreSQL +correctness state used for its transactions, pre-authorized codes, proof replay, +evaluations, audit, and credential status. Explicit in-memory state MAY be used +only for local single-process development. ## 9. Delegated (federated) evaluation @@ -265,13 +317,15 @@ REQ-PR-NOTARY-033: Self-attestation policy denials MUST use the public problem c These constraints are stated so a reader does not infer a capability from the route list that the reviewed implementation does not provide. - Plugin rule type: Declared in configuration, unimplemented (REQ-PR-NOTARY-007). -- Holder binding: `did:jwk` is the only supported proof-of-possession binding method (REQ-PR-NOTARY-015). -- OID4VCI profile: The OID4VCI surface is a scoped self-attestation issuance profile, not a full issuer or general external-wallet interoperability claim (REQ-PR-NOTARY-016, REQ-PR-NOTARY-027). +- Holder binding: OID4VCI supports EdDSA `did:jwk` holder proof only. ES256 holder proof is not supported (REQ-PR-NOTARY-015, REQ-PR-NOTARY-017). +- OID4VCI profile: The OID4VCI surface is a registry-backed issuer-initiated pre-authorized profile, not a full issuer or general external-wallet interoperability claim. EUDI, HAIP, PAR, DPoP, and wallet attestation are not supported profiles (REQ-PR-NOTARY-016, REQ-PR-NOTARY-039). - Delegated issuance: Delegated self-attestation is evaluation and rendering only. Direct and OID4VCI credential issuance reject delegated evaluations, and delegated credential-profile bindings are invalid configuration (REQ-PR-NOTARY-030, REQ-PR-NOTARY-031, REQ-PR-NOTARY-032). - Notary transaction details: Notary-issued transaction and credential access tokens are bound to the configured Notary issuer before the transaction-details requirement applies. Externally issued OIDC tokens are not treated as Notary tokens by `typ` alone (REQ-PR-NOTARY-035, REQ-PR-NOTARY-036). - Federation: Static-peer delegated evaluation only; no dynamic discovery, shared replay storage, or federated credential issuance (REQ-PR-NOTARY-019). - Relay dependency: Registry-backed claims require their exact compiler-pinned Relay consultation to be ready. Notary does not provide a direct-source or self-attested fallback (REQ-PR-NOTARY-006). -- Issuance provenance: Source-free and legacy evaluations are nonissuable. Every selected root must retain an exact compiler pin for every registry-backed claim in its executed dependency closure and one normalized execution record per unique consultation ULID. Direct issuance performs the exact-set check before holder-proof replay mutation; OID4VCI preserves nonce-before-evaluation ordering (REQ-PR-NOTARY-037). +- Issuance provenance: Source-free and legacy evaluations are nonissuable. Every selected root must retain an exact compiler pin for every registry-backed claim in its executed dependency closure and one normalized execution record per unique consultation ULID. Direct issuance performs the exact-set check before holder-proof replay mutation; OID4VCI evaluates and stores the registry transaction before rendering an offer, then revalidates it after holder-proof nonce consumption (REQ-PR-NOTARY-027, REQ-PR-NOTARY-037). +- Status: A status-bearing credential cannot selectively hide status, and verification fails closed if the signed status cannot be validated from the exact trusted HTTPS origin (REQ-PR-NOTARY-038). +- External evidence: Source tests establish the implemented profile. Wallet, verifier, and OpenID conformance claims require immutable evidence from a frozen candidate artifact and are otherwise candidate-only. - Project fixtures: Offline fixtures prove the compiled request and response contract against synthetic observations. They do not prove live source interoperability or production approval. - Admin reload: The admin reload route returns HTTP 501 with code `registry.admin.capability.not_supported` in the standalone router; it performs no reload; runtime configuration changes require deploying a signed local bundle and restarting the service. @@ -286,9 +340,9 @@ A Registry Notary deployment conforms to this specification when it: - evaluates claims itself from pinned Relay outputs or permitted self-attestation, without direct source connectors, using only implemented rule types (REQ-PR-NOTARY-005, REQ-PR-NOTARY-006, REQ-PR-NOTARY-007); - records and honors the disclosure mode, and never leaks a redacted value (REQ-PR-NOTARY-008, REQ-PR-NOTARY-009, REQ-PR-NOTARY-010); - returns the claim-result format, may render CCCEV-shaped JSON-LD, and states its CCCEV output as a profiled subset (REQ-PR-NOTARY-011, REQ-PR-NOTARY-012); -- issues only SD-JWT VC credentials from exact registry-backed evaluation provenance, with digest-based selective disclosure and `did:jwk` holder binding (REQ-PR-NOTARY-013, REQ-PR-NOTARY-014, REQ-PR-NOTARY-015, REQ-PR-NOTARY-037); +- issues only SD-JWT VC credentials from exact registry-backed evaluation provenance, with EdDSA or ES256 issuer signing, digest-based selective disclosure, EdDSA `did:jwk` OID4VCI holder binding, and fail-closed status verification for status-bearing credentials (REQ-PR-NOTARY-013, REQ-PR-NOTARY-014, REQ-PR-NOTARY-015, REQ-PR-NOTARY-037, REQ-PR-NOTARY-038); - treats self-attestation as constrained authenticated trust context, including delegated self-attestation only when requester, dependent target, relationship proof, and stored-evaluation metadata all bind to the configured policy (REQ-PR-NOTARY-026, REQ-PR-NOTARY-029, REQ-PR-NOTARY-030, REQ-PR-NOTARY-031, REQ-PR-NOTARY-035); -- exposes OID4VCI as a scoped non-full-issuer subset that requires holder proof of possession, rejects expired or already-consumed nonces where nonces are enabled, requires Notary-issued access tokens to carry matching transaction details, and keeps delegated self-attestation evaluation-only across both credential paths (REQ-PR-NOTARY-016, REQ-PR-NOTARY-017, REQ-PR-NOTARY-027, REQ-PR-NOTARY-030, REQ-PR-NOTARY-031, REQ-PR-NOTARY-032, REQ-PR-NOTARY-036); +- exposes OID4VCI as a registry-backed non-full-issuer subset with issuer-initiated pre-authorized code only, secure transaction-code defaults, EdDSA `did:jwk` holder proof, matching stored transaction details, one Notary per Relay authority, and no source-free or delegated issuance (REQ-PR-NOTARY-016, REQ-PR-NOTARY-017, REQ-PR-NOTARY-027, REQ-PR-NOTARY-032, REQ-PR-NOTARY-036, REQ-PR-NOTARY-039, REQ-PR-NOTARY-040, REQ-PR-NOTARY-041); - restricts federation to verified, static peers and never issues credentials over it (REQ-PR-NOTARY-018, REQ-PR-NOTARY-019); - audits every evaluation, fails the request when it cannot, reports errors as problem+json, and carries policy/provenance context without raw requester secrets (REQ-PR-NOTARY-020, REQ-PR-NOTARY-021, REQ-PR-NOTARY-022, REQ-PR-NOTARY-028, REQ-PR-NOTARY-033). @@ -306,6 +360,7 @@ This specification is `verified`: every requirement describes shipped behavior a - Delegated relationship configuration validation is implemented in [`config.rs`](https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-core/src/config.rs). - Request context derivation, stored-evaluation revalidation, OID4VCI delegated-token rejection, explicit target binding, and proof-gated Relay consultations are implemented in Registry Notary Server. - Private evaluation provenance capture and the shared direct/OID4VCI issuance verifier (REQ-PR-NOTARY-037) are implemented in Registry Notary Server's evaluation and render runtime modules. +- Complete pre-authorized browser callback, token, credential, EdDSA and ES256 issuer, EdDSA holder, route-removal, replay, and unsupported-profile coverage is implemented in Registry Notary Server standalone HTTP tests. - OIDC principal derivation from the configured principal claim, without client id, authorized party, or audience fallback (REQ-PR-NOTARY-034), is implemented in [`standalone.rs`](https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/standalone.rs)'s `principal_from_oidc` function and its `oidc_principal_requires_configured_principal_claim` test. - The issuer-bound self-attestation transaction-token gate (REQ-PR-NOTARY-035) is implemented in [`api.rs`](https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/api.rs)'s `self_attestation_requires_authorization_details` function. - The issuer-bound OID4VCI transaction-detail gate (REQ-PR-NOTARY-036) is implemented in [`api.rs`](https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/api.rs)'s `oid4vci_requires_authorization_details` and `require_oid4vci_issuance_authorization_details` functions. diff --git a/docs/site/src/content/docs/spec/rs-terms.mdx b/docs/site/src/content/docs/spec/rs-terms.mdx index 6c792237a..0b6ab7c19 100644 --- a/docs/site/src/content/docs/spec/rs-terms.mdx +++ b/docs/site/src/content/docs/spec/rs-terms.mdx @@ -61,6 +61,7 @@ The key words in this document are interpreted per [RS-DOC](../rs-doc/) Section | 0.6.0 | 2026-07-13 | draft | Replaced direct Notary source connectors and sidecars with compiler-pinned Relay consultations and Registry Stack project authoring terms. | | 0.6.1 | 2026-07-14 | draft | Added the complete Registry Stack project-authoring vocabulary and clarified compiler-derived credential membership. | | 0.7.0 | 2026-07-16 | draft | Defined the formal stack as four products and classified Solmara Lab as a separate adopter demo. | +| 0.8.0 | 2026-07-19 | draft | Defined the registry-backed, issuer-initiated OID4VCI profile and its algorithm, transaction-code, and non-conformance boundaries. | ## 1. Products @@ -94,13 +95,13 @@ The registry stack comprises four formal products. Product names are Title Case. **signed response credentials**: Removed Registry Relay feature that attached a W3C Verifiable Credentials Data Model (VCDM) 2.0 VC-JWT signed credential to entity record and aggregate responses. Registry Relay no longer accepts the legacy `provenance` configuration key, no longer serves `/.well-known/did.json`, `/schemas/{claim_type}/{version}`, or `/contexts/{vocab}/{version}`, and no longer returns `application/vc+jwt`. Use Registry Notary for credential issuance. -**SD-JWT VC**: Selective Disclosure JWT Verifiable Credential (IETF draft). Registry Notary issues SD-JWT VC credentials signed with EdDSA. Media type: `application/dc+sd-jwt`. +**SD-JWT VC**: Selective Disclosure JWT Verifiable Credential (IETF draft). Registry Notary issues registry-backed SD-JWT VC credentials signed with EdDSA or ES256. OID4VCI holder proof remains EdDSA with `did:jwk` binding. Media type: `application/dc+sd-jwt`. -**OID4VCI**: OpenID for Verifiable Credential Issuance. The protocol a wallet uses to obtain a credential from an issuer. Registry Notary implements an OID4VCI issuance flow: a credential offer, issuer metadata at `/.well-known/openid-credential-issuer`, a nonce endpoint, and a holder proof. The surface is a profiled subset of OID4VCI Draft 13 advertising the `dc+sd-jwt` format; full issuer conformance is not asserted (`openid4vci.support: not_full_issuer`). +**OID4VCI**: OpenID for Verifiable Credential Issuance. Registry Notary implements a profiled registry-backed issuer subset with `dc+sd-jwt`, an issuer-initiated pre-authorized code, a transaction-bound proof nonce returned by the token response, and EdDSA `did:jwk` holder proof. The identity provider's authorization code is an internal Notary input, not a wallet grant. The profile has no public nonce endpoint, response next nonce, source-free issuance, ES256 holder proof, EUDI, HAIP, PAR, DPoP, or wallet-attestation claim. Full issuer and external-wallet conformance are not asserted (`openid4vci.support: not_full_issuer`). **DID**: Decentralized Identifier. W3C DID Core 1.0. Registry Relay no longer publishes a DID document. Registry Notary parses `did:jwk` values for credential subjects. -**eSignet**: Open-source identity and authentication service (part of MOSIP). Registry Notary can use eSignet as the authorization server for subject-bound evaluation and registry-backed credential flows. Not a Registry Stack product term; included here because it appears in Solmara Lab documentation. +**eSignet**: Open-source identity and authentication service (part of MOSIP). Registry Notary can use eSignet for the internal browser authentication leg of a registry-backed pre-authorized credential transaction. Its authorization code is consumed by Notary and is not a wallet grant. Not a Registry Stack product term. ## 3. Metadata and standards terms diff --git a/docs/site/src/data/contracts.yaml b/docs/site/src/data/contracts.yaml index 6208fc797..b555a9f75 100644 --- a/docs/site/src/data/contracts.yaml +++ b/docs/site/src/data/contracts.yaml @@ -29,18 +29,21 @@ name: Registry Notary OID4VCI surface owner: registry-notary status: current-source - surface: OID4VCI offer start and callback (`/oid4vci/offer/start`, `/oid4vci/offer/callback`), credential offer (`GET /oid4vci/credential-offer`), nonce (`POST /oid4vci/nonce`), token (`POST /oid4vci/token`), credential request (`POST /oid4vci/credential`), VCT credential and metadata routes (`/credentials/{vct_path}`, `/.well-known/vct/{vct_path}`), and issuer metadata (`GET /.well-known/openid-credential-issuer`). Primitives sourced from the `registry-platform-oid4vci` crate. + surface: OID4VCI issuer metadata, Type Metadata, offer start and authenticated callback, pre-authorized token redemption, and registry-backed credential issuance. The wallet-facing grant is issuer-initiated pre-authorized code. Primitives are sourced from the `registry-platform-oid4vci` crate. source_of_truth: label: Registry Notary OID4VCI routes url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/api.rs consumer_note: >- - OID4VCI flow advertising `dc+sd-jwt` credential format. Credential - configurations accept non-delegated registry-backed claims only and issuance verifies the - exact dependency-closure Relay execution provenance before signing. Solmara Lab carries - a `just hosted-smoke` harness for issuer metadata, credential offer, nonce, - authorization redirect, and unauthenticated denial paths. Beta-13 hosted evidence remains - pending until the lab is repinned to published v0.11.0 digests and that harness passes. - Full OID4VCI Draft 13 `vc+sd-jwt` wallet conformance is not asserted at this version. + Registry Notary advertises `dc+sd-jwt`, EdDSA `did:jwk` holder proof, and + the configured EdDSA or ES256 issuer algorithm. Credential configurations + accept non-delegated registry-backed claims only, and issuance verifies the + stored transaction plus exact dependency-closure Relay execution provenance + before signing. Transaction code is required by default. An explicit + no-PIN profile has a maximum 300-second bearer-offer window. There is no + wallet-facing authorization-code grant, public nonce route, response next + nonce, source-free issuance, ES256 holder proof, PAR, DPoP, wallet + attestation, EUDI, or HAIP claim. External wallet and verifier evidence is + candidate-only until recorded against a frozen artifact. - id: registry-notary.federated-evaluation name: Registry Notary Federated Evaluation MVP owner: registry-notary diff --git a/docs/site/src/data/generated/contracts.json b/docs/site/src/data/generated/contracts.json index 02981ce41..a34ed7442 100644 --- a/docs/site/src/data/generated/contracts.json +++ b/docs/site/src/data/generated/contracts.json @@ -40,12 +40,12 @@ "name": "Registry Notary OID4VCI surface", "owner": "registry-notary", "status": "current-source", - "surface": "OID4VCI offer start and callback (`/oid4vci/offer/start`, `/oid4vci/offer/callback`), credential offer (`GET /oid4vci/credential-offer`), nonce (`POST /oid4vci/nonce`), token (`POST /oid4vci/token`), credential request (`POST /oid4vci/credential`), VCT credential and metadata routes (`/credentials/{vct_path}`, `/.well-known/vct/{vct_path}`), and issuer metadata (`GET /.well-known/openid-credential-issuer`). Primitives sourced from the `registry-platform-oid4vci` crate.", + "surface": "OID4VCI issuer metadata, Type Metadata, offer start and authenticated callback, pre-authorized token redemption, and registry-backed credential issuance. The wallet-facing grant is issuer-initiated pre-authorized code. Primitives are sourced from the `registry-platform-oid4vci` crate.", "source_of_truth": { "label": "Registry Notary OID4VCI routes", "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/api.rs" }, - "consumer_note": "OID4VCI flow advertising `dc+sd-jwt` credential format. Credential configurations accept non-delegated registry-backed claims only and issuance verifies the exact dependency-closure Relay execution provenance before signing. Solmara Lab carries a `just hosted-smoke` harness for issuer metadata, credential offer, nonce, authorization redirect, and unauthenticated denial paths. Beta-13 hosted evidence remains pending until the lab is repinned to published v0.11.0 digests and that harness passes. Full OID4VCI Draft 13 `vc+sd-jwt` wallet conformance is not asserted at this version." + "consumer_note": "Registry Notary advertises `dc+sd-jwt`, EdDSA `did:jwk` holder proof, and the configured EdDSA or ES256 issuer algorithm. Credential configurations accept non-delegated registry-backed claims only, and issuance verifies the stored transaction plus exact dependency-closure Relay execution provenance before signing. Transaction code is required by default. An explicit no-PIN profile has a maximum 300-second bearer-offer window. There is no wallet-facing authorization-code grant, public nonce route, response next nonce, source-free issuance, ES256 holder proof, PAR, DPoP, wallet attestation, EUDI, or HAIP claim. External wallet and verifier evidence is candidate-only until recorded against a frozen artifact." }, { "id": "registry-notary.federated-evaluation", diff --git a/docs/site/src/data/generated/projects.json b/docs/site/src/data/generated/projects.json index 78baa4492..53a78aa2f 100644 --- a/docs/site/src/data/generated/projects.json +++ b/docs/site/src/data/generated/projects.json @@ -7,7 +7,7 @@ "role": "Shared Rust workspace for security and operational primitives consumed by Registry Relay, Registry Notary, and related registry services, including auth helpers, OIDC verification, JWKS fetching, audit envelopes, HTTP security, outbound HTTP policy, crypto, SD-JWT VC support, and test fixtures.", "owns": [ "Shared auth, OIDC, JWKS fetching, audit, HTTP security, outbound HTTP, crypto, SD-JWT, and testing primitives.", - "OID4VCI credential offer, nonce, credential request, and issuer metadata primitives via the `registry-platform-oid4vci` crate.", + "OID4VCI pre-authorized offer, token request, credential request, issuer metadata, and replay-bound holder-proof primitives via the `registry-platform-oid4vci` crate.", "Cross-service crate APIs that must behave consistently in Relay, Notary, and future registry services.", "Shared Rust hygiene templates and supported integration-test fixtures." ], @@ -106,12 +106,14 @@ "Registry Notary API routes.", "Static-peer delegated evaluation at `/federation/v1/evaluations`.", "Credential issuance workflow, exact Relay execution provenance verification, and claim-to-credential mapping.", - "OID4VCI Draft 13 credential offer flow (credential-offer, nonce, credential endpoints) and the `/.well-known/openid-credential-issuer` metadata endpoint.", + "Profiled OID4VCI issuer-initiated pre-authorized flow, transaction-bound holder proof, Type Metadata, credential endpoint, and issuer metadata.", "Compiler-pinned Registry Relay consultation contracts for registry-backed claims.", "Source-free and self-attested claim evaluation." ], "does_not_own": [ "Registry source access, credentials, protocol adaptation, or Registry Relay runtime internals.", + "Source-free, delegated, or registry-backed evaluation-only credential issuance.", + "Wallet-facing authorization-code, EUDI, HAIP, PAR, DPoP, wallet-attestation, or ES256-holder profiles.", "Portable metadata renderers owned by Registry Manifest.", "Shared security and operational primitives owned by Registry Platform.", "Open federation, dynamic trust-chain discovery, shared replay storage, or federated credential issuance.", diff --git a/docs/site/src/data/generated/standards.json b/docs/site/src/data/generated/standards.json index e64ced3ff..f23306175 100644 --- a/docs/site/src/data/generated/standards.json +++ b/docs/site/src/data/generated/standards.json @@ -665,21 +665,21 @@ "name": "OpenID for Verifiable Credential Issuance", "standards_body": "OpenID Foundation", "official_url": "https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html", - "version_or_profile": "OID4VCI Draft 13, profiled issuer subset (SD-JWT VC `dc+sd-jwt` format; advertised as `openid4vci.support: not_full_issuer`)", + "version_or_profile": "Profiled issuer subset using `dc+sd-jwt`, issuer-initiated pre-authorized code, and `openid4vci.support: not_full_issuer`", "status": "used", "claim_level": "emits", "adoption_mode": "profiled", "used_by": [ "registry-notary", - "registry-platform", - "solmara-lab" + "registry-platform" ], "surfaces": [ - "credential offer endpoint", - "nonce endpoint", + "issuer-initiated offer start and authenticated callback", + "pre-authorized token endpoint with transaction-bound proof nonce", "credential issuance endpoint", + "SD-JWT VC Type Metadata", "issuer metadata at `/.well-known/openid-credential-issuer`", - "wallet-facing citizen self-attestation flow" + "registry-backed holder-wallet issuance" ], "evidence_docs": [ { @@ -691,16 +691,12 @@ "url": "https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-platform-oid4vci" }, { - "label": "Historical Registry Lab citizen OID4VCI smoke script", - "url": "https://github.com/registrystack/registry-stack/blob/v0.9.0/lab/scripts/smoke-citizen-oid4vci.sh" - }, - { - "label": "Solmara Lab hosted OID4VCI smoke", - "url": "https://github.com/registrystack/solmara-lab/blob/00cd51a2be4a405ef75d337ddcede0f9de134b1f/scripts/smoke-hosted.py" + "label": "Registry Notary wallet facade specification", + "url": "https://github.com/registrystack/registry-stack/blob/main/products/notary/specs/openid4vci-wallet-facade-spec.md" } ], - "last_checked": "2026-06-13", - "notes": "Registry Notary exposes the OID4VCI credential offer, nonce, credential, and issuer-metadata routes as a profiled subset of OID4VCI Draft 13. The historical v0.9.0 Registry Lab issuer metadata advertised `format: dc+sd-jwt` for the citizen credential configuration. Registry Platform owns the underlying primitives in the `registry-platform-oid4vci` crate. The historical Registry Lab exercised the flow through the `just citizen-oid4vci-*` recipes at v0.9.0. Solmara Lab carries a `just hosted-smoke` harness for issuer metadata, credential offer, nonce, authorization redirect, and unauthenticated denial paths. Beta-13 hosted evidence remains pending until the lab is repinned to published v0.11.0 digests and that harness passes. Full OID4VCI issuer conformance is not asserted: the server advertises itself as `openid4vci.support: not_full_issuer`, and the surfaces emit the protocol shape validated by the checked smoke paths." + "last_checked": "2026-07-19", + "notes": "Registry Notary exposes a registry-backed, issuer-initiated pre-authorized code flow. The identity provider's authorization code is an internal Notary input and not a wallet grant. Metadata advertises `dc+sd-jwt`, EdDSA `did:jwk` holder proof, and the configured EdDSA or ES256 issuer algorithm. Transaction code is required by default; an explicit no-PIN profile has a maximum 300-second bearer-offer window. Source-free claims cannot authorize issuance. The profile makes no claim for a public nonce endpoint, response next nonce, wallet-facing authorization code, ES256 holder proof, EUDI, HAIP, PAR, DPoP, or wallet attestation. Source tests cover the full issuance and client-verification journey. External wallet, verifier, and OpenID conformance evidence remains candidate-only until captured from a frozen release artifact." }, { "id": "w3c-did", diff --git a/docs/site/src/data/projects.yaml b/docs/site/src/data/projects.yaml index 97c057fb7..b2ab56968 100644 --- a/docs/site/src/data/projects.yaml +++ b/docs/site/src/data/projects.yaml @@ -5,7 +5,7 @@ role: Shared Rust workspace for security and operational primitives consumed by Registry Relay, Registry Notary, and related registry services, including auth helpers, OIDC verification, JWKS fetching, audit envelopes, HTTP security, outbound HTTP policy, crypto, SD-JWT VC support, and test fixtures. owns: - Shared auth, OIDC, JWKS fetching, audit, HTTP security, outbound HTTP, crypto, SD-JWT, and testing primitives. - - OID4VCI credential offer, nonce, credential request, and issuer metadata primitives via the `registry-platform-oid4vci` crate. + - OID4VCI pre-authorized offer, token request, credential request, issuer metadata, and replay-bound holder-proof primitives via the `registry-platform-oid4vci` crate. - Cross-service crate APIs that must behave consistently in Relay, Notary, and future registry services. - Shared Rust hygiene templates and supported integration-test fixtures. does_not_own: @@ -75,11 +75,13 @@ - Registry Notary API routes. - Static-peer delegated evaluation at `/federation/v1/evaluations`. - Credential issuance workflow, exact Relay execution provenance verification, and claim-to-credential mapping. - - OID4VCI Draft 13 credential offer flow (credential-offer, nonce, credential endpoints) and the `/.well-known/openid-credential-issuer` metadata endpoint. + - Profiled OID4VCI issuer-initiated pre-authorized flow, transaction-bound holder proof, Type Metadata, credential endpoint, and issuer metadata. - Compiler-pinned Registry Relay consultation contracts for registry-backed claims. - Source-free and self-attested claim evaluation. does_not_own: - Registry source access, credentials, protocol adaptation, or Registry Relay runtime internals. + - Source-free, delegated, or registry-backed evaluation-only credential issuance. + - Wallet-facing authorization-code, EUDI, HAIP, PAR, DPoP, wallet-attestation, or ES256-holder profiles. - Portable metadata renderers owned by Registry Manifest. - Shared security and operational primitives owned by Registry Platform. - Open federation, dynamic trust-chain discovery, shared replay storage, or federated credential issuance. diff --git a/docs/site/src/data/standards.yaml b/docs/site/src/data/standards.yaml index 3c84c315a..2303d6101 100644 --- a/docs/site/src/data/standards.yaml +++ b/docs/site/src/data/standards.yaml @@ -480,46 +480,41 @@ name: OpenID for Verifiable Credential Issuance standards_body: OpenID Foundation official_url: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html - version_or_profile: 'OID4VCI Draft 13, profiled issuer subset (SD-JWT VC `dc+sd-jwt` format; advertised as `openid4vci.support: not_full_issuer`)' + version_or_profile: 'Profiled issuer subset using `dc+sd-jwt`, issuer-initiated pre-authorized code, and `openid4vci.support: not_full_issuer`' status: used claim_level: emits adoption_mode: profiled used_by: - registry-notary - registry-platform - - solmara-lab surfaces: - - credential offer endpoint - - nonce endpoint + - issuer-initiated offer start and authenticated callback + - pre-authorized token endpoint with transaction-bound proof nonce - credential issuance endpoint + - SD-JWT VC Type Metadata - issuer metadata at `/.well-known/openid-credential-issuer` - - wallet-facing citizen self-attestation flow + - registry-backed holder-wallet issuance evidence_docs: - label: Registry Notary OID4VCI routes url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/api.rs - label: Registry Platform OID4VCI crate url: https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-platform-oid4vci - - label: Historical Registry Lab citizen OID4VCI smoke script - url: https://github.com/registrystack/registry-stack/blob/v0.9.0/lab/scripts/smoke-citizen-oid4vci.sh - - label: Solmara Lab hosted OID4VCI smoke - url: https://github.com/registrystack/solmara-lab/blob/00cd51a2be4a405ef75d337ddcede0f9de134b1f/scripts/smoke-hosted.py - last_checked: 2026-06-13 + - label: Registry Notary wallet facade specification + url: https://github.com/registrystack/registry-stack/blob/main/products/notary/specs/openid4vci-wallet-facade-spec.md + last_checked: 2026-07-19 notes: >- - Registry Notary exposes the OID4VCI credential offer, nonce, credential, - and issuer-metadata routes as a profiled subset of OID4VCI Draft 13. The - historical v0.9.0 Registry Lab issuer metadata advertised - `format: dc+sd-jwt` for the citizen credential configuration. Registry - Platform owns the underlying primitives - in the `registry-platform-oid4vci` crate. The historical Registry Lab - exercised the flow through the `just citizen-oid4vci-*` recipes at v0.9.0. - Solmara Lab carries a `just hosted-smoke` harness for issuer metadata, - credential offer, nonce, authorization redirect, and unauthenticated - denial paths. Beta-13 hosted evidence remains pending until the lab is - repinned to published v0.11.0 digests and that harness passes. Full OID4VCI - issuer conformance is not asserted: the - server advertises itself as - `openid4vci.support: not_full_issuer`, and the surfaces emit the protocol - shape validated by the checked smoke paths. + Registry Notary exposes a registry-backed, issuer-initiated + pre-authorized code flow. The identity provider's authorization code is an + internal Notary input and not a wallet grant. Metadata advertises + `dc+sd-jwt`, EdDSA `did:jwk` holder proof, and the configured EdDSA or + ES256 issuer algorithm. Transaction code is required by default; an + explicit no-PIN profile has a maximum 300-second bearer-offer window. + Source-free claims cannot authorize issuance. The profile makes no claim + for a public nonce endpoint, response next nonce, wallet-facing + authorization code, ES256 holder proof, EUDI, HAIP, PAR, DPoP, or wallet + attestation. Source tests cover the full issuance and client-verification + journey. External wallet, verifier, and OpenID conformance evidence + remains candidate-only until captured from a frozen release artifact. - id: w3c-did name: W3C Decentralized Identifiers (DID Core) standards_body: W3C From e2507ab51dac54d0df5848dd1cb782cbb8cfd6cf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 18:56:17 +0700 Subject: [PATCH 41/65] test(release): constrain OID4VCI conformance claims Signed-off-by: Jeremi Joslin --- release/conformance/openid/README.md | 36 ++++++++++++------- release/conformance/openid/plan-map.json | 31 ++++++++-------- .../scripts/test_openid_conformance_runner.py | 33 ++++++++++++++++- 3 files changed, 73 insertions(+), 27 deletions(-) diff --git a/release/conformance/openid/README.md b/release/conformance/openid/README.md index 40ac92028..369727b8d 100644 --- a/release/conformance/openid/README.md +++ b/release/conformance/openid/README.md @@ -17,28 +17,39 @@ mapping until the image, Python, and JAR pins are reviewed with it. ## Evidence boundary The checked-in runner, plan map, and non-secret configuration template make -the suite invocation repeatable. They do not yet complete issue #205: +the suite invocation repeatable. They are not external conformance evidence by +themselves: -- The supported Registry Notary topology must use published release images - pinned by digest and checked-in non-secret configuration. +- The supported Registry Notary topology must use a frozen release-candidate + image pinned by digest and checked-in non-secret configuration. - The full OID4VCI issuer plan needs an adapter that sends the issuer-initiated credential offer to the suite's `/credential_offer` callback. +- The upstream full-plan shape currently selects DPoP. Registry Notary 1.0 does + not support or claim DPoP, wallet attestation, PAR, EUDI, HAIP, an + authorization-code wallet grant, or ES256 holder proof. - Registry Relay needs a separate pinned-topology smoke with `auth.mode: oidc`. The OIDF suite has no generic resource-server plan for that surface. -Solmara Lab may provide the running topology during development, but release -gating and reproduction of the suite invocation must not require it. +Development and historical demo runs are not release evidence. A reviewed +result becomes evidence only when it records the candidate image digest, suite +commit, exact plan variants, configuration digest, start and completion times, +and unmodified result status without retaining secrets. ## Plan mapping [`plan-map.json`](plan-map.json) is the machine-readable mapping. -- `notary-oid4vci-issuer-metadata` is the applicable initial slice for Registry - Notary's citizen OID4VCI issuer. It runs +- `notary-oid4vci-issuer-metadata` is a candidate-only slice for Registry + Notary's registry-backed OID4VCI issuer. It runs `oid4vci-1_0-issuer-test-plan` with only `oid4vci-1_0-issuer-metadata-test`. - `notary-oid4vci-issuer-full` is mapped but blocked until a topology adapter - bridges a Notary credential offer into the suite callback. + bridges a Notary pre-authorized offer into the suite callback and the suite + path matches the supported Registry Notary profile. + +The suite's `sender_constrain=dpop` selector is required by the upstream plan +shape. The metadata-only module does not exercise DPoP, and the selector must +not be reported as product support. The map also records why Relay OIDC bearer validation and third-party OpenID Providers are outside the available OIDF plan set. That exclusion is not a @@ -52,7 +63,7 @@ substitute for exercising Relay's OIDC path. - A Registry Notary issuer whose image is pinned by digest and whose issuer URL is reachable from the conformance-suite container -## Run the metadata slice +## Run the candidate metadata slice List the mapped scenarios, prepare the pinned suite, and start it: @@ -62,7 +73,7 @@ release/scripts/openid-conformance-runner.py prepare release/scripts/openid-conformance-runner.py up ``` -Start the pinned Registry Notary topology separately. Its configured credential +Start the frozen Registry Notary candidate topology separately. Its configured credential issuer URL must exactly match its metadata and be reachable from the suite container. Then run: @@ -113,5 +124,6 @@ Review and redact an export before turning it into release evidence. A failed or warned result must remain visible in the reviewed summary. The first metadata-only run and its known failures are recorded in -[`initial-report.md`](initial-report.md). It is historical evidence from the -retired hosted lab, not proof of the still-open pinned release topology. +[`initial-report.md`](initial-report.md). It is historical context only. It is +not evidence for the current candidate, any wallet, any verifier, or the full +issuer profile. diff --git a/release/conformance/openid/plan-map.json b/release/conformance/openid/plan-map.json index d7a918574..75c02d370 100644 --- a/release/conformance/openid/plan-map.json +++ b/release/conformance/openid/plan-map.json @@ -11,8 +11,8 @@ "scenarios": [ { "id": "notary-oid4vci-issuer-metadata", - "surface": "Registry Notary citizen OID4VCI issuer", - "status": "applicable", + "surface": "Registry Notary registry-backed OID4VCI issuer metadata", + "status": "candidate-only", "suite_plan": "oid4vci-1_0-issuer-test-plan", "suite_modules": [ "oid4vci-1_0-issuer-metadata-test" @@ -36,20 +36,22 @@ "default_credential_configuration_id": "person_is_alive_sd_jwt" }, "requires": [ - "A Registry Notary release image pinned by digest and configured with checked-in, non-secret topology configuration.", + "A frozen Registry Notary release-candidate image pinned by digest and configured with checked-in, non-secret topology configuration.", "An issuer URL reachable from the OIDF conformance-suite server container that exactly matches the credential issuer metadata.", - "No bearer tokens, proof JWTs, issued credentials, or civil identifiers committed from generated results." + "A reviewed result export bound to the candidate image digest, suite commit, plan variants, and configuration digest.", + "No bearer tokens, proof JWTs, issued credentials, transaction codes, or civil identifiers committed from generated results." ], "notes": [ - "This initial repeatable suite slice exercises OID4VCI issuer metadata without the suite callback ceremony.", - "Solmara Lab may provide the running topology, but reproducing the suite invocation does not require that repository.", - "The full OID4VCI issuer plan remains mapped below and must not be represented as passing until a credential-offer callback adapter exists." + "This repeatable suite slice exercises issuer metadata only and is not a wallet, verifier, or full issuer conformance claim.", + "The suite requires the sender_constrain=dpop plan selector, but the metadata-only module does not exercise DPoP and Registry Notary does not support or claim DPoP.", + "No external result is release evidence until it is captured from the frozen candidate artifact with the required immutable identifiers.", + "The full OID4VCI issuer plan remains blocked by the suite callback adapter and by suite profile requirements outside Registry Notary 1.0." ] }, { "id": "notary-oid4vci-issuer-full", - "surface": "Registry Notary citizen OID4VCI issuer", - "status": "blocked-by-credential-offer-adapter", + "surface": "Registry Notary registry-backed OID4VCI issuer", + "status": "blocked-by-suite-profile-and-offer-adapter", "suite_plan": "oid4vci-1_0-issuer-test-plan", "suite_modules": [], "variants": { @@ -65,13 +67,14 @@ }, "config_template": "registry-notary-oid4vci-issuer.template.json", "requires": [ - "A pinned Registry Notary release topology with a suite-reachable issuer URL.", - "A bridge that sends or redirects a Notary credential offer to the suite's exposed /credential_offer endpoint.", - "A recorded policy decision on whether the first full run targets pre-authorized-code or authorization-code OID4VCI." + "A frozen Registry Notary release-candidate topology with a suite-reachable issuer URL.", + "A bridge that sends the issuer-initiated Notary pre-authorized offer to the suite's exposed /credential_offer endpoint.", + "A reviewed suite path that does not require DPoP, wallet attestation, PAR, an authorization-code wallet grant, ES256 holder proof, EUDI, or HAIP behavior outside Registry Notary 1.0." ], "notes": [ - "The existing endpoint smoke flow pulls the Notary offer endpoint directly; the OIDF issuer plan waits for a callback into the suite.", - "Do not treat this blocked mapping as certification evidence." + "Registry Notary supports issuer-initiated pre-authorized code only. The identity provider's authorization code is internal to Notary and is not a wallet grant.", + "The checked-in sender_constrain=dpop variant is required by this upstream suite plan shape and does not represent Registry Notary DPoP support.", + "Do not treat this blocked mapping, a development topology, or historical wallet smoke output as certification evidence." ] } ], diff --git a/release/scripts/test_openid_conformance_runner.py b/release/scripts/test_openid_conformance_runner.py index 03e7e25a5..02d0a29e9 100755 --- a/release/scripts/test_openid_conformance_runner.py +++ b/release/scripts/test_openid_conformance_runner.py @@ -62,6 +62,37 @@ def test_release_defaults_do_not_reference_retired_lab_paths(self) -> None: self.assertNotIn("REGISTRY_LAB_", serialized) self.assertNotIn("blocked-by-lab", serialized) + def test_notary_mapping_is_candidate_only_and_matches_the_1_0_profile(self) -> None: + metadata = self.runner.find_scenario( + self.plan_map, "notary-oid4vci-issuer-metadata" + ) + full = self.runner.find_scenario( + self.plan_map, "notary-oid4vci-issuer-full" + ) + + self.assertEqual("candidate-only", metadata["status"]) + self.assertEqual( + "pre_authorization_code", metadata["variants"]["vci_grant_type"] + ) + self.assertIn("registry-backed", metadata["surface"]) + metadata_notes = " ".join(metadata["notes"]) + self.assertIn("does not support or claim DPoP", metadata_notes) + self.assertIn("frozen candidate artifact", metadata_notes) + + self.assertEqual( + "blocked-by-suite-profile-and-offer-adapter", full["status"] + ) + self.assertEqual( + "pre_authorization_code", full["variants"]["vci_grant_type"] + ) + full_contract = " ".join(full["requires"] + full["notes"]) + self.assertIn("pre-authorized offer", full_contract) + self.assertIn("is not a wallet grant", full_contract) + self.assertNotIn( + "policy decision on whether the first full run targets", + full_contract, + ) + def test_builder_override_pins_maven_image_by_digest(self) -> None: override = self.runner.BUILDER_COMPOSE_OVERRIDE_PATH.read_text( encoding="utf-8" @@ -417,7 +448,7 @@ def test_blocked_full_scenario_requires_explicit_override(self) -> None: ] ) with self.assertRaisesRegex( - self.runner.RunnerError, "blocked-by-credential-offer-adapter" + self.runner.RunnerError, "blocked-by-suite-profile-and-offer-adapter" ): self.runner.cmd_run(args) From 6e6530fcc0a801a7f1eca5f3dc0b0d1ad7d4e724 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 19:01:33 +0700 Subject: [PATCH 42/65] refactor(notary): group OID4VCI materialization inputs Signed-off-by: Jeremi Joslin --- .../src/api/oid4vci/credential.rs | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/crates/registry-notary-server/src/api/oid4vci/credential.rs b/crates/registry-notary-server/src/api/oid4vci/credential.rs index ef68aff9a..100933804 100644 --- a/crates/registry-notary-server/src/api/oid4vci/credential.rs +++ b/crates/registry-notary-server/src/api/oid4vci/credential.rs @@ -191,11 +191,13 @@ pub(in crate::api) async fn oid4vci_credential( &state, evidence, &principal, - &validated_proof, - configuration_id, - configuration, - &transaction, - nonce, + Oid4vciTransactionMaterialization { + validated_proof: &validated_proof, + configuration_id, + configuration, + transaction: &transaction, + nonce, + }, ) .await; let (response_body, evaluation) = match materialized { @@ -282,16 +284,27 @@ pub(in crate::api) fn oid4vci_credential_response_with_audit( Ok(response) } -pub(in crate::api) async fn materialize_oid4vci_transaction( +struct Oid4vciTransactionMaterialization<'a> { + validated_proof: &'a ValidatedProof, + configuration_id: &'a str, + configuration: &'a Oid4vciCredentialConfigurationConfig, + transaction: &'a IssuanceTransaction, + nonce: &'a str, +} + +async fn materialize_oid4vci_transaction( state: &RegistryNotaryApiState, evidence: &EvidenceConfig, principal: &EvidencePrincipal, - validated_proof: &ValidatedProof, - configuration_id: &str, - configuration: &Oid4vciCredentialConfigurationConfig, - transaction: &IssuanceTransaction, - nonce: &str, + materialization: Oid4vciTransactionMaterialization<'_>, ) -> Result<(Value, registry_notary_core::StoredEvaluation), Oid4vciWireError> { + let Oid4vciTransactionMaterialization { + validated_proof, + configuration_id, + configuration, + transaction, + nonce, + } = materialization; let key = state .subject_access_rate_keys .oid4vci_nonce(&state.oid4vci.credential_issuer, configuration_id, nonce) From 83d2c50fede0c62a438ef9056f55d17d50fb3bcf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 19:18:41 +0700 Subject: [PATCH 43/65] fix(notary): remove retired OID4VCI client surfaces Signed-off-by: Jeremi Joslin --- crates/registry-notary-client/src/client.rs | 48 ------------- .../src/oid4vci/metadata.rs | 3 +- .../registry-notary-client/src/responses.rs | 7 -- .../tests/client_contract.rs | 68 +++++-------------- crates/registry-notary-server/src/openapi.rs | 7 +- .../docs/reference/apis/registry-notary.mdx | 6 +- docs/site/src/data/generated/standards.json | 2 +- docs/site/src/data/standards.yaml | 7 +- products/notary/CHANGELOG.md | 5 +- products/notary/bindings/node/src/client.js | 34 ---------- products/notary/bindings/node/src/index.d.ts | 2 - .../notary/bindings/node/test/client.test.js | 26 ++----- products/notary/bindings/node/test/types.ts | 4 -- .../bindings/python/registry_notary/client.py | 32 --------- .../bindings/python/tests/test_client.py | 16 ++--- .../docs/credential-issuance-migration.md | 8 ++- .../notary/openapi/oasdiff-1.0-err-ignore.txt | 1 + .../openapi/registry-notary.openapi.json | 4 -- .../notary/scripts/check-openapi-contract.sh | 20 ++++-- 19 files changed, 70 insertions(+), 230 deletions(-) diff --git a/crates/registry-notary-client/src/client.rs b/crates/registry-notary-client/src/client.rs index bb216abc8..6f2dca23c 100644 --- a/crates/registry-notary-client/src/client.rs +++ b/crates/registry-notary-client/src/client.rs @@ -385,49 +385,6 @@ impl RegistryNotaryClient { .await } - #[cfg(feature = "oid4vci")] - /// Fetch an OpenID4VCI credential offer. - pub async fn oid4vci_credential_offer( - &self, - credential_configuration_id: Option<&str>, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.reject_idempotency(&options)?; - let path = credential_configuration_id.map_or_else( - || "/oid4vci/credential-offer".to_string(), - |id| { - format!( - "/oid4vci/credential-offer?credential_configuration_id={}", - encode_query_value(id) - ) - }, - ); - self.get_json(&path, options, LIMIT_DISCOVERY, ErrorKind::Oid4vci) - .await - } - - #[cfg(feature = "oid4vci")] - /// Request an OpenID4VCI nonce. - pub async fn oid4vci_nonce( - &self, - request: Option, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.reject_idempotency(&options)?; - let body = request.unwrap_or(registry_platform_oid4vci::NonceRequest { - credential_configuration_id: None, - }); - self.post_json( - "/oid4vci/nonce", - &body, - options, - LIMIT_OPERATION, - RouteRetry::PostNoRetry, - ErrorKind::Oid4vci, - ) - .await - } - #[cfg(feature = "oid4vci")] /// Submit an OpenID4VCI credential request. /// @@ -1205,11 +1162,6 @@ fn encode_path_segment(segment: &str) -> String { .collect() } -#[cfg_attr(not(feature = "oid4vci"), allow(dead_code))] -fn encode_query_value(value: &str) -> String { - encode_path_segment(value).replace("%20", "+") -} - /// Fluent builder for one high-level evaluation request. pub struct EvaluateBuilder<'a> { client: &'a RegistryNotaryClient, diff --git a/crates/registry-notary-client/src/oid4vci/metadata.rs b/crates/registry-notary-client/src/oid4vci/metadata.rs index 2d4a6d483..0c46adf9d 100644 --- a/crates/registry-notary-client/src/oid4vci/metadata.rs +++ b/crates/registry-notary-client/src/oid4vci/metadata.rs @@ -2,6 +2,5 @@ //! OpenID4VCI metadata re-exports. pub use registry_platform_oid4vci::{ - CredentialIssuerMetadata, CredentialOffer, CredentialRequest, CredentialResponse, NonceRequest, - NonceResponse, + CredentialIssuerMetadata, CredentialOffer, CredentialRequest, CredentialResponse, }; diff --git a/crates/registry-notary-client/src/responses.rs b/crates/registry-notary-client/src/responses.rs index dfef40891..e73e92c12 100644 --- a/crates/registry-notary-client/src/responses.rs +++ b/crates/registry-notary-client/src/responses.rs @@ -255,13 +255,6 @@ impl_safe_debug!( registry_platform_oid4vci::CredentialOffer, ); -#[cfg(feature = "oid4vci")] -impl SafeDebug for registry_platform_oid4vci::NonceResponse { - fn fmt_debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("") - } -} - #[cfg(feature = "oid4vci")] impl SafeDebug for registry_platform_oid4vci::CredentialResponse { fn fmt_debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { diff --git a/crates/registry-notary-client/tests/client_contract.rs b/crates/registry-notary-client/tests/client_contract.rs index 868b508e1..c98ce2790 100644 --- a/crates/registry-notary-client/tests/client_contract.rs +++ b/crates/registry-notary-client/tests/client_contract.rs @@ -1019,7 +1019,7 @@ async fn federation_posts_already_signed_jws_without_minting() { #[tokio::test] async fn oid4vci_errors_use_oid4vci_envelope() { let app = Router::new().route( - "/oid4vci/nonce", + "/oid4vci/credential", post(|| async { ( StatusCode::BAD_REQUEST, @@ -1037,7 +1037,20 @@ async fn oid4vci_errors_use_oid4vci_envelope() { .expect("client builds"); let error = client - .oid4vci_nonce(None, RequestOptions::default()) + .oid4vci_credential( + registry_platform_oid4vci::CredentialRequest { + format: registry_platform_oid4vci::SD_JWT_VC_FORMAT.to_string(), + credential_identifier: Some("person_is_alive_sd_jwt".to_string()), + credential_configuration_id: None, + vct: None, + proof: registry_platform_oid4vci::CredentialRequestProof { + proof_type: registry_platform_oid4vci::PROOF_TYPE_JWT.to_string(), + jwt: "proof-jwt".to_string(), + }, + proofs: registry_platform_oid4vci::CredentialRequestProofs::default(), + }, + RequestOptions::default(), + ) .await .expect_err("oid4vci error maps"); @@ -1070,11 +1083,6 @@ async fn oid4vci_success_routes_parse_typed_responses() { "/.well-known/openid-credential-issuer", get(oid4vci_metadata_handler), ) - .route( - "/oid4vci/credential-offer", - get(oid4vci_credential_offer_handler), - ) - .route("/oid4vci/nonce", post(oid4vci_nonce_handler)) .route("/oid4vci/credential", post(oid4vci_credential_handler)); let base = spawn(app).await; let client = RegistryNotaryClient::builder(base) @@ -1085,14 +1093,6 @@ async fn oid4vci_success_routes_parse_typed_responses() { .oid4vci_issuer_metadata(RequestOptions::default()) .await .expect("metadata"); - let offer = client - .oid4vci_credential_offer(Some("person is alive"), RequestOptions::default()) - .await - .expect("offer"); - let nonce = client - .oid4vci_nonce(None, RequestOptions::default()) - .await - .expect("nonce"); let credential = client .oid4vci_credential( registry_platform_oid4vci::CredentialRequest { @@ -1112,11 +1112,6 @@ async fn oid4vci_success_routes_parse_typed_responses() { .expect("credential"); assert_eq!(metadata.body.credential_issuer, "https://issuer.example"); - assert_eq!( - offer.body.credential_configuration_ids, - vec!["person_is_alive_sd_jwt"] - ); - assert_eq!(nonce.body.c_nonce, "nonce-1"); assert_eq!( credential.body.credential, registry_platform_oid4vci::CredentialValue::from("sd-jwt-credential") @@ -1125,13 +1120,6 @@ async fn oid4vci_success_routes_parse_typed_responses() { let metadata_debug = format!("{metadata:?}"); assert!(metadata_debug.contains("https://issuer.example")); - let offer_debug = format!("{offer:?}"); - assert!(offer_debug.contains("person_is_alive_sd_jwt")); - - let nonce_debug = format!("{nonce:?}"); - assert!(nonce_debug.contains("")); - assert!(!nonce_debug.contains("nonce-1")); - let credential_debug = format!("{credential:?}"); assert!(credential_debug.contains("")); assert!(!credential_debug.contains("sd-jwt-credential")); @@ -1443,42 +1431,18 @@ async fn oid4vci_metadata_handler() -> Response { Json(json!({ "credential_issuer": "https://issuer.example", "credential_endpoint": "https://issuer.example/oid4vci/credential", - "nonce_endpoint": "https://issuer.example/oid4vci/nonce", "credential_configurations_supported": {} })) .into_response() } -#[cfg(feature = "oid4vci")] -async fn oid4vci_credential_offer_handler(uri: Uri) -> Response { - assert_eq!( - uri.query(), - Some("credential_configuration_id=person+is+alive") - ); - Json(json!({ - "credential_issuer": "https://issuer.example", - "credential_configuration_ids": ["person_is_alive_sd_jwt"], - "grants": {} - })) - .into_response() -} - -#[cfg(feature = "oid4vci")] -async fn oid4vci_nonce_handler(body: Bytes) -> Response { - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("nonce body parses"); - assert!(parsed["credential_configuration_id"].is_null()); - Json(json!({ "c_nonce": "nonce-1", "c_nonce_expires_in": 60 })).into_response() -} - #[cfg(feature = "oid4vci")] async fn oid4vci_credential_handler(body: Bytes) -> Response { let parsed: serde_json::Value = serde_json::from_slice(&body).expect("credential body parses"); assert_eq!(parsed["proof"]["jwt"], "proof-jwt"); Json(json!({ "credential": "sd-jwt-credential", - "format": "dc+sd-jwt", - "c_nonce": "nonce-2", - "c_nonce_expires_in": 60 + "format": "dc+sd-jwt" })) .into_response() } diff --git a/crates/registry-notary-server/src/openapi.rs b/crates/registry-notary-server/src/openapi.rs index 3efc36f44..994d59313 100644 --- a/crates/registry-notary-server/src/openapi.rs +++ b/crates/registry-notary-server/src/openapi.rs @@ -2890,7 +2890,6 @@ fn credential_issuer_metadata_schema() -> Value { "credential_issuer": { "type": "string", "format": "uri" }, "credential_endpoint": { "type": "string", "format": "uri" }, "token_endpoint": { "type": "string", "format": "uri" }, - "nonce_endpoint": { "type": "string", "format": "uri" }, "authorization_servers": { "type": "array", "items": { "type": "string", "format": "uri" } }, "display": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "credential_configurations_supported": { @@ -4206,6 +4205,12 @@ mod tests { doc["components"]["schemas"]["CredentialIssuerMetadata"]["type"], json!("object") ); + assert!( + doc["components"]["schemas"]["CredentialIssuerMetadata"]["properties"] + .get("nonce_endpoint") + .is_none(), + "the 1.0 issuer metadata schema must not advertise a public nonce endpoint" + ); assert_eq!( doc["components"]["schemas"]["CredentialRequest"]["type"], json!("object") diff --git a/docs/site/src/content/docs/reference/apis/registry-notary.mdx b/docs/site/src/content/docs/reference/apis/registry-notary.mdx index fde5ab9b1..4b31556bd 100644 --- a/docs/site/src/content/docs/reference/apis/registry-notary.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-notary.mdx @@ -73,9 +73,9 @@ For a full explanation of how the levels interact and the downgrade strategies, Notary issues SD-JWT VC credentials: -- Token type `dc+sd-jwt`, signing algorithm EdDSA (Ed25519). -- The issuer key is sourced from an environment variable (an OKP Ed25519 JWK); the public key is - published at `GET /.well-known/evidence/jwks.json`. +- Token type `dc+sd-jwt`, with issuer signing configured as EdDSA (Ed25519) or ES256 (P-256). +- The issuer key comes from the configured signing provider; its public JWK is published at + `GET /.well-known/evidence/jwks.json`. - Individual claim fields are wrapped in SD-JWT disclosures with SHA-256 digests for selective disclosure. diff --git a/docs/site/src/data/generated/standards.json b/docs/site/src/data/generated/standards.json index f23306175..b5f298b58 100644 --- a/docs/site/src/data/generated/standards.json +++ b/docs/site/src/data/generated/standards.json @@ -388,7 +388,7 @@ } ], "last_checked": "2026-06-13", - "notes": "Registry Platform owns reusable SD-JWT VC issuance and holder-proof helpers. Registry Notary owns the claim-to-credential workflow and service routes that use them. The profile is constrained: `application/dc+sd-jwt` credentials, EdDSA over Ed25519, `did:jwk` holder binding. Pre-rename format aliases such as `application/vc+sd-jwt` are rejected in operator configuration. Full SD-JWT VC conformance is not claimed." + "notes": "Registry Platform owns reusable SD-JWT VC issuance and holder-proof helpers. Registry Notary owns the claim-to-credential workflow and service routes that use them. The profile is constrained: `application/dc+sd-jwt` credentials, issuer signing with EdDSA over Ed25519 or ES256 over P-256, and EdDSA `did:jwk` holder binding. Pre-rename format aliases such as `application/vc+sd-jwt` are rejected in operator configuration. Full SD-JWT VC conformance is not claimed." }, { "id": "verifiable-credentials", diff --git a/docs/site/src/data/standards.yaml b/docs/site/src/data/standards.yaml index 2303d6101..bc58484d2 100644 --- a/docs/site/src/data/standards.yaml +++ b/docs/site/src/data/standards.yaml @@ -270,9 +270,10 @@ Registry Platform owns reusable SD-JWT VC issuance and holder-proof helpers. Registry Notary owns the claim-to-credential workflow and service routes that use them. The profile is constrained: `application/dc+sd-jwt` credentials, - EdDSA over Ed25519, `did:jwk` holder binding. Pre-rename format aliases such - as `application/vc+sd-jwt` are rejected in operator configuration. Full - SD-JWT VC conformance is not claimed. + issuer signing with EdDSA over Ed25519 or ES256 over P-256, and EdDSA + `did:jwk` holder binding. Pre-rename format aliases such as + `application/vc+sd-jwt` are rejected in operator configuration. Full SD-JWT + VC conformance is not claimed. - id: verifiable-credentials name: Verifiable Credentials Data Model standards_body: W3C diff --git a/products/notary/CHANGELOG.md b/products/notary/CHANGELOG.md index f7fe19ddb..a3e7dcbf2 100644 --- a/products/notary/CHANGELOG.md +++ b/products/notary/CHANGELOG.md @@ -31,7 +31,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - BREAKING: the Registry Notary 1.0 wallet facade now supports only issuer-initiated pre-authorized code backed by a stored registry transaction. `GET /oid4vci/credential-offer` and `POST /oid4vci/nonce` are removed, and - credential responses no longer expose `c_nonce` or `c_nonce_expires_in`. + issuer metadata no longer advertises `nonce_endpoint`. Credential responses + no longer expose `c_nonce` or `c_nonce_expires_in`. + The Rust, Node.js, and Python client helpers for those removed routes are + removed as well. Start at `GET /oid4vci/offer/start`, complete the identity-provider callback, redeem the rendered offer at `POST /oid4vci/token`, and use that token response's transaction-bound proof nonce. The identity provider's diff --git a/products/notary/bindings/node/src/client.js b/products/notary/bindings/node/src/client.js index 296f4ff65..77c2a5b0f 100644 --- a/products/notary/bindings/node/src/client.js +++ b/products/notary/bindings/node/src/client.js @@ -334,40 +334,6 @@ export class RegistryNotaryClient { }); } - /** - * @param {string | undefined} credentialConfigurationId - * @param {{ requestId?: string, signal?: AbortSignal }} [options] - * @returns {Promise} - */ - async oid4vciCredentialOffer(credentialConfigurationId = undefined, options = {}) { - const path = - credentialConfigurationId === undefined - ? "/oid4vci/credential-offer" - : `/oid4vci/credential-offer?credential_configuration_id=${encodeURIComponent(credentialConfigurationId)}`; - return await this.getJson(path, { - accept: "application/json", - requestId: options.requestId, - signal: options.signal, - errorKind: "oid4vci", - }); - } - - /** - * @param {Record | undefined} request - * @param {{ requestId?: string, traceparent?: string, signal?: AbortSignal }} [options] - * @returns {Promise} - */ - async oid4vciNonce(request = undefined, options = {}) { - return await this.requestJson("/oid4vci/nonce", request ?? { credential_configuration_id: null }, { - accept: "application/json", - requestId: options.requestId, - traceparent: options.traceparent, - signal: options.signal, - retryKind: "post_no_retry", - errorKind: "oid4vci", - }); - } - /** * @param {Record} request * @param {{ requestId?: string, traceparent?: string, signal?: AbortSignal }} [options] diff --git a/products/notary/bindings/node/src/index.d.ts b/products/notary/bindings/node/src/index.d.ts index 3bc01c2c0..5429eab57 100644 --- a/products/notary/bindings/node/src/index.d.ts +++ b/products/notary/bindings/node/src/index.d.ts @@ -168,8 +168,6 @@ export class RegistryNotaryClient { issueCredentialRequest(request: Record, options?: RequestOptions): Promise; credentialStatus(credentialId: string, options?: GetRequestOptions): Promise; oid4vciIssuerMetadata(options?: GetRequestOptions): Promise; - oid4vciCredentialOffer(credentialConfigurationId?: string, options?: GetRequestOptions): Promise; - oid4vciNonce(request?: Record, options?: RequestOptions): Promise; oid4vciCredential(request: Record, options?: RequestOptions): Promise; federationEvaluateJws(compactJws: string, options?: RequestOptions): Promise; } diff --git a/products/notary/bindings/node/test/client.test.js b/products/notary/bindings/node/test/client.test.js index b80289735..4f35e7929 100644 --- a/products/notary/bindings/node/test/client.test.js +++ b/products/notary/bindings/node/test/client.test.js @@ -559,14 +559,8 @@ test("OID4VCI and federation helpers use route-specific wire shapes", async () = if (String(url).endsWith("/.well-known/openid-credential-issuer")) { return jsonResponse({ credential_issuer: "https://issuer.example" }); } - if (String(url).includes("/oid4vci/credential-offer")) { - return jsonResponse({ credential_issuer: "https://issuer.example", credentials: [] }); - } - if (String(url).endsWith("/oid4vci/nonce")) { - return jsonResponse({ c_nonce: "nonce-secret" }); - } if (String(url).endsWith("/oid4vci/credential")) { - return jsonResponse({ format: "vc+sd-jwt", credential: "credential-secret" }); + return jsonResponse({ format: "dc+sd-jwt", credential: "credential-secret" }); } return new Response("response.jws.compact", { status: 200, @@ -576,23 +570,17 @@ test("OID4VCI and federation helpers use route-specific wire shapes", async () = }); assert.deepEqual(await client.oid4vciIssuerMetadata(), { credential_issuer: "https://issuer.example" }); - assert.deepEqual(await client.oid4vciCredentialOffer("config one"), { - credential_issuer: "https://issuer.example", - credentials: [], - }); - assert.deepEqual(await client.oid4vciNonce(), { c_nonce: "nonce-secret" }); assert.deepEqual(await client.oid4vciCredential({ proof: { jwt: "holder-proof-secret" } }), { - format: "vc+sd-jwt", + format: "dc+sd-jwt", credential: "credential-secret", }); assert.equal(await client.federationEvaluateJws("request.jws.compact"), "response.jws.compact"); - assert.equal(String(calls[1].url), "https://notary.example/oid4vci/credential-offer?credential_configuration_id=config%20one"); - assert.deepEqual(JSON.parse(calls[2].init.body), { credential_configuration_id: null }); - assert.deepEqual(JSON.parse(calls[3].init.body), { proof: { jwt: "holder-proof-secret" } }); - assert.equal(calls[4].init.body, "request.jws.compact"); - assert.equal(calls[4].init.headers.get("content-type"), "application/jwt"); - assert.equal(calls[4].init.headers.get("accept"), "application/jwt"); + assert.equal(String(calls[1].url), "https://notary.example/oid4vci/credential"); + assert.deepEqual(JSON.parse(calls[1].init.body), { proof: { jwt: "holder-proof-secret" } }); + assert.equal(calls[2].init.body, "request.jws.compact"); + assert.equal(calls[2].init.headers.get("content-type"), "application/jwt"); + assert.equal(calls[2].init.headers.get("accept"), "application/jwt"); }); test("OID4VCI errors redact descriptions and credential material", async () => { diff --git a/products/notary/bindings/node/test/types.ts b/products/notary/bindings/node/test/types.ts index 8104c1e9e..bbfa38c56 100644 --- a/products/notary/bindings/node/test/types.ts +++ b/products/notary/bindings/node/test/types.ts @@ -62,8 +62,6 @@ const jwks: Promise = client.issuerJwks(); const refreshedJwks: Promise = client.refreshJwks(); const jwk: Promise | undefined> = client.getJwk("key-1"); const oidMetadata: Promise = client.oid4vciIssuerMetadata(); -const oidOffer: Promise = client.oid4vciCredentialOffer("config one"); -const oidNonce: Promise = client.oid4vciNonce(); const oidCredential: Promise = client.oid4vciCredential({ proof: { jwt: "jwt" } }); const federation: Promise = client.federationEvaluateJws("request.jws.compact"); @@ -87,8 +85,6 @@ void jwks; void refreshedJwks; void jwk; void oidMetadata; -void oidOffer; -void oidNonce; void oidCredential; void federation; void errors; diff --git a/products/notary/bindings/python/registry_notary/client.py b/products/notary/bindings/python/registry_notary/client.py index de5fa101e..f79f52847 100644 --- a/products/notary/bindings/python/registry_notary/client.py +++ b/products/notary/bindings/python/registry_notary/client.py @@ -329,38 +329,6 @@ def oid4vci_issuer_metadata(self, *, request_id: str | None = None) -> dict[str, error_kind="oid4vci", ) - def oid4vci_credential_offer( - self, - credential_configuration_id: str | None = None, - *, - request_id: str | None = None, - ) -> dict[str, Any]: - path = "/oid4vci/credential-offer" - if credential_configuration_id is not None: - path = ( - "/oid4vci/credential-offer?credential_configuration_id=" - f"{quote(credential_configuration_id, safe='')}" - ) - return self._decode_json_response(self._get(path, request_id=request_id), error_kind="oid4vci") - - def oid4vci_nonce( - self, - request: Mapping[str, Any] | None = None, - *, - request_id: str | None = None, - traceparent: str | None = None, - ) -> dict[str, Any]: - response = self._post_json( - "/oid4vci/nonce", - request or {"credential_configuration_id": None}, - purpose=None, - request_id=request_id, - traceparent=traceparent, - accept=JSON, - retry_kind="post_no_retry", - ) - return self._decode_json_response(response, error_kind="oid4vci") - def oid4vci_credential( self, request: Mapping[str, Any], diff --git a/products/notary/bindings/python/tests/test_client.py b/products/notary/bindings/python/tests/test_client.py index 1ebfd05e8..3809cb854 100644 --- a/products/notary/bindings/python/tests/test_client.py +++ b/products/notary/bindings/python/tests/test_client.py @@ -466,29 +466,23 @@ def test_discovery_jwks_oid4vci_and_federation_helpers(self) -> None: recorder = _Recorder( responses=[ (200, {"credential_issuer": "https://issuer.example"}, None), - (200, {"credential_issuer": "https://issuer.example", "credentials": []}, None), - (200, {"c_nonce": "nonce-secret"}, None), - (200, {"format": "vc+sd-jwt", "credential": "credential-secret"}, None), + (200, {"format": "dc+sd-jwt", "credential": "credential-secret"}, None), (200, b"response.jws.compact", {"content-type": "application/jwt"}), ] ) with Server(recorder) as base_url: client = RegistryNotaryClient(base_url=base_url) metadata = client.oid4vci_issuer_metadata() - offer = client.oid4vci_credential_offer("config one") - nonce = client.oid4vci_nonce() credential = client.oid4vci_credential({"proof": {"jwt": "holder-proof-secret"}}) jws = client.federation_evaluate_jws("request.jws.compact") self.assertEqual(metadata["credential_issuer"], "https://issuer.example") - self.assertEqual(offer["credentials"], []) - self.assertEqual(nonce["c_nonce"], "nonce-secret") self.assertEqual(credential["credential"], "credential-secret") self.assertEqual(jws, "response.jws.compact") - self.assertEqual(recorder.requests[1]["path"], "/oid4vci/credential-offer?credential_configuration_id=config%20one") - self.assertEqual(recorder.requests[3]["body"], {"proof": {"jwt": "holder-proof-secret"}}) - self.assertEqual(recorder.requests[4]["body"], "request.jws.compact") - self.assertEqual(recorder.requests[4]["headers"]["Content-Type"], "application/jwt") + self.assertEqual(recorder.requests[1]["path"], "/oid4vci/credential") + self.assertEqual(recorder.requests[1]["body"], {"proof": {"jwt": "holder-proof-secret"}}) + self.assertEqual(recorder.requests[2]["body"], "request.jws.compact") + self.assertEqual(recorder.requests[2]["headers"]["Content-Type"], "application/jwt") def test_openspp_contract_routes_versioned_refs_retries_jwks_and_problem_codes(self) -> None: retry_policy = RetryPolicy( diff --git a/products/notary/docs/credential-issuance-migration.md b/products/notary/docs/credential-issuance-migration.md index 9272ea15f..2155fec1c 100644 --- a/products/notary/docs/credential-issuance-migration.md +++ b/products/notary/docs/credential-issuance-migration.md @@ -10,10 +10,14 @@ consultations. This applies to `POST /v1/credentials` and The OID4VCI surface also changes to issuer-initiated pre-authorized code only. Remove integrations that call the former credential-offer or public nonce routes, or that treat an identity-provider authorization code as a wallet -grant. Start the browser journey at `GET /oid4vci/offer/start`, import the offer +grant. The corresponding Rust `oid4vci_credential_offer` and `oid4vci_nonce`, +Node.js `oid4vciCredentialOffer` and `oid4vciNonce`, and Python +`oid4vci_credential_offer` and `oid4vci_nonce` client helpers are also removed. +Start the browser journey at `GET /oid4vci/offer/start`, import the offer rendered after the callback, redeem its pre-authorized code at `POST /oid4vci/token`, and use the proof nonce from that token response. The -credential response no longer returns a next nonce. +issuer metadata no longer contains `nonce_endpoint`, and the credential +response no longer returns a next nonce. ## Configuration changes diff --git a/products/notary/openapi/oasdiff-1.0-err-ignore.txt b/products/notary/openapi/oasdiff-1.0-err-ignore.txt index 2af6b2688..c93a49de7 100644 --- a/products/notary/openapi/oasdiff-1.0-err-ignore.txt +++ b/products/notary/openapi/oasdiff-1.0-err-ignore.txt @@ -4,5 +4,6 @@ # file must be removed after the comparison baseline includes the new contract. GET /oid4vci/credential-offer api path removed without deprecation POST /oid4vci/nonce api path removed without deprecation +GET /.well-known/openid-credential-issuer removed the optional property 'nonce_endpoint' from the response with the '200' status POST /oid4vci/credential removed the optional property 'c_nonce' from the response with the '200' status POST /oid4vci/credential removed the optional property 'c_nonce_expires_in' from the response with the '200' status diff --git a/products/notary/openapi/registry-notary.openapi.json b/products/notary/openapi/registry-notary.openapi.json index 578b7a381..457b25dbf 100644 --- a/products/notary/openapi/registry-notary.openapi.json +++ b/products/notary/openapi/registry-notary.openapi.json @@ -752,10 +752,6 @@ }, "type": "array" }, - "nonce_endpoint": { - "format": "uri", - "type": "string" - }, "token_endpoint": { "format": "uri", "type": "string" diff --git a/products/notary/scripts/check-openapi-contract.sh b/products/notary/scripts/check-openapi-contract.sh index 0d08a12a3..f48d480b6 100755 --- a/products/notary/scripts/check-openapi-contract.sh +++ b/products/notary/scripts/check-openapi-contract.sh @@ -74,6 +74,7 @@ from pathlib import Path expected = { "GET /oid4vci/credential-offer api path removed without deprecation", "POST /oid4vci/nonce api path removed without deprecation", + "GET /.well-known/openid-credential-issuer removed the optional property 'nonce_endpoint' from the response with the '200' status", "POST /oid4vci/credential removed the optional property 'c_nonce' from the response with the '200' status", "POST /oid4vci/credential removed the optional property 'c_nonce_expires_in' from the response with the '200' status", } @@ -92,11 +93,22 @@ if allowed != expected: f"Notary OpenAPI 1.0 allowlist is not exact; missing={missing}, extra={extra}" ) -raw_diff = raw_path.read_text(encoding="utf-8") -stale = sorted(line for line in allowed if line not in raw_diff) -if stale: +observed = set() +for raw_line in raw_path.read_text(encoding="utf-8").splitlines(): + marker = "in API " + if marker not in raw_line: + continue + change = raw_line.split(marker, 1)[1] + if " [" in change: + change = change.rsplit(" [", 1)[0] + observed.add(change.strip().removesuffix(".").rstrip()) + +if observed != allowed: + missing = sorted(allowed - observed) + extra = sorted(observed - allowed) raise SystemExit( - "Notary OpenAPI 1.0 allowlist contains stale entries: " + "; ".join(stale) + f"Notary OpenAPI 1.0 raw diff is not exactly allowlisted; " + f"missing={missing}, extra={extra}" ) PY From e3f0449ebaacbc774f11b6e0f81a3ffea1489a52 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 19:24:21 +0700 Subject: [PATCH 44/65] test(notary): gate ES256 fixture with CEL support Signed-off-by: Jeremi Joslin --- crates/registry-notary-server/tests/standalone_http/support.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/registry-notary-server/tests/standalone_http/support.rs b/crates/registry-notary-server/tests/standalone_http/support.rs index 874f22e1c..0097914b7 100644 --- a/crates/registry-notary-server/tests/standalone_http/support.rs +++ b/crates/registry-notary-server/tests/standalone_http/support.rs @@ -55,6 +55,7 @@ pub(super) use ulid::Ulid; pub(super) const TEST_AUDIT_SECRET: &str = "0123456789abcdef0123456789abcdef"; pub(super) const TEST_ISSUER_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA"}"#; +#[cfg(feature = "registry-notary-cel")] pub(super) const TEST_ISSUER_ES256_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256"}"#; pub(super) const TEST_ACCESS_TOKEN_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"8jFBgUJxaaQimd4NjzxhvPYyNbcOnnZsqOntZbpP3Xk","x":"XvW-aWwJCWSYoYudTB9OZqNHURKElnnyGNa6DQNjzZk","alg":"EdDSA"}"#; pub(super) const TEST_ESIGNET_RP_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"EOLPz23yGd5Ju5e-PYybLE-YyvjgXLhGzS6XgmszzXs","x":"3v5jZ5rAf7KGvcC3zuKh6-ujgtA0ABa4jqmAWXq-S_c","alg":"EdDSA"}"#; From eaa657fbb3b82515f743dc10673b3e83d9d64991 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 19:28:25 +0700 Subject: [PATCH 45/65] test(notary): report safe batch conformance failures Signed-off-by: Jeremi Joslin --- products/notary/scripts/postgresql-conformance.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/products/notary/scripts/postgresql-conformance.sh b/products/notary/scripts/postgresql-conformance.sh index 3bfcab210..54a0b4403 100755 --- a/products/notary/scripts/postgresql-conformance.sh +++ b/products/notary/scripts/postgresql-conformance.sh @@ -665,8 +665,15 @@ wait "${curl_pid_a}" || fail "first concurrent batch request failed" wait "${curl_pid_b}" || fail "second concurrent batch request failed" batch_id="$(jq --exit-status --raw-output '.batch_id | select(type == "string" and length > 0)' "${work_dir}/response-a1.json")" -[[ "$(jq --raw-output '.summary.succeeded' "${work_dir}/response-a1.json")" == "1" ]] \ - || fail "the concurrent batch did not produce one successful evaluation" +if [[ "$(jq --raw-output '.summary.succeeded' "${work_dir}/response-a1.json")" != "1" ]]; then + jq --compact-output \ + '{summary, items: [.items[] | {input_index, status, error_codes: [.errors[].code]}]}' \ + "${work_dir}/response-a1.json" >&2 || true + jq --compact-output \ + '{summary, items: [.items[] | {input_index, status, error_codes: [.errors[].code]}]}' \ + "${work_dir}/response-a2.json" >&2 || true + fail "the concurrent batch did not produce one successful evaluation" +fi evaluation_id="$(jq --exit-status --raw-output '.items[0].evaluation_id | select(type == "string" and length > 0)' "${work_dir}/response-a1.json")" [[ "$(jq --raw-output '.batch_id' "${work_dir}/response-a2.json")" == "${batch_id}" ]] \ || fail "concurrent instances did not observe one batch owner" From 5bd8bbf657a76bd009dd6b0e43cfe9355b47e23b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 19:54:04 +0700 Subject: [PATCH 46/65] release: add external integration evidence packet Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 6 + release/READINESS.md | 6 + release/conformance/integrations/README.md | 149 +++ .../dhis2-tracker-2.41.9.profile.json | 82 ++ .../profiles/opencrvs-dci-v1.9.profile.json | 104 ++ .../schema/run-result.schema.json | 252 ++++ release/scripts/check-gates-inventory.py | 8 + release/scripts/integration-e2-runner.py | 1147 +++++++++++++++++ release/scripts/test_check_gates_inventory.py | 20 + release/scripts/test_integration_e2_runner.py | 480 +++++++ 10 files changed, 2254 insertions(+) create mode 100644 release/conformance/integrations/README.md create mode 100644 release/conformance/integrations/profiles/dhis2-tracker-2.41.9.profile.json create mode 100644 release/conformance/integrations/profiles/opencrvs-dci-v1.9.profile.json create mode 100644 release/conformance/integrations/schema/run-result.schema.json create mode 100755 release/scripts/integration-e2-runner.py create mode 100644 release/scripts/test_integration_e2_runner.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc699b879..605878edd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,6 +288,12 @@ jobs: - name: Test OpenID conformance runner run: python3 -m unittest release/scripts/test_openid_conformance_runner.py + - name: Test external integration evidence runner + run: python3 -m unittest release/scripts/test_integration_e2_runner.py + + - name: Validate external integration evidence packet + run: python3 release/scripts/integration-e2-runner.py validate + - name: Gate inventory run: python3 release/scripts/check-gates-inventory.py diff --git a/release/READINESS.md b/release/READINESS.md index d2223976b..43a428b55 100644 --- a/release/READINESS.md +++ b/release/READINESS.md @@ -52,6 +52,12 @@ write. historical hosted-lab failures but does not yet prove a pinned release topology. - [ ] Credentialing, OID4VCI, and status-list interop proof (#57). +- [ ] OpenCRVS and DHIS2 project-authored integration proof (#72). The + [candidate-neutral source packet](conformance/integrations/README.md) + pins both unofficial profiles, closes the public result shape, and + validates published candidate assets. It is not live evidence; the + external compatibility, instance, source-side audit, and teardown + prerequisites remain required for each candidate run. ## 4. Adversarial verification diff --git a/release/conformance/integrations/README.md b/release/conformance/integrations/README.md new file mode 100644 index 000000000..d34a7294d --- /dev/null +++ b/release/conformance/integrations/README.md @@ -0,0 +1,149 @@ +# External integration evidence + +This directory defines the release-owned evidence boundary for Registry Stack's +OpenCRVS and DHIS2 integration profiles. It lets an approved operator prepare a +repeatable run and publish a bounded result without publishing source records, +credentials, infrastructure details, or raw logs. + +Both profiles are **Registry Stack-supported unofficial integration profiles**. +They prove one reviewed read operation against one exact upstream baseline. +They are not product certification or general conformance claims. + +## Evidence boundary + +The checked-in profiles, schema, and runner are a source packet, not live +evidence. A passing public result also requires all of these external inputs: + +- A published Registry Stack candidate and its complete signed release assets. +- An owner-approved non-production source instance with stable test records. +- Owner-attested metadata, source routes, identifiers, and credentials. +- A source-side audit or request-counter probe that can distinguish zero from + one data-operation call for every case. +- Approved restricted evidence storage, retention, redaction, and teardown. + +For OpenCRVS, the `/registry/sync/search` compatibility probe must pass against +the exact pinned DCI adapter, core, and Farajaland tuple before the live run. +The current starter's synthetic route is not evidence that the real operation +is compatible. + +For DHIS2, the instance owner must attest every metadata UID used by the +authored adapter. The `DEMO_*` values in the starter are examples and cannot +appear in a live evidence project. + +Do not simulate either prerequisite. Do not convert fixture output, a dry run, +or application-only logs into candidate evidence. + +## Inspect the plan + +Validate the checked-in packet: + +```sh +python3 release/scripts/integration-e2-runner.py validate +``` + +Inspect either profile as a readable plan: + +```sh +python3 release/scripts/integration-e2-runner.py plan \ + --profile opencrvs-dci-v1.9 +``` + +Use `dry-run` for the same bounded contract as JSON: + +```sh +python3 release/scripts/integration-e2-runner.py dry-run \ + --profile dhis2-tracker-2.41.9 > integration-plan.json +``` + +The JSON has `candidate_evidence: false` and +`status: planned_not_executed`. It includes input names, not values. + +## Run the operator-owned journey + +The approved operator wrapper must execute these stages in order and within +the profile limits: + +1. Download the candidate assets listed in `release/VERIFY.md` into a fresh, + dedicated directory. +2. Validate checksums, signatures, provenance, capsule lineage, image locks, + digest files, and the candidate binary's self-reported version. +3. Initialize the profile's starter with the verified candidate + `registryctl` binary. +4. Apply only the reviewed authored changes listed in the selected profile. + Do not edit generated YAML. +5. Run the offline project `test`, `check`, and `build` commands. Record hashes + of authored inputs, the build review, and both generated closures. +6. Deploy one candidate-digest Registry Relay, Registry Notary, and PostgreSQL + set per authority. +7. Query the approved source-side probe before and after every closed test + case. The five trust denials must prove no data-operation contact. +8. Retain raw evidence only in the approved restricted location. Publish only + safe result codes, timings, contact classifications, correlation hashes, + and evidence hashes. +9. Seed restricted-value canaries, scan the public artifact, and reject any + match. Re-hash generated files to prove they were not edited after build. +10. Attempt scoped teardown from a `finally` path, even after a failed case. + Record the bounded duration, outcome, and sanitized evidence hash. + +`source_data_access` counts only the profile's reviewed data operation. For +OpenCRVS, OAuth or JSON Web Key Set (JWKS) traffic does not count as a +`/registry/sync/search` call. The source-side evidence still has to account for +that supporting traffic. + +The operator wrapper owns product credentials, network access, deployment +details, source probe invocation, restricted storage, and cleanup. Those +instance-specific operations are intentionally not embedded in this public +runner. + +Run the candidate-only validation before creating the project: + +```sh +python3 release/scripts/integration-e2-runner.py validate \ + --candidate-dir /restricted/candidate-assets \ + --tag v1.0.0 +``` + +## Validate candidate evidence + +Create an owner-only canary file with one unique 8 to 128 character ASCII value +per line. Canary values can contain letters, digits, `.`, `_`, `:`, `@`, and +`-`. Seed the same values into the restricted test inputs so the scan can +detect accidental disclosure. Do not pass canary values on the command line. + +```sh +chmod 0600 /restricted/run-72.canaries + +python3 release/scripts/integration-e2-runner.py validate \ + --profile opencrvs-dci-v1.9 \ + --candidate-dir /restricted/candidate-assets \ + --tag v1.0.0 \ + --result /restricted/sanitized-run-result.json \ + --canary-file /restricted/run-72.canaries +``` + +This validation requires `cosign` and `slsa-verifier`. It rejects missing or +extra candidate assets, symlinks, wrong checksums, invalid signatures or +provenance, capsule and image-lock disagreements, an unbounded result, an +unknown public field, a canary match, a passed case without the required +source-side contact classification, a changed generated project, and failed +or over-time teardown. + +The result schema is +[`schema/run-result.schema.json`](schema/run-result.schema.json). It is closed: +raw responses, request bodies, headers, tokens, source identifiers, hostnames, +and credentials have no public fields. A failed run can remain as honest +non-closing evidence, but `status: passed` requires every applicable case and +teardown to pass. + +## Review the source packet + +- [`profiles/opencrvs-dci-v1.9.profile.json`](profiles/opencrvs-dci-v1.9.profile.json) + pins the OpenCRVS tuple and signed exact-UIN search. +- [`profiles/dhis2-tracker-2.41.9.profile.json`](profiles/dhis2-tracker-2.41.9.profile.json) + pins DHIS2 2.41.9 and the singleton tracked-entity read. +- [`schema/run-result.schema.json`](schema/run-result.schema.json) defines the + only public result shape. + +Review raw evidence and the owner attestation outside the public repository. +Commit only the sanitized result after a maintainer confirms the candidate +identity, case semantics, redaction report, and teardown status. diff --git a/release/conformance/integrations/profiles/dhis2-tracker-2.41.9.profile.json b/release/conformance/integrations/profiles/dhis2-tracker-2.41.9.profile.json new file mode 100644 index 000000000..9c73b6f3d --- /dev/null +++ b/release/conformance/integrations/profiles/dhis2-tracker-2.41.9.profile.json @@ -0,0 +1,82 @@ +{ + "schema_version": "registry.release.integration_e2_profile.v1", + "profile_id": "dhis2-tracker-2.41.9", + "support_status": "Registry Stack-supported unofficial integration profile", + "starter": "dhis2-tracker", + "source": { + "product": "dhis2-tracker", + "baseline": [ + { + "component": "dhis2", + "version": "2.41.9", + "commit": "ce6404687f6a5806e2661cffe4bc7a1d9b2ad2ed" + } + ], + "operations": [ + { + "id": "tracked-entity-read", + "role": "data", + "method": "GET", + "path": "/api/tracker/trackedEntities/{uid}", + "selector": "exact tracked entity UID", + "field_projection": "trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]]", + "max_calls": 1 + } + ] + }, + "authored_contract": { + "integration_alias": "health-record", + "generated_yaml_edits_allowed": false, + "reviewed_changes": [ + "Replace every DEMO_* metadata UID in the authored Rhai adapter with an owner-attested instance value.", + "Keep the exact singleton trackedEntities resource path, reviewed field projection, and echoed UID comparison.", + "Bind the owner-attested source origin and Basic authentication through the reviewed environment input.", + "Keep source origins, metadata UIDs, selectors, and credentials out of the checked-in profile and public result." + ] + }, + "dynamic_inputs": [ + { "env": "DHIS2_SOURCE_ORIGIN", "classification": "restricted", "purpose": "source origin" }, + { "env": "DHIS2_USERNAME", "classification": "secret", "purpose": "Basic authentication username" }, + { "env": "DHIS2_PASSWORD", "classification": "secret", "purpose": "Basic authentication password" }, + { "env": "DHIS2_CHILD_PROGRAM_UID", "classification": "restricted", "purpose": "child programme metadata" }, + { "env": "DHIS2_MATERNAL_PROGRAM_UID", "classification": "restricted", "purpose": "maternal programme metadata" }, + { "env": "DHIS2_TB_PROGRAM_UID", "classification": "restricted", "purpose": "tuberculosis programme metadata" }, + { "env": "DHIS2_CHILD_VISIT_STAGE_UID", "classification": "restricted", "purpose": "child visit stage metadata" }, + { "env": "DHIS2_FIRST_NAME_ATTRIBUTE_UID", "classification": "restricted", "purpose": "first-name attribute metadata" }, + { "env": "DHIS2_LAST_NAME_ATTRIBUTE_UID", "classification": "restricted", "purpose": "last-name attribute metadata" }, + { "env": "DHIS2_BIRTH_DATE_ATTRIBUTE_UID", "classification": "restricted", "purpose": "birth-date attribute metadata" }, + { "env": "DHIS2_RECONCILIATION_ATTRIBUTE_UID", "classification": "restricted", "purpose": "reconciliation attribute metadata" }, + { "env": "DHIS2_MATCH_TRACKED_ENTITY", "classification": "subject", "purpose": "match selector" }, + { "env": "DHIS2_NO_MATCH_TRACKED_ENTITY", "classification": "subject", "purpose": "no-match selector" }, + { "env": "DHIS2_MISMATCH_TRACKED_ENTITY", "classification": "subject", "purpose": "selector-mismatch selector" }, + { "env": "REGISTRY_INTEGRATION_E2_SOURCE_PROBE", "classification": "restricted", "purpose": "source-side audit probe path" }, + { "env": "REGISTRY_INTEGRATION_E2_CANARY_FILE", "classification": "restricted", "purpose": "redaction canary file path" } + ], + "cases": [ + { "id": "authorized-match", "expected_result_code": "match", "expected_source_data_access": "contacted_once" }, + { "id": "authorized-no-match", "expected_result_code": "no-match", "expected_source_data_access": "contacted_once" }, + { "id": "ambiguity", "expected_result_code": "not-applicable", "expected_source_data_access": "not_applicable" }, + { "id": "invalid-selector", "expected_result_code": "invalid-selector", "expected_source_data_access": "not_contacted" }, + { "id": "wrong-caller", "expected_result_code": "denied-wrong-caller", "expected_source_data_access": "not_contacted" }, + { "id": "missing-scope", "expected_result_code": "denied-missing-scope", "expected_source_data_access": "not_contacted" }, + { "id": "wrong-purpose", "expected_result_code": "denied-wrong-purpose", "expected_source_data_access": "not_contacted" }, + { "id": "stale-contract", "expected_result_code": "denied-stale-contract", "expected_source_data_access": "not_contacted" }, + { "id": "subject-mismatch", "expected_result_code": "subject-mismatch", "expected_source_data_access": "contacted_once" }, + { "id": "source-authorization-failure", "expected_result_code": "source-authorization-failure", "expected_source_data_access": "contacted_once" }, + { "id": "deadline-enforced", "expected_result_code": "deadline-exceeded", "expected_source_data_access": "contacted_once" } + ], + "prerequisites": [ + "A frozen published Registry Stack 1.0 candidate with independently verified assets.", + "An owner-approved non-production DHIS2 2.41.9 instance provides stable match, no-match, mismatch, authorization-failure, and delayed-response cases.", + "Every DEMO_* value in the starter is replaced by an owner-attested metadata UID before project test, check, and build.", + "Source-side audit or request-counter access can prove whether the tracked entity operation was contacted for every case.", + "Restricted raw-evidence storage, retention, redaction review, and scoped teardown are approved." + ], + "limits": { + "case_timeout_seconds": 30, + "run_timeout_seconds": 1200, + "raw_evidence_bytes": 8388608, + "public_result_bytes": 1048576, + "teardown_timeout_seconds": 300 + } +} diff --git a/release/conformance/integrations/profiles/opencrvs-dci-v1.9.profile.json b/release/conformance/integrations/profiles/opencrvs-dci-v1.9.profile.json new file mode 100644 index 000000000..bc2b54959 --- /dev/null +++ b/release/conformance/integrations/profiles/opencrvs-dci-v1.9.profile.json @@ -0,0 +1,104 @@ +{ + "schema_version": "registry.release.integration_e2_profile.v1", + "profile_id": "opencrvs-dci-v1.9", + "support_status": "Registry Stack-supported unofficial integration profile", + "starter": "opencrvs-dci", + "source": { + "product": "opencrvs", + "baseline": [ + { + "component": "dci-adapter", + "version": "v1.9.0-rc.1", + "commit": "5e31d1e381d4bd8c7c74112d714fd49d263c6df7" + }, + { + "component": "core", + "version": "v1.9.5", + "commit": "7243ccb79eb84254420878ff5146c6e50f084554" + }, + { + "component": "farajaland", + "version": "v1.9.5", + "commit": "3c1a5612d8d7bebddd43463682860b38ef14bcb4" + } + ], + "operations": [ + { + "id": "oauth-token", + "role": "credential", + "method": "POST", + "path": "owner-attested", + "max_calls": 1 + }, + { + "id": "jwks", + "role": "verification", + "method": "GET", + "path": "owner-attested", + "max_calls": 1 + }, + { + "id": "signed-uin-search", + "role": "data", + "method": "POST", + "path": "/registry/sync/search", + "selector": "exact UIN", + "max_calls": 1 + } + ] + }, + "authored_contract": { + "integration_alias": "birth-record", + "generated_yaml_edits_allowed": false, + "reviewed_changes": [ + "Replace the synthetic /dci/v1/birth/search path with /registry/sync/search only after the compatibility probe passes.", + "Bind the owner-attested OAuth, JWKS, sender, receiver, registry, and record identifiers through the reviewed project inputs.", + "Keep the signed DCI helper and exact UIN selector verification in the authored integration and Rhai adapter.", + "Keep source origins and all live values out of the checked-in profile and public result." + ] + }, + "dynamic_inputs": [ + { "env": "OPENCRVS_SOURCE_ORIGIN", "classification": "restricted", "purpose": "source origin" }, + { "env": "OPENCRVS_OAUTH_ORIGIN", "classification": "restricted", "purpose": "OAuth origin" }, + { "env": "OPENCRVS_OAUTH_PATH", "classification": "restricted", "purpose": "OAuth token path" }, + { "env": "OPENCRVS_JWKS_ORIGIN", "classification": "restricted", "purpose": "JWKS origin" }, + { "env": "OPENCRVS_JWKS_PATH", "classification": "restricted", "purpose": "JWKS path" }, + { "env": "OPENCRVS_SENDER_ID", "classification": "restricted", "purpose": "DCI sender" }, + { "env": "OPENCRVS_RECEIVER_ID", "classification": "restricted", "purpose": "DCI receiver" }, + { "env": "OPENCRVS_CLIENT_ID", "classification": "secret", "purpose": "OAuth client id" }, + { "env": "OPENCRVS_CLIENT_SECRET", "classification": "secret", "purpose": "OAuth client secret" }, + { "env": "OPENCRVS_MATCH_UIN", "classification": "subject", "purpose": "match selector" }, + { "env": "OPENCRVS_NO_MATCH_UIN", "classification": "subject", "purpose": "no-match selector" }, + { "env": "OPENCRVS_AMBIGUOUS_UIN", "classification": "subject", "purpose": "ambiguity selector" }, + { "env": "OPENCRVS_MISMATCH_UIN", "classification": "subject", "purpose": "selector-mismatch selector" }, + { "env": "REGISTRY_INTEGRATION_E2_SOURCE_PROBE", "classification": "restricted", "purpose": "source-side audit probe path" }, + { "env": "REGISTRY_INTEGRATION_E2_CANARY_FILE", "classification": "restricted", "purpose": "redaction canary file path" } + ], + "cases": [ + { "id": "authorized-match", "expected_result_code": "match", "expected_source_data_access": "contacted_once" }, + { "id": "authorized-no-match", "expected_result_code": "no-match", "expected_source_data_access": "contacted_once" }, + { "id": "ambiguity", "expected_result_code": "ambiguous", "expected_source_data_access": "contacted_once" }, + { "id": "invalid-selector", "expected_result_code": "invalid-selector", "expected_source_data_access": "not_contacted" }, + { "id": "wrong-caller", "expected_result_code": "denied-wrong-caller", "expected_source_data_access": "not_contacted" }, + { "id": "missing-scope", "expected_result_code": "denied-missing-scope", "expected_source_data_access": "not_contacted" }, + { "id": "wrong-purpose", "expected_result_code": "denied-wrong-purpose", "expected_source_data_access": "not_contacted" }, + { "id": "stale-contract", "expected_result_code": "denied-stale-contract", "expected_source_data_access": "not_contacted" }, + { "id": "subject-mismatch", "expected_result_code": "subject-mismatch", "expected_source_data_access": "contacted_once" }, + { "id": "source-authorization-failure", "expected_result_code": "source-authorization-failure", "expected_source_data_access": "not_contacted" }, + { "id": "deadline-enforced", "expected_result_code": "deadline-exceeded", "expected_source_data_access": "contacted_once" } + ], + "prerequisites": [ + "A frozen published Registry Stack 1.0 candidate with independently verified assets.", + "The /registry/sync/search compatibility probe passes against the exact owner-attested tuple.", + "An owner-approved non-production instance provides stable match, no-match, ambiguity, mismatch, authorization-failure, and delayed-response cases.", + "Source-side audit or request-counter access can prove whether /registry/sync/search was contacted for every case.", + "Restricted raw-evidence storage, retention, redaction review, and scoped teardown are approved." + ], + "limits": { + "case_timeout_seconds": 60, + "run_timeout_seconds": 1800, + "raw_evidence_bytes": 8388608, + "public_result_bytes": 1048576, + "teardown_timeout_seconds": 300 + } +} diff --git a/release/conformance/integrations/schema/run-result.schema.json b/release/conformance/integrations/schema/run-result.schema.json new file mode 100644 index 000000000..8e83bcee3 --- /dev/null +++ b/release/conformance/integrations/schema/run-result.schema.json @@ -0,0 +1,252 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://registrystack.org/schemas/release/integration-e2-run-result-v1.json", + "title": "Registry Stack integration E2 public run result", + "description": "Closed, redaction-safe public evidence for one published-candidate OpenCRVS or DHIS2 integration run. Raw source evidence is intentionally excluded.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "record_kind", + "run_id", + "profile_id", + "support_status", + "status", + "started_at", + "completed_at", + "release", + "source", + "project", + "cases", + "redaction", + "teardown", + "limitations" + ], + "properties": { + "schema_version": { "const": "registry.release.integration_e2_run_result.v1" }, + "record_kind": { "const": "candidate_evidence" }, + "run_id": { "$ref": "#/$defs/slug" }, + "profile_id": { + "enum": ["opencrvs-dci-v1.9", "dhis2-tracker-2.41.9"] + }, + "support_status": { + "const": "Registry Stack-supported unofficial integration profile" + }, + "status": { "enum": ["passed", "failed"] }, + "started_at": { "$ref": "#/$defs/timestamp" }, + "completed_at": { "$ref": "#/$defs/timestamp" }, + "release": { "$ref": "#/$defs/release" }, + "source": { "$ref": "#/$defs/source" }, + "project": { "$ref": "#/$defs/project" }, + "cases": { + "type": "array", + "minItems": 11, + "maxItems": 11, + "items": { "$ref": "#/$defs/case" } + }, + "redaction": { "$ref": "#/$defs/redaction" }, + "teardown": { "$ref": "#/$defs/teardown" }, + "limitations": { + "type": "array", + "minItems": 4, + "maxItems": 6, + "uniqueItems": true, + "items": { + "enum": [ + "unofficial-integration-profile", + "single-pinned-product-version", + "single-reviewed-read-operation", + "non-production-instance", + "not-product-certification", + "not-general-country-system-conformance" + ] + } + } + }, + "$defs": { + "slug": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]{0,127}$" + }, + "sha256": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + }, + "timestamp": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + }, + "image": { + "type": "string", + "pattern": "^ghcr\\.io/registrystack/registry-(?:relay|notary)@sha256:[0-9a-f]{64}$" + }, + "baseline": { + "type": "object", + "additionalProperties": false, + "required": ["component", "version", "commit"], + "properties": { + "component": { "$ref": "#/$defs/slug" }, + "version": { "type": "string", "pattern": "^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$" }, + "commit": { "$ref": "#/$defs/commit" } + } + }, + "release": { + "type": "object", + "additionalProperties": false, + "required": [ + "tag", + "version", + "source_commit", + "registryctl_asset_sha256", + "image_lock_sha256", + "release_capsule_sha256", + "relay_image", + "notary_image", + "candidate_assets_verified", + "authenticity_verified" + ], + "properties": { + "tag": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "source_commit": { "$ref": "#/$defs/commit" }, + "registryctl_asset_sha256": { "$ref": "#/$defs/sha256" }, + "image_lock_sha256": { "$ref": "#/$defs/sha256" }, + "release_capsule_sha256": { "$ref": "#/$defs/sha256" }, + "relay_image": { "$ref": "#/$defs/image" }, + "notary_image": { "$ref": "#/$defs/image" }, + "candidate_assets_verified": { "const": true }, + "authenticity_verified": { "const": true } + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["product", "baseline", "operation_id", "method", "path", "owner_attestation_sha256"], + "properties": { + "product": { "enum": ["opencrvs", "dhis2-tracker"] }, + "baseline": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": { "$ref": "#/$defs/baseline" } + }, + "operation_id": { "$ref": "#/$defs/slug" }, + "method": { "enum": ["GET", "POST"] }, + "path": { + "enum": ["/registry/sync/search", "/api/tracker/trackedEntities/{uid}"] + }, + "owner_attestation_sha256": { "$ref": "#/$defs/sha256" } + } + }, + "project": { + "type": "object", + "additionalProperties": false, + "required": [ + "starter", + "starter_content_digest", + "authored_inputs_sha256", + "build_review_sha256", + "relay_closure_sha256", + "notary_closure_sha256", + "generated_files_unchanged" + ], + "properties": { + "starter": { "enum": ["opencrvs-dci", "dhis2-tracker"] }, + "starter_content_digest": { "$ref": "#/$defs/sha256" }, + "authored_inputs_sha256": { "$ref": "#/$defs/sha256" }, + "build_review_sha256": { "$ref": "#/$defs/sha256" }, + "relay_closure_sha256": { "$ref": "#/$defs/sha256" }, + "notary_closure_sha256": { "$ref": "#/$defs/sha256" }, + "generated_files_unchanged": { "const": true } + } + }, + "case": { + "type": "object", + "additionalProperties": false, + "required": [ + "case_id", + "outcome", + "started_at", + "completed_at", + "duration_ms", + "result_code", + "source_data_access", + "source_data_access_evidence_sha256", + "audit_correlation_sha256", + "evidence_sha256" + ], + "properties": { + "case_id": { + "enum": [ + "authorized-match", + "authorized-no-match", + "ambiguity", + "invalid-selector", + "wrong-caller", + "missing-scope", + "wrong-purpose", + "stale-contract", + "subject-mismatch", + "source-authorization-failure", + "deadline-enforced" + ] + }, + "outcome": { "enum": ["passed", "failed", "not_applicable"] }, + "started_at": { "$ref": "#/$defs/timestamp" }, + "completed_at": { "$ref": "#/$defs/timestamp" }, + "duration_ms": { "type": "integer", "minimum": 0, "maximum": 1800000 }, + "result_code": { "$ref": "#/$defs/slug" }, + "source_data_access": { + "enum": ["not_contacted", "contacted_once", "not_applicable", "unknown"] + }, + "source_data_access_evidence_sha256": { "$ref": "#/$defs/sha256" }, + "audit_correlation_sha256": { "$ref": "#/$defs/sha256" }, + "evidence_sha256": { "$ref": "#/$defs/sha256" } + } + }, + "redaction": { + "type": "object", + "additionalProperties": false, + "required": [ + "passed", + "scanned_artifacts", + "scanned_bytes", + "seeded_canaries", + "forbidden_values_found", + "restricted_raw_evidence_bytes", + "raw_evidence_retained_restricted", + "report_sha256" + ], + "properties": { + "passed": { "const": true }, + "scanned_artifacts": { "type": "integer", "minimum": 1, "maximum": 4096 }, + "scanned_bytes": { "type": "integer", "minimum": 1, "maximum": 67108864 }, + "seeded_canaries": { "type": "integer", "minimum": 1, "maximum": 128 }, + "forbidden_values_found": { "const": 0 }, + "restricted_raw_evidence_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 8388608 + }, + "raw_evidence_retained_restricted": { "const": true }, + "report_sha256": { "$ref": "#/$defs/sha256" } + } + }, + "teardown": { + "type": "object", + "additionalProperties": false, + "required": ["attempted", "status", "duration_ms", "completed_at", "evidence_sha256"], + "properties": { + "attempted": { "const": true }, + "status": { "enum": ["completed", "failed"] }, + "duration_ms": { "type": "integer", "minimum": 0, "maximum": 300000 }, + "completed_at": { "$ref": "#/$defs/timestamp" }, + "evidence_sha256": { "$ref": "#/$defs/sha256" } + } + } + } +} diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index b7f0f807b..e057c9416 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -33,6 +33,14 @@ "OpenID conformance runner tests", "run: python3 -m unittest release/scripts/test_openid_conformance_runner.py", ), + ( + "External integration evidence runner tests", + "run: python3 -m unittest release/scripts/test_integration_e2_runner.py", + ), + ( + "External integration evidence packet", + "run: python3 release/scripts/integration-e2-runner.py validate", + ), ("Release manifest validation", "release/scripts/registry-release validate"), ("Release docset validation", "release/scripts/registry-release validate-docsets"), ("Release import audit", "release/scripts/registry-release audit"), diff --git a/release/scripts/integration-e2-runner.py b/release/scripts/integration-e2-runner.py new file mode 100755 index 000000000..8ac8051f1 --- /dev/null +++ b/release/scripts/integration-e2-runner.py @@ -0,0 +1,1147 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Prepare and validate candidate-neutral external integration evidence. + +This tool deliberately does not drive an unreviewed live product instance. It +closes the portable plan, candidate trust checks, and public evidence boundary +while leaving instance-specific orchestration to an approved operator wrapper. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import re +import shutil +import stat +import subprocess +import sys +from pathlib import Path +from typing import Any, Callable + + +ROOT = Path(__file__).resolve().parents[2] +CONFIG_DIR = ROOT / "release" / "conformance" / "integrations" +PROFILE_DIR = CONFIG_DIR / "profiles" +SCHEMA_PATH = CONFIG_DIR / "schema" / "run-result.schema.json" +PROFILE_SCHEMA = "registry.release.integration_e2_profile.v1" +RESULT_SCHEMA = "registry.release.integration_e2_run_result.v1" +SUPPORT_STATUS = "Registry Stack-supported unofficial integration profile" +CAPSULE_REPOSITORY = "registrystack/registry-stack" +SLSA_SOURCE_URI = "github.com/registrystack/registry-stack" +RELEASE_WORKFLOW = ( + "https://github.com/registrystack/registry-stack/.github/workflows/" + "release.yml@refs/tags/{tag}" +) +PROFILE_FILES = { + "opencrvs-dci-v1.9": "opencrvs-dci-v1.9.profile.json", + "dhis2-tracker-2.41.9": "dhis2-tracker-2.41.9.profile.json", +} +CASE_IDS = ( + "authorized-match", + "authorized-no-match", + "ambiguity", + "invalid-selector", + "wrong-caller", + "missing-scope", + "wrong-purpose", + "stale-contract", + "subject-mismatch", + "source-authorization-failure", + "deadline-enforced", +) +TAG = re.compile(r"^v([0-9]+\.[0-9]+\.[0-9]+)$") +COMMIT = re.compile(r"^[0-9a-f]{40}$") +MAX_ASSET_BYTES = 128 * 1024 * 1024 +SAFE_CANARY = re.compile(rb"^[A-Za-z0-9._:@-]{8,128}$") +REQUIRED_LIMITATIONS = { + "unofficial-integration-profile", + "single-pinned-product-version", + "single-reviewed-read-operation", + "non-production-instance", + "not-product-certification", + "not-general-country-system-conformance", +} +EXPECTED_PROFILE_BINDINGS = { + "opencrvs-dci-v1.9": { + "starter": "opencrvs-dci", + "product": "opencrvs", + "baseline": [ + ("dci-adapter", "v1.9.0-rc.1", "5e31d1e381d4bd8c7c74112d714fd49d263c6df7"), + ("core", "v1.9.5", "7243ccb79eb84254420878ff5146c6e50f084554"), + ("farajaland", "v1.9.5", "3c1a5612d8d7bebddd43463682860b38ef14bcb4"), + ], + "operations": [ + ("oauth-token", "credential", "POST", "owner-attested", 1), + ("jwks", "verification", "GET", "owner-attested", 1), + ("signed-uin-search", "data", "POST", "/registry/sync/search", 1), + ], + "inputs": ( + "OPENCRVS_SOURCE_ORIGIN", + "OPENCRVS_OAUTH_ORIGIN", + "OPENCRVS_OAUTH_PATH", + "OPENCRVS_JWKS_ORIGIN", + "OPENCRVS_JWKS_PATH", + "OPENCRVS_SENDER_ID", + "OPENCRVS_RECEIVER_ID", + "OPENCRVS_CLIENT_ID", + "OPENCRVS_CLIENT_SECRET", + "OPENCRVS_MATCH_UIN", + "OPENCRVS_NO_MATCH_UIN", + "OPENCRVS_AMBIGUOUS_UIN", + "OPENCRVS_MISMATCH_UIN", + "REGISTRY_INTEGRATION_E2_SOURCE_PROBE", + "REGISTRY_INTEGRATION_E2_CANARY_FILE", + ), + "case_access": ( + "contacted_once", + "contacted_once", + "contacted_once", + "not_contacted", + "not_contacted", + "not_contacted", + "not_contacted", + "not_contacted", + "contacted_once", + "not_contacted", + "contacted_once", + ), + "result_codes": ( + "match", + "no-match", + "ambiguous", + "invalid-selector", + "denied-wrong-caller", + "denied-missing-scope", + "denied-wrong-purpose", + "denied-stale-contract", + "subject-mismatch", + "source-authorization-failure", + "deadline-exceeded", + ), + "limits": (60, 1800, 8388608, 1048576, 300), + }, + "dhis2-tracker-2.41.9": { + "starter": "dhis2-tracker", + "product": "dhis2-tracker", + "baseline": [ + ("dhis2", "2.41.9", "ce6404687f6a5806e2661cffe4bc7a1d9b2ad2ed"), + ], + "operations": [ + ( + "tracked-entity-read", + "data", + "GET", + "/api/tracker/trackedEntities/{uid}", + 1, + ), + ], + "inputs": ( + "DHIS2_SOURCE_ORIGIN", + "DHIS2_USERNAME", + "DHIS2_PASSWORD", + "DHIS2_CHILD_PROGRAM_UID", + "DHIS2_MATERNAL_PROGRAM_UID", + "DHIS2_TB_PROGRAM_UID", + "DHIS2_CHILD_VISIT_STAGE_UID", + "DHIS2_FIRST_NAME_ATTRIBUTE_UID", + "DHIS2_LAST_NAME_ATTRIBUTE_UID", + "DHIS2_BIRTH_DATE_ATTRIBUTE_UID", + "DHIS2_RECONCILIATION_ATTRIBUTE_UID", + "DHIS2_MATCH_TRACKED_ENTITY", + "DHIS2_NO_MATCH_TRACKED_ENTITY", + "DHIS2_MISMATCH_TRACKED_ENTITY", + "REGISTRY_INTEGRATION_E2_SOURCE_PROBE", + "REGISTRY_INTEGRATION_E2_CANARY_FILE", + ), + "case_access": ( + "contacted_once", + "contacted_once", + "not_applicable", + "not_contacted", + "not_contacted", + "not_contacted", + "not_contacted", + "not_contacted", + "contacted_once", + "contacted_once", + "contacted_once", + ), + "result_codes": ( + "match", + "no-match", + "not-applicable", + "invalid-selector", + "denied-wrong-caller", + "denied-missing-scope", + "denied-wrong-purpose", + "denied-stale-contract", + "subject-mismatch", + "source-authorization-failure", + "deadline-exceeded", + ), + "limits": (30, 1200, 8388608, 1048576, 300), + }, +} + + +class RunnerError(RuntimeError): + """A user-actionable integration evidence error.""" + + +def load_json(path: Path, *, max_bytes: int = 1024 * 1024) -> Any: + require_regular_file(path, max_bytes=max_bytes) + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RunnerError(f"could not read valid JSON from {path}: {exc}") from exc + + +def require_regular_file(path: Path, *, max_bytes: int) -> None: + try: + info = path.lstat() + except OSError as exc: + raise RunnerError(f"required file is unavailable: {path}: {exc}") from exc + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + raise RunnerError(f"required path must be a regular, non-symlink file: {path}") + if info.st_size <= 0 or info.st_size > max_bytes: + raise RunnerError( + f"file size for {path} must be between 1 and {max_bytes} bytes" + ) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def require_object(value: Any, label: str, keys: set[str]) -> dict[str, Any]: + if not isinstance(value, dict): + raise RunnerError(f"{label} must be an object") + missing = keys - set(value) + unknown = set(value) - keys + if missing or unknown: + details = [] + if missing: + details.append("missing " + ", ".join(sorted(missing))) + if unknown: + details.append("unknown " + ", ".join(sorted(unknown))) + raise RunnerError(f"{label} has invalid fields: {'; '.join(details)}") + return value + + +def load_profile(profile_id: str) -> dict[str, Any]: + try: + path = PROFILE_DIR / PROFILE_FILES[profile_id] + except KeyError as exc: + raise RunnerError(f"unknown integration profile: {profile_id}") from exc + profile = load_json(path) + validate_profile(profile, path.name) + if profile["profile_id"] != profile_id: + raise RunnerError(f"{path.name} declares the wrong profile_id") + return profile + + +def validate_profile(value: Any, label: str) -> None: + profile = require_object( + value, + label, + { + "schema_version", + "profile_id", + "support_status", + "starter", + "source", + "authored_contract", + "dynamic_inputs", + "cases", + "prerequisites", + "limits", + }, + ) + if profile["schema_version"] != PROFILE_SCHEMA: + raise RunnerError(f"{label} has an unsupported schema_version") + if profile["profile_id"] not in PROFILE_FILES: + raise RunnerError(f"{label} has an unknown profile_id") + expected = EXPECTED_PROFILE_BINDINGS[profile["profile_id"]] + if profile["support_status"] != SUPPORT_STATUS: + raise RunnerError(f"{label} must use the reviewed support wording") + if profile["starter"] != expected["starter"]: + raise RunnerError(f"{label} has the wrong pinned starter") + authored = require_object( + profile["authored_contract"], + f"{label}.authored_contract", + {"integration_alias", "generated_yaml_edits_allowed", "reviewed_changes"}, + ) + if authored["generated_yaml_edits_allowed"] is not False: + raise RunnerError(f"{label} must prohibit generated YAML edits") + if ( + not isinstance(authored["integration_alias"], str) + or not isinstance(authored["reviewed_changes"], list) + or not authored["reviewed_changes"] + ): + raise RunnerError( + f"{label}.authored_contract must contain reviewed authored changes" + ) + + source = require_object( + profile["source"], f"{label}.source", {"product", "baseline", "operations"} + ) + if source["product"] != expected["product"]: + raise RunnerError(f"{label} has the wrong pinned source product") + baseline = source["baseline"] + if not isinstance(baseline, list): + raise RunnerError(f"{label}.source.baseline must be an array") + baseline_tuples = [] + for index, item in enumerate(baseline): + entry = require_object( + item, + f"{label}.source.baseline[{index}]", + {"component", "version", "commit"}, + ) + baseline_tuples.append((entry["component"], entry["version"], entry["commit"])) + if baseline_tuples != expected["baseline"]: + raise RunnerError(f"{label} does not use the exact reviewed upstream baseline") + operations = source["operations"] + if not isinstance(operations, list): + raise RunnerError(f"{label}.source.operations must be an array") + operation_tuples = [] + for index, item in enumerate(operations): + if not isinstance(item, dict): + raise RunnerError(f"{label}.source.operations[{index}] must be an object") + allowed = { + "id", + "role", + "method", + "path", + "max_calls", + "selector", + "field_projection", + } + required = {"id", "role", "method", "path", "max_calls"} + if set(item) - allowed or required - set(item): + raise RunnerError(f"{label}.source.operations[{index}] has invalid fields") + operation_tuples.append( + (item["id"], item["role"], item["method"], item["path"], item["max_calls"]) + ) + expected_keys = required | ( + {"selector", "field_projection"} + if item["id"] == "tracked-entity-read" + else {"selector"} + if item["id"] == "signed-uin-search" + else set() + ) + if set(item) != expected_keys: + raise RunnerError( + f"{label}.source.operations[{index}] has unexpected optional fields" + ) + if operation_tuples != expected["operations"]: + raise RunnerError(f"{label} does not use the exact reviewed source operations") + data_operation = next(item for item in operations if item["role"] == "data") + if data_operation.get("selector") not in {"exact UIN", "exact tracked entity UID"}: + raise RunnerError( + f"{label} data operation must use the reviewed exact selector" + ) + if ( + profile["profile_id"].startswith("dhis2") + and data_operation.get("field_projection") + != "trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]]" + ): + raise RunnerError(f"{label} does not use the reviewed DHIS2 field projection") + + dynamic = profile["dynamic_inputs"] + if not isinstance(dynamic, list) or not dynamic: + raise RunnerError(f"{label}.dynamic_inputs must be a non-empty list") + env_names = [] + for index, item in enumerate(dynamic): + entry = require_object( + item, + f"{label}.dynamic_inputs[{index}]", + {"env", "classification", "purpose"}, + ) + if ( + entry["classification"] not in {"restricted", "secret", "subject"} + or not isinstance(entry["purpose"], str) + or not entry["purpose"] + ): + raise RunnerError( + f"{label}.dynamic_inputs[{index}] has an invalid classification or purpose" + ) + env_names.append(entry["env"]) + if tuple(env_names) != expected["inputs"]: + raise RunnerError( + f"{label}.dynamic_inputs does not match the reviewed input names" + ) + cases = profile["cases"] + if not isinstance(cases, list): + raise RunnerError(f"{label}.cases must be an array") + for index, case in enumerate(cases): + require_object( + case, + f"{label}.cases[{index}]", + {"id", "expected_result_code", "expected_source_data_access"}, + ) + if [case["id"] for case in cases] != list(CASE_IDS): + raise RunnerError(f"{label}.cases must contain the closed ordered case set") + allowed_access = {"not_contacted", "contacted_once", "not_applicable"} + if any( + case.get("expected_source_data_access") not in allowed_access for case in cases + ): + raise RunnerError( + f"{label}.cases contains an invalid source access expectation" + ) + if ( + tuple(case["expected_source_data_access"] for case in cases) + != expected["case_access"] + ): + raise RunnerError( + f"{label}.cases does not match the reviewed source access contract" + ) + if ( + tuple(case["expected_result_code"] for case in cases) + != expected["result_codes"] + ): + raise RunnerError( + f"{label}.cases does not match the reviewed safe result codes" + ) + prerequisites = profile["prerequisites"] + if ( + not isinstance(prerequisites, list) + or len(prerequisites) < 5 + or any(not isinstance(item, str) or not item for item in prerequisites) + ): + raise RunnerError( + f"{label}.prerequisites must make external ownership explicit" + ) + limits = require_object( + profile["limits"], + f"{label}.limits", + { + "case_timeout_seconds", + "run_timeout_seconds", + "raw_evidence_bytes", + "public_result_bytes", + "teardown_timeout_seconds", + }, + ) + if any(not isinstance(item, int) or item <= 0 for item in limits.values()): + raise RunnerError(f"{label}.limits must contain positive integers") + actual_limits = tuple( + limits[name] + for name in ( + "case_timeout_seconds", + "run_timeout_seconds", + "raw_evidence_bytes", + "public_result_bytes", + "teardown_timeout_seconds", + ) + ) + if actual_limits != expected["limits"]: + raise RunnerError( + f"{label}.limits does not match the reviewed bounded contract" + ) + + +def validate_packet() -> None: + schema = load_json(SCHEMA_PATH) + if schema.get("$schema") != "https://json-schema.org/draft/2020-12/schema": + raise RunnerError("run result schema must use JSON Schema draft 2020-12") + if schema.get("additionalProperties") is not False: + raise RunnerError("run result schema must be closed") + assert_closed_schema(schema) + for profile_id in PROFILE_FILES: + load_profile(profile_id) + + +def assert_closed_schema(value: Any, label: str = "schema") -> None: + if isinstance(value, dict): + if ( + value.get("type") == "object" + and value.get("additionalProperties") is not False + ): + raise RunnerError(f"{label} contains an open object schema") + for name, item in value.items(): + assert_closed_schema(item, f"{label}.{name}") + elif isinstance(value, list): + for index, item in enumerate(value): + assert_closed_schema(item, f"{label}[{index}]") + + +def parse_checksums(path: Path) -> dict[str, str]: + require_regular_file(path, max_bytes=1024 * 1024) + checksums: dict[str, str] = {} + for line_number, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), 1 + ): + match = re.fullmatch(r"([0-9a-f]{64}) \*?([^/\x00]+)", line) + if match is None: + raise RunnerError(f"SHA256SUMS line {line_number} has an unsafe format") + digest, name = match.groups() + if name in checksums: + raise RunnerError(f"SHA256SUMS contains duplicate entry {name}") + checksums[name] = digest + return checksums + + +def signed_subject_names(tag: str) -> tuple[str, ...]: + binary = f"registryctl-{tag}-linux-amd64" + image_lock = f"registryctl-{tag}-image-lock.json" + return ( + binary, + image_lock, + f"{image_lock}.spdx.json", + f"registry-stack-{tag}-release-capsule.json", + "registry-relay.digest", + "registry-notary.digest", + ) + + +def required_asset_names(tag: str) -> set[str]: + subjects = signed_subject_names(tag) + return { + *subjects, + *(f"{name}.sig" for name in subjects), + *(f"{name}.pem" for name in subjects), + "SHA256SUMS", + f"registry-stack-{tag}-release-provenance.intoto.jsonl", + } + + +def run_authenticity_command(command: list[str]) -> None: + result = subprocess.run(command, text=True, capture_output=True, check=False) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip().splitlines() + suffix = f": {detail[-1]}" if detail else "" + raise RunnerError(f"authenticity command failed ({command[0]}){suffix}") + + +def verify_authenticity( + asset_dir: Path, + tag: str, + *, + command_runner: Callable[[list[str]], None] = run_authenticity_command, +) -> None: + cosign = shutil.which("cosign") + slsa = shutil.which("slsa-verifier") + if not cosign or not slsa: + missing = [ + name + for name, path in (("cosign", cosign), ("slsa-verifier", slsa)) + if not path + ] + raise RunnerError( + "candidate authenticity verification requires installed " + + " and ".join(missing) + ) + provenance = asset_dir / f"registry-stack-{tag}-release-provenance.intoto.jsonl" + identity = RELEASE_WORKFLOW.format(tag=tag) + for name in signed_subject_names(tag): + subject = asset_dir / name + command_runner( + [ + cosign, + "verify-blob", + str(subject), + "--signature", + str(asset_dir / f"{name}.sig"), + "--certificate", + str(asset_dir / f"{name}.pem"), + "--certificate-oidc-issuer", + "https://token.actions.githubusercontent.com", + "--certificate-identity", + identity, + ] + ) + command_runner( + [ + slsa, + "verify-artifact", + str(subject), + "--provenance-path", + str(provenance), + "--source-uri", + SLSA_SOURCE_URI, + "--source-tag", + tag, + ] + ) + + +def exact_digest_file(path: Path, repository: str) -> str: + require_regular_file(path, max_bytes=1024) + value = path.read_text(encoding="utf-8").strip() + expected = re.compile( + rf"^ghcr\.io/registrystack/{re.escape(repository)}@sha256:[0-9a-f]{{64}}$" + ) + if expected.fullmatch(value) is None: + raise RunnerError( + f"{path.name} must contain one digest-bound {repository} reference" + ) + return value + + +def find_named(items: Any, name: str, label: str) -> dict[str, Any]: + if not isinstance(items, list): + raise RunnerError(f"release capsule {label} must be an array") + matches = [ + item for item in items if isinstance(item, dict) and item.get("name") == name + ] + if len(matches) != 1: + raise RunnerError( + f"release capsule must contain exactly one {label} entry for {name}" + ) + return matches[0] + + +def verify_file_sbom(path: Path, subject_name: str, subject_sha256: str) -> None: + document = load_json(path, max_bytes=16 * 1024 * 1024) + if not isinstance(document, dict): + raise RunnerError(f"{path.name} must be an SPDX JSON object") + described = document.get("documentDescribes") + packages = document.get("packages") + if not isinstance(described, list) or not isinstance(packages, list): + raise RunnerError(f"{path.name} must contain SPDX subjects and packages") + described_ids = {item for item in described if isinstance(item, str)} + for package in packages: + if not isinstance(package, dict) or package.get("SPDXID") not in described_ids: + continue + if ( + package.get("name") != subject_name + and package.get("packageFileName") != subject_name + ): + continue + checksums = package.get("checksums") + if isinstance(checksums, list) and any( + isinstance(item, dict) + and item.get("algorithm") == "SHA256" + and item.get("checksumValue") == subject_sha256 + for item in checksums + ): + return + raise RunnerError( + f"{path.name} does not describe {subject_name} at its actual SHA-256" + ) + + +def verify_candidate_assets( + asset_dir: Path, + tag: str, + *, + authenticate: Callable[[Path, str], None] = verify_authenticity, + binary_runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, +) -> dict[str, str]: + tag_match = TAG.fullmatch(tag) + if tag_match is None: + raise RunnerError("candidate tag must be a stable vMAJOR.MINOR.PATCH tag") + if not asset_dir.is_dir() or asset_dir.is_symlink(): + raise RunnerError("candidate asset directory must be a real directory") + required = required_asset_names(tag) + actual = {path.name for path in asset_dir.iterdir()} + missing = required - actual + unknown = actual - required + if missing or unknown: + details = [] + if missing: + details.append("missing " + ", ".join(sorted(missing))) + if unknown: + details.append("unexpected " + ", ".join(sorted(unknown))) + raise RunnerError("candidate asset set is not closed: " + "; ".join(details)) + for name in sorted(required): + require_regular_file(asset_dir / name, max_bytes=MAX_ASSET_BYTES) + + binary_name = f"registryctl-{tag}-linux-amd64" + image_lock_name = f"registryctl-{tag}-image-lock.json" + capsule_name = f"registry-stack-{tag}-release-capsule.json" + checksums = parse_checksums(asset_dir / "SHA256SUMS") + for name in (binary_name, image_lock_name): + if checksums.get(name) != sha256(asset_dir / name): + raise RunnerError(f"{name} does not match its SHA256SUMS entry") + + binary = asset_dir / binary_name + binary.chmod(binary.stat().st_mode | stat.S_IXUSR) + version_result = binary_runner( + [str(binary), "--version"], + text=True, + capture_output=True, + check=False, + timeout=10, + ) + if ( + version_result.returncode != 0 + or version_result.stdout.strip() != f"registryctl {tag_match.group(1)}" + ): + raise RunnerError( + f"{binary_name} does not self-report registryctl {tag_match.group(1)}" + ) + + lock = require_object( + load_json(asset_dir / image_lock_name), + image_lock_name, + { + "schema_version", + "release_tag", + "manifest_source_ref", + "tag_target", + "platform", + "images", + }, + ) + if ( + lock["schema_version"] != "registryctl.release_image_lock.v1" + or lock["release_tag"] != tag + ): + raise RunnerError("candidate image lock has the wrong schema or release tag") + if lock["platform"] != "linux/amd64": + raise RunnerError("candidate image lock must target linux/amd64") + if not COMMIT.fullmatch(str(lock["manifest_source_ref"])) or not COMMIT.fullmatch( + str(lock["tag_target"]) + ): + raise RunnerError("candidate image lock source refs must be full commits") + images = require_object( + lock["images"], "image lock images", {"registry-relay", "registry-notary"} + ) + relay = exact_digest_file(asset_dir / "registry-relay.digest", "registry-relay") + notary = exact_digest_file(asset_dir / "registry-notary.digest", "registry-notary") + if images != {"registry-relay": relay, "registry-notary": notary}: + raise RunnerError("candidate digest files do not match the image lock") + + capsule = load_json(asset_dir / capsule_name, max_bytes=8 * 1024 * 1024) + if not isinstance(capsule, dict): + raise RunnerError("release capsule must be an object") + if capsule.get("release_tag") != tag or capsule.get("version") != tag_match.group( + 1 + ): + raise RunnerError("release capsule identity does not match the candidate tag") + if capsule.get("repository") != CAPSULE_REPOSITORY: + raise RunnerError(f"release capsule repository must be {CAPSULE_REPOSITORY}") + source = capsule.get("source") + if ( + not isinstance(source, dict) + or source.get("source_tag") != tag + or source.get("source_ref") != lock["manifest_source_ref"] + or source.get("source_commit") != lock["tag_target"] + ): + raise RunnerError( + "release capsule source lineage does not match the image lock" + ) + lineage = source.get("lineage") + expected_lineage_keys = { + "tag_matches_source_tag", + "head_matches_tag_target", + "source_ref_ancestor_or_equal", + "default_branch_reachable", + } + if ( + not isinstance(lineage, dict) + or set(lineage) != expected_lineage_keys + or any(value is not True for value in lineage.values()) + ): + raise RunnerError("release capsule does not prove every source lineage check") + binary_entry = find_named(capsule.get("binaries"), binary_name, "binaries") + lock_entry = find_named( + capsule.get("release_files"), image_lock_name, "release_files" + ) + if binary_entry.get("sha256") != sha256(binary): + raise RunnerError( + "release capsule binary hash does not match the candidate asset" + ) + if lock_entry.get("kind") != "registryctl-release-image-lock" or lock_entry.get( + "sha256" + ) != sha256(asset_dir / image_lock_name): + raise RunnerError( + "release capsule image-lock classification or hash is invalid" + ) + lock_sbom_name = f"{image_lock_name}.spdx.json" + lock_sbom = asset_dir / lock_sbom_name + verify_file_sbom(lock_sbom, image_lock_name, sha256(asset_dir / image_lock_name)) + expected_sbom = { + "asset_name": lock_sbom_name, + "subject": image_lock_name, + "format": "spdx-json", + "sha256": sha256(lock_sbom), + } + if lock_entry.get("sbom") != expected_sbom: + raise RunnerError("release capsule image-lock SBOM binding is invalid") + capsule_image_items = capsule.get("images") + if not isinstance(capsule_image_items, list): + raise RunnerError("release capsule images must be an array") + capsule_image_names = [ + item.get("name") for item in capsule_image_items if isinstance(item, dict) + ] + if len(capsule_image_names) != 2 or set(capsule_image_names) != { + "registry-relay", + "registry-notary", + }: + raise RunnerError("release capsule must contain exactly the two product images") + relay_entry = find_named(capsule_image_items, "registry-relay", "images") + notary_entry = find_named(capsule_image_items, "registry-notary", "images") + if ( + relay_entry.get("digest_ref") != relay + or notary_entry.get("digest_ref") != notary + ): + raise RunnerError( + "release capsule images do not match the candidate digest files" + ) + + authenticate(asset_dir, tag) + return { + "tag": tag, + "version": tag_match.group(1), + "source_commit": lock["tag_target"], + "registryctl_asset_sha256": f"sha256:{sha256(binary)}", + "image_lock_sha256": f"sha256:{sha256(asset_dir / image_lock_name)}", + "release_capsule_sha256": f"sha256:{sha256(asset_dir / capsule_name)}", + "relay_image": relay, + "notary_image": notary, + } + + +def resolve_ref(schema: dict[str, Any], reference: str) -> dict[str, Any]: + if not reference.startswith("#/"): + raise RunnerError(f"unsupported external schema reference: {reference}") + value: Any = schema + for component in reference[2:].split("/"): + value = value[component] + if not isinstance(value, dict): + raise RunnerError( + f"schema reference does not resolve to an object: {reference}" + ) + return value + + +def validate_against_schema( + value: Any, rule: dict[str, Any], schema: dict[str, Any], label: str = "result" +) -> None: + if "$ref" in rule: + validate_against_schema(value, resolve_ref(schema, rule["$ref"]), schema, label) + return + if "const" in rule and value != rule["const"]: + raise RunnerError(f"{label} must equal {rule['const']!r}") + if "enum" in rule and value not in rule["enum"]: + raise RunnerError(f"{label} is outside the closed allowed set") + kind = rule.get("type") + if kind == "object": + if not isinstance(value, dict): + raise RunnerError(f"{label} must be an object") + required = set(rule.get("required", [])) + missing = required - set(value) + if missing: + raise RunnerError(f"{label} is missing {', '.join(sorted(missing))}") + properties = rule.get("properties", {}) + if rule.get("additionalProperties") is False: + unknown = set(value) - set(properties) + if unknown: + raise RunnerError( + f"{label} has unknown fields: {', '.join(sorted(unknown))}" + ) + for name, item in value.items(): + if name in properties: + validate_against_schema( + item, properties[name], schema, f"{label}.{name}" + ) + elif kind == "array": + if not isinstance(value, list): + raise RunnerError(f"{label} must be an array") + if len(value) < rule.get("minItems", 0) or len(value) > rule.get( + "maxItems", sys.maxsize + ): + raise RunnerError(f"{label} has an invalid item count") + if rule.get("uniqueItems") and len( + {json.dumps(item, sort_keys=True) for item in value} + ) != len(value): + raise RunnerError(f"{label} must contain unique values") + for index, item in enumerate(value): + validate_against_schema( + item, rule.get("items", {}), schema, f"{label}[{index}]" + ) + elif kind == "string": + if not isinstance(value, str): + raise RunnerError(f"{label} must be a string") + if "pattern" in rule and re.fullmatch(rule["pattern"], value) is None: + raise RunnerError(f"{label} has an invalid or unsafe value") + elif kind == "integer": + if not isinstance(value, int) or isinstance(value, bool): + raise RunnerError(f"{label} must be an integer") + if value < rule.get("minimum", value) or value > rule.get("maximum", value): + raise RunnerError(f"{label} is outside its allowed range") + + +def read_canaries(path: Path) -> list[bytes]: + require_regular_file(path, max_bytes=64 * 1024) + mode = path.stat().st_mode + if mode & (stat.S_IRWXG | stat.S_IRWXO): + raise RunnerError("canary file must not grant group or other permissions") + canaries = [] + for line_number, line in enumerate(path.read_bytes().splitlines(), 1): + if SAFE_CANARY.fullmatch(line) is None: + raise RunnerError( + f"canary file line {line_number} must contain 8 to 128 safe ASCII bytes" + ) + canaries.append(line) + if not canaries or len(canaries) > 128 or len(set(canaries)) != len(canaries): + raise RunnerError("canary file must contain 1 to 128 unique values") + return canaries + + +def parse_timestamp(value: str) -> dt.datetime: + return dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=dt.timezone.utc + ) + + +def validate_result( + path: Path, profile: dict[str, Any], canary_file: Path +) -> dict[str, Any]: + limit = profile["limits"]["public_result_bytes"] + require_regular_file(path, max_bytes=limit) + public_bytes = path.read_bytes() + canaries = read_canaries(canary_file) + if any(canary in public_bytes for canary in canaries): + raise RunnerError("public result contains a seeded restricted-value canary") + result = load_json(path, max_bytes=limit) + schema = load_json(SCHEMA_PATH) + validate_against_schema(result, schema, schema) + if result["profile_id"] != profile["profile_id"]: + raise RunnerError( + "public result profile_id does not match the selected profile" + ) + + data_operation = next( + item for item in profile["source"]["operations"] if item["role"] == "data" + ) + expected_source = { + "product": profile["source"]["product"], + "baseline": profile["source"]["baseline"], + "operation_id": data_operation["id"], + "method": data_operation["method"], + "path": data_operation["path"], + } + for key, expected in expected_source.items(): + if result["source"][key] != expected: + raise RunnerError( + f"public result source.{key} does not match the pinned profile" + ) + if result["project"]["starter"] != profile["starter"]: + raise RunnerError("public result starter does not match the pinned profile") + if set(result["limitations"]) != REQUIRED_LIMITATIONS: + raise RunnerError("public result must retain every profile limitation") + + cases = result["cases"] + if [case["case_id"] for case in cases] != list(CASE_IDS): + raise RunnerError("public result cases must use the closed ordered case set") + expectations = {item["id"]: item for item in profile["cases"]} + all_passed = True + run_started = parse_timestamp(result["started_at"]) + run_completed = parse_timestamp(result["completed_at"]) + latest_case_completion = run_started + for case in cases: + expectation = expectations[case["case_id"]] + expected_access = expectation["expected_source_data_access"] + if ( + case["outcome"] == "passed" + and case["source_data_access"] != expected_access + ): + raise RunnerError( + f"{case['case_id']} passed without expected source-side access evidence {expected_access}" + ) + if ( + case["outcome"] in {"passed", "not_applicable"} + and case["result_code"] != expectation["expected_result_code"] + ): + raise RunnerError( + f"{case['case_id']} does not use the reviewed safe result code" + ) + if expected_access == "not_applicable": + if ( + case["outcome"] != "not_applicable" + or case["source_data_access"] != "not_applicable" + ): + raise RunnerError( + f"{case['case_id']} must record the profile's not-applicable proof" + ) + elif case["outcome"] != "passed": + all_passed = False + if case["duration_ms"] > profile["limits"]["case_timeout_seconds"] * 1000: + raise RunnerError(f"{case['case_id']} exceeds the profile case timeout") + case_started = parse_timestamp(case["started_at"]) + case_completed = parse_timestamp(case["completed_at"]) + if case_completed < case_started: + raise RunnerError(f"{case['case_id']} completes before it starts") + if case_started < run_started or case_completed > run_completed: + raise RunnerError(f"{case['case_id']} falls outside the recorded run") + latest_case_completion = max(latest_case_completion, case_completed) + if run_completed < run_started: + raise RunnerError("public result completes before it starts") + run_ms = (run_completed - run_started).total_seconds() * 1000 + if run_ms > profile["limits"]["run_timeout_seconds"] * 1000: + raise RunnerError("public result exceeds the profile run timeout") + if result["redaction"]["seeded_canaries"] != len(canaries): + raise RunnerError( + "public result seeded_canaries does not match the protected canary file" + ) + if result["redaction"]["scanned_bytes"] < len(public_bytes): + raise RunnerError( + "redaction scan byte count does not include the public result" + ) + if ( + result["redaction"]["restricted_raw_evidence_bytes"] + > profile["limits"]["raw_evidence_bytes"] + ): + raise RunnerError("restricted raw evidence exceeds the profile byte limit") + if ( + result["teardown"]["duration_ms"] + > profile["limits"]["teardown_timeout_seconds"] * 1000 + ): + raise RunnerError("teardown exceeds the profile timeout") + teardown_completed = parse_timestamp(result["teardown"]["completed_at"]) + if teardown_completed < latest_case_completion: + raise RunnerError("teardown completion precedes a recorded test case") + if teardown_completed > run_completed: + raise RunnerError("public result completion must include the teardown attempt") + complete = all_passed and result["teardown"]["status"] == "completed" + if (result["status"] == "passed") != complete: + raise RunnerError( + "public result status is inconsistent with cases and teardown" + ) + return result + + +def plan_document(profile: dict[str, Any]) -> dict[str, Any]: + return { + "schema_version": "registry.release.integration_e2_plan.v1", + "profile_id": profile["profile_id"], + "support_status": profile["support_status"], + "candidate_evidence": False, + "status": "planned_not_executed", + "starter": profile["starter"], + "pinned_source": profile["source"]["baseline"], + "source_operations": profile["source"]["operations"], + "authored_contract": profile["authored_contract"], + "required_input_names": [item["env"] for item in profile["dynamic_inputs"]], + "cases": profile["cases"], + "prerequisites": profile["prerequisites"], + "limits": profile["limits"], + "stages": [ + "Verify the closed candidate asset set, checksums, signatures, provenance, source lineage, and digest locks.", + "Initialize the pinned starter with that candidate registryctl binary.", + "Apply only the profile's reviewed authored inputs; never edit generated YAML.", + "Run the offline project test, check, build, and generated-file hash review.", + "Deploy one digest-pinned Relay, Notary, and PostgreSQL set per authority within the approved run timeout.", + "Probe source-side audit or request counters before and after every closed test case.", + "Capture bounded restricted evidence and emit only hashes, timings, safe codes, and source-contact classifications.", + "Scan the public result for seeded canaries and forbidden values.", + "Re-hash generated project outputs and reject hand edits.", + "Attempt scoped teardown in a finally path and record its sanitized evidence hash.", + ], + } + + +def print_plan(profile: dict[str, Any], *, as_json: bool) -> None: + plan = plan_document(profile) + if as_json: + print(json.dumps(plan, indent=2, sort_keys=True)) + return + print(f"{plan['profile_id']}: {plan['support_status']}") + print("Status: planned, not executed; this is not candidate evidence.") + print("Prerequisites:") + for item in plan["prerequisites"]: + print(f" - {item}") + print("Stages:") + for index, item in enumerate(plan["stages"], 1): + print(f" {index}. {item}") + + +def parser() -> argparse.ArgumentParser: + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--profile", choices=sorted(PROFILE_FILES), required=True) + root = argparse.ArgumentParser(description=__doc__) + commands = root.add_subparsers(dest="command", required=True) + plan = commands.add_parser( + "plan", parents=[common], help="show prerequisites and bounded stages" + ) + plan.add_argument("--json", action="store_true") + commands.add_parser( + "dry-run", + parents=[common], + help="emit the non-evidence orchestration plan as JSON", + ) + validate = commands.add_parser( + "validate", help="validate the source packet and optional real evidence" + ) + validate.add_argument("--profile", choices=sorted(PROFILE_FILES)) + validate.add_argument("--candidate-dir", type=Path) + validate.add_argument("--tag") + validate.add_argument("--result", type=Path) + validate.add_argument("--canary-file", type=Path) + return root + + +def main(argv: list[str] | None = None) -> int: + args = parser().parse_args(argv) + try: + validate_packet() + if args.command == "plan": + print_plan(load_profile(args.profile), as_json=args.json) + elif args.command == "dry-run": + print_plan(load_profile(args.profile), as_json=True) + else: + candidate_requested = args.candidate_dir is not None or args.tag is not None + if candidate_requested and (args.candidate_dir is None or args.tag is None): + raise RunnerError( + "candidate validation requires --candidate-dir and --tag together" + ) + result_requested = args.result is not None or args.canary_file is not None + if result_requested and not candidate_requested: + raise RunnerError( + "public result validation also requires --candidate-dir and --tag" + ) + if result_requested and ( + args.profile is None or args.result is None or args.canary_file is None + ): + raise RunnerError( + "result validation requires --profile, --result, and --canary-file together" + ) + candidate = None + result = None + if candidate_requested: + candidate = verify_candidate_assets( + args.candidate_dir.resolve(), args.tag + ) + if result_requested: + profile = load_profile(args.profile) + result = validate_result( + args.result.resolve(), profile, args.canary_file.resolve() + ) + elif args.profile is not None: + load_profile(args.profile) + if candidate is not None and result is not None: + expected_release = { + **candidate, + "candidate_assets_verified": True, + "authenticity_verified": True, + } + if result["release"] != expected_release: + raise RunnerError( + "public result release identity does not match verified candidate assets" + ) + if candidate is not None and result is not None: + print("integration E2 candidate result validation passed") + elif candidate is not None: + print("integration E2 candidate asset validation passed") + elif args.profile is not None: + print(f"integration E2 profile validation passed: {args.profile}") + else: + print("integration E2 source packet validation passed") + except (RunnerError, OSError, subprocess.SubprocessError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index 0e95bdf80..4b36e5c54 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -58,6 +58,26 @@ def test_missing_openid_conformance_runner_tests_are_reported(self) -> None: "OpenID conformance runner tests", self.module.missing_gates(text) ) + def test_missing_external_integration_runner_tests_are_reported(self) -> None: + text = self.workflow.replace( + "python3 -m unittest release/scripts/test_integration_e2_runner.py", + "python3 release/scripts/integration-e2-runner.py dry-run", + ) + self.assertIn( + "External integration evidence runner tests", + self.module.missing_gates(text), + ) + + def test_missing_external_integration_packet_validation_is_reported(self) -> None: + text = self.workflow.replace( + "python3 release/scripts/integration-e2-runner.py validate", + "python3 release/scripts/integration-e2-runner.py plan", + ) + self.assertIn( + "External integration evidence packet", + self.module.missing_gates(text), + ) + def test_missing_stable_surface_gate_is_reported(self) -> None: text = self.workflow.replace( "run: python3 release/scripts/check-stable-surface-compatibility.py", diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py new file mode 100644 index 000000000..cf4c14c05 --- /dev/null +++ b/release/scripts/test_integration_e2_runner.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "release" / "scripts" / "integration-e2-runner.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("integration_e2_runner", SCRIPT) + if spec is None or spec.loader is None: + raise ImportError(f"could not load module spec from {SCRIPT}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class IntegrationE2RunnerTest(unittest.TestCase): + def setUp(self) -> None: + self.module = load_module() + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.tag = "v1.0.0" + self.commit = "1" * 40 + self.relay = "ghcr.io/registrystack/registry-relay@sha256:" + "2" * 64 + self.notary = "ghcr.io/registrystack/registry-notary@sha256:" + "3" * 64 + + def tearDown(self) -> None: + self.temporary.cleanup() + + @staticmethod + def write_json(path: Path, value: object) -> None: + path.write_text(json.dumps(value, sort_keys=True) + "\n", encoding="utf-8") + + def make_candidate(self) -> Path: + candidate = self.root / "candidate" + candidate.mkdir() + binary_name = f"registryctl-{self.tag}-linux-amd64" + lock_name = f"registryctl-{self.tag}-image-lock.json" + capsule_name = f"registry-stack-{self.tag}-release-capsule.json" + (candidate / binary_name).write_bytes(b"candidate-registryctl") + lock = { + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": self.tag, + "manifest_source_ref": "4" * 40, + "tag_target": self.commit, + "platform": "linux/amd64", + "images": { + "registry-relay": self.relay, + "registry-notary": self.notary, + }, + } + self.write_json(candidate / lock_name, lock) + lock_sha = hashlib.sha256((candidate / lock_name).read_bytes()).hexdigest() + lock_sbom_name = f"{lock_name}.spdx.json" + lock_subject_id = "SPDXRef-lock-subject" + self.write_json( + candidate / lock_sbom_name, + { + "documentDescribes": [lock_subject_id], + "packages": [ + { + "SPDXID": lock_subject_id, + "name": lock_name, + "packageFileName": lock_name, + "checksums": [ + {"algorithm": "SHA256", "checksumValue": lock_sha} + ], + } + ], + }, + ) + (candidate / "registry-relay.digest").write_text( + self.relay + "\n", encoding="utf-8" + ) + (candidate / "registry-notary.digest").write_text( + self.notary + "\n", encoding="utf-8" + ) + capsule = { + "release_tag": self.tag, + "version": "1.0.0", + "repository": self.module.CAPSULE_REPOSITORY, + "source": { + "source_tag": self.tag, + "source_ref": "4" * 40, + "source_commit": self.commit, + "lineage": { + "tag_matches_source_tag": True, + "head_matches_tag_target": True, + "source_ref_ancestor_or_equal": True, + "default_branch_reachable": True, + }, + }, + "binaries": [ + { + "name": binary_name, + "sha256": hashlib.sha256( + (candidate / binary_name).read_bytes() + ).hexdigest(), + } + ], + "release_files": [ + { + "name": lock_name, + "kind": "registryctl-release-image-lock", + "sha256": lock_sha, + "sbom": { + "asset_name": lock_sbom_name, + "subject": lock_name, + "format": "spdx-json", + "sha256": hashlib.sha256( + (candidate / lock_sbom_name).read_bytes() + ).hexdigest(), + }, + } + ], + "images": [ + {"name": "registry-relay", "digest_ref": self.relay}, + {"name": "registry-notary", "digest_ref": self.notary}, + ], + } + self.write_json(candidate / capsule_name, capsule) + binary_sha = hashlib.sha256((candidate / binary_name).read_bytes()).hexdigest() + (candidate / "SHA256SUMS").write_text( + f"{binary_sha} {binary_name}\n{lock_sha} {lock_name}\n", + encoding="utf-8", + ) + for name in self.module.signed_subject_names(self.tag): + self.assertTrue((candidate / name).exists()) + (candidate / f"{name}.sig").write_text( + "fixture signature\n", encoding="utf-8" + ) + (candidate / f"{name}.pem").write_text( + "fixture certificate\n", encoding="utf-8" + ) + ( + candidate / f"registry-stack-{self.tag}-release-provenance.intoto.jsonl" + ).write_text("fixture provenance\n", encoding="utf-8") + self.assertEqual( + self.module.required_asset_names(self.tag), + {path.name for path in candidate.iterdir()}, + ) + return candidate + + @staticmethod + def binary_result(*_args, **_kwargs): + return subprocess.CompletedProcess([], 0, "registryctl 1.0.0\n", "") + + def candidate_metadata(self, candidate: Path) -> dict[str, str]: + return self.module.verify_candidate_assets( + candidate, + self.tag, + authenticate=lambda _directory, _tag: None, + binary_runner=self.binary_result, + ) + + def make_canary_file(self) -> Path: + path = self.root / "canaries" + path.write_text("registry-secret-canary-72\n", encoding="utf-8") + path.chmod(0o600) + return path + + def make_result(self, profile_id: str, candidate: Path) -> dict[str, object]: + profile = self.module.load_profile(profile_id) + operation = next( + item for item in profile["source"]["operations"] if item["role"] == "data" + ) + hash_value = "sha256:" + "a" * 64 + cases = [] + for expected in profile["cases"]: + not_applicable = expected["expected_source_data_access"] == "not_applicable" + cases.append( + { + "case_id": expected["id"], + "outcome": "not_applicable" if not_applicable else "passed", + "started_at": "2026-07-19T01:00:00Z", + "completed_at": "2026-07-19T01:00:01Z", + "duration_ms": 1000, + "result_code": expected["expected_result_code"], + "source_data_access": expected["expected_source_data_access"], + "source_data_access_evidence_sha256": hash_value, + "audit_correlation_sha256": hash_value, + "evidence_sha256": hash_value, + } + ) + candidate_metadata = self.candidate_metadata(candidate) + return { + "schema_version": self.module.RESULT_SCHEMA, + "record_kind": "candidate_evidence", + "run_id": "candidate-1", + "profile_id": profile_id, + "support_status": self.module.SUPPORT_STATUS, + "status": "passed", + "started_at": "2026-07-19T01:00:00Z", + "completed_at": "2026-07-19T01:03:00Z", + "release": { + **candidate_metadata, + "candidate_assets_verified": True, + "authenticity_verified": True, + }, + "source": { + "product": profile["source"]["product"], + "baseline": profile["source"]["baseline"], + "operation_id": operation["id"], + "method": operation["method"], + "path": operation["path"], + "owner_attestation_sha256": hash_value, + }, + "project": { + "starter": profile["starter"], + "starter_content_digest": hash_value, + "authored_inputs_sha256": hash_value, + "build_review_sha256": hash_value, + "relay_closure_sha256": hash_value, + "notary_closure_sha256": hash_value, + "generated_files_unchanged": True, + }, + "cases": cases, + "redaction": { + "passed": True, + "scanned_artifacts": 1, + "scanned_bytes": 65536, + "seeded_canaries": 1, + "forbidden_values_found": 0, + "restricted_raw_evidence_bytes": 4096, + "raw_evidence_retained_restricted": True, + "report_sha256": hash_value, + }, + "teardown": { + "attempted": True, + "status": "completed", + "duration_ms": 1000, + "completed_at": "2026-07-19T01:02:00Z", + "evidence_sha256": hash_value, + }, + "limitations": [ + "unofficial-integration-profile", + "single-pinned-product-version", + "single-reviewed-read-operation", + "non-production-instance", + "not-product-certification", + "not-general-country-system-conformance", + ], + } + + def write_result(self, result: dict[str, object]) -> Path: + path = self.root / "public-result.json" + self.write_json(path, result) + return path + + def test_checked_in_packet_is_closed_and_valid(self) -> None: + self.module.validate_packet() + + def test_nested_result_objects_must_remain_closed(self) -> None: + schema = self.module.load_json(self.module.SCHEMA_PATH) + schema["$defs"]["case"]["additionalProperties"] = True + with self.assertRaisesRegex(self.module.RunnerError, "open object schema"): + self.module.assert_closed_schema(schema) + + def test_profile_rejects_a_drifted_upstream_pin(self) -> None: + profile = self.module.load_profile("opencrvs-dci-v1.9") + profile["source"]["baseline"][0]["commit"] = "0" * 40 + with self.assertRaisesRegex( + self.module.RunnerError, "exact reviewed upstream baseline" + ): + self.module.validate_profile(profile, "tampered profile") + + def test_dry_run_is_explicitly_not_evidence_and_hides_values(self) -> None: + profile = self.module.load_profile("opencrvs-dci-v1.9") + plan = self.module.plan_document(profile) + self.assertFalse(plan["candidate_evidence"]) + self.assertEqual("planned_not_executed", plan["status"]) + self.assertIn("OPENCRVS_CLIENT_SECRET", plan["required_input_names"]) + self.assertEqual( + self.module.CASE_IDS, tuple(case["id"] for case in plan["cases"]) + ) + self.assertIn( + "/registry/sync/search", + [operation["path"] for operation in plan["source_operations"]], + ) + serialized = json.dumps(plan) + self.assertNotIn("secret value", serialized) + self.assertTrue( + any("compatibility probe" in item for item in plan["prerequisites"]) + ) + + def test_candidate_assets_cross_validate_all_release_bindings(self) -> None: + candidate = self.make_candidate() + metadata = self.candidate_metadata(candidate) + self.assertEqual(self.relay, metadata["relay_image"]) + self.assertEqual(self.commit, metadata["source_commit"]) + + def test_candidate_rejects_an_unexpected_asset(self) -> None: + candidate = self.make_candidate() + (candidate / "unreviewed-output.txt").write_text("no\n", encoding="utf-8") + with self.assertRaisesRegex(self.module.RunnerError, "asset set is not closed"): + self.candidate_metadata(candidate) + + def test_candidate_rejects_digest_not_bound_by_image_lock(self) -> None: + candidate = self.make_candidate() + (candidate / "registry-relay.digest").write_text( + "ghcr.io/registrystack/registry-relay@sha256:" + "9" * 64 + "\n", + encoding="utf-8", + ) + with self.assertRaisesRegex( + self.module.RunnerError, "do not match the image lock" + ): + self.candidate_metadata(candidate) + + def test_candidate_rejects_an_image_lock_sbom_for_the_wrong_subject(self) -> None: + candidate = self.make_candidate() + sbom = candidate / f"registryctl-{self.tag}-image-lock.json.spdx.json" + document = json.loads(sbom.read_text(encoding="utf-8")) + document["packages"][0]["checksums"][0]["checksumValue"] = "0" * 64 + self.write_json(sbom, document) + with self.assertRaisesRegex(self.module.RunnerError, "does not describe"): + self.candidate_metadata(candidate) + + def test_candidate_authenticity_cannot_silently_skip_missing_tools(self) -> None: + candidate = self.make_candidate() + with mock.patch.object(self.module.shutil, "which", return_value=None): + with self.assertRaisesRegex(self.module.RunnerError, "requires installed"): + self.module.verify_authenticity(candidate, self.tag) + + def test_candidate_authenticity_binds_every_subject_to_tagged_workflow( + self, + ) -> None: + candidate = self.make_candidate() + commands = [] + with mock.patch.object( + self.module.shutil, + "which", + side_effect=["/tools/cosign", "/tools/slsa-verifier"], + ): + self.module.verify_authenticity( + candidate, self.tag, command_runner=commands.append + ) + self.assertEqual(12, len(commands)) + cosign_commands = [ + command for command in commands if command[0].endswith("cosign") + ] + self.assertEqual(6, len(cosign_commands)) + identity = self.module.RELEASE_WORKFLOW.format(tag=self.tag) + self.assertTrue( + all( + command[command.index("--certificate-identity") + 1] == identity + for command in cosign_commands + ) + ) + + def test_valid_opencrvs_result_passes(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + path = self.write_result(result) + validated = self.module.validate_result( + path, self.module.load_profile("opencrvs-dci-v1.9"), self.make_canary_file() + ) + self.assertEqual("passed", validated["status"]) + + def test_valid_dhis2_result_records_singleton_ambiguity_as_not_applicable( + self, + ) -> None: + candidate = self.make_candidate() + result = self.make_result("dhis2-tracker-2.41.9", candidate) + path = self.write_result(result) + self.module.validate_result( + path, + self.module.load_profile("dhis2-tracker-2.41.9"), + self.make_canary_file(), + ) + + def test_pre_source_denial_cannot_pass_after_source_contact(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + invalid_selector = next( + case for case in result["cases"] if case["case_id"] == "invalid-selector" + ) + invalid_selector["source_data_access"] = "contacted_once" + with self.assertRaisesRegex( + self.module.RunnerError, "expected source-side access evidence" + ): + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + + def test_passed_case_requires_the_reviewed_safe_result_code(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + result["cases"][0]["result_code"] = "unexpected-result" + with self.assertRaisesRegex( + self.module.RunnerError, "reviewed safe result code" + ): + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + + def test_unknown_public_raw_evidence_field_is_rejected(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + result["raw_output"] = "must never be public" + with self.assertRaisesRegex(self.module.RunnerError, "unknown fields"): + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + + def test_seeded_canary_in_public_result_is_rejected_before_schema_validation( + self, + ) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + result["run_id"] = "registry-secret-canary-72" + with self.assertRaisesRegex( + self.module.RunnerError, "seeded restricted-value canary" + ): + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + + def test_canary_file_must_be_owner_only(self) -> None: + canaries = self.make_canary_file() + canaries.chmod(0o644) + with self.assertRaisesRegex(self.module.RunnerError, "group or other"): + self.module.read_canaries(canaries) + + def test_failed_run_is_accepted_as_honest_non_closing_evidence(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + result["status"] = "failed" + result["cases"][0]["outcome"] = "failed" + result["cases"][0]["source_data_access"] = "unknown" + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + + def test_passed_status_requires_successful_teardown(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + result["teardown"]["status"] = "failed" + with self.assertRaisesRegex(self.module.RunnerError, "status is inconsistent"): + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + + def test_public_result_must_retain_all_claim_limitations(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + result["limitations"].remove("non-production-instance") + with self.assertRaisesRegex( + self.module.RunnerError, "every profile limitation" + ): + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + + +if __name__ == "__main__": + unittest.main() From a218faba5d434f1dce2737edd433936c4e390d5f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 20:08:18 +0700 Subject: [PATCH 47/65] fix(release): harden integration evidence validation Signed-off-by: Jeremi Joslin --- release/conformance/integrations/README.md | 55 +++-- .../schema/run-result.schema.json | 15 +- release/scripts/integration-e2-runner.py | 151 ++++++++---- release/scripts/test_integration_e2_runner.py | 215 ++++++++++++++++++ 4 files changed, 371 insertions(+), 65 deletions(-) diff --git a/release/conformance/integrations/README.md b/release/conformance/integrations/README.md index d34a7294d..efc1a909d 100644 --- a/release/conformance/integrations/README.md +++ b/release/conformance/integrations/README.md @@ -65,14 +65,17 @@ the profile limits: 1. Download the candidate assets listed in `release/VERIFY.md` into a fresh, dedicated directory. -2. Validate checksums, signatures, provenance, capsule lineage, image locks, - digest files, and the candidate binary's self-reported version. +2. Use the public runner to validate checksums, signatures, provenance, + capsule lineage, image locks, and digest files. The runner executes the + candidate binary for its self-reported version only after every passive and + authenticity check passes. 3. Initialize the profile's starter with the verified candidate `registryctl` binary. 4. Apply only the reviewed authored changes listed in the selected profile. Do not edit generated YAML. -5. Run the offline project `test`, `check`, and `build` commands. Record hashes - of authored inputs, the build review, and both generated closures. +5. Run the offline project `test`, `check`, and `build` commands. Inspect the + generated project, then record hashes of authored inputs, the build review, + and both generated closures. 6. Deploy one candidate-digest Registry Relay, Registry Notary, and PostgreSQL set per authority. 7. Query the approved source-side probe before and after every closed test @@ -80,10 +83,13 @@ the profile limits: 8. Retain raw evidence only in the approved restricted location. Publish only safe result codes, timings, contact classifications, correlation hashes, and evidence hashes. -9. Seed restricted-value canaries, scan the public artifact, and reject any - match. Re-hash generated files to prove they were not edited after build. +9. Seed restricted-value canaries. Scan restricted evidence before producing + the public artifact, then re-hash generated files and compare them with the + reviewed build hashes. The public runner separately scans the supplied + public result for the same canaries. 10. Attempt scoped teardown from a `finally` path, even after a failed case. - Record the bounded duration, outcome, and sanitized evidence hash. + Record its start and completion times, bounded duration, outcome, and + sanitized evidence hash. `source_data_access` counts only the profile's reviewed data operation. For OpenCRVS, OAuth or JSON Web Key Set (JWKS) traffic does not count as a @@ -91,7 +97,9 @@ OpenCRVS, OAuth or JSON Web Key Set (JWKS) traffic does not count as a that supporting traffic. The operator wrapper owns product credentials, network access, deployment -details, source probe invocation, restricted storage, and cleanup. Those +details, project inspection, source probe invocation, restricted evidence +inspection and storage, generated-file comparisons, and cleanup. A maintainer +must compare the public hashes and flags with that restricted evidence. Those instance-specific operations are intentionally not embedded in this public runner. @@ -121,19 +129,28 @@ python3 release/scripts/integration-e2-runner.py validate \ --canary-file /restricted/run-72.canaries ``` -This validation requires `cosign` and `slsa-verifier`. It rejects missing or -extra candidate assets, symlinks, wrong checksums, invalid signatures or -provenance, capsule and image-lock disagreements, an unbounded result, an -unknown public field, a canary match, a passed case without the required -source-side contact classification, a changed generated project, and failed -or over-time teardown. +This validation requires `cosign` and `slsa-verifier`. The runner independently +rejects missing or extra candidate assets, symlinks, wrong checksums, invalid +signatures or provenance, capsule and image-lock disagreements, an unbounded +result, an unknown public field, a public-result canary match, inconsistent +timestamps or durations, a passed case without the required recorded +source-contact classification, over-time recorded teardown, and a `passed` +status paired with failed recorded teardown. + +The public runner receives neither the generated project nor restricted raw +evidence. It validates the closed flags, hashes, timings, and classifications +in the sanitized result, but it cannot independently re-hash the project, +inspect source-side audit records, or confirm the restricted redaction report. +The operator wrapper performs those checks, and a maintainer reviews their +restricted evidence before treating the result as proof. The result schema is [`schema/run-result.schema.json`](schema/run-result.schema.json). It is closed: raw responses, request bodies, headers, tokens, source identifiers, hostnames, and credentials have no public fields. A failed run can remain as honest -non-closing evidence, but `status: passed` requires every applicable case and -teardown to pass. +non-closing evidence. The validator accepts `status: passed` only when every +applicable case and teardown is recorded as passed; maintainer review still +establishes whether the recorded hashes correspond to the restricted evidence. ## Review the source packet @@ -145,5 +162,7 @@ teardown to pass. only public result shape. Review raw evidence and the owner attestation outside the public repository. -Commit only the sanitized result after a maintainer confirms the candidate -identity, case semantics, redaction report, and teardown status. +Commit only the sanitized result after a maintainer compares its hashes and +flags with the generated project, source audit records, redaction report, and +teardown evidence. Candidate identity is the part the public runner verifies +directly. diff --git a/release/conformance/integrations/schema/run-result.schema.json b/release/conformance/integrations/schema/run-result.schema.json index 8e83bcee3..b47653c85 100644 --- a/release/conformance/integrations/schema/run-result.schema.json +++ b/release/conformance/integrations/schema/run-result.schema.json @@ -78,7 +78,7 @@ }, "timestamp": { "type": "string", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$" }, "image": { "type": "string", @@ -144,6 +144,7 @@ }, "project": { "type": "object", + "description": "Operator-attested project hashes and generated-file comparison. The public validator validates this shape but does not receive the project files.", "additionalProperties": false, "required": [ "starter", @@ -161,7 +162,10 @@ "build_review_sha256": { "$ref": "#/$defs/sha256" }, "relay_closure_sha256": { "$ref": "#/$defs/sha256" }, "notary_closure_sha256": { "$ref": "#/$defs/sha256" }, - "generated_files_unchanged": { "const": true } + "generated_files_unchanged": { + "description": "Operator and maintainer attestation backed by restricted comparison evidence.", + "const": true + } } }, "case": { @@ -210,6 +214,7 @@ }, "redaction": { "type": "object", + "description": "Operator-attested restricted-evidence scan summary. The public runner independently scans only the supplied public result for canaries.", "additionalProperties": false, "required": [ "passed", @@ -238,12 +243,14 @@ }, "teardown": { "type": "object", + "description": "Operator-attested scoped teardown result with validator-cross-checked public timing fields.", "additionalProperties": false, - "required": ["attempted", "status", "duration_ms", "completed_at", "evidence_sha256"], + "required": ["attempted", "status", "started_at", "duration_ms", "completed_at", "evidence_sha256"], "properties": { "attempted": { "const": true }, "status": { "enum": ["completed", "failed"] }, - "duration_ms": { "type": "integer", "minimum": 0, "maximum": 300000 }, + "started_at": { "$ref": "#/$defs/timestamp" }, + "duration_ms": { "type": "integer", "minimum": 0, "maximum": 1800000 }, "completed_at": { "$ref": "#/$defs/timestamp" }, "evidence_sha256": { "$ref": "#/$defs/sha256" } } diff --git a/release/scripts/integration-e2-runner.py b/release/scripts/integration-e2-runner.py index 8ac8051f1..39ac7d4f9 100755 --- a/release/scripts/integration-e2-runner.py +++ b/release/scripts/integration-e2-runner.py @@ -638,8 +638,16 @@ def verify_candidate_assets( tag_match = TAG.fullmatch(tag) if tag_match is None: raise RunnerError("candidate tag must be a stable vMAJOR.MINOR.PATCH tag") - if not asset_dir.is_dir() or asset_dir.is_symlink(): - raise RunnerError("candidate asset directory must be a real directory") + try: + asset_dir_info = asset_dir.lstat() + except OSError as exc: + raise RunnerError( + f"candidate asset directory is unavailable: {asset_dir}: {exc}" + ) from exc + if stat.S_ISLNK(asset_dir_info.st_mode) or not stat.S_ISDIR(asset_dir_info.st_mode): + raise RunnerError( + "candidate asset directory must be a real, non-symlink directory" + ) required = required_asset_names(tag) actual = {path.name for path in asset_dir.iterdir()} missing = required - actual @@ -663,22 +671,6 @@ def verify_candidate_assets( raise RunnerError(f"{name} does not match its SHA256SUMS entry") binary = asset_dir / binary_name - binary.chmod(binary.stat().st_mode | stat.S_IXUSR) - version_result = binary_runner( - [str(binary), "--version"], - text=True, - capture_output=True, - check=False, - timeout=10, - ) - if ( - version_result.returncode != 0 - or version_result.stdout.strip() != f"registryctl {tag_match.group(1)}" - ): - raise RunnerError( - f"{binary_name} does not self-report registryctl {tag_match.group(1)}" - ) - lock = require_object( load_json(asset_dir / image_lock_name), image_lock_name, @@ -788,14 +780,39 @@ def verify_candidate_assets( "release capsule images do not match the candidate digest files" ) + signed_subject_hashes = { + name: sha256(asset_dir / name) for name in signed_subject_names(tag) + } + # Candidate-controlled code must remain passive until every local binding + # and both external authenticity mechanisms have accepted the assets. authenticate(asset_dir, tag) + if any( + sha256(asset_dir / name) != digest + for name, digest in signed_subject_hashes.items() + ): + raise RunnerError("a signed candidate subject changed during verification") + binary.chmod(binary.stat().st_mode | stat.S_IXUSR) + version_result = binary_runner( + [str(binary), "--version"], + text=True, + capture_output=True, + check=False, + timeout=10, + ) + if ( + version_result.returncode != 0 + or version_result.stdout.strip() != f"registryctl {tag_match.group(1)}" + ): + raise RunnerError( + f"{binary_name} does not self-report registryctl {tag_match.group(1)}" + ) return { "tag": tag, "version": tag_match.group(1), "source_commit": lock["tag_target"], - "registryctl_asset_sha256": f"sha256:{sha256(binary)}", - "image_lock_sha256": f"sha256:{sha256(asset_dir / image_lock_name)}", - "release_capsule_sha256": f"sha256:{sha256(asset_dir / capsule_name)}", + "registryctl_asset_sha256": f"sha256:{signed_subject_hashes[binary_name]}", + "image_lock_sha256": f"sha256:{signed_subject_hashes[image_lock_name]}", + "release_capsule_sha256": f"sha256:{signed_subject_hashes[capsule_name]}", "relay_image": relay, "notary_image": notary, } @@ -814,15 +831,46 @@ def resolve_ref(schema: dict[str, Any], reference: str) -> dict[str, Any]: return value +def json_value_equal(actual: Any, expected: Any) -> bool: + """Compare JSON values without Python's bool-as-int equivalence.""" + if isinstance(actual, bool) or isinstance(expected, bool): + return ( + isinstance(actual, bool) + and isinstance(expected, bool) + and actual == expected + ) + if actual is None or expected is None: + return actual is expected + if isinstance(actual, list) or isinstance(expected, list): + return ( + isinstance(actual, list) + and isinstance(expected, list) + and len(actual) == len(expected) + and all( + json_value_equal(left, right) for left, right in zip(actual, expected) + ) + ) + if isinstance(actual, dict) or isinstance(expected, dict): + return ( + isinstance(actual, dict) + and isinstance(expected, dict) + and set(actual) == set(expected) + and all(json_value_equal(actual[key], expected[key]) for key in actual) + ) + return actual == expected + + def validate_against_schema( value: Any, rule: dict[str, Any], schema: dict[str, Any], label: str = "result" ) -> None: if "$ref" in rule: validate_against_schema(value, resolve_ref(schema, rule["$ref"]), schema, label) return - if "const" in rule and value != rule["const"]: + if "const" in rule and not json_value_equal(value, rule["const"]): raise RunnerError(f"{label} must equal {rule['const']!r}") - if "enum" in rule and value not in rule["enum"]: + if "enum" in rule and not any( + json_value_equal(value, allowed) for allowed in rule["enum"] + ): raise RunnerError(f"{label} is outside the closed allowed set") kind = rule.get("type") if kind == "object": @@ -889,8 +937,15 @@ def read_canaries(path: Path) -> list[bytes]: def parse_timestamp(value: str) -> dt.datetime: - return dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace( - tzinfo=dt.timezone.utc + return dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00") + + +def elapsed_milliseconds(started: dt.datetime, completed: dt.datetime) -> int: + elapsed = completed - started + return ( + elapsed.days * 86_400_000 + + elapsed.seconds * 1000 + + elapsed.microseconds // 1000 ) @@ -938,6 +993,8 @@ def validate_result( all_passed = True run_started = parse_timestamp(result["started_at"]) run_completed = parse_timestamp(result["completed_at"]) + if run_completed < run_started: + raise RunnerError("public result completes before it starts") latest_case_completion = run_started for case in cases: expectation = expectations[case["case_id"]] @@ -966,18 +1023,21 @@ def validate_result( ) elif case["outcome"] != "passed": all_passed = False - if case["duration_ms"] > profile["limits"]["case_timeout_seconds"] * 1000: - raise RunnerError(f"{case['case_id']} exceeds the profile case timeout") case_started = parse_timestamp(case["started_at"]) case_completed = parse_timestamp(case["completed_at"]) if case_completed < case_started: raise RunnerError(f"{case['case_id']} completes before it starts") + case_elapsed_ms = elapsed_milliseconds(case_started, case_completed) + if case["duration_ms"] != case_elapsed_ms: + raise RunnerError( + f"{case['case_id']} duration_ms does not match its timestamps" + ) + if case_elapsed_ms > profile["limits"]["case_timeout_seconds"] * 1000: + raise RunnerError(f"{case['case_id']} exceeds the profile case timeout") if case_started < run_started or case_completed > run_completed: raise RunnerError(f"{case['case_id']} falls outside the recorded run") latest_case_completion = max(latest_case_completion, case_completed) - if run_completed < run_started: - raise RunnerError("public result completes before it starts") - run_ms = (run_completed - run_started).total_seconds() * 1000 + run_ms = elapsed_milliseconds(run_started, run_completed) if run_ms > profile["limits"]["run_timeout_seconds"] * 1000: raise RunnerError("public result exceeds the profile run timeout") if result["redaction"]["seeded_canaries"] != len(canaries): @@ -993,14 +1053,19 @@ def validate_result( > profile["limits"]["raw_evidence_bytes"] ): raise RunnerError("restricted raw evidence exceeds the profile byte limit") - if ( - result["teardown"]["duration_ms"] - > profile["limits"]["teardown_timeout_seconds"] * 1000 - ): - raise RunnerError("teardown exceeds the profile timeout") + teardown_started = parse_timestamp(result["teardown"]["started_at"]) teardown_completed = parse_timestamp(result["teardown"]["completed_at"]) - if teardown_completed < latest_case_completion: - raise RunnerError("teardown completion precedes a recorded test case") + if teardown_completed < teardown_started: + raise RunnerError("teardown completes before it starts") + teardown_elapsed_ms = elapsed_milliseconds(teardown_started, teardown_completed) + if result["teardown"]["duration_ms"] != teardown_elapsed_ms: + raise RunnerError("teardown duration_ms does not match its timestamps") + if teardown_elapsed_ms > profile["limits"]["teardown_timeout_seconds"] * 1000: + raise RunnerError("teardown exceeds the profile timeout") + if teardown_started < latest_case_completion: + raise RunnerError("teardown starts before the recorded test cases complete") + if teardown_started < run_started: + raise RunnerError("teardown starts before the recorded run") if teardown_completed > run_completed: raise RunnerError("public result completion must include the teardown attempt") complete = all_passed and result["teardown"]["status"] == "completed" @@ -1018,6 +1083,7 @@ def plan_document(profile: dict[str, Any]) -> dict[str, Any]: "support_status": profile["support_status"], "candidate_evidence": False, "status": "planned_not_executed", + "executor": "approved_operator_wrapper", "starter": profile["starter"], "pinned_source": profile["source"]["baseline"], "source_operations": profile["source"]["operations"], @@ -1048,6 +1114,9 @@ def print_plan(profile: dict[str, Any], *, as_json: bool) -> None: return print(f"{plan['profile_id']}: {plan['support_status']}") print("Status: planned, not executed; this is not candidate evidence.") + print( + "Executor: approved operator wrapper; the public runner does not run live stages." + ) print("Prerequisites:") for item in plan["prerequisites"]: print(f" - {item}") @@ -1109,14 +1178,10 @@ def main(argv: list[str] | None = None) -> int: candidate = None result = None if candidate_requested: - candidate = verify_candidate_assets( - args.candidate_dir.resolve(), args.tag - ) + candidate = verify_candidate_assets(args.candidate_dir, args.tag) if result_requested: profile = load_profile(args.profile) - result = validate_result( - args.result.resolve(), profile, args.canary_file.resolve() - ) + result = validate_result(args.result, profile, args.canary_file) elif args.profile is not None: load_profile(args.profile) if candidate is not None and result is not None: diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index cf4c14c05..d879ef768 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -3,10 +3,12 @@ import hashlib import importlib.util +import io import json import subprocess import tempfile import unittest +from contextlib import redirect_stderr from pathlib import Path from unittest import mock @@ -238,6 +240,7 @@ def make_result(self, profile_id: str, candidate: Path) -> dict[str, object]: "teardown": { "attempted": True, "status": "completed", + "started_at": "2026-07-19T01:01:59Z", "duration_ms": 1000, "completed_at": "2026-07-19T01:02:00Z", "evidence_sha256": hash_value, @@ -279,6 +282,7 @@ def test_dry_run_is_explicitly_not_evidence_and_hides_values(self) -> None: plan = self.module.plan_document(profile) self.assertFalse(plan["candidate_evidence"]) self.assertEqual("planned_not_executed", plan["status"]) + self.assertEqual("approved_operator_wrapper", plan["executor"]) self.assertIn("OPENCRVS_CLIENT_SECRET", plan["required_input_names"]) self.assertEqual( self.module.CASE_IDS, tuple(case["id"] for case in plan["cases"]) @@ -299,6 +303,95 @@ def test_candidate_assets_cross_validate_all_release_bindings(self) -> None: self.assertEqual(self.relay, metadata["relay_image"]) self.assertEqual(self.commit, metadata["source_commit"]) + def test_authenticity_precedes_candidate_binary_execution(self) -> None: + candidate = self.make_candidate() + events = [] + + def authenticate(_directory, _tag): + events.append("authenticated") + + def execute(*_args, **_kwargs): + self.assertEqual(["authenticated"], events) + events.append("executed") + return self.binary_result() + + self.module.verify_candidate_assets( + candidate, + self.tag, + authenticate=authenticate, + binary_runner=execute, + ) + self.assertEqual(["authenticated", "executed"], events) + + def test_authenticity_failure_prevents_candidate_binary_execution(self) -> None: + candidate = self.make_candidate() + events = [] + + def reject_authenticity(_directory, _tag): + events.append("authenticity-rejected") + raise self.module.RunnerError("invalid signature fixture") + + def execute(*_args, **_kwargs): + events.append("executed") + return self.binary_result() + + with self.assertRaisesRegex(self.module.RunnerError, "invalid signature"): + self.module.verify_candidate_assets( + candidate, + self.tag, + authenticate=reject_authenticity, + binary_runner=execute, + ) + self.assertEqual(["authenticity-rejected"], events) + + def test_subject_change_during_authenticity_prevents_binary_execution(self) -> None: + candidate = self.make_candidate() + binary = candidate / f"registryctl-{self.tag}-linux-amd64" + events = [] + + def mutate_during_authenticity(_directory, _tag): + binary.write_bytes(b"changed-after-passive-checks") + events.append("mutated") + + def execute(*_args, **_kwargs): + events.append("executed") + return self.binary_result() + + with self.assertRaisesRegex(self.module.RunnerError, "changed during"): + self.module.verify_candidate_assets( + candidate, + self.tag, + authenticate=mutate_during_authenticity, + binary_runner=execute, + ) + self.assertEqual(["mutated"], events) + + def test_late_passive_binding_failure_prevents_binary_execution(self) -> None: + candidate = self.make_candidate() + capsule_path = candidate / f"registry-stack-{self.tag}-release-capsule.json" + capsule = json.loads(capsule_path.read_text(encoding="utf-8")) + capsule["images"][1]["digest_ref"] = ( + "ghcr.io/registrystack/registry-notary@sha256:" + "9" * 64 + ) + self.write_json(capsule_path, capsule) + events = [] + + def authenticate(_directory, _tag): + events.append("authenticated") + + def execute(*_args, **_kwargs): + events.append("executed") + return self.binary_result() + + with self.assertRaisesRegex(self.module.RunnerError, "capsule images"): + self.module.verify_candidate_assets( + candidate, + self.tag, + authenticate=authenticate, + binary_runner=execute, + ) + self.assertEqual([], events) + def test_candidate_rejects_an_unexpected_asset(self) -> None: candidate = self.make_candidate() (candidate / "unreviewed-output.txt").write_text("no\n", encoding="utf-8") @@ -357,6 +450,43 @@ def test_candidate_authenticity_binds_every_subject_to_tagged_workflow( ) ) + def test_cli_rejects_symlink_candidate_directory_before_normalization(self) -> None: + candidate = self.make_candidate() + candidate_link = self.root / "candidate-link" + candidate_link.symlink_to(candidate, target_is_directory=True) + stderr = io.StringIO() + with redirect_stderr(stderr): + status = self.module.main( + [ + "validate", + "--candidate-dir", + str(candidate_link), + "--tag", + self.tag, + ] + ) + self.assertEqual(1, status) + self.assertIn("non-symlink directory", stderr.getvalue()) + + def test_json_const_does_not_accept_integer_for_true(self) -> None: + with self.assertRaisesRegex(self.module.RunnerError, "must equal True"): + self.module.validate_against_schema(1, {"const": True}, {}) + + def test_json_boolean_enum_does_not_accept_integer(self) -> None: + with self.assertRaisesRegex(self.module.RunnerError, "closed allowed set"): + self.module.validate_against_schema(1, {"enum": [True, False]}, {}) + + def test_full_result_rejects_integer_for_boolean_attestation(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + result["project"]["generated_files_unchanged"] = 1 + with self.assertRaisesRegex(self.module.RunnerError, "must equal True"): + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + def test_valid_opencrvs_result_passes(self) -> None: candidate = self.make_candidate() result = self.make_result("opencrvs-dci-v1.9", candidate) @@ -462,6 +592,91 @@ def test_passed_status_requires_successful_teardown(self) -> None: self.make_canary_file(), ) + def test_case_duration_must_match_timestamps(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + result["cases"][0]["duration_ms"] = 999 + with self.assertRaisesRegex( + self.module.RunnerError, "duration_ms does not match" + ): + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + + def test_case_duration_supports_millisecond_timestamp_precision(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + result["cases"][0]["completed_at"] = "2026-07-19T01:00:00.250Z" + result["cases"][0]["duration_ms"] = 250 + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + + def test_case_timestamp_elapsed_time_must_respect_profile_bound(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + result["cases"][0]["completed_at"] = "2026-07-19T01:01:01Z" + result["cases"][0]["duration_ms"] = 61000 + with self.assertRaisesRegex(self.module.RunnerError, "profile case timeout"): + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + + def test_teardown_started_at_is_required_by_closed_schema(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + del result["teardown"]["started_at"] + with self.assertRaisesRegex(self.module.RunnerError, "started_at"): + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + + def test_teardown_duration_must_match_timestamps(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + result["teardown"]["duration_ms"] = 999 + with self.assertRaisesRegex(self.module.RunnerError, "teardown duration_ms"): + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + + def test_teardown_timestamp_elapsed_time_must_respect_profile_bound(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + result["teardown"]["completed_at"] = "2026-07-19T01:07:00Z" + result["teardown"]["duration_ms"] = 301000 + result["completed_at"] = "2026-07-19T01:08:00Z" + with self.assertRaisesRegex(self.module.RunnerError, "teardown exceeds"): + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + + def test_teardown_cannot_start_before_cases_complete(self) -> None: + candidate = self.make_candidate() + result = self.make_result("opencrvs-dci-v1.9", candidate) + result["teardown"]["started_at"] = "2026-07-19T01:00:00Z" + result["teardown"]["completed_at"] = "2026-07-19T01:00:01Z" + with self.assertRaisesRegex( + self.module.RunnerError, "before.*test cases complete" + ): + self.module.validate_result( + self.write_result(result), + self.module.load_profile("opencrvs-dci-v1.9"), + self.make_canary_file(), + ) + def test_public_result_must_retain_all_claim_limitations(self) -> None: candidate = self.make_candidate() result = self.make_result("opencrvs-dci-v1.9", candidate) From 93abd4d806917dcff3f20e23f697cce46a8a02ba Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 20:15:35 +0700 Subject: [PATCH 48/65] fix(release): isolate verified candidate execution Signed-off-by: Jeremi Joslin --- release/conformance/integrations/README.md | 21 ++- release/scripts/integration-e2-runner.py | 140 ++++++++++++++++-- release/scripts/test_integration_e2_runner.py | 56 ++++++- 3 files changed, 195 insertions(+), 22 deletions(-) diff --git a/release/conformance/integrations/README.md b/release/conformance/integrations/README.md index efc1a909d..71584a0bd 100644 --- a/release/conformance/integrations/README.md +++ b/release/conformance/integrations/README.md @@ -66,11 +66,16 @@ the profile limits: 1. Download the candidate assets listed in `release/VERIFY.md` into a fresh, dedicated directory. 2. Use the public runner to validate checksums, signatures, provenance, - capsule lineage, image locks, and digest files. The runner executes the - candidate binary for its self-reported version only after every passive and - authenticity check passes. -3. Initialize the profile's starter with the verified candidate - `registryctl` binary. + capsule lineage, image locks, and digest files. The runner copies the exact + closed asset set through no-follow file descriptors into a fresh owner-only + temporary snapshot. It makes every snapshot file non-writable, verifies + and authenticates only snapshot files, then executes only the snapshot + binary for its self-reported version. It removes the snapshot after success + or failure. The source-directory binary is never executed. +3. Have the approved operator wrapper create its own authenticated, + non-writable snapshot and initialize the profile's starter only from that + snapshot. A completed candidate-only validation does not make the mutable + source directory safe for later execution. 4. Apply only the reviewed authored changes listed in the selected profile. Do not edit generated YAML. 5. Run the offline project `test`, `check`, and `build` commands. Inspect the @@ -137,6 +142,12 @@ timestamps or durations, a passed case without the required recorded source-contact classification, over-time recorded teardown, and a `passed` status paired with failed recorded teardown. +Candidate validation always uses a disposable private snapshot. Replacing a +file in the supplied candidate directory during or after validation cannot +change the executable path used for the version check. The runner removes the +snapshot in its cleanup path and never returns the snapshot path as an +operator artifact. + The public runner receives neither the generated project nor restricted raw evidence. It validates the closed flags, hashes, timings, and classifications in the sanitized result, but it cannot independently re-hash the project, diff --git a/release/scripts/integration-e2-runner.py b/release/scripts/integration-e2-runner.py index 39ac7d4f9..f4b0282ad 100755 --- a/release/scripts/integration-e2-runner.py +++ b/release/scripts/integration-e2-runner.py @@ -13,13 +13,16 @@ import datetime as dt import hashlib import json +import os import re import shutil import stat import subprocess import sys +import tempfile +from contextlib import contextmanager from pathlib import Path -from typing import Any, Callable +from typing import Any, Callable, Iterator ROOT = Path(__file__).resolve().parents[2] @@ -628,6 +631,109 @@ def verify_file_sbom(path: Path, subject_name: str, subject_sha256: str) -> None ) +def require_candidate_directory(path: Path) -> None: + try: + info = path.lstat() + except OSError as exc: + raise RunnerError( + f"candidate asset directory is unavailable: {path}: {exc}" + ) from exc + if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode): + raise RunnerError( + "candidate asset directory must be a real, non-symlink directory" + ) + + +@contextmanager +def candidate_asset_snapshot(asset_dir: Path, tag: str) -> Iterator[Path]: + """Copy the closed candidate set through no-follow descriptors and remove it.""" + require_candidate_directory(asset_dir) + no_follow = getattr(os, "O_NOFOLLOW", None) + directory_flag = getattr(os, "O_DIRECTORY", None) + if no_follow is None or directory_flag is None: + raise RunnerError("candidate snapshotting requires O_NOFOLLOW and O_DIRECTORY") + directory_fd = None + try: + directory_fd = os.open( + asset_dir, + os.O_RDONLY | os.O_CLOEXEC | no_follow | directory_flag, + ) + required = required_asset_names(tag) + actual = set(os.listdir(directory_fd)) + missing = required - actual + unknown = actual - required + if missing or unknown: + details = [] + if missing: + details.append("missing " + ", ".join(sorted(missing))) + if unknown: + details.append("unexpected " + ", ".join(sorted(unknown))) + raise RunnerError( + "candidate asset set is not closed: " + "; ".join(details) + ) + + with tempfile.TemporaryDirectory( + prefix="registry-integration-e2-candidate-" + ) as temporary: + snapshot = Path(temporary) + snapshot.chmod(0o700) + binary_name = f"registryctl-{tag}-linux-amd64" + for name in sorted(required): + source_fd = None + destination_fd = None + try: + source_fd = os.open( + name, + os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | no_follow, + dir_fd=directory_fd, + ) + source_info = os.fstat(source_fd) + if not stat.S_ISREG(source_info.st_mode): + raise RunnerError( + f"candidate asset must be a regular, non-symlink file: {name}" + ) + if ( + source_info.st_size <= 0 + or source_info.st_size > MAX_ASSET_BYTES + ): + raise RunnerError( + f"candidate asset size for {name} must be between 1 and {MAX_ASSET_BYTES} bytes" + ) + destination = snapshot / name + destination_fd = os.open( + destination, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | no_follow, + 0o600, + ) + with os.fdopen(source_fd, "rb") as source_handle: + source_fd = None + with os.fdopen(destination_fd, "wb") as destination_handle: + destination_fd = None + shutil.copyfileobj(source_handle, destination_handle) + if destination.stat().st_size != source_info.st_size: + raise RunnerError( + f"candidate asset changed while snapshotting: {name}" + ) + destination.chmod(0o500 if name == binary_name else 0o400) + finally: + if source_fd is not None: + os.close(source_fd) + if destination_fd is not None: + os.close(destination_fd) + snapshot.chmod(0o500) + try: + yield snapshot + finally: + snapshot.chmod(0o700) + except OSError as exc: + raise RunnerError( + f"could not create private candidate snapshot: {exc}" + ) from exc + finally: + if directory_fd is not None: + os.close(directory_fd) + + def verify_candidate_assets( asset_dir: Path, tag: str, @@ -638,16 +744,26 @@ def verify_candidate_assets( tag_match = TAG.fullmatch(tag) if tag_match is None: raise RunnerError("candidate tag must be a stable vMAJOR.MINOR.PATCH tag") - try: - asset_dir_info = asset_dir.lstat() - except OSError as exc: - raise RunnerError( - f"candidate asset directory is unavailable: {asset_dir}: {exc}" - ) from exc - if stat.S_ISLNK(asset_dir_info.st_mode) or not stat.S_ISDIR(asset_dir_info.st_mode): - raise RunnerError( - "candidate asset directory must be a real, non-symlink directory" + with candidate_asset_snapshot(asset_dir, tag) as snapshot: + return verify_candidate_snapshot( + snapshot, + tag, + authenticate=authenticate, + binary_runner=binary_runner, ) + + +def verify_candidate_snapshot( + asset_dir: Path, + tag: str, + *, + authenticate: Callable[[Path, str], None], + binary_runner: Callable[..., subprocess.CompletedProcess[str]], +) -> dict[str, str]: + tag_match = TAG.fullmatch(tag) + if tag_match is None: + raise RunnerError("candidate tag must be a stable vMAJOR.MINOR.PATCH tag") + require_candidate_directory(asset_dir) required = required_asset_names(tag) actual = {path.name for path in asset_dir.iterdir()} missing = required - actual @@ -1093,8 +1209,8 @@ def plan_document(profile: dict[str, Any]) -> dict[str, Any]: "prerequisites": profile["prerequisites"], "limits": profile["limits"], "stages": [ - "Verify the closed candidate asset set, checksums, signatures, provenance, source lineage, and digest locks.", - "Initialize the pinned starter with that candidate registryctl binary.", + "Copy the closed candidate assets without following symlinks, then verify and version-check only the private non-writable snapshot.", + "Have the operator wrapper create its own authenticated non-writable snapshot and initialize the pinned starter only from that snapshot.", "Apply only the profile's reviewed authored inputs; never edit generated YAML.", "Run the offline project test, check, build, and generated-file hash review.", "Deploy one digest-pinned Relay, Notary, and PostgreSQL set per authority within the approved run timeout.", diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index d879ef768..79c51be94 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -5,6 +5,9 @@ import importlib.util import io import json +import os +import shlex +import stat import subprocess import tempfile import unittest @@ -49,7 +52,10 @@ def make_candidate(self) -> Path: binary_name = f"registryctl-{self.tag}-linux-amd64" lock_name = f"registryctl-{self.tag}-image-lock.json" capsule_name = f"registry-stack-{self.tag}-release-capsule.json" - (candidate / binary_name).write_bytes(b"candidate-registryctl") + (candidate / binary_name).write_text( + "#!/bin/sh\nprintf 'registryctl 1.0.0\\n'\n", + encoding="utf-8", + ) lock = { "schema_version": "registryctl.release_image_lock.v1", "release_tag": self.tag, @@ -307,7 +313,8 @@ def test_authenticity_precedes_candidate_binary_execution(self) -> None: candidate = self.make_candidate() events = [] - def authenticate(_directory, _tag): + def authenticate(directory, _tag): + self.assertNotEqual(candidate, directory) events.append("authenticated") def execute(*_args, **_kwargs): @@ -326,8 +333,10 @@ def execute(*_args, **_kwargs): def test_authenticity_failure_prevents_candidate_binary_execution(self) -> None: candidate = self.make_candidate() events = [] + snapshots = [] - def reject_authenticity(_directory, _tag): + def reject_authenticity(directory, _tag): + snapshots.append(directory) events.append("authenticity-rejected") raise self.module.RunnerError("invalid signature fixture") @@ -343,13 +352,16 @@ def execute(*_args, **_kwargs): binary_runner=execute, ) self.assertEqual(["authenticity-rejected"], events) + self.assertEqual(1, len(snapshots)) + self.assertFalse(snapshots[0].exists()) def test_subject_change_during_authenticity_prevents_binary_execution(self) -> None: candidate = self.make_candidate() - binary = candidate / f"registryctl-{self.tag}-linux-amd64" events = [] - def mutate_during_authenticity(_directory, _tag): + def mutate_during_authenticity(directory, _tag): + binary = directory / f"registryctl-{self.tag}-linux-amd64" + binary.chmod(0o700) binary.write_bytes(b"changed-after-passive-checks") events.append("mutated") @@ -366,6 +378,40 @@ def execute(*_args, **_kwargs): ) self.assertEqual(["mutated"], events) + def test_original_binary_replacement_after_final_hash_never_executes(self) -> None: + candidate = self.make_candidate() + original_binary = candidate / f"registryctl-{self.tag}-linux-amd64" + replacement_marker = self.root / "replacement-executed" + snapshots = [] + + def authenticate(directory, _tag): + snapshots.append(directory) + self.assertEqual(0o500, stat.S_IMODE(directory.stat().st_mode)) + self.assertEqual(os.geteuid(), directory.stat().st_uid) + for asset in directory.iterdir(): + self.assertEqual(0, asset.stat().st_mode & 0o222) + + def replace_original_then_execute(command, **kwargs): + self.assertNotEqual(original_binary, Path(command[0])) + original_binary.write_text( + "#!/bin/sh\n" + f": > {shlex.quote(str(replacement_marker))}\n" + "printf 'registryctl 1.0.0\\n'\n", + encoding="utf-8", + ) + original_binary.chmod(0o700) + return subprocess.run(command, **kwargs) + + self.module.verify_candidate_assets( + candidate, + self.tag, + authenticate=authenticate, + binary_runner=replace_original_then_execute, + ) + self.assertFalse(replacement_marker.exists()) + self.assertEqual(1, len(snapshots)) + self.assertFalse(snapshots[0].exists()) + def test_late_passive_binding_failure_prevents_binary_execution(self) -> None: candidate = self.make_candidate() capsule_path = candidate / f"registry-stack-{self.tag}-release-capsule.json" From 9052cdcec00270ad92a3f36ceb2274e25ef20473 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 20:05:18 +0700 Subject: [PATCH 49/65] test(release): add Relay OIDC candidate smoke Signed-off-by: Jeremi Joslin --- release/READINESS.md | 7 +- release/conformance/openid/README.md | 14 +- release/conformance/openid/plan-map.json | 4 +- release/conformance/relay-oidc/README.md | 95 ++ .../conformance/relay-oidc/docker-compose.yml | 107 ++ release/conformance/relay-oidc/records.csv | 2 + .../relay-oidc/relay.template.yaml | 88 ++ .../conformance/relay-oidc/zitadel-helper.py | 387 +++++++ release/scripts/openid-conformance-runner.py | 2 +- release/scripts/relay-oidc-smoke.py | 977 ++++++++++++++++++ .../scripts/test_openid_conformance_runner.py | 23 + release/scripts/test_relay_oidc_smoke.py | 310 ++++++ 12 files changed, 2008 insertions(+), 8 deletions(-) create mode 100644 release/conformance/relay-oidc/README.md create mode 100644 release/conformance/relay-oidc/docker-compose.yml create mode 100644 release/conformance/relay-oidc/records.csv create mode 100644 release/conformance/relay-oidc/relay.template.yaml create mode 100755 release/conformance/relay-oidc/zitadel-helper.py create mode 100755 release/scripts/relay-oidc-smoke.py create mode 100644 release/scripts/test_relay_oidc_smoke.py diff --git a/release/READINESS.md b/release/READINESS.md index 43a428b55..434c9c3ab 100644 --- a/release/READINESS.md +++ b/release/READINESS.md @@ -46,8 +46,11 @@ write. deployment topology (#205). Must not depend on the retired monorepo lab, which was replaced by the standalone [Solmara Lab](https://github.com/registrystack/solmara-lab) (#224). - The release-owned [harness and plan mapping](conformance/openid/README.md) - no longer depend on either lab. Its + The release-owned [OIDF harness and plan mapping](conformance/openid/README.md) + and [Relay OIDC smoke](conformance/relay-oidc/README.md) no longer depend + on either lab. The Relay smoke is source-ready and directly runnable + against a digest-pinned published candidate, but no reviewed 1.0 + candidate result exists yet. The OIDF harness's [initial report](conformance/openid/initial-report.md) preserves the historical hosted-lab failures but does not yet prove a pinned release topology. diff --git a/release/conformance/openid/README.md b/release/conformance/openid/README.md index 369727b8d..19b010f86 100644 --- a/release/conformance/openid/README.md +++ b/release/conformance/openid/README.md @@ -27,8 +27,10 @@ themselves: - The upstream full-plan shape currently selects DPoP. Registry Notary 1.0 does not support or claim DPoP, wallet attestation, PAR, EUDI, HAIP, an authorization-code wallet grant, or ES256 holder proof. -- Registry Relay needs a separate pinned-topology smoke with `auth.mode: oidc`. - The OIDF suite has no generic resource-server plan for that surface. +- Registry Relay uses the separate + [candidate-neutral Relay and Zitadel smoke](../relay-oidc/README.md) with + `auth.mode: oidc`. The OIDF suite has no generic resource-server plan for + that surface. Development and historical demo runs are not release evidence. A reviewed result becomes evidence only when it records the candidate image digest, suite @@ -53,7 +55,9 @@ not be reported as product support. The map also records why Relay OIDC bearer validation and third-party OpenID Providers are outside the available OIDF plan set. That exclusion is not a -substitute for exercising Relay's OIDC path. +substitute for exercising Relay's OIDC path. The release-owned Relay smoke is +directly runnable against a published image digest, but its output remains +unreviewed until a maintainer binds it to the release candidate. ## Prerequisites @@ -83,6 +87,10 @@ REGISTRY_OPENID_CONFORMANCE_ISSUER_URL="https://issuer.example.test" \ notary-oid4vci-issuer-metadata ``` +Candidate-only scenarios are directly runnable. `--allow-blocked` is reserved +for deliberate investigation of scenarios whose status is explicitly blocked; +it does not turn their output into release evidence. + Set `REGISTRY_OPENID_CONFORMANCE_AUTHORIZATION_SERVER` when the authorization server differs from the issuer. Set `REGISTRY_OPENID_CONFORMANCE_CREDENTIAL_CONFIGURATION_ID` when the topology diff --git a/release/conformance/openid/plan-map.json b/release/conformance/openid/plan-map.json index 75c02d370..57b9f84a1 100644 --- a/release/conformance/openid/plan-map.json +++ b/release/conformance/openid/plan-map.json @@ -81,8 +81,8 @@ "non_oidf_surfaces": [ { "surface": "Registry Relay OIDC bearer validation", - "evidence": "A pinned release-topology smoke with auth.mode set to oidc is still required by issue #205.", - "reason": "The OIDF suite does not publish a generic resource-server conformance plan for a Relay-style protected API. The release topology must separately verify discovery, signature, audience, token type, and scope mapping." + "evidence": "The release-owned candidate-neutral Relay and Zitadel smoke is directly runnable against a published Relay image digest. Its live output requires candidate binding and maintainer review before it becomes release evidence.", + "reason": "The OIDF suite does not publish a generic resource-server conformance plan for a Relay-style protected API. The separate release topology verifies discovery, signature, audience, token type, and native Zitadel role-object scope mapping." }, { "surface": "Third-party OpenID Providers used by demonstration topologies", diff --git a/release/conformance/relay-oidc/README.md b/release/conformance/relay-oidc/README.md new file mode 100644 index 000000000..0cb07bbd5 --- /dev/null +++ b/release/conformance/relay-oidc/README.md @@ -0,0 +1,95 @@ +# Registry Relay OIDC release smoke + +This directory owns the candidate-neutral Relay and Zitadel topology required +by issue #205. It exercises a published Registry Relay image as an OAuth 2.0 +resource server with `auth.mode: oidc`. It does not build Relay from the source +checkout and does not depend on a hosted environment or Solmara Lab. + +The topology uses digest-pinned Zitadel, PostgreSQL, and Python images. The +runner accepts Relay only as the exact image reference +`ghcr.io/registrystack/registry-relay@sha256:`. The candidate source +commit and release identifier are also mandatory, so an output cannot be +mistaken for evidence about a different candidate. + +## Evidence boundary + +The checked-in assets and their offline tests prove that the harness is +reviewable and candidate-neutral. They are not live release evidence. A live +run writes a report classified as `unreviewed-live-candidate-output` with +`review_required: true`. A maintainer must bind that report to the published +candidate manifest and review it before it can become release evidence. + +The report contains identifiers, configuration and topology digests, bounded +diagnostics, and assertion results. It never contains the bootstrap PAT, client +secret, access token, database password, audit hash secret, or Zitadel master +key. Runtime secrets live only in a mode `0700` temporary directory, secret +files use mode `0600`, and the runner removes the exact Compose project, +volumes, and temporary directory even after a failed assertion. A canary scan +rejects secret material in subprocess output and the report. + +Do not commit a raw or unreviewed run directory. Do not describe a development +or earlier-release image run as evidence for a 1.0 release candidate. + +## What the live smoke asserts + +The runner provisions a fresh Zitadel project, native project role, and machine +service account. It requests the role using Zitadel's native project-role +scope, inspects the resulting role-object claim, and binds Relay to Zitadel's +project-specific native claim name. The legacy native claim name remains +accepted for Zitadel compatibility. Relay maps `registry-smoke-reader` to +`smoke_registry:metadata` and requires the service account's organization key +to carry an active value. + +It then requires these exact results: + +- the running Relay container references the requested digest; +- no credential returns `401 auth.missing_credential`; +- the valid Zitadel role token returns `200` and exposes the synthetic dataset; +- a structurally valid token with a changed signature returns + `401 auth.token_signature_invalid`; +- an audience mismatch returns `401 auth.audience_mismatch`; +- an unaccepted JOSE token type returns `401 auth.malformed_credential`; and +- a role-object organization-key mismatch returns `403 auth.scope_denied`. + +Zitadel, the helper, and Relay share Zitadel's network namespace. This keeps the +HTTP issuer and discovery URL on loopback, which is the only insecure fetch +form Relay permits for local development. The Relay API is published separately +on a randomly selected loopback port. + +## Offline review + +Python 3.11 or later is required. Validate the checked-in topology and render a +candidate-bound plan without Docker or network access: + +```bash +release/scripts/relay-oidc-smoke.py validate + +release/scripts/relay-oidc-smoke.py plan \ + --relay-image 'ghcr.io/registrystack/registry-relay@sha256:<64-lowercase-hex>' \ + --candidate-source-ref '<40-lowercase-hex-commit>' \ + --release-id '1.0.0-rc.1' +``` + +The plan deliberately records `live_evidence: false`. + +## Run a published candidate + +Docker with Docker Compose is required. The Relay image must already be +published by digest: + +```bash +release/scripts/relay-oidc-smoke.py run \ + --relay-image 'ghcr.io/registrystack/registry-relay@sha256:<64-lowercase-hex>' \ + --candidate-source-ref '<40-lowercase-hex-commit>' \ + --release-id '1.0.0-rc.1' +``` + +The command prints only the path to the unreviewed report. Use `--output-dir` +to choose an empty report directory and `--host-port` only when a fixed free +loopback port is required. The default output is under +`target/relay-oidc-smoke/`, which Git ignores. + +If teardown fails, treat the run as an error. The diagnostic includes the exact +random Compose project name only in the local command output, so an operator +can inspect and remove that isolated project without risking unrelated Docker +resources. diff --git a/release/conformance/relay-oidc/docker-compose.yml b/release/conformance/relay-oidc/docker-compose.yml new file mode 100644 index 000000000..afed49ab3 --- /dev/null +++ b/release/conformance/relay-oidc/docker-compose.yml @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: Apache-2.0 + +name: registry-release-relay-oidc + +services: + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + environment: + POSTGRES_DB: zitadel + POSTGRES_PASSWORD: ${REGISTRY_RELAY_OIDC_SMOKE_POSTGRES_PASSWORD:?runner must provide PostgreSQL password} + POSTGRES_USER: postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d zitadel"] + interval: 2s + timeout: 3s + retries: 45 + volumes: + - postgres-data:/var/lib/postgresql/data + + seed-init: + image: python:3.13-slim-trixie@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91 + user: "0:0" + command: + - python + - -c + - "import os; os.chown('/seed', 1000, 1000)" + volumes: + - zitadel-seed:/seed + + zitadel: + image: ghcr.io/zitadel/zitadel:v2.66.4@sha256:13bbbdeb3689fdd4191ab7a355aeee1ebebdc266633e6126c14a2d27f12b808f + command: + - start-from-init + - --masterkey + - ${REGISTRY_RELAY_OIDC_SMOKE_ZITADEL_MASTERKEY:?runner must provide Zitadel master key} + - --tlsMode + - disabled + environment: + ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${REGISTRY_RELAY_OIDC_SMOKE_POSTGRES_PASSWORD} + ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres + ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel + ZITADEL_DATABASE_POSTGRES_HOST: postgres + ZITADEL_DATABASE_POSTGRES_PORT: 5432 + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${REGISTRY_RELAY_OIDC_SMOKE_POSTGRES_PASSWORD} + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable + ZITADEL_DATABASE_POSTGRES_USER_USERNAME: postgres + ZITADEL_EXTERNALDOMAIN: localhost + ZITADEL_EXTERNALPORT: "8080" + ZITADEL_EXTERNALSECURE: "false" + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Registry Relay smoke bootstrap + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: relay-smoke-bootstrap + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-12-31T23:59:59Z" + ZITADEL_FIRSTINSTANCE_PATPATH: /seed/bootstrap.pat + ZITADEL_TLS_ENABLED: "false" + depends_on: + postgres: + condition: service_healthy + seed-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "/app/zitadel", "ready"] + interval: 5s + timeout: 5s + retries: 45 + start_period: 20s + ports: + - "127.0.0.1:${REGISTRY_RELAY_OIDC_SMOKE_HOST_PORT:?runner must provide Relay host port}:18080" + volumes: + - zitadel-seed:/seed + + helper: + image: python:3.13-slim-trixie@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91 + network_mode: service:zitadel + depends_on: + zitadel: + condition: service_healthy + environment: + REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_GID: ${REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_GID:?runner must provide runtime gid} + REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_UID: ${REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_UID:?runner must provide runtime uid} + REGISTRY_RELAY_OIDC_SMOKE_RUN_ID: ${REGISTRY_RELAY_OIDC_SMOKE_RUN_ID:?runner must provide run id} + REGISTRY_RELAY_OIDC_SMOKE_SECRET_CANARY: ${REGISTRY_RELAY_OIDC_SMOKE_SECRET_CANARY:?runner must provide secret canary} + entrypoint: ["python", "/harness/zitadel-helper.py"] + volumes: + - ${REGISTRY_RELAY_OIDC_SMOKE_CONFIG_DIR:?runner must provide config directory}:/harness:ro + - ${REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_DIR:?runner must provide runtime directory}:/runtime + - zitadel-seed:/seed:ro + + relay: + image: ${REGISTRY_RELAY_OIDC_SMOKE_RELAY_IMAGE:?runner must provide digest-pinned Registry Relay image} + platform: linux/amd64 + network_mode: service:zitadel + depends_on: + zitadel: + condition: service_healthy + command: ["--config", "/etc/registry-relay/config.yaml"] + environment: + REGISTRY_RELAY_AUDIT_HASH_SECRET: ${REGISTRY_RELAY_OIDC_SMOKE_AUDIT_HASH_SECRET:?runner must provide audit hash secret} + volumes: + - ${REGISTRY_RELAY_OIDC_SMOKE_RELAY_CONFIG:?runner must provide Relay config}:/etc/registry-relay/config.yaml:ro + - ${REGISTRY_RELAY_OIDC_SMOKE_CONFIG_DIR}/records.csv:/fixtures/records.csv:ro + - relay-cache:/var/lib/registry-relay/cache + +volumes: + postgres-data: + relay-cache: + zitadel-seed: diff --git a/release/conformance/relay-oidc/records.csv b/release/conformance/relay-oidc/records.csv new file mode 100644 index 000000000..18ff381a0 --- /dev/null +++ b/release/conformance/relay-oidc/records.csv @@ -0,0 +1,2 @@ +person_id,display_name +person-001,Registry Smoke Person diff --git a/release/conformance/relay-oidc/relay.template.yaml b/release/conformance/relay-oidc/relay.template.yaml new file mode 100644 index 000000000..4d1446002 --- /dev/null +++ b/release/conformance/relay-oidc/relay.template.yaml @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: Apache-2.0 +deployment: + profile: local + +server: + bind: 0.0.0.0:18080 + cache_dir: /var/lib/registry-relay/cache + +catalog: + title: Registry Relay OIDC release smoke + base_url: $base_url + publisher: Registry Stack release verification + participant_id: did:web:relay-oidc-smoke.registry.invalid + +vocabularies: {} + +auth: + mode: oidc + failure_throttle: + enabled: false + oidc: + issuer: http://localhost:8080 + audiences: + - $audience + discovery_url: http://localhost:8080/.well-known/openid-configuration + allow_dev_insecure_fetch_urls: true + allowed_algorithms: + - RS256 + jwks_cache_ttl: 10m + leeway: 60s + scope_claim: $scope_claim + scope_map: + "registry-smoke-reader": "smoke_registry:metadata" + scope_object_required_keys: + - $required_org + allowed_clients: + - $allowed_client + allowed_token_types: + - $allowed_token_type + +audit: + sink: stdout + format: jsonl + hash_secret_env: REGISTRY_RELAY_AUDIT_HASH_SECRET + +datasets: + - id: smoke_registry + title: Synthetic release smoke registry + description: Non-sensitive fixture used only for release verification. + owner: Registry Stack release verification + sensitivity: public + access_rights: restricted + update_frequency: monthly + defaults: + refresh: + mode: manual + tables: + - id: people_table + materialization: snapshot + source: + type: file + path: /fixtures/records.csv + primary_key: person_id + schema: + strict: true + fields: + - name: person_id + type: string + nullable: false + - name: display_name + type: string + nullable: false + entities: + - name: person + title: Synthetic person + description: Synthetic record for the OIDC release smoke. + table: people_table + fields: + - name: id + from: person_id + - name: display_name + access: + metadata_scope: smoke_registry:metadata + aggregate_scope: smoke_registry:aggregate + read_scope: smoke_registry:rows + api: + default_limit: 10 + max_limit: 10 diff --git a/release/conformance/relay-oidc/zitadel-helper.py b/release/conformance/relay-oidc/zitadel-helper.py new file mode 100755 index 000000000..c8f71fb58 --- /dev/null +++ b/release/conformance/relay-oidc/zitadel-helper.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Provision and use the ephemeral Zitadel instance for the Relay OIDC smoke. + +This helper runs inside the pinned Python container in docker-compose.yml. It +never prints a credential or bearer token. Secret-bearing files are written +atomically with mode 0600 into the runner-owned ephemeral runtime directory. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + + +BASE_URL = "http://localhost:8080" +MANAGEMENT_URL = f"{BASE_URL}/management/v1" +PAT_PATH = Path("/seed/bootstrap.pat") +PUBLIC_PATH = Path("/runtime/topology.json") +SECRET_PATH = Path("/runtime/topology-secret.json") +TOKEN_PATH = Path("/runtime/access-token") +MAX_RESPONSE_BYTES = 1_048_576 +ROLE_KEY = "registry-smoke-reader" + + +class HelperError(RuntimeError): + """A bounded diagnostic safe to show without redaction.""" + + +def read_bounded(response: Any) -> bytes: + content_length = response.headers.get("Content-Length") + if content_length: + try: + if int(content_length) > MAX_RESPONSE_BYTES: + raise HelperError("Zitadel response exceeded the one MiB limit") + except ValueError as exc: + raise HelperError("Zitadel returned an invalid Content-Length") from exc + body = response.read(MAX_RESPONSE_BYTES + 1) + if len(body) > MAX_RESPONSE_BYTES: + raise HelperError("Zitadel response exceeded the one MiB limit") + return body + + +def request( + method: str, + url: str, + *, + bearer: str | None = None, + org_id: str | None = None, + json_body: dict[str, Any] | None = None, + form_body: dict[str, str] | None = None, + basic_auth: tuple[str, str] | None = None, + accepted_statuses: tuple[int, ...] = (200,), + parse_json: bool = True, +) -> dict[str, Any]: + if json_body is not None and form_body is not None: + raise HelperError("helper request cannot mix JSON and form bodies") + headers = {"Accept": "application/json"} + data: bytes | None = None + if bearer: + headers["Authorization"] = f"Bearer {bearer}" + if org_id: + headers["x-zitadel-orgid"] = org_id + if json_body is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(json_body, separators=(",", ":")).encode("utf-8") + elif form_body is not None: + headers["Content-Type"] = "application/x-www-form-urlencoded" + data = urllib.parse.urlencode(form_body).encode("ascii") + if basic_auth: + encoded = base64.b64encode(f"{basic_auth[0]}:{basic_auth[1]}".encode()).decode() + headers["Authorization"] = f"Basic {encoded}" + + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=15) as response: + status = response.status + body = read_bounded(response) + except urllib.error.HTTPError as exc: + # Discard response bodies. Zitadel create responses can contain secrets, + # and diagnostics do not need their unbounded or provider-controlled text. + exc.read(MAX_RESPONSE_BYTES + 1) + if exc.code in accepted_statuses: + return {} + raise HelperError( + f"Zitadel API returned HTTP {exc.code} for {method}" + ) from None + except (urllib.error.URLError, TimeoutError, OSError): + raise HelperError(f"Zitadel API transport failed for {method}") from None + + if status not in accepted_statuses: + raise HelperError(f"Zitadel API returned unexpected HTTP {status} for {method}") + if not parse_json or not body: + return {} + try: + parsed = json.loads(body) + except json.JSONDecodeError: + raise HelperError("Zitadel API returned malformed JSON") from None + if not isinstance(parsed, dict): + raise HelperError("Zitadel API response was not an object") + return parsed + + +def wait_ready() -> None: + deadline = time.monotonic() + 180 + while time.monotonic() < deadline: + try: + request("GET", f"{BASE_URL}/debug/healthz", parse_json=False) + pat = read_pat() + request( + "POST", + f"{MANAGEMENT_URL}/projects/_search", + bearer=pat, + json_body={"queries": []}, + ) + return + except (HelperError, OSError): + pass + time.sleep(2) + raise HelperError("Zitadel or its bootstrap PAT did not become ready") + + +def read_pat() -> str: + try: + raw = PAT_PATH.read_bytes() + except OSError: + raise HelperError("Zitadel bootstrap PAT is unavailable") from None + if len(raw) > 16_384: + raise HelperError("Zitadel bootstrap PAT exceeded the size limit") + try: + value = raw.decode("ascii").strip() + except UnicodeDecodeError: + raise HelperError("Zitadel bootstrap PAT was not ASCII") from None + if not value or any(char.isspace() for char in value): + raise HelperError("Zitadel bootstrap PAT was empty or malformed") + return value + + +def nested_string(value: dict[str, Any], *path: str) -> str: + current: Any = value + for part in path: + if not isinstance(current, dict): + raise HelperError(f"Zitadel response omitted {'.'.join(path)}") + current = current.get(part) + if not isinstance(current, str) or not current: + raise HelperError(f"Zitadel response omitted {'.'.join(path)}") + return current + + +def runtime_owner() -> tuple[int, int]: + raw_uid = os.environ.get("REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_UID", "") + raw_gid = os.environ.get("REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_GID", "") + if not raw_uid.isdigit() or not raw_gid.isdigit(): + raise HelperError("runner runtime uid and gid must be decimal integers") + uid = int(raw_uid) + gid = int(raw_gid) + if uid > 2**32 - 2 or gid > 2**32 - 2: + raise HelperError("runner runtime uid or gid is outside the supported range") + return uid, gid + + +def atomic_json(path: Path, payload: dict[str, Any], mode: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp") + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(payload, handle, sort_keys=True, separators=(",", ":")) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, mode) + os.chown(temporary, *runtime_owner()) + os.replace(temporary, path) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def atomic_secret(path: Path, value: str) -> None: + if not value or "\n" in value or "\r" in value: + raise HelperError("refusing to write an empty or multiline secret") + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp") + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + with os.fdopen(descriptor, "w", encoding="ascii") as handle: + handle.write(value) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, 0o600) + os.chown(temporary, *runtime_owner()) + os.replace(temporary, path) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def provision() -> None: + wait_ready() + pat = read_pat() + run_id = os.environ.get("REGISTRY_RELAY_OIDC_SMOKE_RUN_ID", "") + canary = os.environ.get("REGISTRY_RELAY_OIDC_SMOKE_SECRET_CANARY", "") + if not run_id or not canary: + raise HelperError("runner identity and secret canary are required") + project_name = f"Registry Relay release smoke {run_id}" + service_username = f"relay-smoke-{run_id}" + + project = request( + "POST", + f"{MANAGEMENT_URL}/projects", + bearer=pat, + json_body={"name": project_name, "projectRoleAssertion": True}, + ) + project_id = nested_string(project, "id") + detail = request("GET", f"{MANAGEMENT_URL}/projects/{project_id}", bearer=pat) + project_org_id = nested_string(detail, "project", "details", "resourceOwner") + + request( + "POST", + f"{MANAGEMENT_URL}/projects/{project_id}/roles", + bearer=pat, + org_id=project_org_id, + json_body={ + "roleKey": ROLE_KEY, + "displayName": "Registry smoke metadata reader", + "group": "registry-relay", + }, + ) + + service = request( + "POST", + f"{MANAGEMENT_URL}/users/machine", + bearer=pat, + org_id=project_org_id, + json_body={ + "userName": service_username, + "name": "Registry Relay smoke client", + "description": "Ephemeral client for release verification", + }, + ) + service_id = nested_string(service, "userId") + service_detail = request( + "GET", + f"{MANAGEMENT_URL}/users/{service_id}", + bearer=pat, + org_id=project_org_id, + ) + service_org_id = nested_string(service_detail, "user", "details", "resourceOwner") + request( + "PUT", + f"{MANAGEMENT_URL}/users/{service_id}/machine", + bearer=pat, + org_id=service_org_id, + json_body={ + "name": "Registry Relay smoke client", + "description": "Ephemeral client for release verification", + "accessTokenType": 1, + }, + ) + generated_secret = request( + "PUT", + f"{MANAGEMENT_URL}/users/{service_id}/secret", + bearer=pat, + org_id=service_org_id, + json_body={}, + ) + client_id = nested_string(generated_secret, "clientId") + client_secret = nested_string(generated_secret, "clientSecret") + + request( + "POST", + f"{MANAGEMENT_URL}/users/{service_id}/grants", + bearer=pat, + org_id=project_org_id, + json_body={"projectId": project_id, "roleKeys": [ROLE_KEY]}, + ) + + atomic_json( + PUBLIC_PATH, + { + "client_id": client_id, + "issuer": BASE_URL, + "project_id": project_id, + "project_org_id": project_org_id, + "role_key": ROLE_KEY, + "service_account_id": service_id, + "service_account_org_id": service_org_id, + }, + 0o644, + ) + atomic_json( + SECRET_PATH, + {"client_id": client_id, "client_secret": client_secret, "canary": canary}, + 0o600, + ) + print("zitadel-helper: ephemeral project, role, and service account provisioned") + + +def load_json(path: Path, *, secret: bool = False) -> dict[str, Any]: + try: + if secret and path.stat().st_mode & 0o077: + raise HelperError("secret runtime file permissions are too broad") + raw = path.read_bytes() + except OSError: + raise HelperError( + f"required runtime file is unavailable: {path.name}" + ) from None + if len(raw) > 65_536: + raise HelperError(f"runtime file exceeded size limit: {path.name}") + try: + value = json.loads(raw) + except json.JSONDecodeError: + raise HelperError(f"runtime file is malformed: {path.name}") from None + if not isinstance(value, dict): + raise HelperError(f"runtime file is not an object: {path.name}") + return value + + +def mint() -> None: + public = load_json(PUBLIC_PATH) + secret = load_json(SECRET_PATH, secret=True) + project_id = nested_string(public, "project_id") + role_key = nested_string(public, "role_key") + client_id = nested_string(secret, "client_id") + client_secret = nested_string(secret, "client_secret") + if client_id != nested_string(public, "client_id"): + raise HelperError("public and secret client identifiers differ") + scope = " ".join( + [ + "openid", + f"urn:zitadel:iam:org:project:id:{project_id}:aud", + f"urn:zitadel:iam:org:project:role:{role_key}", + ] + ) + response = request( + "POST", + f"{BASE_URL}/oauth/v2/token", + form_body={"grant_type": "client_credentials", "scope": scope}, + basic_auth=(client_id, client_secret), + ) + token = nested_string(response, "access_token") + if response.get("token_type") != "Bearer": + raise HelperError("Zitadel token endpoint did not return a Bearer token") + atomic_secret(TOKEN_PATH, token) + print("zitadel-helper: bearer token written to the private runtime file") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("provision", "mint")) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + if args.command == "provision": + provision() + else: + mint() + return 0 + except HelperError as exc: + print(f"zitadel-helper: {exc}", file=os.sys.stderr) + return 2 + except Exception: + # The helper deliberately suppresses unexpected exception details: API + # responses and local variables can hold credentials. The runner still + # receives a failing status and can inspect provider logs locally. + print("zitadel-helper: unexpected internal failure", file=os.sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/release/scripts/openid-conformance-runner.py b/release/scripts/openid-conformance-runner.py index 7265905b0..8f412887b 100755 --- a/release/scripts/openid-conformance-runner.py +++ b/release/scripts/openid-conformance-runner.py @@ -426,7 +426,7 @@ def cmd_render_config(args: argparse.Namespace) -> int: def cmd_run(args: argparse.Namespace) -> int: plan_map = load_plan_map(args.plan_map) scenario = find_scenario(plan_map, args.scenario) - if scenario.get("status") != "applicable" and not args.allow_blocked: + if scenario.get("status") not in {"applicable", "candidate-only"} and not args.allow_blocked: raise RunnerError( f"scenario {scenario['id']} is {scenario.get('status')}; " "pass --allow-blocked to run it anyway" diff --git a/release/scripts/relay-oidc-smoke.py b/release/scripts/relay-oidc-smoke.py new file mode 100755 index 000000000..9994dc381 --- /dev/null +++ b/release/scripts/relay-oidc-smoke.py @@ -0,0 +1,977 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Run the Registry Relay OIDC release smoke against a pinned image digest.""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import datetime as dt +import hashlib +import json +import os +import re +import secrets +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path +from string import Template +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[2] +CONFIG_DIR = REPO_ROOT / "release" / "conformance" / "relay-oidc" +COMPOSE_PATH = CONFIG_DIR / "docker-compose.yml" +TEMPLATE_PATH = CONFIG_DIR / "relay.template.yaml" +FIXTURE_PATH = CONFIG_DIR / "records.csv" +HELPER_PATH = CONFIG_DIR / "zitadel-helper.py" +DEFAULT_WORK_ROOT = REPO_ROOT / "target" / "relay-oidc-smoke" +SCHEMA_VERSION = "registry.release.relay_oidc_smoke.v1" +RELAY_IMAGE_RE = re.compile( + r"^ghcr\.io/registrystack/registry-relay@sha256:[0-9a-f]{64}$" +) +DIGEST_IMAGE_RE = re.compile(r"^[^\s@]+:[^\s@]+@sha256:[0-9a-f]{64}$") +SOURCE_REF_RE = re.compile(r"^[0-9a-f]{40}$") +RELEASE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$") +RUN_ID_RE = re.compile(r"^[0-9a-f]{12}$") +MAX_HTTP_BODY = 65_536 +REQUIRED_CHECKS = ( + "candidate-image-binding", + "missing-credential", + "valid-zitadel-role-mapping", + "tampered-signature", + "audience-mismatch", + "token-type-denied", + "organization-role-scope-denied", +) + + +class SmokeError(RuntimeError): + """A bounded, user-actionable runner failure.""" + + +class CheckFailure(SmokeError): + """A conformance assertion failed.""" + + +def utc_now() -> str: + return ( + dt.datetime.now(dt.UTC) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def file_sha256(path: Path) -> str: + return sha256_bytes(path.read_bytes()) + + +def validate_relay_image(value: str) -> str: + if not RELAY_IMAGE_RE.fullmatch(value): + raise SmokeError( + "Relay image must be exactly " + "ghcr.io/registrystack/registry-relay@sha256:<64 lowercase hex>" + ) + return value + + +def validate_source_ref(value: str) -> str: + if not SOURCE_REF_RE.fullmatch(value): + raise SmokeError( + "candidate source ref must be 40 lowercase hexadecimal characters" + ) + return value + + +def validate_release_id(value: str) -> str: + if not RELEASE_ID_RE.fullmatch(value): + raise SmokeError("release id contains unsupported characters or is too long") + return value + + +def compose_image_entries(text: str) -> list[str]: + return [ + match.group(1).strip() + for line in text.splitlines() + if (match := re.match(r"^\s+image:\s+(.+?)\s*$", line)) + ] + + +def validate_assets() -> dict[str, Any]: + required = (COMPOSE_PATH, TEMPLATE_PATH, FIXTURE_PATH, HELPER_PATH) + missing = [str(path) for path in required if not path.is_file()] + if missing: + raise SmokeError(f"missing Relay OIDC smoke assets: {', '.join(missing)}") + + compose = COMPOSE_PATH.read_text(encoding="utf-8") + if re.search(r"^\s+build:\s*", compose, re.MULTILINE): + raise SmokeError("release smoke Compose topology must not build images") + images = compose_image_entries(compose) + relay_entries = [ + image + for image in images + if image.startswith("${REGISTRY_RELAY_OIDC_SMOKE_RELAY_IMAGE") + ] + if relay_entries != [ + "${REGISTRY_RELAY_OIDC_SMOKE_RELAY_IMAGE:" + "?runner must provide digest-pinned Registry Relay image}" + ]: + raise SmokeError( + "Compose topology must declare one runner-supplied Relay image" + ) + literal_images = [image for image in images if image not in relay_entries] + if len(literal_images) < 3: + raise SmokeError("Compose topology is missing pinned supporting images") + for image in literal_images: + if not DIGEST_IMAGE_RE.fullmatch(image): + raise SmokeError(f"supporting image is not tag-and-digest pinned: {image}") + if ":latest" in compose or "@sha256:" not in compose: + raise SmokeError("Compose topology contains a mutable or missing image pin") + for ownership_env in ( + "REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_UID", + "REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_GID", + ): + if ownership_env not in compose: + raise SmokeError( + f"Compose topology does not preserve host ownership: {ownership_env}" + ) + + template = TEMPLATE_PATH.read_text(encoding="utf-8") + required_template_fragments = ( + "mode: oidc", + "discovery_url: http://localhost:8080/.well-known/openid-configuration", + "scope_claim: $scope_claim", + '"registry-smoke-reader": "smoke_registry:metadata"', + "$audience", + "$required_org", + "$allowed_client", + "$allowed_token_type", + ) + for fragment in required_template_fragments: + if fragment not in template: + raise SmokeError(f"Relay template is missing required contract: {fragment}") + fixture = FIXTURE_PATH.read_text(encoding="utf-8") + if fixture != "person_id,display_name\nperson-001,Registry Smoke Person\n": + raise SmokeError("synthetic Relay smoke fixture has unexpected content") + + return { + "compose_sha256": file_sha256(COMPOSE_PATH), + "fixture_sha256": file_sha256(FIXTURE_PATH), + "helper_sha256": file_sha256(HELPER_PATH), + "support_images": sorted(set(literal_images)), + "template_sha256": file_sha256(TEMPLATE_PATH), + } + + +def plan_document(relay_image: str, source_ref: str, release_id: str) -> dict[str, Any]: + assets = validate_assets() + return { + "schema_version": SCHEMA_VERSION, + "operation": "relay-oidc-smoke", + "classification": "candidate-neutral-harness-plan", + "release_id": validate_release_id(release_id), + "candidate_source_ref": validate_source_ref(source_ref), + "relay_image": validate_relay_image(relay_image), + "topology": assets, + "checks": list(REQUIRED_CHECKS), + "plan_network_required": False, + "live_run_requires_docker": True, + "live_run_network_required": True, + "live_evidence": False, + "notes": [ + "This plan validates checked-in inputs only and is not conformance evidence.", + "A live run remains unreviewed until its digest-bound report is " + "reviewed without raw secrets.", + ], + } + + +class SensitiveGuard: + """Redact and detect values that must not leave ephemeral runtime state.""" + + def __init__(self, *values: str): + self._values: set[str] = set() + for value in values: + self.add(value) + + def add(self, value: str | None) -> None: + if value and len(value) >= 8: + self._values.add(value) + + def redact(self, text: str) -> str: + rendered = text + for value in sorted(self._values, key=len, reverse=True): + rendered = rendered.replace(value, "") + return rendered + + def assert_clean_bytes(self, value: bytes, context: str) -> None: + for secret in self._values: + if secret.encode("utf-8") in value: + raise SmokeError(f"sensitive-value canary detected in {context}") + + def assert_clean_tree(self, root: Path) -> None: + for path in root.rglob("*"): + if path.is_symlink(): + raise SmokeError(f"refusing to scan symlink in output: {path.name}") + if path.is_file(): + self.assert_clean_bytes(path.read_bytes(), path.name) + + +def run_checked( + command: list[str], + *, + env: dict[str, str], + guard: SensitiveGuard, + timeout: int = 300, + accepted: tuple[int, ...] = (0,), +) -> subprocess.CompletedProcess[str]: + try: + result = subprocess.run( + command, + env=env, + text=True, + capture_output=True, + check=False, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + raise SmokeError(f"command timed out: {' '.join(command[:4])}") from None + combined = f"{result.stdout}\n{result.stderr}" + guard.assert_clean_bytes(combined.encode("utf-8"), "subprocess output") + if result.returncode not in accepted: + diagnostic = guard.redact(combined).strip() + if len(diagnostic) > 2_000: + diagnostic = diagnostic[:2_000] + "..." + raise SmokeError( + f"command failed ({result.returncode}): {' '.join(command[:4])}" + + (f"\n{diagnostic}" if diagnostic else "") + ) + return result + + +def compose_command(project_name: str, *args: str) -> list[str]: + return [ + "docker", + "compose", + "--project-name", + project_name, + "-f", + str(COMPOSE_PATH), + *args, + ] + + +def free_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def read_limited(path: Path, limit: int, *, private: bool = False) -> bytes: + if path.is_symlink() or not path.is_file(): + raise SmokeError(f"required runtime file is not regular: {path.name}") + if private and path.stat().st_mode & 0o077: + raise SmokeError(f"secret runtime file permissions are too broad: {path.name}") + if path.stat().st_size > limit: + raise SmokeError(f"runtime file exceeds size limit: {path.name}") + return path.read_bytes() + + +def read_json_object(path: Path, *, private: bool = False) -> dict[str, Any]: + raw = read_limited(path, 65_536, private=private) + try: + value = json.loads(raw) + except json.JSONDecodeError: + raise SmokeError(f"runtime file is malformed JSON: {path.name}") from None + if not isinstance(value, dict): + raise SmokeError(f"runtime JSON is not an object: {path.name}") + return value + + +def require_string(value: dict[str, Any], key: str) -> str: + candidate = value.get(key) + if not isinstance(candidate, str) or not candidate: + raise SmokeError(f"runtime metadata omitted {key}") + return candidate + + +def decode_jwt_segment(segment: str, context: str) -> dict[str, Any]: + if len(segment) > 16_384: + raise SmokeError(f"JWT {context} segment exceeds size limit") + try: + raw = base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4)) + parsed = json.loads(raw) + except (ValueError, json.JSONDecodeError): + raise SmokeError(f"JWT {context} segment is malformed") from None + if not isinstance(parsed, dict): + raise SmokeError(f"JWT {context} is not an object") + return parsed + + +def inspect_token(token: str, topology: dict[str, Any]) -> dict[str, Any]: + parts = token.split(".") + if len(parts) != 3 or any(not part for part in parts): + raise SmokeError("Zitadel access token is not a compact JWT") + header = decode_jwt_segment(parts[0], "header") + claims = decode_jwt_segment(parts[1], "payload") + if header.get("alg") != "RS256": + raise SmokeError("Zitadel access token does not use the pinned RS256 profile") + token_type = header.get("typ") + if not isinstance(token_type, str) or token_type.lower() not in {"jwt", "at+jwt"}: + raise SmokeError("Zitadel access token has an unsupported JOSE typ") + if not isinstance(header.get("kid"), str) or not header["kid"]: + raise SmokeError("Zitadel access token has no signing key id") + if claims.get("iss") != "http://localhost:8080": + raise SmokeError( + "Zitadel access token issuer does not match the local topology" + ) + + project_id = require_string(topology, "project_id") + audiences = claims.get("aud") + if isinstance(audiences, str): + audiences = [audiences] + if not isinstance(audiences, list) or project_id not in audiences: + raise SmokeError( + "Zitadel access token is not audience-bound to the smoke project" + ) + + client = claims.get("azp") or claims.get("client_id") + if not isinstance(client, str) or not client: + raise SmokeError("Zitadel access token has no authorized client claim") + configured_client = require_string(topology, "client_id") + if client != configured_client: + raise SmokeError( + "Zitadel access token client differs from the provisioned client" + ) + + role_key = require_string(topology, "role_key") + org_id = require_string(topology, "service_account_org_id") + claim_names = ( + f"urn:zitadel:iam:org:project:{project_id}:roles", + "urn:zitadel:iam:org:project:roles", + ) + claim_name = next( + (name for name in claim_names if isinstance(claims.get(name), dict)), None + ) + if claim_name is None: + raise SmokeError( + "Zitadel access token omitted a native project-role object claim" + ) + roles = claims[claim_name] + if not isinstance(roles, dict) or not isinstance(roles.get(role_key), dict): + raise SmokeError( + "Zitadel access token omitted the provisioned native project role" + ) + active_org_value = roles[role_key].get(org_id) + if not isinstance(active_org_value, str) or not active_org_value.strip(): + raise SmokeError( + "Zitadel role claim is not active for the service account organization" + ) + + return { + "allowed_client": client, + "audience": project_id, + "required_org": org_id, + "scope_claim": claim_name, + "token_type": token_type, + } + + +def atomic_text(path: Path, value: str, mode: int = 0o644) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp") + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(value) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, mode) + os.replace(temporary, path) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def render_relay_config( + token_profile: dict[str, Any], + *, + host_port: int, + audience: str | None = None, + required_org: str | None = None, + allowed_token_type: str | None = None, +) -> str: + params = { + "base_url": json.dumps(f"http://127.0.0.1:{host_port}"), + "audience": json.dumps(audience or require_string(token_profile, "audience")), + "required_org": json.dumps( + required_org or require_string(token_profile, "required_org") + ), + "allowed_client": json.dumps(require_string(token_profile, "allowed_client")), + "scope_claim": json.dumps(require_string(token_profile, "scope_claim")), + "allowed_token_type": json.dumps( + allowed_token_type or require_string(token_profile, "token_type") + ), + } + rendered = Template(TEMPLATE_PATH.read_text(encoding="utf-8")).substitute(params) + if "$" in rendered: + raise SmokeError("Relay configuration template left unresolved placeholders") + return rendered + + +def tamper_signature(token: str) -> str: + parts = token.split(".") + if len(parts) != 3 or not parts[2]: + raise SmokeError("cannot tamper malformed JWT") + try: + signature = bytearray( + base64.urlsafe_b64decode(parts[2] + "=" * (-len(parts[2]) % 4)) + ) + except (ValueError, binascii.Error): + raise SmokeError("cannot tamper malformed JWT signature") from None + if not signature: + raise SmokeError("cannot tamper empty JWT signature") + signature[-1] ^= 0x01 + encoded = base64.urlsafe_b64encode(signature).rstrip(b"=").decode("ascii") + return f"{parts[0]}.{parts[1]}.{encoded}" + + +def http_json( + url: str, token: str | None, guard: SensitiveGuard +) -> tuple[int, dict[str, Any]]: + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(url, headers=headers, method="GET") + try: + with urllib.request.urlopen(request, timeout=15) as response: + status = response.status + body = response.read(MAX_HTTP_BODY + 1) + except urllib.error.HTTPError as exc: + status = exc.code + body = exc.read(MAX_HTTP_BODY + 1) + except (urllib.error.URLError, TimeoutError, OSError): + raise SmokeError("Relay request transport failed") from None + if len(body) > MAX_HTTP_BODY: + raise SmokeError("Relay response exceeded the 64 KiB smoke limit") + guard.assert_clean_bytes(body, "Relay response") + if not body: + return status, {} + try: + parsed = json.loads(body) + except json.JSONDecodeError: + raise SmokeError("Relay returned malformed JSON") from None + if not isinstance(parsed, dict): + raise SmokeError("Relay response is not a JSON object") + return status, parsed + + +def expected_response( + checks: list[dict[str, Any]], + check_id: str, + response: tuple[int, dict[str, Any]], + *, + status: int, + code: str | None = None, +) -> dict[str, Any]: + actual_status, body = response + actual_code = body.get("code") if isinstance(body.get("code"), str) else None + check = { + "id": check_id, + "expected_status": status, + "actual_status": actual_status, + "expected_code": code, + "actual_code": actual_code, + "result": "pass" if actual_status == status and actual_code == code else "fail", + } + checks.append(check) + if check["result"] != "pass": + raise CheckFailure( + f"{check_id} expected HTTP {status}/{code or '-'}, " + f"got {actual_status}/{actual_code or '-'}" + ) + return body + + +def wait_for_relay( + base_url: str, env: dict[str, str], project: str, guard: SensitiveGuard +) -> None: + deadline = time.monotonic() + 120 + last_error = "" + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(f"{base_url}/healthz", timeout=3) as response: + if response.status == 200: + return + except (urllib.error.URLError, TimeoutError, OSError) as exc: + last_error = ( + str(exc.reason) if isinstance(exc, urllib.error.URLError) else str(exc) + ) + time.sleep(1) + logs = run_checked( + compose_command(project, "logs", "--no-color", "--tail", "100", "relay"), + env=env, + guard=guard, + timeout=30, + accepted=(0, 1), + ) + diagnostic = guard.redact(f"{logs.stdout}\n{logs.stderr}").strip() + if len(diagnostic) > 2_000: + diagnostic = diagnostic[-2_000:] + raise SmokeError(f"Relay did not become healthy: {last_error}\n{diagnostic}") + + +def start_relay( + config_path: Path, + rendered: str, + *, + base_url: str, + env: dict[str, str], + project: str, + guard: SensitiveGuard, +) -> str: + atomic_text(config_path, rendered) + run_checked( + compose_command(project, "up", "-d", "--force-recreate", "relay"), + env=env, + guard=guard, + ) + wait_for_relay(base_url, env, project, guard) + return sha256_bytes(rendered.encode("utf-8")) + + +def relay_container_image( + env: dict[str, str], project: str, guard: SensitiveGuard +) -> str: + container = run_checked( + compose_command(project, "ps", "-q", "relay"), env=env, guard=guard + ).stdout.strip() + if not re.fullmatch(r"[0-9a-f]{12,64}", container): + raise SmokeError("could not resolve the running Relay container") + return run_checked( + ["docker", "inspect", "--format", "{{.Config.Image}}", container], + env=env, + guard=guard, + ).stdout.strip() + + +def safe_report( + report: dict[str, Any], output_dir: Path, guard: SensitiveGuard +) -> Path: + allowed_keys = { + "schema_version", + "classification", + "review_required", + "contains_sensitive_material", + "release_id", + "candidate_source_ref", + "relay_image", + "started_at", + "completed_at", + "result", + "failure_stage", + "diagnostic", + "cleanup", + "topology", + "configuration_digests", + "checks", + } + unexpected = set(report) - allowed_keys + if unexpected: + raise SmokeError( + f"report contains non-allowlisted fields: {sorted(unexpected)}" + ) + raw = (json.dumps(report, indent=2, sort_keys=True) + "\n").encode("utf-8") + guard.assert_clean_bytes(raw, "report") + path = output_dir / "relay-oidc-smoke-report.json" + atomic_text(path, raw.decode("utf-8")) + guard.assert_clean_tree(output_dir) + return path + + +def ensure_empty_output(path: Path) -> Path: + resolved = path.expanduser().resolve() + if resolved == Path(resolved.anchor) or resolved == REPO_ROOT: + raise SmokeError("output directory is too broad") + if resolved.exists(): + if not resolved.is_dir() or resolved.is_symlink(): + raise SmokeError("output path must be a regular directory") + if any(resolved.iterdir()): + raise SmokeError("output directory must be empty") + else: + resolved.mkdir(parents=True, mode=0o755) + return resolved + + +def default_output_dir() -> Path: + stamp = dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ") + return DEFAULT_WORK_ROOT / f"run-{stamp}-{secrets.token_hex(3)}" + + +def execute_live(args: argparse.Namespace) -> Path: + assets = validate_assets() + relay_image = validate_relay_image(args.relay_image) + source_ref = validate_source_ref(args.candidate_source_ref) + release_id = validate_release_id(args.release_id) + if not shutil.which("docker"): + raise SmokeError("Docker with Compose is required for a live smoke") + + output_dir = ensure_empty_output( + Path(args.output_dir) if args.output_dir else default_output_dir() + ) + DEFAULT_WORK_ROOT.mkdir(parents=True, exist_ok=True) + run_id = secrets.token_hex(6) + if not RUN_ID_RE.fullmatch(run_id): + raise SmokeError("internal run id generation failed") + project = f"registry-relay-oidc-{run_id}" + host_port = args.host_port or free_loopback_port() + if not 1_024 <= host_port <= 65_535: + raise SmokeError("host port must be between 1024 and 65535") + base_url = f"http://127.0.0.1:{host_port}" + + canary = f"RSOIDC_CANARY_{secrets.token_hex(16)}" + postgres_password = f"{canary}_postgres" + audit_secret = f"{canary}_audit_{secrets.token_hex(16)}" + master_key = secrets.token_hex(16) + guard = SensitiveGuard(canary, postgres_password, audit_secret, master_key) + runtime_dir = Path( + tempfile.mkdtemp(prefix=f"runtime-{run_id}-", dir=DEFAULT_WORK_ROOT) + ) + os.chmod(runtime_dir, 0o700) + config_path = runtime_dir / "relay.yaml" + atomic_text(config_path, "# placeholder; Relay is not started before rendering\n") + + env = os.environ.copy() + env.update( + { + "REGISTRY_RELAY_OIDC_SMOKE_AUDIT_HASH_SECRET": audit_secret, + "REGISTRY_RELAY_OIDC_SMOKE_CONFIG_DIR": str(CONFIG_DIR), + "REGISTRY_RELAY_OIDC_SMOKE_HOST_PORT": str(host_port), + "REGISTRY_RELAY_OIDC_SMOKE_POSTGRES_PASSWORD": postgres_password, + "REGISTRY_RELAY_OIDC_SMOKE_RELAY_CONFIG": str(config_path), + "REGISTRY_RELAY_OIDC_SMOKE_RELAY_IMAGE": relay_image, + "REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_GID": str(os.getgid()), + "REGISTRY_RELAY_OIDC_SMOKE_RUN_ID": run_id, + "REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_DIR": str(runtime_dir), + "REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_UID": str(os.getuid()), + "REGISTRY_RELAY_OIDC_SMOKE_SECRET_CANARY": canary, + "REGISTRY_RELAY_OIDC_SMOKE_ZITADEL_MASTERKEY": master_key, + } + ) + + report: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "classification": "unreviewed-live-candidate-output", + "review_required": True, + "contains_sensitive_material": False, + "release_id": release_id, + "candidate_source_ref": source_ref, + "relay_image": relay_image, + "started_at": utc_now(), + "completed_at": None, + "result": "error", + "failure_stage": None, + "diagnostic": None, + "cleanup": "pending", + "topology": assets, + "configuration_digests": {}, + "checks": [], + } + stage = "topology-start" + primary_error: SmokeError | None = None + try: + run_checked( + compose_command(project, "up", "-d", "zitadel"), + env=env, + guard=guard, + timeout=420, + ) + stage = "zitadel-provision" + provision_result = run_checked( + compose_command(project, "run", "--rm", "--no-deps", "helper", "provision"), + env=env, + guard=guard, + timeout=240, + ) + topology = read_json_object(runtime_dir / "topology.json") + secret_topology = read_json_object( + runtime_dir / "topology-secret.json", private=True + ) + guard.add(require_string(secret_topology, "client_secret")) + guard.assert_clean_bytes( + f"{provision_result.stdout}\n{provision_result.stderr}".encode("utf-8"), + "provision subprocess output after credential creation", + ) + if require_string(secret_topology, "canary") != canary: + raise SmokeError( + "secret canary did not survive the provider bootstrap boundary" + ) + + stage = "token-mint" + mint_result = run_checked( + compose_command(project, "run", "--rm", "--no-deps", "helper", "mint"), + env=env, + guard=guard, + timeout=120, + ) + token = read_limited(runtime_dir / "access-token", 32_768, private=True).decode( + "ascii" + ) + if not token or any(char.isspace() for char in token): + raise SmokeError("helper produced an empty or malformed access token") + guard.add(token) + guard.assert_clean_bytes( + f"{mint_result.stdout}\n{mint_result.stderr}".encode("utf-8"), + "mint subprocess output after token creation", + ) + token_profile = inspect_token(token, topology) + + stage = "positive-relay-start" + positive = render_relay_config(token_profile, host_port=host_port) + report["configuration_digests"]["positive"] = start_relay( + config_path, + positive, + base_url=base_url, + env=env, + project=project, + guard=guard, + ) + running_image = relay_container_image(env, project, guard) + image_check = { + "id": "candidate-image-binding", + "expected_status": None, + "actual_status": None, + "expected_code": relay_image, + "actual_code": running_image, + "result": "pass" if running_image == relay_image else "fail", + } + report["checks"].append(image_check) + if image_check["result"] != "pass": + raise CheckFailure( + "running Relay container is not bound to the requested digest" + ) + + stage = "positive-and-signature-checks" + endpoint = f"{base_url}/v1/datasets" + expected_response( + report["checks"], + "missing-credential", + http_json(endpoint, None, guard), + status=401, + code="auth.missing_credential", + ) + valid_body = expected_response( + report["checks"], + "valid-zitadel-role-mapping", + http_json(endpoint, token, guard), + status=200, + ) + datasets = valid_body.get("data") + if not isinstance(datasets, list) or not any( + isinstance(item, dict) and item.get("dataset_id") == "smoke_registry" + for item in datasets + ): + report["checks"][-1]["result"] = "fail" + raise CheckFailure( + "valid token response did not expose the mapped smoke dataset" + ) + expected_response( + report["checks"], + "tampered-signature", + http_json(endpoint, tamper_signature(token), guard), + status=401, + code="auth.token_signature_invalid", + ) + + stage = "audience-mismatch-check" + wrong_audience = render_relay_config( + token_profile, + host_port=host_port, + audience="registry-relay-smoke-wrong-audience", + ) + report["configuration_digests"]["wrong_audience"] = start_relay( + config_path, + wrong_audience, + base_url=base_url, + env=env, + project=project, + guard=guard, + ) + expected_response( + report["checks"], + "audience-mismatch", + http_json(endpoint, token, guard), + status=401, + code="auth.audience_mismatch", + ) + + stage = "token-type-check" + wrong_type = render_relay_config( + token_profile, + host_port=host_port, + allowed_token_type="registry-smoke-invalid+jwt", + ) + report["configuration_digests"]["wrong_token_type"] = start_relay( + config_path, + wrong_type, + base_url=base_url, + env=env, + project=project, + guard=guard, + ) + expected_response( + report["checks"], + "token-type-denied", + http_json(endpoint, token, guard), + status=401, + code="auth.malformed_credential", + ) + + stage = "organization-role-scope-check" + wrong_org = render_relay_config( + token_profile, + host_port=host_port, + required_org="registry-smoke-wrong-organization", + ) + report["configuration_digests"]["wrong_organization"] = start_relay( + config_path, + wrong_org, + base_url=base_url, + env=env, + project=project, + guard=guard, + ) + expected_response( + report["checks"], + "organization-role-scope-denied", + http_json(endpoint, token, guard), + status=403, + code="auth.scope_denied", + ) + report["result"] = "pass" + except (SmokeError, UnicodeDecodeError, OSError) as exc: + if isinstance(exc, SmokeError): + primary_error = exc + elif isinstance(exc, UnicodeDecodeError): + primary_error = SmokeError("token was not ASCII") + else: + primary_error = SmokeError(f"local runtime operation failed: {exc}") + report["result"] = ( + "fail" if isinstance(primary_error, CheckFailure) else "error" + ) + report["failure_stage"] = stage + report["diagnostic"] = guard.redact(str(primary_error))[:2_000] + except KeyboardInterrupt: + primary_error = SmokeError("run interrupted") + report["result"] = "error" + report["failure_stage"] = stage + report["diagnostic"] = "run interrupted" + finally: + cleanup_error: SmokeError | None = None + try: + run_checked( + compose_command(project, "down", "--volumes", "--remove-orphans"), + env=env, + guard=guard, + timeout=180, + ) + report["cleanup"] = "complete" + except SmokeError as exc: + cleanup_error = exc + report["cleanup"] = "failed" + if primary_error is None: + primary_error = exc + report["result"] = "error" + report["failure_stage"] = "cleanup" + report["diagnostic"] = guard.redact(str(exc))[:2_000] + finally: + shutil.rmtree(runtime_dir) + report["completed_at"] = utc_now() + report_path = safe_report(report, output_dir, guard) + if cleanup_error and primary_error is not cleanup_error: + print( + guard.redact(f"relay-oidc-smoke: cleanup also failed: {cleanup_error}"), + file=sys.stderr, + ) + + if primary_error: + raise SmokeError(f"{primary_error}; unreviewed report: {report_path}") + return report_path + + +def cmd_validate(args: argparse.Namespace) -> int: + del args + print(json.dumps(validate_assets(), indent=2, sort_keys=True)) + return 0 + + +def cmd_plan(args: argparse.Namespace) -> int: + print( + json.dumps( + plan_document(args.relay_image, args.candidate_source_ref, args.release_id), + indent=2, + sort_keys=True, + ) + ) + return 0 + + +def cmd_run(args: argparse.Namespace) -> int: + print(execute_live(args)) + return 0 + + +def add_candidate_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--relay-image", required=True) + parser.add_argument("--candidate-source-ref", required=True) + parser.add_argument("--release-id", required=True) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + validate_parser = subparsers.add_parser( + "validate", help="validate checked-in topology without Docker or network access" + ) + validate_parser.set_defaults(func=cmd_validate) + + plan_parser = subparsers.add_parser( + "plan", help="render a candidate-bound offline execution plan" + ) + add_candidate_args(plan_parser) + plan_parser.set_defaults(func=cmd_plan) + + run_parser = subparsers.add_parser("run", help="run the live ephemeral smoke") + add_candidate_args(run_parser) + run_parser.add_argument("--host-port", type=int) + run_parser.add_argument("--output-dir") + run_parser.set_defaults(func=cmd_run) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + try: + return int(args.func(args)) + except (OSError, SmokeError) as exc: + print(f"relay-oidc-smoke: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/release/scripts/test_openid_conformance_runner.py b/release/scripts/test_openid_conformance_runner.py index 02d0a29e9..245b0ccfa 100755 --- a/release/scripts/test_openid_conformance_runner.py +++ b/release/scripts/test_openid_conformance_runner.py @@ -452,6 +452,29 @@ def test_blocked_full_scenario_requires_explicit_override(self) -> None: ): self.runner.cmd_run(args) + def test_candidate_only_metadata_scenario_runs_without_blocked_override(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + args = self.runner.parse_args( + [ + "run", + "notary-oid4vci-issuer-metadata", + "--issuer-url", + "https://issuer.example.test", + "--output-dir", + tmp, + "--suite-dir", + str(Path(tmp) / "suite"), + "--no-prepare", + "--dry-run", + ] + ) + with patch("builtins.print") as printed: + self.assertEqual(0, self.runner.cmd_run(args)) + invocation = json.loads(printed.call_args.args[0]) + self.assertIn( + "oid4vci-1_0-issuer-metadata-test", " ".join(invocation["command"]) + ) + if __name__ == "__main__": main() diff --git a/release/scripts/test_relay_oidc_smoke.py b/release/scripts/test_relay_oidc_smoke.py new file mode 100644 index 000000000..8828b057a --- /dev/null +++ b/release/scripts/test_relay_oidc_smoke.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import base64 +import importlib.util +import json +import sys +import tempfile +from pathlib import Path +from unittest import TestCase, main +from unittest.mock import patch + + +SCRIPT_DIR = Path(__file__).resolve().parent +RUNNER_PATH = SCRIPT_DIR / "relay-oidc-smoke.py" +HELPER_PATH = SCRIPT_DIR.parent / "conformance" / "relay-oidc" / "zitadel-helper.py" + + +def load_runner(): + spec = importlib.util.spec_from_file_location("relay_oidc_smoke", RUNNER_PATH) + if not spec or not spec.loader: + raise RuntimeError(f"could not load {RUNNER_PATH}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def load_helper(): + spec = importlib.util.spec_from_file_location( + "relay_oidc_zitadel_helper", HELPER_PATH + ) + if not spec or not spec.loader: + raise RuntimeError(f"could not load {HELPER_PATH}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class PlainTextResponse: + status = 200 + headers: dict[str, str] = {} + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, _limit: int) -> bytes: + return b"ok" + + +def encode_json(value: dict[str, object]) -> str: + raw = json.dumps(value, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def fake_token( + *, + role_claim: object | None = None, + claim_name: str = "urn:zitadel:iam:org:project:roles", +) -> str: + header = encode_json({"alg": "RS256", "typ": "JWT", "kid": "key-1"}) + payload: dict[str, object] = { + "iss": "http://localhost:8080", + "aud": ["project-1"], + "azp": "client-1", + } + if role_claim is not None: + payload[claim_name] = role_claim + signature = base64.urlsafe_b64encode(b"synthetic-signature").rstrip(b"=").decode() + return f"{header}.{encode_json(payload)}.{signature}" + + +class RelayOidcSmokeTest(TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.runner = load_runner() + cls.helper = load_helper() + + def topology(self) -> dict[str, str]: + return { + "client_id": "client-1", + "project_id": "project-1", + "role_key": "registry-smoke-reader", + "service_account_org_id": "org-1", + } + + def test_assets_are_candidate_neutral_and_digest_pinned(self) -> None: + assets = self.runner.validate_assets() + compose = self.runner.COMPOSE_PATH.read_text(encoding="utf-8") + + self.assertNotIn("build:", compose) + self.assertEqual(3, len(assets["support_images"])) + self.assertEqual( + len(assets["support_images"]), len(set(assets["support_images"])) + ) + for image in assets["support_images"]: + self.assertRegex(image, self.runner.DIGEST_IMAGE_RE) + self.assertNotIn("ghcr.io/registrystack/registry-relay@sha256:", compose) + + def test_relay_image_requires_exact_repository_and_lowercase_digest(self) -> None: + valid = "ghcr.io/registrystack/registry-relay@sha256:" + "a" * 64 + self.assertEqual(valid, self.runner.validate_relay_image(valid)) + + invalid = ( + "ghcr.io/registrystack/registry-relay:1.0.0", + "ghcr.io/registrystack/registry-relay:1.0.0@sha256:" + "a" * 64, + "example.test/registry-relay@sha256:" + "a" * 64, + "ghcr.io/registrystack/registry-relay@sha256:" + "A" * 64, + ) + for image in invalid: + with self.subTest(image=image): + with self.assertRaises(self.runner.SmokeError): + self.runner.validate_relay_image(image) + + def test_plan_is_offline_and_does_not_claim_live_evidence(self) -> None: + plan = self.runner.plan_document( + "ghcr.io/registrystack/registry-relay@sha256:" + "a" * 64, + "b" * 40, + "1.0.0-rc.1", + ) + + self.assertFalse(plan["plan_network_required"]) + self.assertTrue(plan["live_run_requires_docker"]) + self.assertTrue(plan["live_run_network_required"]) + self.assertFalse(plan["live_evidence"]) + self.assertEqual(list(self.runner.REQUIRED_CHECKS), plan["checks"]) + self.assertEqual("candidate-neutral-harness-plan", plan["classification"]) + + def test_candidate_identifiers_are_bounded(self) -> None: + self.assertEqual("a" * 40, self.runner.validate_source_ref("a" * 40)) + self.assertEqual("1.0.0-rc.1", self.runner.validate_release_id("1.0.0-rc.1")) + for source_ref in ("a" * 39, "A" * 40, "main"): + with self.assertRaises(self.runner.SmokeError): + self.runner.validate_source_ref(source_ref) + for release_id in ("", "contains space", "a" * 65): + with self.assertRaises(self.runner.SmokeError): + self.runner.validate_release_id(release_id) + + def test_native_zitadel_role_claim_drives_scope_profile(self) -> None: + token = fake_token(role_claim={"registry-smoke-reader": {"org-1": "active"}}) + profile = self.runner.inspect_token(token, self.topology()) + + self.assertEqual("client-1", profile["allowed_client"]) + self.assertEqual("project-1", profile["audience"]) + self.assertEqual("org-1", profile["required_org"]) + self.assertEqual("urn:zitadel:iam:org:project:roles", profile["scope_claim"]) + rendered = self.runner.render_relay_config(profile, host_port=19191) + self.assertIn("mode: oidc", rendered) + self.assertIn('scope_claim: "urn:zitadel:iam:org:project:roles"', rendered) + self.assertIn('"registry-smoke-reader": "smoke_registry:metadata"', rendered) + self.assertIn('- "org-1"', rendered) + self.assertNotIn("$", rendered) + + def test_project_specific_native_role_claim_is_preferred(self) -> None: + claim_name = "urn:zitadel:iam:org:project:project-1:roles" + token = fake_token( + claim_name=claim_name, + role_claim={"registry-smoke-reader": {"org-1": "active"}}, + ) + profile = self.runner.inspect_token(token, self.topology()) + rendered = self.runner.render_relay_config(profile, host_port=19191) + + self.assertEqual(claim_name, profile["scope_claim"]) + self.assertIn(f'scope_claim: "{claim_name}"', rendered) + + def test_role_claim_requires_the_service_account_organization(self) -> None: + wrong_org = fake_token( + role_claim={"registry-smoke-reader": {"org-other": "active"}} + ) + with self.assertRaisesRegex(self.runner.SmokeError, "not active"): + self.runner.inspect_token(wrong_org, self.topology()) + with self.assertRaisesRegex(self.runner.SmokeError, "omitted"): + self.runner.inspect_token(fake_token(), self.topology()) + + def test_signature_tamper_changes_decoded_signature_bytes(self) -> None: + token = fake_token(role_claim={}) + tampered = self.runner.tamper_signature(token) + original_segment = token.split(".")[2] + tampered_segment = tampered.split(".")[2] + + def decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + self.assertEqual(token.split(".")[:2], tampered.split(".")[:2]) + self.assertNotEqual(decode(original_segment), decode(tampered_segment)) + + def test_sensitive_guard_redacts_and_rejects_leaks(self) -> None: + guard = self.runner.SensitiveGuard("secret-canary-value") + self.assertEqual("token=", guard.redact("token=secret-canary-value")) + with self.assertRaisesRegex(self.runner.SmokeError, "canary detected"): + guard.assert_clean_bytes(b"prefix secret-canary-value suffix", "test") + + def test_relay_wait_retries_connection_level_os_errors(self) -> None: + class HealthyResponse: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + guard = self.runner.SensitiveGuard("secret-canary-value") + with patch.object( + self.runner.urllib.request, + "urlopen", + side_effect=[ConnectionResetError("restart"), HealthyResponse()], + ): + with patch.object(self.runner.time, "sleep"): + self.runner.wait_for_relay( + "http://127.0.0.1:19191", {}, "project", guard + ) + + def test_safe_report_allowlist_and_canary_scan(self) -> None: + guard = self.runner.SensitiveGuard("secret-canary-value") + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) + with self.assertRaisesRegex(self.runner.SmokeError, "non-allowlisted"): + self.runner.safe_report({"raw_token": "value"}, output, guard) + with self.assertRaisesRegex(self.runner.SmokeError, "canary detected"): + self.runner.safe_report( + {"diagnostic": "secret-canary-value"}, output, guard + ) + path = self.runner.safe_report( + { + "schema_version": self.runner.SCHEMA_VERSION, + "classification": "unreviewed-live-candidate-output", + "review_required": True, + "contains_sensitive_material": False, + "result": "pass", + "checks": [], + }, + output, + guard, + ) + self.assertEqual(0o644, path.stat().st_mode & 0o777) + report = json.loads(path.read_text(encoding="utf-8")) + self.assertTrue(report["review_required"]) + self.assertFalse(report["contains_sensitive_material"]) + + def test_output_directory_must_be_empty(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "output" + output.mkdir() + (output / "existing").write_text("preserve", encoding="utf-8") + with self.assertRaisesRegex(self.runner.SmokeError, "must be empty"): + self.runner.ensure_empty_output(output) + self.assertEqual("preserve", (output / "existing").read_text()) + + def test_run_requires_all_candidate_binding_arguments(self) -> None: + with patch.object(sys, "stderr"): + with self.assertRaises(SystemExit): + self.runner.parse_args(["run", "--release-id", "1.0.0-rc.1"]) + + def test_helper_never_logs_secret_response_fields(self) -> None: + helper = self.runner.HELPER_PATH.read_text(encoding="utf-8") + self.assertNotIn("print(client_secret", helper) + self.assertNotIn("print(token", helper) + self.assertIn("unexpected internal failure", helper) + self.assertIn("0o600", helper) + self.assertIn("parse_json=False", helper) + self.assertIn('f"{MANAGEMENT_URL}/projects/_search"', helper) + self.assertNotIn('"Host":', helper) + + def test_helper_accepts_plain_text_health_response_only_when_requested( + self, + ) -> None: + with patch.object( + self.helper.urllib.request, "urlopen", return_value=PlainTextResponse() + ): + self.assertEqual( + {}, + self.helper.request( + "GET", "http://localhost:8080/debug/healthz", parse_json=False + ), + ) + with self.assertRaisesRegex(self.helper.HelperError, "malformed JSON"): + self.helper.request("GET", "http://localhost:8080/management/v1") + + def test_helper_writes_runtime_secrets_for_the_invoking_host_user(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "secret" + env = { + "REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_UID": str(self.runner.os.getuid()), + "REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_GID": str(self.runner.os.getgid()), + } + with patch.dict(self.helper.os.environ, env, clear=False): + self.helper.atomic_secret(path, "synthetic-secret") + + self.assertEqual(0o600, path.stat().st_mode & 0o777) + self.assertEqual(self.runner.os.getuid(), path.stat().st_uid) + self.assertEqual(self.runner.os.getgid(), path.stat().st_gid) + + def test_helper_rejects_invalid_runtime_ownership(self) -> None: + env = { + "REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_UID": "not-a-uid", + "REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_GID": "123", + } + with patch.dict(self.helper.os.environ, env, clear=False): + with self.assertRaisesRegex(self.helper.HelperError, "decimal integers"): + self.helper.runtime_owner() + + +if __name__ == "__main__": + main() From ecc21b97263173c093adfe03e44428e651496029 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 20:18:33 +0700 Subject: [PATCH 50/65] fix(release): harden Relay OIDC smoke redirects Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 6 + release/conformance/relay-oidc/README.md | 25 ++++- .../conformance/relay-oidc/zitadel-helper.py | 29 ++++- release/scripts/check-gates-inventory.py | 8 ++ release/scripts/relay-oidc-smoke.py | 28 ++++- release/scripts/test_check_gates_inventory.py | 16 +++ release/scripts/test_relay_oidc_smoke.py | 106 +++++++++++++++++- 7 files changed, 200 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 605878edd..7201b5c1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -294,6 +294,12 @@ jobs: - name: Validate external integration evidence packet run: python3 release/scripts/integration-e2-runner.py validate + - name: Test Relay OIDC smoke + run: python3 -m unittest release/scripts/test_relay_oidc_smoke.py + + - name: Validate Relay OIDC smoke assets + run: python3 release/scripts/relay-oidc-smoke.py validate + - name: Gate inventory run: python3 release/scripts/check-gates-inventory.py diff --git a/release/conformance/relay-oidc/README.md b/release/conformance/relay-oidc/README.md index 0cb07bbd5..c00c2281b 100644 --- a/release/conformance/relay-oidc/README.md +++ b/release/conformance/relay-oidc/README.md @@ -7,9 +7,11 @@ checkout and does not depend on a hosted environment or Solmara Lab. The topology uses digest-pinned Zitadel, PostgreSQL, and Python images. The runner accepts Relay only as the exact image reference -`ghcr.io/registrystack/registry-relay@sha256:`. The candidate source -commit and release identifier are also mandatory, so an output cannot be -mistaken for evidence about a different candidate. +`ghcr.io/registrystack/registry-relay@sha256:`. The operator-supplied +candidate source commit and release identifier are mandatory and recorded for +later comparison with the release manifest. The runner does not derive or +cryptographically verify either identifier from the image. A maintainer must +perform that manifest binding before treating a report as release evidence. ## Evidence boundary @@ -22,9 +24,20 @@ candidate manifest and review it before it can become release evidence. The report contains identifiers, configuration and topology digests, bounded diagnostics, and assertion results. It never contains the bootstrap PAT, client secret, access token, database password, audit hash secret, or Zitadel master -key. Runtime secrets live only in a mode `0700` temporary directory, secret -files use mode `0600`, and the runner removes the exact Compose project, -volumes, and temporary directory even after a failed assertion. A canary scan +key. Runtime secrets are not confined to files while the smoke is running. The +database password, audit hash secret, and secret canary exist ephemerally in +isolated container environment metadata, and the Zitadel master key exists in +its container command metadata. A Docker daemon administrator can inspect that +metadata during the run. The bootstrap PAT lives in a project-scoped named +volume. The generated client secret and access token are stored only in mode +`0600` files under the runner's mode `0700` private runtime directory, which is +bind-mounted into the helper; the helper necessarily also handles them in +process memory and request headers. + +The HTTP clients bypass environment proxies and never follow redirects, so the +bootstrap PAT, client credentials, and Relay bearer remain on their exact local +origin. The runner removes the exact Compose project, its named volumes, and +the private runtime directory even after a failed assertion. A canary scan rejects secret material in subprocess output and the report. Do not commit a raw or unreviewed run directory. Do not describe a development diff --git a/release/conformance/relay-oidc/zitadel-helper.py b/release/conformance/relay-oidc/zitadel-helper.py index c8f71fb58..61c489c5f 100755 --- a/release/conformance/relay-oidc/zitadel-helper.py +++ b/release/conformance/relay-oidc/zitadel-helper.py @@ -31,6 +31,19 @@ ROLE_KEY = "registry-smoke-reader" +class NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """Prevent credentials from being forwarded to a redirect target.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + del req, fp, code, msg, headers, newurl + return None + + +NO_REDIRECT_OPENER = urllib.request.build_opener( + urllib.request.ProxyHandler({}), NoRedirectHandler() +) + + class HelperError(RuntimeError): """A bounded diagnostic safe to show without redaction.""" @@ -81,18 +94,22 @@ def request( req = urllib.request.Request(url, data=data, headers=headers, method=method) try: - with urllib.request.urlopen(req, timeout=15) as response: + with NO_REDIRECT_OPENER.open(req, timeout=15) as response: status = response.status body = read_bounded(response) except urllib.error.HTTPError as exc: # Discard response bodies. Zitadel create responses can contain secrets, # and diagnostics do not need their unbounded or provider-controlled text. - exc.read(MAX_RESPONSE_BYTES + 1) - if exc.code in accepted_statuses: + status = exc.code + try: + exc.read(MAX_RESPONSE_BYTES + 1) + finally: + exc.close() + if 300 <= status < 400: + raise HelperError(f"Zitadel API redirect refused for {method}") from None + if status in accepted_statuses: return {} - raise HelperError( - f"Zitadel API returned HTTP {exc.code} for {method}" - ) from None + raise HelperError(f"Zitadel API returned HTTP {status} for {method}") from None except (urllib.error.URLError, TimeoutError, OSError): raise HelperError(f"Zitadel API transport failed for {method}") from None diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index e057c9416..fa066dcb6 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -41,6 +41,14 @@ "External integration evidence packet", "run: python3 release/scripts/integration-e2-runner.py validate", ), + ( + "Relay OIDC smoke tests", + "run: python3 -m unittest release/scripts/test_relay_oidc_smoke.py", + ), + ( + "Relay OIDC smoke offline validation", + "run: python3 release/scripts/relay-oidc-smoke.py validate", + ), ("Release manifest validation", "release/scripts/registry-release validate"), ("Release docset validation", "release/scripts/registry-release validate-docsets"), ("Release import audit", "release/scripts/registry-release audit"), diff --git a/release/scripts/relay-oidc-smoke.py b/release/scripts/relay-oidc-smoke.py index 9994dc381..70f7cc796 100755 --- a/release/scripts/relay-oidc-smoke.py +++ b/release/scripts/relay-oidc-smoke.py @@ -53,6 +53,19 @@ ) +class NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """Keep release assertions on the exact loopback origin they target.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + del req, fp, code, msg, headers, newurl + return None + + +NO_REDIRECT_OPENER = urllib.request.build_opener( + urllib.request.ProxyHandler({}), NoRedirectHandler() +) + + class SmokeError(RuntimeError): """A bounded, user-actionable runner failure.""" @@ -458,12 +471,17 @@ def http_json( headers["Authorization"] = f"Bearer {token}" request = urllib.request.Request(url, headers=headers, method="GET") try: - with urllib.request.urlopen(request, timeout=15) as response: + with NO_REDIRECT_OPENER.open(request, timeout=15) as response: status = response.status body = response.read(MAX_HTTP_BODY + 1) except urllib.error.HTTPError as exc: status = exc.code - body = exc.read(MAX_HTTP_BODY + 1) + try: + body = exc.read(MAX_HTTP_BODY + 1) + finally: + exc.close() + if 300 <= status < 400: + raise SmokeError("Relay endpoint returned a redirect") from None except (urllib.error.URLError, TimeoutError, OSError): raise SmokeError("Relay request transport failed") from None if len(body) > MAX_HTTP_BODY: @@ -514,9 +532,13 @@ def wait_for_relay( last_error = "" while time.monotonic() < deadline: try: - with urllib.request.urlopen(f"{base_url}/healthz", timeout=3) as response: + with NO_REDIRECT_OPENER.open(f"{base_url}/healthz", timeout=3) as response: if response.status == 200: return + except urllib.error.HTTPError as exc: + status = exc.code + exc.close() + last_error = f"HTTP {status}" except (urllib.error.URLError, TimeoutError, OSError) as exc: last_error = ( str(exc.reason) if isinstance(exc, urllib.error.URLError) else str(exc) diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index 4b36e5c54..9bc2c2e8a 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -78,6 +78,22 @@ def test_missing_external_integration_packet_validation_is_reported(self) -> Non self.module.missing_gates(text), ) + def test_missing_relay_oidc_smoke_tests_are_reported(self) -> None: + text = self.workflow.replace( + "python3 -m unittest release/scripts/test_relay_oidc_smoke.py", + "python3 release/scripts/relay-oidc-smoke.py plan", + ) + self.assertIn("Relay OIDC smoke tests", self.module.missing_gates(text)) + + def test_missing_relay_oidc_offline_validation_is_reported(self) -> None: + text = self.workflow.replace( + "run: python3 release/scripts/relay-oidc-smoke.py validate", + "run: python3 release/scripts/relay-oidc-smoke.py skip-validation", + ) + self.assertIn( + "Relay OIDC smoke offline validation", self.module.missing_gates(text) + ) + def test_missing_stable_surface_gate_is_reported(self) -> None: text = self.workflow.replace( "run: python3 release/scripts/check-stable-surface-compatibility.py", diff --git a/release/scripts/test_relay_oidc_smoke.py b/release/scripts/test_relay_oidc_smoke.py index 8828b057a..7ef629641 100644 --- a/release/scripts/test_relay_oidc_smoke.py +++ b/release/scripts/test_relay_oidc_smoke.py @@ -3,10 +3,13 @@ from __future__ import annotations import base64 +import contextlib import importlib.util import json import sys import tempfile +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from unittest import TestCase, main from unittest.mock import patch @@ -53,6 +56,61 @@ def read(self, _limit: int) -> bytes: return b"ok" +class RecordingHttpServer(ThreadingHTTPServer): + redirect_target: str | None = None + authorization_headers: list[str | None] + + +class RecordingHandler(BaseHTTPRequestHandler): + server: RecordingHttpServer + + def record_and_respond(self) -> None: + self.server.authorization_headers.append(self.headers.get("Authorization")) + content_length = int(self.headers.get("Content-Length", "0")) + if content_length: + self.rfile.read(content_length) + if self.server.redirect_target: + self.send_response(302) + self.send_header("Location", self.server.redirect_target) + else: + self.send_response(204) + self.end_headers() + + def do_GET(self) -> None: + self.record_and_respond() + + def do_POST(self) -> None: + self.record_and_respond() + + def log_message(self, _format: str, *_args) -> None: + return + + +@contextlib.contextmanager +def cross_origin_redirect(): + target = RecordingHttpServer(("127.0.0.1", 0), RecordingHandler) + target.authorization_headers = [] + source = RecordingHttpServer(("127.0.0.1", 0), RecordingHandler) + source.authorization_headers = [] + source.redirect_target = f"http://127.0.0.1:{target.server_port}/redirect-target" + source_url = f"http://127.0.0.1:{source.server_port}/start" + threads = [ + threading.Thread(target=server.serve_forever, daemon=True) + for server in (target, source) + ] + for thread in threads: + thread.start() + try: + yield source_url, source, target + finally: + source.shutdown() + target.shutdown() + source.server_close() + target.server_close() + for thread in threads: + thread.join(timeout=2) + + def encode_json(value: dict[str, object]) -> str: raw = json.dumps(value, separators=(",", ":")).encode("utf-8") return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") @@ -207,8 +265,8 @@ def __exit__(self, *_args): guard = self.runner.SensitiveGuard("secret-canary-value") with patch.object( - self.runner.urllib.request, - "urlopen", + self.runner.NO_REDIRECT_OPENER, + "open", side_effect=[ConnectionResetError("restart"), HealthyResponse()], ): with patch.object(self.runner.time, "sleep"): @@ -216,6 +274,16 @@ def __exit__(self, *_args): "http://127.0.0.1:19191", {}, "project", guard ) + def test_relay_bearer_is_not_forwarded_across_redirect(self) -> None: + bearer = "synthetic-bearer-secret" + guard = self.runner.SensitiveGuard(bearer) + with cross_origin_redirect() as (source_url, source, target): + with self.assertRaisesRegex(self.runner.SmokeError, "returned a redirect"): + self.runner.http_json(source_url, bearer, guard) + + self.assertEqual([f"Bearer {bearer}"], source.authorization_headers) + self.assertEqual([], target.authorization_headers) + def test_safe_report_allowlist_and_canary_scan(self) -> None: guard = self.runner.SensitiveGuard("secret-canary-value") with tempfile.TemporaryDirectory() as tmp: @@ -267,11 +335,18 @@ def test_helper_never_logs_secret_response_fields(self) -> None: self.assertIn('f"{MANAGEMENT_URL}/projects/_search"', helper) self.assertNotIn('"Host":', helper) + def test_readme_states_binding_and_ephemeral_secret_boundaries(self) -> None: + readme = (self.runner.CONFIG_DIR / "README.md").read_text(encoding="utf-8") + self.assertIn("does not derive or\ncryptographically verify", readme) + self.assertIn("container environment metadata", readme) + self.assertIn("container command metadata", readme) + self.assertIn("never follow redirects", readme) + def test_helper_accepts_plain_text_health_response_only_when_requested( self, ) -> None: with patch.object( - self.helper.urllib.request, "urlopen", return_value=PlainTextResponse() + self.helper.NO_REDIRECT_OPENER, "open", return_value=PlainTextResponse() ): self.assertEqual( {}, @@ -282,6 +357,31 @@ def test_helper_accepts_plain_text_health_response_only_when_requested( with self.assertRaisesRegex(self.helper.HelperError, "malformed JSON"): self.helper.request("GET", "http://localhost:8080/management/v1") + def test_helper_bootstrap_pat_is_not_forwarded_across_redirect(self) -> None: + pat = "synthetic-bootstrap-pat" + with cross_origin_redirect() as (source_url, source, target): + with self.assertRaisesRegex(self.helper.HelperError, "redirect refused"): + self.helper.request("GET", source_url, bearer=pat) + + self.assertEqual([f"Bearer {pat}"], source.authorization_headers) + self.assertEqual([], target.authorization_headers) + + def test_helper_client_credentials_are_not_forwarded_across_redirect(self) -> None: + client_id = "synthetic-client" + client_secret = "synthetic-client-secret" + encoded = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() + with cross_origin_redirect() as (source_url, source, target): + with self.assertRaisesRegex(self.helper.HelperError, "redirect refused"): + self.helper.request( + "POST", + source_url, + form_body={"grant_type": "client_credentials"}, + basic_auth=(client_id, client_secret), + ) + + self.assertEqual([f"Basic {encoded}"], source.authorization_headers) + self.assertEqual([], target.authorization_headers) + def test_helper_writes_runtime_secrets_for_the_invoking_host_user(self) -> None: with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "secret" From bba683deb08643b46d92c07a8601c6411e999aaa Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 20:23:04 +0700 Subject: [PATCH 51/65] docs(release): wrap Relay OIDC smoke commands Signed-off-by: Jeremi Joslin --- release/conformance/relay-oidc/README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/release/conformance/relay-oidc/README.md b/release/conformance/relay-oidc/README.md index c00c2281b..01ab0696b 100644 --- a/release/conformance/relay-oidc/README.md +++ b/release/conformance/relay-oidc/README.md @@ -75,10 +75,13 @@ Python 3.11 or later is required. Validate the checked-in topology and render a candidate-bound plan without Docker or network access: ```bash +RELAY_IMAGE='ghcr.io/registrystack/registry-relay@sha256:'\ +'<64-lowercase-hex>' + release/scripts/relay-oidc-smoke.py validate release/scripts/relay-oidc-smoke.py plan \ - --relay-image 'ghcr.io/registrystack/registry-relay@sha256:<64-lowercase-hex>' \ + --relay-image "$RELAY_IMAGE" \ --candidate-source-ref '<40-lowercase-hex-commit>' \ --release-id '1.0.0-rc.1' ``` @@ -91,8 +94,11 @@ Docker with Docker Compose is required. The Relay image must already be published by digest: ```bash +RELAY_IMAGE='ghcr.io/registrystack/registry-relay@sha256:'\ +'<64-lowercase-hex>' + release/scripts/relay-oidc-smoke.py run \ - --relay-image 'ghcr.io/registrystack/registry-relay@sha256:<64-lowercase-hex>' \ + --relay-image "$RELAY_IMAGE" \ --candidate-source-ref '<40-lowercase-hex-commit>' \ --release-id '1.0.0-rc.1' ``` From 7a2b8d7ff13e2427422ff72eeca2b380c0a6452b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 20:42:30 +0700 Subject: [PATCH 52/65] chore(repo): single-source example profiles Signed-off-by: Jeremi Joslin --- .gitignore | 1 + crates/registry-manifest-cli/README.md | 15 ++- crates/registry-manifest-cli/tests/cli.rs | 54 +++++++- crates/registry-relay/docs/development.md | 2 +- crates/registry-relay/docs/metadata.md | 32 +++-- crates/registry-relay/justfile | 17 +-- .../fixtures/metadata.yaml | 99 -------------- .../example-benefits-sync/profile.yaml | 74 ---------- .../fixtures/metadata.yaml | 115 ---------------- .../example-civil-registration/profile.yaml | 73 ---------- .../fixtures/metadata.yaml | 63 --------- .../example-person-schema/profile.yaml | 61 --------- .../fixtures/metadata.yaml | 127 ------------------ .../example-social-benefits/profile.yaml | 78 ----------- .../example-benefits-sync/profile.yaml | 2 +- .../example-civil-registration/profile.yaml | 2 +- .../example-person-schema/profile.yaml | 2 +- .../example-social-benefits/profile.yaml | 2 +- 18 files changed, 95 insertions(+), 724 deletions(-) delete mode 100644 crates/registry-relay/profiles/example-benefits-sync/fixtures/metadata.yaml delete mode 100644 crates/registry-relay/profiles/example-benefits-sync/profile.yaml delete mode 100644 crates/registry-relay/profiles/example-civil-registration/fixtures/metadata.yaml delete mode 100644 crates/registry-relay/profiles/example-civil-registration/profile.yaml delete mode 100644 crates/registry-relay/profiles/example-person-schema/fixtures/metadata.yaml delete mode 100644 crates/registry-relay/profiles/example-person-schema/profile.yaml delete mode 100644 crates/registry-relay/profiles/example-social-benefits/fixtures/metadata.yaml delete mode 100644 crates/registry-relay/profiles/example-social-benefits/profile.yaml diff --git a/.gitignore b/.gitignore index ba86503b2..9068c6c70 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ __pycache__/ /.claude/ /.playwright-mcp/ /worktrees/ +/*.png diff --git a/crates/registry-manifest-cli/README.md b/crates/registry-manifest-cli/README.md index 17fbccff0..3a67e7bad 100644 --- a/crates/registry-manifest-cli/README.md +++ b/crates/registry-manifest-cli/README.md @@ -4,31 +4,32 @@ Command-line validation, rendering, static publication, and profile fixture validation for Registry Manifest. The binary name is `registry-manifest`. +Run the commands below from the monorepo root. ## Commands Validate a metadata manifest: ```sh -cargo run -p registry-manifest-cli -- validate profiles/example-civil-registration/fixtures/metadata.yaml +cargo run -p registry-manifest-cli -- validate products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml ``` Render one artifact: ```sh -cargo run -p registry-manifest-cli -- render profiles/example-civil-registration/fixtures/metadata.yaml --format catalog +cargo run -p registry-manifest-cli -- render products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml --format catalog ``` Render a service-form JSON Schema: ```sh -cargo run -p registry-manifest-cli -- render fixtures/cpsv-ap/health-linked-child-support.metadata.yaml --format form-json-schema --form child-support-review-form +cargo run -p registry-manifest-cli -- render products/manifest/fixtures/cpsv-ap/health-linked-child-support.metadata.yaml --format form-json-schema --form child-support-review-form ``` Publish a static metadata directory: ```sh -cargo run -p registry-manifest-cli -- publish profiles/example-civil-registration/fixtures/metadata.yaml --out target/metadata/public +cargo run -p registry-manifest-cli -- publish products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml --out target/metadata/public ``` By default, publishing writes every artifact, including `.well-known/api-catalog` @@ -45,7 +46,7 @@ itself (for example, when `--out` points at `/srv/site/metadata-public/`), pass `--site-root` so the discovery files land at the URL root: ```sh -cargo run -p registry-manifest-cli -- publish profiles/example-civil-registration/fixtures/metadata.yaml \ +cargo run -p registry-manifest-cli -- publish products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml \ --out /srv/site/metadata-public \ --site-root /srv/site ``` @@ -57,14 +58,14 @@ outside `--out` (or `SITE`, when set). Validate all checked-in profile descriptors and fixtures: ```sh -cargo run -p registry-manifest-cli -- validate-profiles profiles +cargo run -p registry-manifest-cli -- validate-profiles products/manifest/profiles ``` Run the commons contract-kernel check, optionally with consumer manifests: ```sh SOLMARA_LAB_DIR=/path/to/solmara-lab -scripts/check-contract-kernel.sh \ +products/manifest/scripts/check-contract-kernel.sh \ "$SOLMARA_LAB_DIR/metadata/solmara-wave1.metadata.yaml" ``` diff --git a/crates/registry-manifest-cli/tests/cli.rs b/crates/registry-manifest-cli/tests/cli.rs index 64412927e..2672ffde2 100644 --- a/crates/registry-manifest-cli/tests/cli.rs +++ b/crates/registry-manifest-cli/tests/cli.rs @@ -34,6 +34,23 @@ fn temp_dir(name: &str) -> PathBuf { path } +fn collect_yaml_files(root: &Path, paths: &mut Vec) { + let entries = fs::read_dir(root) + .unwrap_or_else(|error| panic!("read profile directory {}: {error}", root.display())); + for entry in entries { + let entry = entry.expect("directory entry"); + let path = entry.path(); + if entry.file_type().expect("file type").is_dir() { + collect_yaml_files(&path, paths); + } else if matches!( + path.extension().and_then(|value| value.to_str()), + Some("yaml" | "yml") + ) { + paths.push(path); + } + } +} + fn write_minimal_manifest(path: &Path, body: &str) { fs::write( path, @@ -646,7 +663,7 @@ datasets: } #[test] -fn validate_profiles_checks_descriptors_and_fixtures() { +fn validate_profiles_checks_canonical_examples_without_relay_copies() { let profiles = manifest_product_root().join("profiles"); let output = Command::new(bin()) .args(["validate-profiles", profiles.to_str().unwrap()]) @@ -660,6 +677,41 @@ fn validate_profiles_checks_descriptors_and_fixtures() { ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); assert!(stdout.contains("validated 4 profile descriptors and fixtures")); + + for profile in [ + "example-benefits-sync", + "example-civil-registration", + "example-person-schema", + "example-social-benefits", + ] { + let profile_root = profiles.join(profile); + assert!( + profile_root.join("profile.yaml").is_file(), + "missing canonical descriptor for {profile}" + ); + assert!( + profile_root.join("fixtures/metadata.yaml").is_file(), + "missing canonical fixture for {profile}" + ); + } + + let relay_profiles = workspace_root().join("crates/registry-relay/profiles"); + let mut copied_yaml = Vec::new(); + for entry in fs::read_dir(&relay_profiles).expect("Relay profiles directory") { + let entry = entry.expect("Relay profile entry"); + if entry.file_type().expect("Relay profile file type").is_dir() + && entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with("example-")) + { + collect_yaml_files(&entry.path(), &mut copied_yaml); + } + } + assert!( + copied_yaml.is_empty(), + "example profile YAML must be owned by products/manifest/profiles, not copied into Relay: {copied_yaml:?}" + ); } #[test] diff --git a/crates/registry-relay/docs/development.md b/crates/registry-relay/docs/development.md index de0c28e63..1a262561b 100644 --- a/crates/registry-relay/docs/development.md +++ b/crates/registry-relay/docs/development.md @@ -91,7 +91,7 @@ cargo test --test api_docs Portable metadata checks are separate from the Relay runtime: ```sh -just metadata-validate profiles/example-civil-registration/fixtures/metadata.yaml +just metadata-validate ../../products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml just metadata-validate-profiles cargo test --test demo_configs_load ``` diff --git a/crates/registry-relay/docs/metadata.md b/crates/registry-relay/docs/metadata.md index 2ad92f823..7414755b8 100644 --- a/crates/registry-relay/docs/metadata.md +++ b/crates/registry-relay/docs/metadata.md @@ -197,7 +197,7 @@ fetched and built by `cargo install --locked --git`. Validate one manifest: ```sh -just metadata-validate profiles/example-civil-registration/fixtures/metadata.yaml +just metadata-validate ../../products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml ``` Validate all ecosystem profile descriptors and fixture manifests: @@ -206,6 +206,10 @@ Validate all ecosystem profile descriptors and fixture manifests: just metadata-validate-profiles ``` +This command also rejects copied `example-*` YAML under Relay's local +`profiles/` directory. Example metadata profiles are owned by Registry +Manifest so their descriptors and fixtures cannot drift between products. + Validate all demo split configs via the Rust integration test: ```sh @@ -240,10 +244,10 @@ direct responses to client requests; any runtime path that surfaces one returns Render individual artifacts: ```sh -just metadata-render profiles/example-civil-registration/fixtures/metadata.yaml catalog target/metadata/catalog.json -just metadata-render profiles/example-civil-registration/fixtures/metadata.yaml dcat target/metadata/dcat.jsonld -just metadata-render profiles/example-civil-registration/fixtures/metadata.yaml shacl target/metadata/shacl.jsonld -just metadata-render profiles/example-civil-registration/fixtures/metadata.yaml json-schema target/metadata/person.schema.json "--dataset vital-events --entity person" +just metadata-render ../../products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml catalog target/metadata/catalog.json +just metadata-render ../../products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml dcat target/metadata/dcat.jsonld +just metadata-render ../../products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml shacl target/metadata/shacl.jsonld +just metadata-render ../../products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml json-schema target/metadata/person.schema.json "--dataset vital-events --entity person" ``` Supported formats are: @@ -270,7 +274,7 @@ API Records surface. Publish a static bundle: ```sh -just metadata-publish profiles/example-social-benefits/fixtures/metadata.yaml target/metadata/example-social-benefits +just metadata-publish ../../products/manifest/profiles/example-social-benefits/fixtures/metadata.yaml target/metadata/example-social-benefits ``` The bundle contains: @@ -642,14 +646,16 @@ Relay clients. ## Profiles -The `profiles/` directory contains non-normative data descriptors and fixtures -for consumers of the portable model. The app profiles are hypothetical examples, -not OpenCRVS, OpenSPP, or other upstream conformance claims: +The canonical `products/manifest/profiles/` directory contains non-normative +data descriptors and fixtures for consumers of the portable model. From the +Relay directory, the hypothetical examples are located at: + +- `../../products/manifest/profiles/example-civil-registration` +- `../../products/manifest/profiles/example-social-benefits` +- `../../products/manifest/profiles/example-person-schema` +- `../../products/manifest/profiles/example-benefits-sync` -- `profiles/example-civil-registration` -- `profiles/example-social-benefits` -- `profiles/example-person-schema` -- `profiles/example-benefits-sync` +These examples are not OpenCRVS, OpenSPP, or other upstream conformance claims. Profiles are data first, not Rust crates. Promote one only when there are multiple generators or validators that need shared code. diff --git a/crates/registry-relay/justfile b/crates/registry-relay/justfile index 1417f242c..50475840b 100644 --- a/crates/registry-relay/justfile +++ b/crates/registry-relay/justfile @@ -72,26 +72,27 @@ validate-catalog-semic-local catalog profile="bregdcatap.2_1_0": # Validate one portable metadata manifest. # Usage: just metadata-validate -# just metadata-validate profiles/example-benefits-sync/fixtures/metadata.yaml -metadata-validate manifest="profiles/example-civil-registration/fixtures/metadata.yaml": +# just metadata-validate ../../products/manifest/profiles/example-benefits-sync/fixtures/metadata.yaml +metadata-validate manifest="../../products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml": manifest_path="$(pwd)/{{manifest}}"; scripts/run_registry_manifest_cli.sh validate "$manifest_path" # Validate all ecosystem profile descriptors and fixture manifests. metadata-validate-profiles: - profiles_path="$(pwd)/profiles"; scripts/run_registry_manifest_cli.sh validate-profiles "$profiles_path" + @copied_yaml="$(find "$(pwd)/profiles" -type f \( -name '*.yaml' -o -name '*.yml' \) -path "$(pwd)/profiles/example-*/*")" || exit 1; if [ -n "$copied_yaml" ]; then printf 'example profile YAML must be owned by products/manifest/profiles, not copied into Relay:\n%s\n' "$copied_yaml" >&2; exit 1; fi + profiles_path="$(pwd)/../../products/manifest/profiles"; scripts/run_registry_manifest_cli.sh validate-profiles "$profiles_path" # Render one static metadata artifact from a manifest. # Usage: just metadata-render -# just metadata-render profiles/example-civil-registration/fixtures/metadata.yaml dcat target/metadata/dcat.jsonld -# just metadata-render profiles/example-civil-registration/fixtures/metadata.yaml json-schema target/metadata/person.schema.json "--dataset vital-events --entity person" -metadata-render manifest="profiles/example-civil-registration/fixtures/metadata.yaml" format="catalog" out="target/metadata/catalog.json" extra="": +# just metadata-render ../../products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml dcat target/metadata/dcat.jsonld +# just metadata-render ../../products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml json-schema target/metadata/person.schema.json "--dataset vital-events --entity person" +metadata-render manifest="../../products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml" format="catalog" out="target/metadata/catalog.json" extra="": mkdir -p $(dirname {{out}}) manifest_path="$(pwd)/{{manifest}}"; out_path="$(pwd)/{{out}}"; scripts/run_registry_manifest_cli.sh render "$manifest_path" --format {{format}} {{extra}} > "$out_path" # Publish a static metadata bundle with index, catalog, DCAT, SHACL, and schemas. # Usage: just metadata-publish -# just metadata-publish profiles/example-social-benefits/fixtures/metadata.yaml target/metadata/example-social-benefits -metadata-publish manifest="profiles/example-civil-registration/fixtures/metadata.yaml" out="target/metadata/public": +# just metadata-publish ../../products/manifest/profiles/example-social-benefits/fixtures/metadata.yaml target/metadata/example-social-benefits +metadata-publish manifest="../../products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml" out="target/metadata/public": manifest_path="$(pwd)/{{manifest}}"; out_path="$(pwd)/{{out}}"; scripts/run_registry_manifest_cli.sh publish "$manifest_path" --out "$out_path" # Check advisories only (alias for a quick security scan). diff --git a/crates/registry-relay/profiles/example-benefits-sync/fixtures/metadata.yaml b/crates/registry-relay/profiles/example-benefits-sync/fixtures/metadata.yaml deleted file mode 100644 index 5fbed1019..000000000 --- a/crates/registry-relay/profiles/example-benefits-sync/fixtures/metadata.yaml +++ /dev/null @@ -1,99 +0,0 @@ -schema_version: registry-manifest/v1 -catalog: - id: example-benefits-sync - base_url: https://dci.example.gov - title: - en: Example Benefits Sync Metadata - description: - en: Portable metadata for a hypothetical benefits sync registry. - publisher: - name: Disability Registry Authority - iri: https://dci.example.gov/authority - authority_type: eli:PublicAuthority - participant_id: https://dci.example.gov/authority - conforms_to: - - https://semiceu.github.io/BRegDCAT-AP/releases/3.0.0/ - standards: - dcat: "3.0" - shacl: "1.1" - json_schema: "2020-12" - application_profiles: - - id: bregdcat-ap - version: "3.0.0" -vocabularies: - sync: https://benefits-sync.example.gov/vocab/ - person: https://person-schema.example.gov/vocab/ - eli: http://data.europa.eu/eli/ontology# -profiles: - - id: example-benefits-sync - version: "1" -datasets: - - id: disability-registry - title: - en: Disability Registry - conforms_to: - - https://semiceu.github.io/BRegDCAT-AP/releases/3.0.0/ - status: active - access_rights: restricted - entities: - - name: disabled_person - title: - en: Disabled Person - identifiers: - - name: subject_id - kind: local - fields: - - name: subject_id - type: string - required: true - constraints: - min_length: 1 - max_length: 64 - concepts: - - sync:RegistrySubject.identifier - - person:Person.identifier - - name: disability_status - type: code - required: true - codelist: disability_status - constraints: - in: - - certified - - not_certified - concepts: - - sync:DisabilityRegistry.disabilityStatus - - name: consent_status - type: code - required: true - codelist: consent_status - constraints: - in: - - granted - - revoked - concepts: - - sync:RegistrySubject.consentStatus -codelists: - - id: disability_status - scheme_iri: https://dci.example.gov/codelists/disability-status - external_ref: https://dci.example.gov/codelists/disability-status.ttl - concepts: - - code: certified - iri: https://dci.example.gov/codelists/disability-status/certified - label: - en: Certified - - code: not_certified - iri: https://dci.example.gov/codelists/disability-status/not-certified - label: - en: Not certified - - id: consent_status - scheme_iri: https://dci.example.gov/codelists/consent-status - external_ref: https://dci.example.gov/codelists/consent-status.ttl - concepts: - - code: granted - iri: https://dci.example.gov/codelists/consent-status/granted - label: - en: Granted - - code: revoked - iri: https://dci.example.gov/codelists/consent-status/revoked - label: - en: Revoked diff --git a/crates/registry-relay/profiles/example-benefits-sync/profile.yaml b/crates/registry-relay/profiles/example-benefits-sync/profile.yaml deleted file mode 100644 index 33b2b76c6..000000000 --- a/crates/registry-relay/profiles/example-benefits-sync/profile.yaml +++ /dev/null @@ -1,74 +0,0 @@ -schema_version: registry-manifest-profile/v1 -profile: - id: example-benefits-sync - version: "1" - name: Hypothetical Benefits Sync Profile - upstream_system: Hypothetical benefits sync app - upstream_url: https://example-benefits-sync.org/ - description: Portable metadata expectations for benefits sync registry sync descriptors and related metadata publication. -supported_input_artifacts: - - kind: metadata_manifest - media_type: application/yaml - description: Registry Relay portable metadata manifest with benefits sync concept references. - - kind: sync_response_schema - media_type: application/schema+json - description: Optional benefits sync response schema used by runtime sync adapters. -required_concepts: - - name: registry subject identifier - iri: sync:RegistrySubject.identifier - - name: disability status - iri: sync:DisabilityRegistry.disabilityStatus - - name: consent status - iri: sync:RegistrySubject.consentStatus -optional_concepts: - - name: program identifier - iri: sync:Program.identifier - - name: transaction status - iri: sync:Transaction.status -required_identifiers: - - entity: disabled_person - name: subject_id - kind: local -cardinality_expectations: - - entity: disabled_person - field: subject_id - min: 1 - max: 1 - - entity: disabled_person - field: disability_status - min: 1 - max: 1 - - entity: disabled_person - field: consent_status - min: 1 - max: 1 -codelist_expectations: - - id: disability_status - required_codes: - - certified - - not_certified - - id: consent_status - required_codes: - - granted - - revoked -unsupported_mappings: - - source: async subscribe and callback flow - reason: benefits sync async operations are Relay runtime behavior, not static metadata. - - source: transaction status polling - reason: Transaction status is runtime state and is not represented in this metadata fixture. -conformance_checks: - - id: example-benefits-sync.required_concepts - severity: error - description: Manifest must include required benefits sync concepts. - - id: example-benefits-sync.identifiers - severity: error - description: Disabled person metadata must expose a stable local subject identifier. - - id: example-benefits-sync.codelists - severity: error - description: Disability and consent codelists must include required codes. -fixtures: - - path: fixtures/metadata.yaml - manifest: true - expect: valid - description: Minimal benefits sync disability registry metadata fixture. -generator_command: null diff --git a/crates/registry-relay/profiles/example-civil-registration/fixtures/metadata.yaml b/crates/registry-relay/profiles/example-civil-registration/fixtures/metadata.yaml deleted file mode 100644 index 21bbb54bc..000000000 --- a/crates/registry-relay/profiles/example-civil-registration/fixtures/metadata.yaml +++ /dev/null @@ -1,115 +0,0 @@ -schema_version: registry-manifest/v1 -catalog: - id: example-civil-registration - base_url: https://civil-registration.example.gov - title: - en: Civil Registration Metadata - description: - en: Portable metadata for a hypothetical civil registration application. - publisher: - name: Civil Registration Authority - iri: https://civil-registration.example.gov/authority - authority_type: eli:PublicAuthority - participant_id: https://civil-registration.example.gov/authority - conforms_to: - - https://semiceu.github.io/BRegDCAT-AP/releases/3.0.0/ - standards: - dcat: "3.0" - shacl: "1.1" - json_schema: "2020-12" - application_profiles: - - id: bregdcat-ap - version: "3.0.0" -vocabularies: - person: https://person-schema.example.gov/vocab/ - civreg: https://civil-registration.example.gov/vocab/ - eli: http://data.europa.eu/eli/ontology# -profiles: - - id: example-civil-registration - version: "1" -datasets: - - id: vital-events - title: - en: Vital Events - conforms_to: - - https://semiceu.github.io/BRegDCAT-AP/releases/3.0.0/ - status: under_development - access_rights: restricted - entities: - - name: person - title: - en: Person - identifiers: - - name: person_id - kind: local - fields: - - name: person_id - type: string - required: true - constraints: - min_length: 1 - max_length: 64 - pattern: "^[A-Za-z0-9-]+$" - concepts: - - person:Person.identifier - - name: birth_date - type: date - concepts: - - person:Person.birthDate - - civreg:BirthRecord.child.birthDate - - name: sex - type: code - codelist: sex - concepts: - - person:Person.sex - - name: vital_event - title: - en: Vital Event - identifiers: - - name: event_id - kind: local - fields: - - name: event_id - type: string - required: true - concepts: - - civreg:VitalEvent.identifier - - name: event_type - type: code - required: true - codelist: vital_event_type - constraints: - in: - - birth - - death - concepts: - - civreg:VitalEvent.type - - name: registration_date - type: date - concepts: - - civreg:VitalEvent.registrationDate -codelists: - - id: sex - scheme_iri: https://civil-registration.example.gov/codelists/sex - external_ref: https://civil-registration.example.gov/codelists/sex.ttl - concepts: - - code: female - iri: https://civil-registration.example.gov/codelists/sex/female - label: - en: Female - - code: male - iri: https://civil-registration.example.gov/codelists/sex/male - label: - en: Male - - id: vital_event_type - scheme_iri: https://civil-registration.example.gov/codelists/vital-event-type - external_ref: https://civil-registration.example.gov/codelists/vital-event-type.ttl - concepts: - - code: birth - iri: https://civil-registration.example.gov/codelists/vital-event-type/birth - label: - en: Birth - - code: death - iri: https://civil-registration.example.gov/codelists/vital-event-type/death - label: - en: Death diff --git a/crates/registry-relay/profiles/example-civil-registration/profile.yaml b/crates/registry-relay/profiles/example-civil-registration/profile.yaml deleted file mode 100644 index e8f31b6be..000000000 --- a/crates/registry-relay/profiles/example-civil-registration/profile.yaml +++ /dev/null @@ -1,73 +0,0 @@ -schema_version: registry-manifest-profile/v1 -profile: - id: example-civil-registration - version: "1" - name: Hypothetical Civil Registration Profile - upstream_system: Hypothetical civil registration app - upstream_url: https://civil-registration.example.gov/ - description: Non-normative example metadata expectations for a fictional civil registration application. -supported_input_artifacts: - - kind: metadata_manifest - media_type: application/yaml - description: Registry Relay portable metadata manifest with fictional civil registration concept references. -required_concepts: - - name: person identifier - iri: person:Person.identifier - - name: birth date - iri: civreg:BirthRecord.child.birthDate - - name: sex - iri: person:Person.sex - - name: event registration date - iri: civreg:VitalEvent.registrationDate -optional_concepts: - - name: death date - iri: civreg:DeathRecord.deceased.deathDate - - name: relationship to mother - iri: civreg:BirthRecord.mother -required_identifiers: - - entity: person - name: person_id - kind: local -cardinality_expectations: - - entity: person - field: person_id - min: 1 - max: 1 - - entity: person - field: birth_date - min: 0 - max: 1 - - entity: vital_event - field: event_id - min: 1 - max: 1 -codelist_expectations: - - id: sex - required_codes: - - female - - male - - id: vital_event_type - required_codes: - - birth - - death -unsupported_mappings: - - source: workflow task state - reason: Operational workflow state is runtime behavior, not portable metadata. - - source: record amendment history - reason: Amendment audit details require source-specific provenance and are not represented in this profile fixture. -conformance_checks: - - id: example-civil-registration.required_concepts - severity: error - description: Manifest must include the required fictional civil registration and person schema concepts. - - id: example-civil-registration.identifiers - severity: error - description: Person metadata must expose a stable local person identifier. - - id: example-civil-registration.codelists - severity: error - description: Sex and vital event type codelists must include the required codes. -fixtures: - - path: fixtures/metadata.yaml - manifest: true - expect: valid - description: Minimal civil registration fixture with person and vital event metadata. -generator_command: null diff --git a/crates/registry-relay/profiles/example-person-schema/fixtures/metadata.yaml b/crates/registry-relay/profiles/example-person-schema/fixtures/metadata.yaml deleted file mode 100644 index 4b8ce9dc7..000000000 --- a/crates/registry-relay/profiles/example-person-schema/fixtures/metadata.yaml +++ /dev/null @@ -1,63 +0,0 @@ -schema_version: registry-manifest/v1 -catalog: - id: example-person-schema - base_url: https://person-schema.example.gov - title: - en: Example Person Schema Metadata - description: - en: Portable metadata for a hypothetical person schema resource. - publisher: - name: Example Person Schema Publisher - iri: https://person-schema.example.gov/publisher - authority_type: eli:PublicAuthority - participant_id: https://person-schema.example.gov/publisher - conforms_to: - - https://json-schema.org/draft/2020-12/schema - standards: - dcat: "3.0" - shacl: "1.1" - json_schema: "2020-12" - application_profiles: [] -vocabularies: - person: https://person-schema.example.gov/vocab/ - eli: http://data.europa.eu/eli/ontology# -profiles: - - id: example-person-schema - version: "1" -datasets: - - id: person-index - title: - en: Person Index - conforms_to: - - https://json-schema.org/draft/2020-12/schema - status: active - access_rights: restricted - entities: - - name: person - title: - en: Person - identifiers: - - name: person_id - kind: local - fields: - - name: person_id - type: string - required: true - constraints: - min_length: 1 - max_length: 64 - concepts: - - person:Person.identifier - - name: given_name - type: string - concepts: - - person:Person.givenName - - name: family_name - type: string - concepts: - - person:Person.familyName - - name: birth_date - type: date - concepts: - - person:Person.birthDate -codelists: [] diff --git a/crates/registry-relay/profiles/example-person-schema/profile.yaml b/crates/registry-relay/profiles/example-person-schema/profile.yaml deleted file mode 100644 index b9040772a..000000000 --- a/crates/registry-relay/profiles/example-person-schema/profile.yaml +++ /dev/null @@ -1,61 +0,0 @@ -schema_version: registry-manifest-profile/v1 -profile: - id: example-person-schema - version: "1" - name: Hypothetical Person Schema Profile - upstream_system: Hypothetical person schema app - upstream_url: https://person-schema.example.gov/vocab/ - description: Non-normative example metadata expectations for person concept and JSON Schema publication. -supported_input_artifacts: - - kind: metadata_manifest - media_type: application/yaml - description: Registry Relay portable metadata manifest with fictional person-schema concept references. - - kind: json_schema - media_type: application/schema+json - description: Draft 2020-12 JSON Schema generated from the manifest. -required_concepts: - - name: person identifier - iri: person:Person.identifier - - name: given name - iri: person:Person.givenName - - name: family name - iri: person:Person.familyName -optional_concepts: - - name: birth date - iri: person:Person.birthDate - - name: administrative area - iri: person:PostalAddress.addressRegion -required_identifiers: - - entity: person - name: person_id - kind: local -cardinality_expectations: - - entity: person - field: person_id - min: 1 - max: 1 - - entity: person - field: given_name - min: 0 - max: 1 - - entity: person - field: family_name - min: 0 - max: 1 -codelist_expectations: [] -unsupported_mappings: - - source: credential issuance rules - reason: Credential issuance is runtime provenance behavior and is outside portable metadata conformance. -conformance_checks: - - id: example-person-schema.required_concepts - severity: error - description: Manifest must include required fictional person-schema concept references. - - id: example-person-schema.identifiers - severity: error - description: Person metadata must expose a stable local identifier. -fixtures: - - path: fixtures/metadata.yaml - manifest: true - expect: valid - description: Minimal hypothetical person-schema fixture. -generator_command: null diff --git a/crates/registry-relay/profiles/example-social-benefits/fixtures/metadata.yaml b/crates/registry-relay/profiles/example-social-benefits/fixtures/metadata.yaml deleted file mode 100644 index ec469ab34..000000000 --- a/crates/registry-relay/profiles/example-social-benefits/fixtures/metadata.yaml +++ /dev/null @@ -1,127 +0,0 @@ -schema_version: registry-manifest/v1 -catalog: - id: example-social-benefits - base_url: https://social-protection.example.gov - title: - en: Social Protection Metadata - description: - en: Portable metadata for a hypothetical social benefits application. - publisher: - name: Social Protection Agency - iri: https://social-protection.example.gov/agency - authority_type: eli:PublicAuthority - participant_id: https://social-protection.example.gov/agency - conforms_to: - - https://semiceu.github.io/BRegDCAT-AP/releases/3.0.0/ - standards: - dcat: "3.0" - shacl: "1.1" - json_schema: "2020-12" - application_profiles: - - id: bregdcat-ap - version: "3.0.0" -vocabularies: - person: https://person-schema.example.gov/vocab/ - benefits: https://social-protection.example.gov/vocab/benefits/ - eli: http://data.europa.eu/eli/ontology# -profiles: - - id: example-social-benefits - version: "1" -datasets: - - id: social-registry - title: - en: Social Registry - conforms_to: - - https://semiceu.github.io/BRegDCAT-AP/releases/3.0.0/ - status: active - access_rights: restricted - entities: - - name: household - title: - en: Household - identifiers: - - name: household_id - kind: local - fields: - - name: household_id - type: string - required: true - concepts: - - benefits:Group.identifier - - name: vulnerability_category - type: code - codelist: vulnerability_category - concepts: - - benefits:SocialRegistry.vulnerabilityCategory - - name: individual - title: - en: Individual - identifiers: - - name: individual_id - kind: local - fields: - - name: individual_id - type: string - required: true - concepts: - - person:Person.identifier - - name: birth_date - type: date - concepts: - - person:Person.birthDate - - name: enrollment - title: - en: Program Enrollment - identifiers: - - name: enrollment_id - kind: local - fields: - - name: enrollment_id - type: string - required: true - concepts: - - benefits:ProgramEnrollment.identifier - - name: enrollment_status - type: code - required: true - codelist: enrollment_status - constraints: - in: - - active - - suspended - - closed - concepts: - - benefits:ProgramEnrollment.status -codelists: - - id: enrollment_status - scheme_iri: https://social-protection.example.gov/codelists/enrollment-status - external_ref: https://social-protection.example.gov/codelists/enrollment-status.ttl - concepts: - - code: active - iri: https://social-protection.example.gov/codelists/enrollment-status/active - label: - en: Active - - code: suspended - iri: https://social-protection.example.gov/codelists/enrollment-status/suspended - label: - en: Suspended - - code: closed - iri: https://social-protection.example.gov/codelists/enrollment-status/closed - label: - en: Closed - - id: vulnerability_category - scheme_iri: https://social-protection.example.gov/codelists/vulnerability-category - external_ref: https://social-protection.example.gov/codelists/vulnerability-category.ttl - concepts: - - code: disability - iri: https://social-protection.example.gov/codelists/vulnerability-category/disability - label: - en: Disability - - code: elderly - iri: https://social-protection.example.gov/codelists/vulnerability-category/elderly - label: - en: Elderly - - code: low_income - iri: https://social-protection.example.gov/codelists/vulnerability-category/low-income - label: - en: Low income diff --git a/crates/registry-relay/profiles/example-social-benefits/profile.yaml b/crates/registry-relay/profiles/example-social-benefits/profile.yaml deleted file mode 100644 index 3c724733d..000000000 --- a/crates/registry-relay/profiles/example-social-benefits/profile.yaml +++ /dev/null @@ -1,78 +0,0 @@ -schema_version: registry-manifest-profile/v1 -profile: - id: example-social-benefits - version: "1" - name: Hypothetical Social Benefits Profile - upstream_system: Hypothetical social benefits app - upstream_url: https://social-protection.example.gov/ - description: Non-normative example metadata expectations for a fictional social benefits application. -supported_input_artifacts: - - kind: metadata_manifest - media_type: application/yaml - description: Registry Relay portable metadata manifest with fictional benefits and person schema concept references. -required_concepts: - - name: household identifier - iri: benefits:Group.identifier - - name: member identifier - iri: person:Person.identifier - - name: enrollment status - iri: benefits:ProgramEnrollment.status - - name: vulnerability category - iri: benefits:SocialRegistry.vulnerabilityCategory -optional_concepts: - - name: household head relationship - iri: benefits:GroupMembership.householdHead - - name: benefit amount - iri: benefits:Entitlement.amount -required_identifiers: - - entity: household - name: household_id - kind: local - - entity: individual - name: individual_id - kind: local -cardinality_expectations: - - entity: household - field: household_id - min: 1 - max: 1 - - entity: individual - field: individual_id - min: 1 - max: 1 - - entity: enrollment - field: enrollment_status - min: 1 - max: 1 -codelist_expectations: - - id: enrollment_status - required_codes: - - active - - suspended - - closed - - id: vulnerability_category - required_codes: - - disability - - elderly - - low_income -unsupported_mappings: - - source: payment provider transaction status - reason: Payment execution and reconciliation are runtime integrations outside portable metadata. - - source: eligibility scoring formula - reason: Scoring formulas are policy logic and should be documented separately from generic metadata. -conformance_checks: - - id: example-social-benefits.required_concepts - severity: error - description: Manifest must include required fictional benefits and person schema concepts. - - id: example-social-benefits.identifiers - severity: error - description: Household and individual entities must expose stable local identifiers. - - id: example-social-benefits.codelists - severity: error - description: Enrollment status and vulnerability category codelists must include required codes. -fixtures: - - path: fixtures/metadata.yaml - manifest: true - expect: valid - description: Minimal social protection fixture with household, individual, and enrollment metadata. -generator_command: null diff --git a/products/manifest/profiles/example-benefits-sync/profile.yaml b/products/manifest/profiles/example-benefits-sync/profile.yaml index 2cba0271d..b81bf7e2b 100644 --- a/products/manifest/profiles/example-benefits-sync/profile.yaml +++ b/products/manifest/profiles/example-benefits-sync/profile.yaml @@ -9,7 +9,7 @@ profile: supported_input_artifacts: - kind: metadata_manifest media_type: application/yaml - description: Registry Metadata portable metadata manifest with benefits sync concept references. + description: Registry Manifest portable metadata manifest with benefits sync concept references. - kind: sync_response_schema media_type: application/schema+json description: Optional benefits sync response schema used by runtime sync adapters. diff --git a/products/manifest/profiles/example-civil-registration/profile.yaml b/products/manifest/profiles/example-civil-registration/profile.yaml index e35b79afc..653c2e839 100644 --- a/products/manifest/profiles/example-civil-registration/profile.yaml +++ b/products/manifest/profiles/example-civil-registration/profile.yaml @@ -9,7 +9,7 @@ profile: supported_input_artifacts: - kind: metadata_manifest media_type: application/yaml - description: Registry Metadata portable metadata manifest with fictional civil registration concept references. + description: Registry Manifest portable metadata manifest with fictional civil registration concept references. required_concepts: - name: person identifier iri: person:Person.identifier diff --git a/products/manifest/profiles/example-person-schema/profile.yaml b/products/manifest/profiles/example-person-schema/profile.yaml index cdd8465d4..facec9538 100644 --- a/products/manifest/profiles/example-person-schema/profile.yaml +++ b/products/manifest/profiles/example-person-schema/profile.yaml @@ -9,7 +9,7 @@ profile: supported_input_artifacts: - kind: metadata_manifest media_type: application/yaml - description: Registry Metadata portable metadata manifest with fictional person-schema concept references. + description: Registry Manifest portable metadata manifest with fictional person-schema concept references. - kind: json_schema media_type: application/schema+json description: Draft 2020-12 JSON Schema generated from the manifest. diff --git a/products/manifest/profiles/example-social-benefits/profile.yaml b/products/manifest/profiles/example-social-benefits/profile.yaml index 2fe857cb6..6b27cf8bb 100644 --- a/products/manifest/profiles/example-social-benefits/profile.yaml +++ b/products/manifest/profiles/example-social-benefits/profile.yaml @@ -9,7 +9,7 @@ profile: supported_input_artifacts: - kind: metadata_manifest media_type: application/yaml - description: Registry Metadata portable metadata manifest with fictional benefits and person schema concept references. + description: Registry Manifest portable metadata manifest with fictional benefits and person schema concept references. required_concepts: - name: household identifier iri: benefits:Group.identifier From 5d0d30758d133c16440a25357337a3fa272140ea Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 18 Jul 2026 21:21:33 +0700 Subject: [PATCH 53/65] feat(editors): add source installer Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 5 +- README.md | 2 + docs/site/src/content/docs/changelog.mdx | 6 + .../content/docs/reference/registryctl.mdx | 22 +- editors/README.md | 64 +++- editors/install.sh | 300 ++++++++++++++++++ editors/tests/install_test.sh | 204 ++++++++++++ editors/vscode/.vscode-test.cjs | 27 +- editors/vscode/README.md | 82 ++--- editors/vscode/package.json | 2 +- editors/vscode/scripts/verify-vsix.cjs | 15 + editors/vscode/src/extension.ts | 45 ++- editors/vscode/test/trusted.test.ts | 6 +- editors/zed/README.md | 42 ++- 14 files changed, 726 insertions(+), 96 deletions(-) create mode 100755 editors/install.sh create mode 100755 editors/tests/install_test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7201b5c1d..d8e8cd3cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -459,7 +459,10 @@ jobs: cache: npm cache-dependency-path: editors/vscode/package-lock.json - - name: Package and test VS Code developer preview + - name: Test editor installer + run: bash editors/tests/install_test.sh + + - name: Package and test VS Code integration working-directory: editors/vscode run: | npm ci diff --git a/README.md b/README.md index 8fd037b14..731c892f7 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ release manifests, and docs. | Read the technical docs | [docs.registrystack.org](https://docs.registrystack.org/) | | Try it without installing anything | [Hosted Solmara Lab](https://solmara.registrystack.org/) | | Run the local demo topology | [Solmara Lab quick start](https://github.com/registrystack/solmara-lab#quick-start) | +| Install VS Code or Zed integration | [Editor integrations](editors/README.md) | | Work on the monorepo | See [Development](#development) | | Review the public roadmap | [ROADMAP.md](ROADMAP.md) | | Review release evidence | See [Release And External Inputs](#release-and-external-inputs) | @@ -71,6 +72,7 @@ flowchart LR material, scripts, performance harnesses, and fixtures that are not normal workspace crates. - `docs/site/`: the public Registry Stack docs site. +- `editors/`: source-installed VS Code and Zed semantic navigation integrations. - `release/`: stack release manifests, schemas, import audit records, and public release and conformance tooling. - `external/`: notes for external inputs that intentionally stay outside this diff --git a/docs/site/src/content/docs/changelog.mdx b/docs/site/src/content/docs/changelog.mdx index b6fd54007..44c76aca9 100644 --- a/docs/site/src/content/docs/changelog.mdx +++ b/docs/site/src/content/docs/changelog.mdx @@ -16,6 +16,12 @@ documents. Per-product release notes live in each product repository; the entries below link to the relevant product pages on this site rather than duplicating release notes. +## 2026-07-19 + +- Documented the source installer for the optional VS Code and Zed semantic-navigation + integrations. Editor installation is user- or profile-scoped and remains separate from + project-local schema setup through `registryctl init` or `registryctl authoring editor`. + ## 2026-07-18 Documentation updates for the v0.11.0 beta-13 release: diff --git a/docs/site/src/content/docs/reference/registryctl.mdx b/docs/site/src/content/docs/reference/registryctl.mdx index 42a58ca1c..65404c135 100644 --- a/docs/site/src/content/docs/reference/registryctl.mdx +++ b/docs/site/src/content/docs/reference/registryctl.mdx @@ -151,12 +151,22 @@ the workspace recommendation for Red Hat YAML. Zed includes YAML language-server The generated schema setup is the stable beta editor path. It provides YAML validation, completion, hover, and formatting without a source checkout. -VS Code and Zed semantic navigation are optional, source-installable developer previews for this -beta. They are not marketplace extensions and are not available as release assets. -Build and install them from the Registry Stack source tree only when you want cross-file -definitions, references, workspace and document symbols, and missing, duplicate, or ambiguous -reference diagnostics. -Both previews launch the same semantic server, either as `registry-language-server` or through +VS Code and Zed semantic navigation are optional beta integrations installed from the Registry +Stack source tree. They are not marketplace extensions and are not available as release assets. +Project configuration and editor installation are separate. Configure projects with `init` or +`authoring editor`, and install an editor integration once from a matching source checkout: + +```sh +./editors/install.sh vscode +./editors/install.sh zed +``` + +The installer verifies the matching `registryctl` and embedded language server without reading or +changing a project. Pass `--open ` only to open a directory after installation; +it does not configure that directory. Use these integrations when you want cross-file definitions, +references, workspace and document symbols, and missing, duplicate, or ambiguous reference +diagnostics. +Both integrations launch the same semantic server, either as `registry-language-server` or through `registryctl authoring language-server`, alongside the YAML language server. The YAML language server continues to own syntax, schemas, completion, hover, and formatting. Follow `editors/vscode/README.md` or `editors/zed/README.md` in the same source checkout. diff --git a/editors/README.md b/editors/README.md index b16838fe7..dc1dcf542 100644 --- a/editors/README.md +++ b/editors/README.md @@ -1,10 +1,10 @@ # Registry Stack editor integrations -VS Code and Zed semantic navigation are source-installable developer previews for this beta. -They are not marketplace extensions or release assets. +Semantic navigation for VS Code and Zed is installable from a Registry Stack source release. +The integrations are beta features and are not yet marketplace extensions or release assets. Use `registryctl init --from ` and its generated editor schema setup as the stable beta path for YAML validation, completion, hover, and formatting. -Install an editor preview only when you also want optional cross-file semantic navigation. +Install the editor integration when you also want optional cross-file semantic navigation. The Registry Stack editor support is split into one reusable language server and thin editor launchers: @@ -22,25 +22,50 @@ The language server watches Registry Stack YAML paths for changes made by genera other tools. An open editor buffer remains authoritative until it is closed, so a filesystem event cannot replace unsaved content. +## Install + +Project setup and editor installation are separate operations. `registryctl init` configures new +projects automatically. For an existing project, refresh its version-matched schema settings with: + +```console +registryctl authoring editor --project-dir /path/to/registry-stack-project +``` + +Install the `registryctl` version that matches this source checkout, then install an integration +once from the repository root: + +```console +./editors/install.sh vscode +./editors/install.sh zed +``` + +The installer verifies the `registryctl` version and embedded language server without reading or +changing a project. VS Code is packaged and installed into the active profile. Pass +`--profile ` to select another VS Code profile. The local VSIX records the verified +`registryctl` path, so an already-running VS Code process does not need to inherit the installer's +`PATH`. Zed is compiled, then requires the command-palette selection that its CLI cannot perform. + +The installer does not trust a project or approve a development extension. Those decisions stay +with the user. Pass `--open ` only as a convenience to open a directory after +installation. It does not configure that directory. Use `--help` for the complete interface. + ## Local end-to-end smoke test -Run the commands in this section from the repository root. They build both server entry points and -create a disposable HTTP starter outside the checkout, so the diagnostic checks below cannot -modify a tracked golden project. +Run the commands in this section from the repository root. They create a disposable HTTP starter +outside the checkout, so the diagnostic checks below cannot modify a tracked golden project. ```console -export REGISTRY_STACK_REPO="$(pwd)" export REGISTRY_STACK_SMOKE_ROOT="$(mktemp -d)" export REGISTRY_STACK_SMOKE_PROJECT="$REGISTRY_STACK_SMOKE_ROOT/project" -cargo build --locked -p registry-language-server -p registryctl -cargo run --locked -p registryctl -- init --from http --project-dir "$REGISTRY_STACK_SMOKE_PROJECT" +registryctl --version +registryctl init --from http --project-dir "$REGISTRY_STACK_SMOKE_PROJECT" ``` -Keep that terminal open so the three variables remain available. Then follow the editor-specific +Keep that terminal open so the two variables remain available. Then follow the editor-specific installation and launch instructions: -- [VS Code developer preview](vscode/README.md#package-install-and-launch) -- [Zed developer preview](zed/README.md#install-and-launch) +- [VS Code](vscode/README.md#install-and-launch) +- [Zed](zed/README.md#install-and-launch) ### Expected behavior @@ -75,8 +100,10 @@ and are separate from diagnostics whose source is `registry-stack`. The same core behavior has non-GUI coverage: ```console +bash editors/tests/install_test.sh cargo test --locked -p registry-language-server cargo test --locked -p registryctl --test language_server +cargo build --locked -p registry-language-server cd editors/vscode && npm ci && npm test ``` @@ -87,3 +114,16 @@ it as `xvfb-run -a npm test`, matching CI. When finished, close the smoke project and remove the temporary directory shown by `$REGISTRY_STACK_SMOKE_ROOT` after checking that it is the directory created by `mktemp` above. + +## Develop the language server from source + +The installer deliberately uses the matching `registryctl` from `PATH`, which exercises the +language server embedded in the installed release. To iterate on language-server source changes, +build the standalone server and configure the editor to use it explicitly: + +```console +cargo build --locked -p registry-language-server +``` + +Follow the editor-specific iteration instructions to point the editor at +`target/debug/registry-language-server` and restart it. diff --git a/editors/install.sh b/editors/install.sh new file mode 100755 index 000000000..57c6f3428 --- /dev/null +++ b/editors/install.sh @@ -0,0 +1,300 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +readonly SCRIPT_DIR +DEFAULT_REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)" +readonly DEFAULT_REPO_ROOT +readonly REPO_ROOT="${REGISTRY_STACK_INSTALLER_REPO_ROOT:-${DEFAULT_REPO_ROOT}}" +readonly EXTENSION_ID="registrystack.registry-stack" +REGISTRYCTL_PATH='' + +usage() { + cat <<'EOF' +Install Registry Stack semantic navigation for an editor. + +Usage: + ./editors/install.sh [options] + +Options: + --profile Existing VS Code profile to use (default: active profile) + --open Open an existing directory after installation + -h, --help Show this help + +The installer never trusts a workspace automatically. Installing for VS Code +updates the active profile unless --profile selects an existing profile. Zed +requires one final command-palette action because its CLI cannot install a +local development extension. Project configuration remains a separate +registryctl init or registryctl authoring editor operation. +EOF +} + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +require_command() { + local command_name="$1" + command -v "${command_name}" >/dev/null 2>&1 || + fail "required command '${command_name}' was not found on PATH" +} + +external_command_path() { + local command_name="$1" + local command_path + command_path="$(type -P "${command_name}")" || + fail "required external command '${command_name}' was not found on PATH" + case "${command_path}" in + /*) ;; + */*) + command_path="$(cd -- "${command_path%/*}" && pwd -P)/${command_path##*/}" + ;; + *) + command_path="${PWD}/${command_path}" + ;; + esac + [[ -f "${command_path}" && -x "${command_path}" ]] || + fail "command '${command_name}' is not an executable file: ${command_path}" + printf '%s\n' "${command_path}" +} + +workspace_version() { + awk ' + /^\[workspace\.package\][[:space:]]*$/ { in_workspace_package = 1; next } + in_workspace_package && /^\[/ { exit } + in_workspace_package && /^[[:space:]]*version[[:space:]]*=/ { + line = $0 + sub(/^[^=]*=[[:space:]]*"/, "", line) + sub(/".*$/, "", line) + print line + exit + } + ' "${REPO_ROOT}/Cargo.toml" +} + +registryctl_version() { + local version_output + version_output="$("${REGISTRYCTL_PATH}" --version)" || + fail "could not run registryctl --version" + case "${version_output}" in + 'registryctl '*) + version_output="${version_output#registryctl }" + printf '%s\n' "${version_output%% *}" + ;; + *) + fail "unexpected registryctl version output: ${version_output}" + ;; + esac +} + +canonical_open_path() { + local requested_path="$1" + [[ -d "${requested_path}" ]] || + fail "directory to open does not exist: ${requested_path}" + + (cd -- "${requested_path}" && pwd -P) +} + +verify_registryctl() { + REGISTRYCTL_PATH="$(external_command_path registryctl)" + + local expected_version + local installed_version + expected_version="$(workspace_version)" + [[ -n "${expected_version}" ]] || + fail "could not read the workspace version from ${REPO_ROOT}/Cargo.toml" + installed_version="$(registryctl_version)" + + if [[ "${installed_version}" != "${expected_version}" ]]; then + fail "this checkout is ${expected_version} but registryctl is ${installed_version}; install the matching registryctl" + fi + + "${REGISTRYCTL_PATH}" authoring language-server --help >/dev/null || + fail "registryctl ${installed_version} does not provide authoring language-server" + printf 'Using registryctl %s from %s\n' \ + "${installed_version}" "${REGISTRYCTL_PATH}" +} + +install_vscode() { + local open_path="$1" + local profile="$2" + local vscode_root="${REPO_ROOT}/editors/vscode" + local vsix="${vscode_root}/registry-stack-dev.vsix" + local install_metadata="${vscode_root}/dist/registryctl-path" + local registryctl_path + local profile_label='active profile' + local -a profile_args=() + + registryctl_path="${REGISTRYCTL_PATH}" + + if [[ -n "${profile}" ]]; then + profile_label="profile ${profile}" + profile_args=(--profile "${profile}") + fi + + require_command node + require_command npm + require_command code + require_command unzip + [[ -f "${vscode_root}/package.json" ]] || + fail "VS Code extension source was not found at ${vscode_root}" + + local node_version + local node_major + node_version="$(node --version)" + node_major="${node_version#v}" + node_major="${node_major%%.*}" + [[ "${node_major}" =~ ^[0-9]+$ ]] || + fail "unexpected Node.js version output: ${node_version}" + ((node_major >= 22)) || + fail "Node.js 22 or newer is required; found ${node_version}" + + printf 'Packaging Registry Stack for VS Code\n' + npm --prefix "${vscode_root}" ci + mkdir -p "${vscode_root}/dist" + [[ -d "${vscode_root}/dist" && ! -L "${vscode_root}/dist" ]] || + fail "VS Code dist path must be a regular directory: ${vscode_root}/dist" + [[ ! -e "${install_metadata}" && ! -L "${install_metadata}" ]] || + fail "temporary installer metadata already exists: ${install_metadata}" + if ! ( + trap 'rm -f -- "${install_metadata}"' EXIT HUP INT TERM + printf '%s\n' "${registryctl_path}" > "${install_metadata}" + REGISTRY_STACK_EXPECT_REGISTRYCTL_PATH="${registryctl_path}" \ + npm --prefix "${vscode_root}" run package:dev + ); then + fail "VS Code extension packaging failed" + fi + [[ -f "${vsix}" && ! -L "${vsix}" ]] || + fail "VS Code package was not created at ${vsix}" + + printf 'Installing %s into the VS Code %s\n' "${EXTENSION_ID}" "${profile_label}" + code "${profile_args[@]}" --install-extension "${vsix}" --force + + printf '\nRegistry Stack editor support is installed for VS Code.\n' + printf 'Project setup remains a separate registryctl operation.\n' + printf 'Workspace trust remains your decision when a project opens.\n' + if [[ -n "${open_path}" ]]; then + printf 'Opening %s with the VS Code %s.\n' "${open_path}" "${profile_label}" + code "${profile_args[@]}" --new-window "${open_path}" + else + printf 'Open a Registry Stack project when you are ready.\n' + fi +} + +install_zed() { + local open_path="$1" + local zed_root="${REPO_ROOT}/editors/zed" + + require_command rustup + require_command cargo + [[ -f "${zed_root}/Cargo.toml" ]] || + fail "Zed extension source was not found at ${zed_root}" + + if ! rustup target list --installed | grep -Fxq 'wasm32-wasip2'; then + printf 'Installing the Rust target required by Zed\n' + rustup target add wasm32-wasip2 + fi + + printf 'Checking the Registry Stack extension for Zed\n' + cargo check --locked --target wasm32-wasip2 \ + --manifest-path "${zed_root}/Cargo.toml" + + if [[ -n "${open_path}" ]]; then + require_command zed + fi + + printf '\nRegistry Stack editor support is prepared for Zed.\n' + printf 'Project setup remains a separate registryctl operation.\n' + printf 'Zed requires one manual installation step:\n' + printf ' 1. Run "Zed: Install Dev Extension" from the command palette.\n' + printf ' 2. Select %s\n' "${zed_root}" + printf ' 3. Run "editor: restart language server".\n' + printf 'Development-extension approval remains your decision.\n' + + if [[ -n "${open_path}" ]]; then + printf 'Opening %s in Zed. Quit an existing Zed process first if it does not inherit PATH.\n' \ + "${open_path}" + zed "${open_path}" + else + printf 'Open a Registry Stack project from this shell when you are ready.\n' + fi +} + +main() { + if (($# == 0)); then + usage >&2 + exit 2 + fi + + case "$1" in + -h | --help) + usage + exit 0 + ;; + esac + + local editor="$1" + shift + case "${editor}" in + vscode | zed) ;; + *) + usage >&2 + fail "unsupported editor '${editor}'; expected vscode or zed" + ;; + esac + + local requested_open_path='' + local vscode_profile='' + local profile_set="false" + + while (($# > 0)); do + case "$1" in + --open) + (($# >= 2)) || fail "--open requires a path" + [[ -n "$2" ]] || fail "--open cannot be empty" + requested_open_path="$2" + shift 2 + ;; + --profile) + (($# >= 2)) || fail "--profile requires a name" + [[ -n "$2" ]] || fail "--profile cannot be empty" + vscode_profile="$2" + profile_set="true" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac + done + + if [[ "${editor}" == "zed" && "${profile_set}" == "true" ]]; then + fail "--profile applies only to VS Code" + fi + + [[ -f "${REPO_ROOT}/Cargo.toml" ]] || + fail "Registry Stack repository root was not found at ${REPO_ROOT}" + + local open_path='' + if [[ -n "${requested_open_path}" ]]; then + open_path="$(canonical_open_path "${requested_open_path}")" + fi + verify_registryctl + + case "${editor}" in + vscode) + install_vscode "${open_path}" "${vscode_profile}" + ;; + zed) + install_zed "${open_path}" + ;; + esac +} + +main "$@" diff --git a/editors/tests/install_test.sh b/editors/tests/install_test.sh new file mode 100755 index 000000000..764d5095c --- /dev/null +++ b/editors/tests/install_test.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TEST_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +readonly TEST_SCRIPT_DIR +REAL_REPO_ROOT="$(cd -- "${TEST_SCRIPT_DIR}/../.." && pwd -P)" +readonly REAL_REPO_ROOT +readonly INSTALLER="${REAL_REPO_ROOT}/editors/install.sh" +TEST_ROOT="$(mktemp -d)" +readonly TEST_ROOT +readonly FAKE_REPO_ROOT="${TEST_ROOT}/repo" +readonly FAKE_OPEN_ROOT="${TEST_ROOT}/directory with spaces" +readonly FAKE_BIN="${TEST_ROOT}/bin" +readonly COMMAND_LOG="${TEST_ROOT}/commands.log" + +cleanup() { + find "${TEST_ROOT}" -depth -delete +} +trap cleanup EXIT + +fail() { + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +assert_contains() { + local expected="$1" + local file="$2" + if ! grep -Fq -- "${expected}" "${file}"; then + printf '%s\n' '--- actual content ---' >&2 + sed -n '1,160p' "${file}" >&2 + fail "expected '${expected}' in ${file}" + fi +} + +assert_not_contains() { + local unexpected="$1" + local file="$2" + if grep -Fq -- "${unexpected}" "${file}"; then + fail "did not expect '${unexpected}' in ${file}" + fi +} + +reset_log() { + : > "${COMMAND_LOG}" +} + +mkdir -p \ + "${FAKE_REPO_ROOT}/editors/vscode" \ + "${FAKE_REPO_ROOT}/editors/zed" \ + "${FAKE_OPEN_ROOT}" \ + "${FAKE_BIN}" +FAKE_OPEN_CANONICAL="$(cd -- "${FAKE_OPEN_ROOT}" && pwd -P)" +readonly FAKE_OPEN_CANONICAL + +printf '%s\n' \ + '[workspace]' \ + '[workspace.package]' \ + 'version = "0.11.0"' \ + > "${FAKE_REPO_ROOT}/Cargo.toml" +printf '{}\n' > "${FAKE_REPO_ROOT}/editors/vscode/package.json" +printf '[package]\nname = "registry-stack-zed"\n' \ + > "${FAKE_REPO_ROOT}/editors/zed/Cargo.toml" + +cat > "${FAKE_BIN}/fake-command" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +command_name="$(basename -- "$0")" +{ + printf '%s' "${command_name}" + for argument in "$@"; do + printf ' <%s>' "${argument}" + done + printf '\n' +} >> "${FAKE_COMMAND_LOG}" + +case "${command_name}" in + registryctl) + if [[ "${1:-}" == "--version" ]]; then + printf 'registryctl %s\n' "${FAKE_REGISTRYCTL_VERSION:-0.11.0}" + fi + ;; + node) + if [[ "${1:-}" == "--version" ]]; then + printf '%s\n' "${FAKE_NODE_VERSION:-v22.12.0}" + fi + ;; + npm) + if [[ " $* " == *' package:dev '* ]]; then + metadata="${FAKE_REPO_ROOT}/editors/vscode/dist/registryctl-path" + [[ -f "${metadata}" ]] + [[ "$(< "${metadata}")" == "${REGISTRY_STACK_EXPECT_REGISTRYCTL_PATH:-}" ]] + : > "${FAKE_REPO_ROOT}/editors/vscode/registry-stack-dev.vsix" + if [[ "${FAKE_NPM_FAIL_PACKAGE:-false}" == "true" ]]; then + exit 41 + fi + fi + ;; + rustup) + if [[ "${1:-}" == "target" && "${2:-}" == "list" && "${3:-}" == "--installed" && "${FAKE_WASIP2_INSTALLED:-false}" == "true" ]]; then + printf 'wasm32-wasip2\n' + fi + ;; +esac +EOF +chmod +x "${FAKE_BIN}/fake-command" + +for command_name in registryctl node npm code rustup cargo zed; do + ln -s "${FAKE_BIN}/fake-command" "${FAKE_BIN}/${command_name}" +done + +export FAKE_COMMAND_LOG="${COMMAND_LOG}" +export FAKE_REPO_ROOT +export PATH="${FAKE_BIN}:${PATH}" +export REGISTRY_STACK_INSTALLER_REPO_ROOT="${FAKE_REPO_ROOT}" + +reset_log +vscode_output="${TEST_ROOT}/vscode-output" +"${INSTALLER}" vscode \ + --profile 'Registry Stack Test' > "${vscode_output}" + +assert_contains 'registryctl <--help>' "${COMMAND_LOG}" +assert_not_contains 'registryctl ' "${COMMAND_LOG}" +assert_contains "npm <--prefix> <${FAKE_REPO_ROOT}/editors/vscode> " "${COMMAND_LOG}" +assert_contains "npm <--prefix> <${FAKE_REPO_ROOT}/editors/vscode> " "${COMMAND_LOG}" +assert_contains "code <--profile> <--install-extension> <${FAKE_REPO_ROOT}/editors/vscode/registry-stack-dev.vsix> <--force>" "${COMMAND_LOG}" +assert_not_contains '<--new-window>' "${COMMAND_LOG}" +assert_contains 'Workspace trust remains your decision' "${vscode_output}" +assert_contains 'Project setup remains a separate registryctl operation' "${vscode_output}" + +reset_log +"${INSTALLER}" vscode > /dev/null +assert_contains "code <--install-extension> <${FAKE_REPO_ROOT}/editors/vscode/registry-stack-dev.vsix> <--force>" "${COMMAND_LOG}" +assert_not_contains '<--profile>' "${COMMAND_LOG}" +if [[ -e "${FAKE_REPO_ROOT}/editors/vscode/dist/registryctl-path" ]]; then + fail 'temporary registryctl metadata remained after successful packaging' +fi + +reset_log +package_failure_output="${TEST_ROOT}/package-failure-output" +if FAKE_NPM_FAIL_PACKAGE=true "${INSTALLER}" vscode \ + > "${package_failure_output}" 2>&1; then + fail 'VS Code package failure should fail the installer' +fi +assert_contains 'VS Code extension packaging failed' "${package_failure_output}" +if [[ -e "${FAKE_REPO_ROOT}/editors/vscode/dist/registryctl-path" ]]; then + fail 'temporary registryctl metadata remained after failed packaging' +fi + +reset_log +"${INSTALLER}" vscode \ + --profile 'Registry Stack Test' \ + --open "${FAKE_OPEN_ROOT}" > /dev/null +assert_contains "code <--profile> <--new-window> <${FAKE_OPEN_CANONICAL}>" "${COMMAND_LOG}" + +reset_log +zed_output="${TEST_ROOT}/zed-output" +"${INSTALLER}" zed > "${zed_output}" +assert_contains 'rustup ' "${COMMAND_LOG}" +assert_contains "cargo <--locked> <--target> <--manifest-path> <${FAKE_REPO_ROOT}/editors/zed/Cargo.toml>" "${COMMAND_LOG}" +assert_not_contains 'zed <' "${COMMAND_LOG}" +assert_contains 'Zed requires one manual installation step' "${zed_output}" +assert_contains "Select ${FAKE_REPO_ROOT}/editors/zed" "${zed_output}" + +reset_log +FAKE_WASIP2_INSTALLED=true "${INSTALLER}" zed \ + --open "${FAKE_OPEN_ROOT}" > /dev/null +assert_not_contains 'rustup ' "${COMMAND_LOG}" +assert_contains "zed <${FAKE_OPEN_CANONICAL}>" "${COMMAND_LOG}" + +reset_log +mismatch_output="${TEST_ROOT}/mismatch-output" +if FAKE_REGISTRYCTL_VERSION=0.10.0 "${INSTALLER}" vscode \ + > "${mismatch_output}" 2>&1; then + fail 'version mismatch should fail' +fi +assert_contains 'this checkout is 0.11.0 but registryctl is 0.10.0' "${mismatch_output}" +assert_not_contains 'npm <' "${COMMAND_LOG}" + +reset_log +old_node_output="${TEST_ROOT}/old-node-output" +if FAKE_NODE_VERSION=v20.19.0 "${INSTALLER}" vscode \ + > "${old_node_output}" 2>&1; then + fail 'old Node.js version should fail' +fi +assert_contains 'Node.js 22 or newer is required' "${old_node_output}" +assert_not_contains 'npm <' "${COMMAND_LOG}" + +missing_output="${TEST_ROOT}/missing-output" +if "${INSTALLER}" vscode --open "${TEST_ROOT}/missing-directory" \ + > "${missing_output}" 2>&1; then + fail 'missing directory passed to --open should fail' +fi +assert_contains 'directory to open does not exist' "${missing_output}" + +help_output="${TEST_ROOT}/help-output" +"${INSTALLER}" --help > "${help_output}" +assert_contains './editors/install.sh ' "${help_output}" +assert_contains '--open ' "${help_output}" +assert_not_contains '--project-dir' "${help_output}" + +printf 'Editor installer tests passed.\n' diff --git a/editors/vscode/.vscode-test.cjs b/editors/vscode/.vscode-test.cjs index 84472542d..fb8a46422 100644 --- a/editors/vscode/.vscode-test.cjs +++ b/editors/vscode/.vscode-test.cjs @@ -11,8 +11,25 @@ const trustedUserData = path.join(testRunDirectory, 'trusted-user-data'); const projectAlpha = path.join(testRunDirectory, 'project-alpha'); const projectBeta = path.join(testRunDirectory, 'project-beta'); const workspaceFolder = path.join(testRunDirectory, 'multi-root.code-workspace'); +const languageServer = path.resolve(__dirname, '../../target/debug/registry-language-server'); +const registryctlWrapper = path.join(testRunDirectory, 'registryctl'); +const installerMetadata = path.join(__dirname, 'dist', 'registryctl-path'); fs.mkdirSync(projectAlpha, { recursive: true }); fs.mkdirSync(projectBeta, { recursive: true }); +fs.writeFileSync( + registryctlWrapper, + [ + '#!/bin/sh', + 'if [ "$#" -ne 2 ] || [ "$1" != "authoring" ] || [ "$2" != "language-server" ]; then', + ' exit 64', + 'fi', + `exec ${shellQuote(languageServer)}`, + '', + ].join('\n'), +); +fs.chmodSync(registryctlWrapper, 0o755); +fs.mkdirSync(path.dirname(installerMetadata), { recursive: true }); +fs.writeFileSync(installerMetadata, `${registryctlWrapper}\n`); fs.writeFileSync( path.join(projectAlpha, 'registry-stack.yaml'), 'version: 1\nregistry: { id: alpha-registry }\nservices: {}\n', @@ -28,15 +45,13 @@ fs.writeFileSync( { name: 'alpha', path: projectAlpha }, { name: 'beta', path: projectBeta }, ], - settings: { - 'registryStack.languageServer.path': path.resolve( - __dirname, - '../../target/debug/registry-language-server', - ), - }, }), ); +function shellQuote(value) { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + module.exports = defineConfig({ files: 'out/test/trusted.test.js', version: '1.91.1', diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 881caab22..1801669b9 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -1,10 +1,10 @@ # Registry Stack for VS Code -This is a source-installable developer preview for the current beta. -It is not published to the VS Code Marketplace and no release VSIX is provided. +This beta integration is installed from a Registry Stack source release. +It is not yet published to the VS Code Marketplace and no release VSIX is provided. For the stable beta path, run `registryctl init --from ` or `registryctl authoring editor --project-dir ` and use the generated YAML schema settings. -Install this preview only for optional semantic navigation. +Install this integration for optional semantic navigation. This extension starts `registry-language-server` for a workspace whose root contains `registry-stack.yaml`. It adds cross-file definitions, references, workspace/document symbols, @@ -16,39 +16,33 @@ each folder whose root contains `registry-stack.yaml`, and it responds when work added or removed. Because the server executes a local binary and reads local files, the extension is disabled in untrusted and virtual workspaces. -## Package, install, and launch +## Install and launch -Prerequisites are Node.js 22, the `code` command-line tool, and the Rust toolchain used by the -repository. First complete the [shared smoke-project setup](../README.md#local-end-to-end-smoke-test). +Prerequisites are Node.js 22 or newer, the `code` command-line tool, and the `registryctl` version +that matches this source checkout. -1. Package and install the extension from this directory: +1. From the repository root, install the integration into the active VS Code profile: ```console - cd "$REGISTRY_STACK_REPO/editors/vscode" - npm ci - npm run package:dev - code --install-extension ./registry-stack-dev.vsix --force + ./editors/install.sh vscode ``` - `package:dev` type-checks the source, bundles its runtime dependencies into - `dist/extension.js`, and verifies that the VSIX contains no external `node_modules` runtime. - The explicit install affects the active VS Code profile. + The installer checks the `registryctl` version and embedded language server, packages the + extension, and installs it without reading or changing a project. The locally built VSIX records + the verified absolute `registryctl` path, so it also works when an existing VS Code process did + not inherit the shell `PATH`. Use `--profile ` to select an existing profile. -2. Open the smoke project as the workspace root: +2. Complete the [shared smoke-project setup](../README.md#local-end-to-end-smoke-test), then open + it in the same profile: ```console code --new-window "$REGISTRY_STACK_SMOKE_PROJECT" ``` -3. Trust the opened workspace. This preview runs a local executable and is disabled in Restricted - Mode and virtual workspaces. Then run **Preferences: Open Workspace Settings (JSON)** and add - this property to the existing generated settings object, replacing the example with the - absolute value of `$REGISTRY_STACK_REPO`: - - ```json - "registryStack.languageServer.path": "/absolute/path/to/registry-stack/target/debug/registry-language-server" - ``` - + Alternatively, `./editors/install.sh vscode --open "$REGISTRY_STACK_SMOKE_PROJECT"` installs + and opens the directory without configuring it. +3. Trust the opened workspace if you have reviewed it. The integration runs a local executable + and is disabled in Restricted Mode and virtual workspaces. 4. Run **Registry Stack: Restart Language Server**. Open **View: Toggle Output**, select the **Registry Stack Language Server (project)** channel, and confirm it reports the smoke project as indexed. @@ -56,18 +50,34 @@ repository. First complete the [shared smoke-project setup](../README.md#local-e `F12` for definitions, `Shift+F12` for references, `Cmd+Shift+O`/`Ctrl+Shift+O` for document symbols, and `Cmd+T`/`Ctrl+T` for workspace symbols. -The source VSIX contains the extension runtime, not a platform server binary. -Its server discovery order is: the explicit `registryStack.languageServer.path` setting, -`registry-language-server` on `PATH`, then a matching `registryctl` on `PATH` running -`registryctl authoring language-server`. -The explicit setting is the shortest reliable preview path because it selects the binary built -from the same checkout. +The source VSIX contains the extension runtime and the verified path to `registryctl`, not a +platform server binary. Its server discovery order is: the explicit +`registryStack.languageServer.path` setting, the installer-selected `registryctl`, +`registry-language-server` on `PATH`, then a matching `registryctl` on `PATH`. Registryctl runs +`registryctl authoring language-server` in both cases. A manually packaged VSIX omits the local +path metadata and retains the PATH-based discovery behavior. + +## Manual packaging + +The installer performs these commands when a maintainer needs to inspect or repeat the individual +packaging steps: + +```console +cd editors/vscode +npm ci +npm run package:dev +code --install-extension ./registry-stack-dev.vsix --force +``` + +`package:dev` type-checks the source, bundles its runtime dependencies into `dist/extension.js`, +and verifies that the VSIX contains no external `node_modules` runtime. ## Iterate - After changing the Rust server, rebuild it from the repository root with - `cargo build --locked -p registry-language-server`, then run - **Registry Stack: Restart Language Server**. + `cargo build --locked -p registry-language-server`. Add + `"registryStack.languageServer.path": "/absolute/path/to/target/debug/registry-language-server"` + to the generated workspace settings, then run **Registry Stack: Restart Language Server**. - After changing the extension, rerun `npm run package:dev`, reinstall the VSIX with `--force`, and run **Developer: Reload Window**. - Run `npm test` after building `registry-language-server` to launch the Extension Host test for @@ -81,15 +91,15 @@ from the same checkout. directory does not activate it. Select **Workspaces: Manage Workspace Trust**, trust the reviewed project, and run **Registry Stack: Restart Language Server**. - If startup reports that no server was found, set `registryStack.languageServer.path` to the - executable built in step 3. Otherwise, add `registry-language-server` to `PATH`, or add the - matching `registryctl` to `PATH` and restart the language server. The output message names the - project folder that failed. + standalone executable built for source iteration. Otherwise, add `registry-language-server` to + `PATH`, or ensure the matching `registryctl` is on the environment inherited by VS Code and + restart the language server. The output message names the project folder that failed. - If navigation is absent, confirm the file's VS Code language mode is YAML and inspect the output channel named for that workspace folder. - Red Hat YAML still owns schema validation, completion, hover, formatting, and syntax errors. Its diagnostics do not indicate a Registry Stack language-server failure. -## Remove the development extension +## Remove the extension ```console code --uninstall-extension registrystack.registry-stack diff --git a/editors/vscode/package.json b/editors/vscode/package.json index a68197b65..d564ff838 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -52,7 +52,7 @@ "type": "string", "default": "", "scope": "machine-overridable", - "description": "Path to the registry-language-server executable. Relative paths resolve from the Registry Stack project root. When empty, the extension searches PATH, then runs `registryctl authoring language-server` from PATH." + "description": "Path to the registry-language-server executable. Relative paths resolve from the Registry Stack project root. When empty, the extension uses the installer-selected registryctl when available, then searches PATH for registry-language-server or registryctl." } } } diff --git a/editors/vscode/scripts/verify-vsix.cjs b/editors/vscode/scripts/verify-vsix.cjs index 91c27807d..598fc5f32 100644 --- a/editors/vscode/scripts/verify-vsix.cjs +++ b/editors/vscode/scripts/verify-vsix.cjs @@ -9,6 +9,10 @@ if (typeof vsixPath !== 'string' || vsixPath.length === 0) { const entries = execFileSync('unzip', ['-Z1', vsixPath], { encoding: 'utf8' }).split('\n'); const requiredEntries = ['extension/package.json', 'extension/dist/extension.js']; +const expectedRegistryctlPath = process.env.REGISTRY_STACK_EXPECT_REGISTRYCTL_PATH; +if (expectedRegistryctlPath) { + requiredEntries.push('extension/dist/registryctl-path'); +} for (const entry of requiredEntries) { if (!entries.includes(entry)) { throw new Error(`VSIX is missing required runtime entry: ${entry}`); @@ -24,3 +28,14 @@ const bundle = execFileSync('unzip', ['-p', vsixPath, 'extension/dist/extension. if (bundle.includes('require("vscode-languageclient') || bundle.includes("require('vscode-languageclient")) { throw new Error('VSIX leaves vscode-languageclient as an external runtime dependency'); } + +if (expectedRegistryctlPath) { + const packagedRegistryctlPath = execFileSync( + 'unzip', + ['-p', vsixPath, 'extension/dist/registryctl-path'], + { encoding: 'utf8' }, + ).trim(); + if (packagedRegistryctlPath !== expectedRegistryctlPath) { + throw new Error('VSIX does not contain the registryctl path selected by the installer'); + } +} diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index ca23ed47e..a26c33d05 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -8,7 +8,6 @@ import { LanguageClient, LanguageClientOptions, ServerOptions, - TransportKind, } from 'vscode-languageclient/node'; const clients = new Map(); @@ -68,20 +67,11 @@ async function startClient( ): Promise { const key = folderKey(projectFolder); try { - const server = resolveServerCommand(projectFolder); + const server = resolveServerCommand(context, projectFolder); const serverOptions: ServerOptions = { - run: { - command: server.command, - args: server.args, - transport: TransportKind.stdio, - options: { cwd: projectFolder.uri.fsPath }, - }, - debug: { - command: server.command, - args: server.args, - transport: TransportKind.stdio, - options: { cwd: projectFolder.uri.fsPath }, - }, + command: server.command, + args: server.args, + options: { cwd: projectFolder.uri.fsPath }, }; const clientOptions: LanguageClientOptions = { documentSelector: [ @@ -144,7 +134,10 @@ function folderKey(folder: vscode.WorkspaceFolder): string { return folder.uri.toString(); } -function resolveServerCommand(projectFolder: vscode.WorkspaceFolder): { +function resolveServerCommand( + context: vscode.ExtensionContext, + projectFolder: vscode.WorkspaceFolder, +): { command: string; args: string[]; } { @@ -167,6 +160,13 @@ function resolveServerCommand(projectFolder: vscode.WorkspaceFolder): { const executable = process.platform === 'win32' ? 'registry-language-server.exe' : 'registry-language-server'; + const packagedRegistryctl = findPackagedRegistryctl(context); + if (packagedRegistryctl !== undefined) { + return { + command: packagedRegistryctl, + args: ['authoring', 'language-server'], + }; + } const standalone = findExecutableOnPath(executable); if (standalone !== undefined) { return { command: standalone, args: [] }; @@ -178,10 +178,23 @@ function resolveServerCommand(projectFolder: vscode.WorkspaceFolder): { return { command: registryctl, args: ['authoring', 'language-server'] }; } throw new Error( - 'No Registry Stack language server was found. Set registryStack.languageServer.path to an executable, add registry-language-server to PATH, or add a matching registryctl to PATH so it can run "registryctl authoring language-server".', + 'No Registry Stack language server was found. Reinstall the integration with a matching registryctl, set registryStack.languageServer.path to an executable, add registry-language-server to PATH, or add a matching registryctl to PATH so it can run "registryctl authoring language-server".', ); } +function findPackagedRegistryctl(context: vscode.ExtensionContext): string | undefined { + const metadataPath = context.asAbsolutePath(path.join('dist', 'registryctl-path')); + try { + const candidate = fs.readFileSync(metadataPath, 'utf8').trim(); + if (candidate !== '' && path.isAbsolute(candidate) && isExecutableFile(candidate)) { + return candidate; + } + } catch { + // Manual packages intentionally omit installer metadata and continue with PATH discovery. + } + return undefined; +} + function isExecutableFile(candidate: string): boolean { try { if (!fs.statSync(candidate).isFile()) { diff --git a/editors/vscode/test/trusted.test.ts b/editors/vscode/test/trusted.test.ts index eb808f181..2bffdc44e 100644 --- a/editors/vscode/test/trusted.test.ts +++ b/editors/vscode/test/trusted.test.ts @@ -7,7 +7,11 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; suite('Registry Stack extension', () => { - test('starts a language server for every trusted Registry Stack workspace folder', async () => { + suiteTeardown(() => { + fs.rmSync(path.resolve(__dirname, '../../dist/registryctl-path'), { force: true }); + }); + + test('uses the installed registryctl for every trusted Registry Stack workspace folder', async () => { assert.strictEqual(vscode.workspace.isTrusted, true); assert.strictEqual(vscode.workspace.workspaceFolders?.length, 2); diff --git a/editors/zed/README.md b/editors/zed/README.md index ecff84b0d..2ceed9b2f 100644 --- a/editors/zed/README.md +++ b/editors/zed/README.md @@ -1,10 +1,10 @@ # Registry Stack for Zed -This is a source-installable developer preview for the current beta. -It is not listed in Zed Extensions and no release artifact is provided. +This beta integration is installed from a Registry Stack source release. +It is not yet listed in Zed Extensions and no release artifact is provided. For the stable beta path, run `registryctl init --from ` or `registryctl authoring editor --project-dir ` and use the generated YAML schema settings. -Install this preview only for optional semantic navigation. +Install this integration for optional semantic navigation. This extension attaches `registry-language-server` to Zed's built-in YAML language. It adds cross-file definitions, references, workspace/document symbols, and Registry Stack reference @@ -13,25 +13,29 @@ completion, formatting, and ordinary hover information. ## Install and launch -Zed requires Rust installed through `rustup` to compile development extensions. First complete the -[shared smoke-project setup](../README.md#local-end-to-end-smoke-test), then verify the required Zed -WebAssembly target from the repository root: +Zed requires Rust installed through `rustup`, the `zed` command-line tool, and the `registryctl` +version that matches this source checkout. Run the installer once from the repository root: ```console -rustup target add wasm32-wasip2 -cargo check --locked --target wasm32-wasip2 --manifest-path editors/zed/Cargo.toml +./editors/install.sh zed ``` -1. Put the freshly built language server on the environment inherited by Zed, then open the smoke - project from the same terminal: +The installer checks the matching `registryctl` and embedded language server, installs the required +`wasm32-wasip2` target when missing, compile-checks the Zed extension, and prints its absolute path. +It does not read or change a project. + +1. Complete the [shared smoke-project setup](../README.md#local-end-to-end-smoke-test), then open it + from the same shell so Zed inherits the matching `registryctl`: ```console - export PATH="$REGISTRY_STACK_REPO/target/debug:$PATH" zed "$REGISTRY_STACK_SMOKE_PROJECT" ``` -2. Run **Zed: Install Dev Extension** from the command palette and select - `$REGISTRY_STACK_REPO/editors/zed`. Zed compiles and installs the WebAssembly extension. + Alternatively, `./editors/install.sh zed --open "$REGISTRY_STACK_SMOKE_PROJECT"` prepares the + extension and opens the directory without configuring it. +2. Run **Zed: Install Dev Extension** from the command palette and select the extension path printed + by the installer. Zed requires this explicit approval because its CLI cannot install a local + development extension. 3. Run `editor: restart language server`, then `dev: open language server logs`. Select `registry-stack` for the smoke project and confirm the server log reports that the project was indexed. Use `zed: open log` instead for extension compilation or launcher failures. @@ -39,10 +43,14 @@ cargo check --locked --target wasm32-wasip2 --manifest-path editors/zed/Cargo.to `F12` for definitions, `Alt+Shift+F12` for references, `Cmd+Shift+O`/`Ctrl+Shift+O` for document symbols, and `Cmd+T`/`Ctrl+T` for workspace symbols. +The installer cannot approve the development extension on the user's behalf. This is a deliberate +Zed trust boundary, not missing automation. + ## Iterate - After changing the Rust server, run `cargo build --locked -p registry-language-server`, then - `editor: restart language server` in Zed. + put `target/debug` before `registryctl` on the environment inherited by Zed, then run + `editor: restart language server`. - After changing the Zed launcher, install the development extension again from the same directory and restart the language server. @@ -53,7 +61,7 @@ cargo check --locked --target wasm32-wasip2 --manifest-path editors/zed/Cargo.to - If Zed cannot find the server, close it, export the updated `PATH`, and relaunch it from that terminal. The launcher first looks for `registry-language-server`, then runs `registryctl authoring language-server` when `registryctl` is on `PATH`. The two executables must - come from the same checkout or beta build that you are previewing. + come from the same checkout or beta build that you are testing. - Use `dev: open language server logs` to inspect how the server was launched. Use `zed: open log` for extension errors. For verbose extension output, close Zed and relaunch it with `zed --foreground "$REGISTRY_STACK_SMOKE_PROJECT"`. @@ -65,10 +73,10 @@ from that page after the smoke test if you do not want the override to remain ac Zed does not permit shipping an external language server inside the extension. The current Zed extension API registers a language server against a language name, but has no worktree-root predicate for `registry-stack.yaml`. -The preview therefore attaches to YAML while the development extension remains installed. +The integration therefore attaches to YAML while the development extension remains installed. It has no Registry Stack behavior without a server binary, but Zed can log a missing-server error when you open unrelated YAML in another worktree. -Keep the development extension installed only while previewing a Registry Stack project, and remove +Keep the development extension installed only while using a Registry Stack project, and remove it afterwards to avoid that noise. See Zed's official [development-extension instructions](https://zed.dev/docs/extensions/developing-extensions#developing-an-extension-locally) From 55ff0bbe1914fd3db485f2a1e41ed2f86957dd85 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 20:59:33 +0700 Subject: [PATCH 54/65] chore(monorepo): finish pre-convergence cleanup Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 4 + .github/workflows/release.yml | 11 + docs/site/.research/README.md | 14 + docs/site/.research/data-audit.md | 4 + docs/site/.research/registry-atlas.md | 4 + docs/site/.research/registry-lab.md | 4 + docs/site/.research/registry-manifest.md | 4 + docs/site/.research/registry-relay.md | 4 + docs/site/.research/registry-witness.md | 4 + docs/site/docs/evidence-link-policy.md | 52 ++++ docs/site/package.json | 4 +- docs/site/scripts/check-built-links.mjs | 16 + docs/site/scripts/check-built-links.test.mjs | 56 ++++ docs/site/scripts/check-evidence-links.mjs | 287 ++++++++++++++++++ .../scripts/check-evidence-links.test.mjs | 183 +++++++++++ docs/site/scripts/check-research-banners.mjs | 36 +++ .../scripts/check-research-banners.test.mjs | 27 ++ .../docs/decisions/rename-2026-05-23.mdx | 2 +- docs/site/src/data/generated/standards.json | 10 +- docs/site/src/data/repo-map.yaml | 16 - docs/site/src/data/standards.yaml | 10 +- products/manifest/.github/workflows/ci.yml | 210 ------------- products/manifest/README.md | 28 +- products/manifest/SECURITY.md | 20 +- products/manifest/docs/profile-fixtures.md | 23 +- release/scripts/check-gates-inventory.py | 4 + release/scripts/test_check_gates_inventory.py | 7 + 27 files changed, 776 insertions(+), 268 deletions(-) create mode 100644 docs/site/.research/README.md create mode 100644 docs/site/docs/evidence-link-policy.md create mode 100644 docs/site/scripts/check-built-links.test.mjs create mode 100644 docs/site/scripts/check-evidence-links.mjs create mode 100644 docs/site/scripts/check-evidence-links.test.mjs create mode 100644 docs/site/scripts/check-research-banners.mjs create mode 100644 docs/site/scripts/check-research-banners.test.mjs delete mode 100644 docs/site/src/data/repo-map.yaml delete mode 100644 products/manifest/.github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8e8cd3cd..94e0399c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -157,6 +157,10 @@ jobs: - name: Cargo metadata run: cargo metadata --locked --format-version 1 >/tmp/registry-stack-cargo-metadata.json + - name: Validate Manifest profiles + working-directory: products/manifest + run: cargo run --locked -p registry-manifest-cli -- validate-profiles profiles + - name: Gate inventory run: python3 release/scripts/check-gates-inventory.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb28ac6c5..15980a3c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,6 +102,17 @@ jobs: echo "manifest=${manifest}" } >> "${GITHUB_OUTPUT}" + - name: Set up evidence checker runtime + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 22.12.0 + + - name: Verify documentation evidence links + working-directory: docs/site + run: >- + npm run check:evidence-links -- + --source-ref "${{ steps.release.outputs.tag_target }}" + - name: Verify lock-bearing release version run: | release/scripts/registry-release verify-registryctl-image-lock-release-version \ diff --git a/docs/site/.research/README.md b/docs/site/.research/README.md new file mode 100644 index 000000000..f897a61d1 --- /dev/null +++ b/docs/site/.research/README.md @@ -0,0 +1,14 @@ +# Historical research notes + +This directory preserves research collected before Registry Stack converged on +the current monorepo and four-product topology. The notes are inputs to past +documentation work, not current architecture guidance or release evidence. + +Current claims belong in the published documentation and must use the pinned +source evidence defined by `src/data/contracts.yaml` and +`src/data/standards.yaml`. + +Every research note in this directory, except this README, must begin with the +exact historical-research banner enforced by +`scripts/check-research-banners.mjs`. The banner is deliberately deterministic +so a note cannot silently lose its historical status. diff --git a/docs/site/.research/data-audit.md b/docs/site/.research/data-audit.md index 36b2cadb4..92be4a678 100644 --- a/docs/site/.research/data-audit.md +++ b/docs/site/.research/data-audit.md @@ -1,3 +1,7 @@ +> **Status: historical research note** +> +> This note records pre-monorepo research and is not current architecture or release evidence. Use the published documentation and pinned source links for current claims. + # Data audit — 2026-05-23 ## Overview diff --git a/docs/site/.research/registry-atlas.md b/docs/site/.research/registry-atlas.md index e4bb2557a..26ab4fb2e 100644 --- a/docs/site/.research/registry-atlas.md +++ b/docs/site/.research/registry-atlas.md @@ -1,3 +1,7 @@ +> **Status: historical research note** +> +> This note records pre-monorepo research and is not current architecture or release evidence. Use the published documentation and pinned source links for current claims. + # registry-atlas — evidence packet Last reviewed: 2026-05-23 diff --git a/docs/site/.research/registry-lab.md b/docs/site/.research/registry-lab.md index ecd8dbfd8..1c68eac3c 100644 --- a/docs/site/.research/registry-lab.md +++ b/docs/site/.research/registry-lab.md @@ -1,3 +1,7 @@ +> **Status: historical research note** +> +> This note records pre-monorepo research and is not current architecture or release evidence. Use the published documentation and pinned source links for current claims. + # registry-lab — evidence packet Last reviewed: 2026-05-23 diff --git a/docs/site/.research/registry-manifest.md b/docs/site/.research/registry-manifest.md index 94e4f59d5..518417197 100644 --- a/docs/site/.research/registry-manifest.md +++ b/docs/site/.research/registry-manifest.md @@ -1,3 +1,7 @@ +> **Status: historical research note** +> +> This note records pre-monorepo research and is not current architecture or release evidence. Use the published documentation and pinned source links for current claims. + # registry-manifest — evidence packet **Last reviewed:** 2026-05-23 diff --git a/docs/site/.research/registry-relay.md b/docs/site/.research/registry-relay.md index ad147785f..f1dbc1202 100644 --- a/docs/site/.research/registry-relay.md +++ b/docs/site/.research/registry-relay.md @@ -1,3 +1,7 @@ +> **Status: historical research note** +> +> This note records pre-monorepo research and is not current architecture or release evidence. Use the published documentation and pinned source links for current claims. + # registry-relay — evidence packet **Last reviewed:** 2026-05-23 diff --git a/docs/site/.research/registry-witness.md b/docs/site/.research/registry-witness.md index fd6c83255..56df01b7e 100644 --- a/docs/site/.research/registry-witness.md +++ b/docs/site/.research/registry-witness.md @@ -1,3 +1,7 @@ +> **Status: historical research note** +> +> This note records pre-monorepo research and is not current architecture or release evidence. Use the published documentation and pinned source links for current claims. + # registry-witness — evidence packet Last reviewed: 2026-05-23 diff --git a/docs/site/docs/evidence-link-policy.md b/docs/site/docs/evidence-link-policy.md new file mode 100644 index 000000000..6c9b3d057 --- /dev/null +++ b/docs/site/docs/evidence-link-policy.md @@ -0,0 +1,52 @@ +# Evidence link policy + +The contract and standards data files support current documentation claims. +Their evidence links must remain reproducible without trusting a moving branch +or making network requests during verification. + +## Scope + +This policy applies to: + +- `source_of_truth.url` in `src/data/contracts.yaml` +- every `evidence_docs[].url` in `src/data/standards.yaml` + +It does not apply to `official_url`. Those links identify an external standards +body or specification and are not presented as locally verified source +evidence. + +## Accepted evidence links + +Repository evidence must use an absolute +`https://github.com/registrystack/registry-stack/blob//` or +`https://github.com/registrystack/registry-stack/tree//` URL. The +reference must be either a semantic-version tag or a full 40-character commit +SHA. Branch names and abbreviated SHAs are not stable evidence identifiers. + +A link to a current Registry Stack documentation page must be root-relative, +such as `/explanation/dpi-safeguards-alignment/`. Do not embed the current +documentation hostname in an evidence field. + +Evidence fields must not point to other hosts. An external source can be listed +as `official_url`, but a current Registry Stack claim needs locally verifiable +repository or documentation evidence. + +## Enforcement + +Run the checker from `docs/site`: + +```sh +npm run check:evidence-links +``` + +The checker reads only local files and Git objects. It verifies that: + +- source YAML evidence URLs match the checked-in generated JSON +- every tag or commit exists in the local repository +- every linked repository path exists at that exact tag or commit +- every root-relative documentation route exists at the selected source commit + +The release workflow passes its resolved tag commit with `--source-ref`, so +current-documentation routes are verified against the same source that the +release uses. The checker has no network fallback. A shallow or incomplete +checkout must fetch the required Git history before running it. diff --git a/docs/site/package.json b/docs/site/package.json index bbe399ed8..56ea2d932 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -17,6 +17,8 @@ "check:llms:built": "node scripts/check-llms.mjs", "check:docset": "node scripts/check-docset.mjs", "check:release-manifests": "python3 ../../release/scripts/registry-release validate-docsets", + "check:evidence-links": "node scripts/check-evidence-links.mjs", + "check:research-banners": "node scripts/check-research-banners.mjs", "check:content": "node scripts/check-doc-frontmatter.mjs", "check:svg": "node scripts/check-svg-a11y.mjs", "check:markdown": "markdownlint-cli2", @@ -29,7 +31,7 @@ "test:tutorial:registryctl": "node --test scripts/registryctl-tutorial.test.mjs", "check:tutorial:registryctl": "bash scripts/check-registryctl-tutorials.sh", "check:links": "npm run build && npm run check:links:built", - "check": "npm run generate && npm run check:docset && npm run check:release-manifests && npm run check:content && npm run check:markdown && npm run check:style && npm run check:style:fixtures && npm run check:openapi && npm run check:config-vocabulary && npm run check:tutorial:dry-run && npm run check:svg && npm run build && npm run check:llms:built && npm run build:archives && npm run check:seo:built && npm run check:links:built", + "check": "npm run generate && npm run check:evidence-links && npm run check:research-banners && npm run check:docset && npm run check:release-manifests && npm run check:content && npm run check:markdown && npm run check:style && npm run check:style:fixtures && npm run check:openapi && npm run check:config-vocabulary && npm run check:tutorial:dry-run && npm run check:svg && npm run build && npm run check:llms:built && npm run build:archives && npm run check:seo:built && npm run check:links:built", "check:seo:built": "node scripts/check-seo.mjs", "check:links:built": "node scripts/check-built-links.mjs" }, diff --git a/docs/site/scripts/check-built-links.mjs b/docs/site/scripts/check-built-links.mjs index acfc17608..9a003263d 100644 --- a/docs/site/scripts/check-built-links.mjs +++ b/docs/site/scripts/check-built-links.mjs @@ -1,6 +1,8 @@ import { readdir, readFile, stat } from 'node:fs/promises'; import { dirname, join, normalize, relative } from 'node:path'; +import { extractEvidenceUrlsFromYaml } from './check-evidence-links.mjs'; + const distDir = 'dist'; const attrPattern = /\s(?:href|src)=["']([^"']+)["']/g; const idPattern = /\sid=["']([^"']+)["']/g; @@ -75,9 +77,22 @@ function targetPath(url) { return join(distDir, path); } +async function currentEvidencePaths() { + const paths = new Set(); + const dataDir = join('src', 'data'); + for (const kind of ['contracts', 'standards']) { + const source = await readFile(join(dataDir, `${kind}.yaml`), 'utf8'); + for (const url of extractEvidenceUrlsFromYaml(source, kind)) { + if (url.startsWith('/')) paths.add(splitUrl(url)[0]); + } + } + return paths; +} + const errors = []; let checked = 0; const idsByFile = new Map(); +const evidencePaths = await currentEvidencePaths(); for (const file of await htmlFiles(distDir)) { const html = await readFile(file, 'utf8'); @@ -96,6 +111,7 @@ for (const file of await htmlFiles(distDir)) { raw.startsWith('/') && raw !== '/' && !raw.startsWith(root) && + !evidencePaths.has(splitUrl(raw)[0]) && !isExternal(raw) ) { errors.push(`${relative('.', file)} links outside its archive: ${raw}`); diff --git a/docs/site/scripts/check-built-links.test.mjs b/docs/site/scripts/check-built-links.test.mjs new file mode 100644 index 000000000..b23751cb5 --- /dev/null +++ b/docs/site/scripts/check-built-links.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +const here = dirname(fileURLToPath(import.meta.url)); +const checker = resolve(here, 'check-built-links.mjs'); + +function write(root, path, content) { + const target = resolve(root, path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, content); +} + +function fixture(t, archivedHref) { + const root = mkdtempSync(resolve(tmpdir(), 'registry-built-links-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + write(root, 'dist/index.html', ''); + write(root, 'dist/explanation/current/index.html', ''); + write( + root, + 'dist/v/v1/reference/standards/index.html', + `Evidence`, + ); + write(root, 'src/data/contracts.yaml', '[]\n'); + write( + root, + 'src/data/standards.yaml', + `- id: test + official_url: /not-evidence/ + evidence_docs: + - label: current + url: /explanation/current/ +`, + ); + return root; +} + +function run(root) { + return spawnSync(process.execPath, [checker], { cwd: root, encoding: 'utf8' }); +} + +test('allows an archived standards page to cite root-relative current evidence', (t) => { + const result = run(fixture(t, '/explanation/current/')); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Built link check passed/); +}); + +test('keeps rejecting unrelated links that escape an archive', (t) => { + const result = run(fixture(t, '/not-evidence/')); + assert.equal(result.status, 1); + assert.match(result.stderr, /links outside its archive/); +}); diff --git a/docs/site/scripts/check-evidence-links.mjs b/docs/site/scripts/check-evidence-links.mjs new file mode 100644 index 000000000..7e382c50e --- /dev/null +++ b/docs/site/scripts/check-evidence-links.mjs @@ -0,0 +1,287 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, posix, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = fileURLToPath(import.meta.url); +const scriptDir = dirname(scriptPath); + +const SAME_REPOSITORY = 'https://github.com/registrystack/registry-stack'; +const SEMVER_TAG = + /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const FULL_COMMIT = /^[0-9a-f]{40}$/; + +function parseYamlScalar(value, location) { + const trimmed = value.trim(); + if (trimmed.startsWith('"')) { + try { + return JSON.parse(trimmed); + } catch (error) { + throw new Error(`${location} has an invalid quoted URL: ${error.message}`); + } + } + if (trimmed.startsWith("'")) { + if (!trimmed.endsWith("'")) { + throw new Error(`${location} has an unterminated quoted URL`); + } + return trimmed.slice(1, -1).replaceAll("''", "'"); + } + if (trimmed === '|' || trimmed === '>') { + throw new Error(`${location} must use a single-line URL scalar`); + } + return trimmed; +} + +export function extractEvidenceUrlsFromYaml(text, kind) { + const urls = []; + let inEvidenceBlock = false; + + for (const [index, line] of text.split(/\r?\n/).entries()) { + if (kind === 'contracts' && /^ source_of_truth:\s*$/.test(line)) { + inEvidenceBlock = true; + continue; + } + if (kind === 'standards' && /^ evidence_docs:\s*$/.test(line)) { + inEvidenceBlock = true; + continue; + } + if (!inEvidenceBlock) { + continue; + } + if (/^ \S/.test(line)) { + inEvidenceBlock = false; + continue; + } + + const indentation = kind === 'contracts' ? 4 : 6; + const match = new RegExp(`^ {${indentation}}url:\\s*(.+?)\\s*$`).exec(line); + if (match) { + urls.push(parseYamlScalar(match[1], `${kind}.yaml:${index + 1}`)); + } + } + + return urls; +} + +function generatedEvidenceUrls(data, kind) { + if (!Array.isArray(data)) { + throw new Error(`generated/${kind}.json must contain a top-level list`); + } + if (kind === 'contracts') { + return data.map((entry, index) => { + const url = entry?.source_of_truth?.url; + if (typeof url !== 'string' || url.length === 0) { + throw new Error(`generated/contracts.json entry ${index + 1} has no source URL`); + } + return url; + }); + } + return data.flatMap((entry, entryIndex) => { + if (!Array.isArray(entry?.evidence_docs)) { + throw new Error(`generated/standards.json entry ${entryIndex + 1} has no evidence_docs`); + } + return entry.evidence_docs.map((evidence, evidenceIndex) => { + if (typeof evidence?.url !== 'string' || evidence.url.length === 0) { + throw new Error( + `generated/standards.json entry ${entryIndex + 1} evidence ${evidenceIndex + 1} has no URL`, + ); + } + return evidence.url; + }); + }); +} + +function readEvidenceUrls(dataDir) { + const urls = []; + for (const kind of ['contracts', 'standards']) { + const yamlUrls = extractEvidenceUrlsFromYaml( + readFileSync(resolve(dataDir, `${kind}.yaml`), 'utf8'), + kind, + ); + const generatedUrls = generatedEvidenceUrls( + JSON.parse(readFileSync(resolve(dataDir, 'generated', `${kind}.json`), 'utf8')), + kind, + ); + if (JSON.stringify(yamlUrls) !== JSON.stringify(generatedUrls)) { + throw new Error( + `${kind}.yaml evidence URLs differ from generated/${kind}.json; run npm run generate`, + ); + } + urls.push(...yamlUrls.map((url, index) => ({ location: `${kind}[${index + 1}]`, url }))); + } + return urls; +} + +function gitObjectExists(repoRoot, object) { + return ( + spawnSync('git', ['cat-file', '-e', object], { + cwd: repoRoot, + encoding: 'utf8', + stdio: 'pipe', + }).status === 0 + ); +} + +function safePathParts(parts) { + try { + return parts.map((part) => decodeURIComponent(part)); + } catch { + return []; + } +} + +function validRepositoryPath(parts) { + return ( + parts.length > 0 && + parts.every( + (part) => + part.length > 0 && + part !== '.' && + part !== '..' && + !part.includes('/') && + !part.includes('\\') && + !part.includes('\0'), + ) + ); +} + +function checkRepositoryEvidence(repoRoot, rawUrl) { + let url; + try { + url = new URL(rawUrl); + } catch { + return 'must be a root-relative docs URL or an absolute Registry Stack GitHub URL'; + } + + if (`${url.origin}${url.pathname}`.startsWith(`${SAME_REPOSITORY}/`) === false) { + return 'external evidence URLs are not locally verifiable'; + } + if (url.search || url.hash || url.username || url.password || url.port) { + return 'repository evidence URLs must not contain credentials, ports, queries, or fragments'; + } + + const parts = safePathParts(url.pathname.split('/').filter(Boolean)); + if ( + parts.length < 5 || + parts[0] !== 'registrystack' || + parts[1] !== 'registry-stack' || + !['blob', 'tree'].includes(parts[2]) + ) { + return 'must use a Registry Stack /blob// or /tree// URL'; + } + + const ref = parts[3]; + const repositoryPath = parts.slice(4); + if (!validRepositoryPath(repositoryPath)) { + return 'contains an invalid or missing repository path'; + } + + let commitish; + if (SEMVER_TAG.test(ref)) { + commitish = `refs/tags/${ref}`; + } else if (FULL_COMMIT.test(ref)) { + commitish = ref; + } else { + return `uses ${ref}, but evidence refs must be semver tags or full 40-character commits`; + } + + if (!gitObjectExists(repoRoot, `${commitish}^{commit}`)) { + return `references missing Git commit or tag ${ref}`; + } + const path = repositoryPath.join('/'); + if (!gitObjectExists(repoRoot, `${commitish}^{commit}:${path}`)) { + return `references missing path ${path} at ${ref}`; + } + return undefined; +} + +function currentDocsCandidates(rawUrl) { + let url; + try { + url = new URL(rawUrl, 'https://docs.registrystack.invalid'); + } catch { + return []; + } + if (url.origin !== 'https://docs.registrystack.invalid' || !rawUrl.startsWith('/')) { + return []; + } + const parts = safePathParts(url.pathname.split('/').filter(Boolean)); + if (!validRepositoryPath(parts)) { + return []; + } + const route = posix.join(...parts); + return [ + `docs/site/src/content/docs/${route}.mdx`, + `docs/site/src/content/docs/${route}.md`, + `docs/site/src/content/docs/${route}/index.mdx`, + `docs/site/src/content/docs/${route}/index.md`, + ]; +} + +function checkCurrentDocsEvidence(repoRoot, sourceRef, rawUrl) { + const candidates = currentDocsCandidates(rawUrl); + if (candidates.length === 0) { + return 'contains an invalid current-docs route'; + } + if (!gitObjectExists(repoRoot, `${sourceRef}^{commit}`)) { + return `cannot resolve release source ${sourceRef}`; + } + if (!candidates.some((path) => gitObjectExists(repoRoot, `${sourceRef}^{commit}:${path}`))) { + return `does not resolve to a documentation page at release source ${sourceRef}`; + } + return undefined; +} + +export function checkEvidenceLinks({ + repoRoot = resolve(scriptDir, '../../..'), + dataDir = resolve(scriptDir, '../src/data'), + sourceRef = 'HEAD', +} = {}) { + const errors = []; + let evidence; + try { + evidence = readEvidenceUrls(dataDir); + } catch (error) { + return { checked: 0, errors: [error.message] }; + } + + for (const item of evidence) { + const error = item.url.startsWith('/') + ? checkCurrentDocsEvidence(repoRoot, sourceRef, item.url) + : checkRepositoryEvidence(repoRoot, item.url); + if (error) { + errors.push(`${item.location}: ${item.url}: ${error}`); + } + } + return { checked: evidence.length, errors }; +} + +function sourceRefArgument(args) { + if (args.length === 0) { + return 'HEAD'; + } + if (args.length === 2 && args[0] === '--source-ref' && args[1]) { + return args[1]; + } + throw new Error('usage: check-evidence-links.mjs [--source-ref ]'); +} + +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + try { + const result = checkEvidenceLinks({ sourceRef: sourceRefArgument(process.argv.slice(2)) }); + if (result.errors.length > 0) { + console.error('Evidence link check failed:'); + for (const error of result.errors) { + console.error(`- ${error}`); + } + process.exitCode = 1; + } else { + console.log(`Verified ${result.checked} evidence links using local Git objects.`); + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/docs/site/scripts/check-evidence-links.test.mjs b/docs/site/scripts/check-evidence-links.test.mjs new file mode 100644 index 000000000..3493528fd --- /dev/null +++ b/docs/site/scripts/check-evidence-links.test.mjs @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +import { checkEvidenceLinks, extractEvidenceUrlsFromYaml } from './check-evidence-links.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(here, '../../..'); + +function git(root, ...args) { + return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); +} + +function createRepository(t) { + const root = mkdtempSync(join(tmpdir(), 'registry-evidence-links-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(resolve(root, 'docs/site/src/content/docs/explanation'), { recursive: true }); + mkdirSync(resolve(root, 'source/tree'), { recursive: true }); + writeFileSync(resolve(root, 'docs/site/src/content/docs/explanation/current.mdx'), '# Current\n'); + writeFileSync(resolve(root, 'source/file.md'), '# Source\n'); + writeFileSync(resolve(root, 'source/tree/item.txt'), 'evidence\n'); + git(root, 'init', '--quiet'); + git(root, 'config', 'user.email', 'tests@example.invalid'); + git(root, 'config', 'user.name', 'Evidence Tests'); + git(root, 'add', '.'); + git(root, 'commit', '--quiet', '-m', 'test evidence'); + git(root, 'tag', 'v1.2.3'); + return { root, commit: git(root, 'rev-parse', 'HEAD') }; +} + +function writeEvidenceData(root, { contractUrls, standardUrls, officialUrl } = {}) { + const dataDir = resolve(root, 'docs/site/src/data'); + mkdirSync(resolve(dataDir, 'generated'), { recursive: true }); + const contracts = (contractUrls ?? []).map((url, index) => ({ + id: `contract-${index}`, + source_of_truth: { label: `Contract ${index}`, url }, + })); + const standards = [ + { + id: 'standard', + official_url: officialUrl ?? 'https://standards.example.invalid/main', + evidence_docs: (standardUrls ?? []).map((url, index) => ({ + label: `Evidence ${index}`, + url, + })), + }, + ]; + const contractsYaml = contracts + .map( + (entry) => + `- id: ${entry.id}\n source_of_truth:\n label: ${entry.source_of_truth.label}\n url: ${entry.source_of_truth.url}\n`, + ) + .join(''); + const standardsYaml = [ + '- id: standard', + ` official_url: ${standards[0].official_url}`, + ' evidence_docs:', + ...standards[0].evidence_docs.flatMap((entry) => [ + ` - label: ${entry.label}`, + ` url: ${entry.url}`, + ]), + '', + ].join('\n'); + writeFileSync(resolve(dataDir, 'contracts.yaml'), contractsYaml); + writeFileSync(resolve(dataDir, 'standards.yaml'), standardsYaml); + writeFileSync(resolve(dataDir, 'generated/contracts.json'), `${JSON.stringify(contracts)}\n`); + writeFileSync(resolve(dataDir, 'generated/standards.json'), `${JSON.stringify(standards)}\n`); + return dataDir; +} + +test('accepts semver tags, full commits, and root-relative current docs', (t) => { + const { root, commit } = createRepository(t); + const dataDir = writeEvidenceData(root, { + contractUrls: [ + 'https://github.com/registrystack/registry-stack/blob/v1.2.3/source/file.md', + ], + standardUrls: [ + `https://github.com/registrystack/registry-stack/tree/${commit}/source/tree`, + '/explanation/current/', + ], + officialUrl: 'https://standards.example.invalid/unverified/current', + }); + + assert.deepEqual(checkEvidenceLinks({ repoRoot: root, dataDir, sourceRef: commit }), { + checked: 3, + errors: [], + }); +}); + +test('rejects branches, short commits, missing refs, and missing paths', async (t) => { + const { root, commit } = createRepository(t); + const cases = [ + { + name: 'branch', + url: 'https://github.com/registrystack/registry-stack/blob/main/source/file.md', + expected: /semver tags or full 40-character commits/, + }, + { + name: 'short commit', + url: `https://github.com/registrystack/registry-stack/blob/${commit.slice(0, 8)}/source/file.md`, + expected: /semver tags or full 40-character commits/, + }, + { + name: 'missing tag', + url: 'https://github.com/registrystack/registry-stack/blob/v9.9.9/source/file.md', + expected: /missing Git commit or tag/, + }, + { + name: 'missing path', + url: 'https://github.com/registrystack/registry-stack/blob/v1.2.3/source/missing.md', + expected: /missing path/, + }, + ]; + + for (const item of cases) { + await t.test(item.name, () => { + const dataDir = writeEvidenceData(root, { contractUrls: [item.url] }); + const result = checkEvidenceLinks({ repoRoot: root, dataDir, sourceRef: commit }); + assert.equal(result.checked, 1); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], item.expected); + }); + } +}); + +test('rejects foreign evidence hosts but excludes official_url from the check', (t) => { + const { root, commit } = createRepository(t); + const dataDir = writeEvidenceData(root, { + contractUrls: ['https://evidence.example.invalid/blob/v1.2.3/source/file.md'], + officialUrl: 'https://github.com/registrystack/registry-stack/blob/main/source/missing.md', + }); + + const result = checkEvidenceLinks({ repoRoot: root, dataDir, sourceRef: commit }); + assert.equal(result.checked, 1); + assert.match(result.errors[0], /external evidence URLs are not locally verifiable/); +}); + +test('rejects a missing current-docs route at the selected source', (t) => { + const { root, commit } = createRepository(t); + const dataDir = writeEvidenceData(root, { standardUrls: ['/explanation/missing/'] }); + const result = checkEvidenceLinks({ repoRoot: root, dataDir, sourceRef: commit }); + assert.match(result.errors[0], /does not resolve to a documentation page/); +}); + +test('rejects generated evidence data that is stale', (t) => { + const { root, commit } = createRepository(t); + const dataDir = writeEvidenceData(root, { + contractUrls: [ + 'https://github.com/registrystack/registry-stack/blob/v1.2.3/source/file.md', + ], + }); + writeFileSync(resolve(dataDir, 'generated/contracts.json'), '[]\n'); + + const result = checkEvidenceLinks({ repoRoot: root, dataDir, sourceRef: commit }); + assert.equal(result.checked, 0); + assert.match(result.errors[0], /run npm run generate/); +}); + +test('extracts only policy-owned YAML fields', () => { + const standards = `- id: test + official_url: https://example.invalid/main + evidence_docs: + - label: source + url: 'https://github.com/registrystack/registry-stack/blob/v1.2.3/source/file.md' + notes: current +`; + assert.deepEqual(extractEvidenceUrlsFromYaml(standards, 'standards'), [ + 'https://github.com/registrystack/registry-stack/blob/v1.2.3/source/file.md', + ]); +}); + +test('release verification uses the resolved tag target without installing the docs tree', () => { + const workflow = readFileSync(resolve(repositoryRoot, '.github/workflows/release.yml'), 'utf8'); + assert.match( + workflow, + /npm run check:evidence-links --\s+--source-ref "\$\{\{ steps\.release\.outputs\.tag_target \}\}"/, + ); + assert.doesNotMatch(workflow, /npm ci/); +}); diff --git a/docs/site/scripts/check-research-banners.mjs b/docs/site/scripts/check-research-banners.mjs new file mode 100644 index 000000000..c534b5ee1 --- /dev/null +++ b/docs/site/scripts/check-research-banners.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = fileURLToPath(import.meta.url); +const scriptDir = dirname(scriptPath); + +export const RESEARCH_STATUS_BANNER = `> **Status: historical research note** +> +> This note records pre-monorepo research and is not current architecture or release evidence. Use the published documentation and pinned source links for current claims. + +`; + +export function researchBannerErrors(researchDir = resolve(scriptDir, '../.research')) { + const names = readdirSync(researchDir) + .filter((name) => name.endsWith('.md') && name !== 'README.md') + .sort(); + return names + .filter((name) => !readFileSync(resolve(researchDir, name), 'utf8').startsWith(RESEARCH_STATUS_BANNER)) + .map((name) => `${name} is missing the exact historical-research banner`); +} + +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + const errors = researchBannerErrors(); + if (errors.length > 0) { + console.error('Research status banner check failed:'); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exitCode = 1; + } else { + console.log('All research notes carry the historical-research banner.'); + } +} diff --git a/docs/site/scripts/check-research-banners.test.mjs b/docs/site/scripts/check-research-banners.test.mjs new file mode 100644 index 000000000..e341b13c3 --- /dev/null +++ b/docs/site/scripts/check-research-banners.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { test } from 'node:test'; + +import { + RESEARCH_STATUS_BANNER, + researchBannerErrors, +} from './check-research-banners.mjs'; + +test('requires the exact deterministic banner on every research note', (t) => { + const root = mkdtempSync(resolve(tmpdir(), 'registry-research-banners-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(root, { recursive: true }); + writeFileSync(resolve(root, 'README.md'), '# Research\n'); + writeFileSync(resolve(root, 'current.md'), `${RESEARCH_STATUS_BANNER}# Preserved body\n`); + writeFileSync(resolve(root, 'missing.md'), '# Unmarked body\n'); + + assert.deepEqual(researchBannerErrors(root), [ + 'missing.md is missing the exact historical-research banner', + ]); +}); + +test('accepts the checked-in historical research notes', () => { + assert.deepEqual(researchBannerErrors(), []); +}); diff --git a/docs/site/src/content/docs/decisions/rename-2026-05-23.mdx b/docs/site/src/content/docs/decisions/rename-2026-05-23.mdx index adafae3ba..7392f6598 100644 --- a/docs/site/src/content/docs/decisions/rename-2026-05-23.mdx +++ b/docs/site/src/content/docs/decisions/rename-2026-05-23.mdx @@ -166,7 +166,7 @@ Legacy names appear only in this decision record, in `rename_status` fields in ` and in historical pages where they explain the pre-rename shape. When the GitHub repos finish their per-phase renames, update `src/data/projects.yaml` -and any affected source links in `src/data/repo-map.yaml`. +and any affected source links in `src/data/contracts.yaml` and `src/data/standards.yaml`. Do not rewrite historical evidence docs unless the owning repo migrates them. ## Superseded by diff --git a/docs/site/src/data/generated/standards.json b/docs/site/src/data/generated/standards.json index b5f298b58..66b2be053 100644 --- a/docs/site/src/data/generated/standards.json +++ b/docs/site/src/data/generated/standards.json @@ -569,11 +569,11 @@ "evidence_docs": [ { "label": "Registry Stack FHIR project-authoring fixture", - "url": "https://github.com/registrystack/registry-stack/tree/2dfcf2dd/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active" + "url": "https://github.com/registrystack/registry-stack/tree/2dfcf2ddf22e2c0fa4bf5fd84eb06b8736057605/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active" }, { "label": "Registry Relay protocol helper", - "url": "https://github.com/registrystack/registry-stack/tree/066bd89e/crates/registry-relay/src/source_plan" + "url": "https://github.com/registrystack/registry-stack/tree/066bd89ea3e4f1ce0bba5d0e696374199d275b73/crates/registry-relay/src/source_plan" } ], "last_checked": "2026-07-09", @@ -625,7 +625,7 @@ "evidence_docs": [ { "label": "DPI safeguards alignment page", - "url": "https://docs.registrystack.org/explanation/dpi-safeguards-alignment/" + "url": "/explanation/dpi-safeguards-alignment/" } ], "last_checked": "2026-06-15", @@ -654,7 +654,7 @@ }, { "label": "Registry Stack OpenCRVS project-authoring fixture", - "url": "https://github.com/registrystack/registry-stack/tree/2dfcf2dd/crates/registryctl/tests/fixtures/project-authoring/opencrvs" + "url": "https://github.com/registrystack/registry-stack/tree/2dfcf2ddf22e2c0fa4bf5fd84eb06b8736057605/crates/registryctl/tests/fixtures/project-authoring/opencrvs" } ], "last_checked": "2026-07-19", @@ -692,7 +692,7 @@ }, { "label": "Registry Notary wallet facade specification", - "url": "https://github.com/registrystack/registry-stack/blob/main/products/notary/specs/openid4vci-wallet-facade-spec.md" + "url": "https://github.com/registrystack/registry-stack/blob/37ce044567f05f8b3088b54f5bd4dbf389fd2ddb/products/notary/specs/openid4vci-wallet-facade-spec.md" } ], "last_checked": "2026-07-19", diff --git a/docs/site/src/data/repo-map.yaml b/docs/site/src/data/repo-map.yaml deleted file mode 100644 index f58ea4752..000000000 --- a/docs/site/src/data/repo-map.yaml +++ /dev/null @@ -1,16 +0,0 @@ -stack_name: registry stack -site_name: Registry stack docs -last_reviewed: 2026-05-23 -projects: - - registry-platform - - registry-relay - - registry-manifest - - registry-notary - - solmara-lab -current_source_repos: - registry-platform: ../registry-platform - registry-relay: ../registry-relay - registry-manifest: ../registry-manifest - registry-notary: ../registry-notary - solmara-lab: ../solmara-lab -rename_plan: ../rename-plan-2026-05-23.md diff --git a/docs/site/src/data/standards.yaml b/docs/site/src/data/standards.yaml index bc58484d2..ba399722b 100644 --- a/docs/site/src/data/standards.yaml +++ b/docs/site/src/data/standards.yaml @@ -409,9 +409,9 @@ - bounded FHIR R4 search-set parsing helper evidence_docs: - label: Registry Stack FHIR project-authoring fixture - url: https://github.com/registrystack/registry-stack/tree/2dfcf2dd/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active + url: https://github.com/registrystack/registry-stack/tree/2dfcf2ddf22e2c0fa4bf5fd84eb06b8736057605/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active - label: Registry Relay protocol helper - url: https://github.com/registrystack/registry-stack/tree/066bd89e/crates/registry-relay/src/source_plan + url: https://github.com/registrystack/registry-stack/tree/066bd89ea3e4f1ce0bba5d0e696374199d275b73/crates/registry-relay/src/source_plan last_checked: 2026-07-09 notes: Registry Relay exposes a bounded FHIR R4 search-set parser to product-neutral Rhai scripts. Project code owns profile-specific matching and projections. Source product and version metadata do not enable the helper, and Registry Stack does not claim to implement a FHIR server or broad FHIR conformance. - id: govstack-digital-registries @@ -449,7 +449,7 @@ - technical alignment positioning evidence_docs: - label: DPI safeguards alignment page - url: https://docs.registrystack.org/explanation/dpi-safeguards-alignment/ + url: /explanation/dpi-safeguards-alignment/ last_checked: 2026-06-15 notes: >- The framework is a governance instrument, not a technical specification, so @@ -474,7 +474,7 @@ - label: Registry Relay README url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md - label: Registry Stack OpenCRVS project-authoring fixture - url: https://github.com/registrystack/registry-stack/tree/2dfcf2dd/crates/registryctl/tests/fixtures/project-authoring/opencrvs + url: https://github.com/registrystack/registry-stack/tree/2dfcf2ddf22e2c0fa4bf5fd84eb06b8736057605/crates/registryctl/tests/fixtures/project-authoring/opencrvs last_checked: 2026-07-19 notes: Relay exposes an SP-DCI sync adapter behind `spdci-api-standards`. The adapter and standards-CEL mapping are experimental, feature-frozen, and outside the 1.0 compatibility promise. A Registry Stack project can also compile a reviewed DCI source journey into a Relay consultation. Registry Notary consumes the consultation result and does not connect to DCI sources directly. - id: oid4vci @@ -501,7 +501,7 @@ - label: Registry Platform OID4VCI crate url: https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-platform-oid4vci - label: Registry Notary wallet facade specification - url: https://github.com/registrystack/registry-stack/blob/main/products/notary/specs/openid4vci-wallet-facade-spec.md + url: https://github.com/registrystack/registry-stack/blob/37ce044567f05f8b3088b54f5bd4dbf389fd2ddb/products/notary/specs/openid4vci-wallet-facade-spec.md last_checked: 2026-07-19 notes: >- Registry Notary exposes a registry-backed, issuer-initiated diff --git a/products/manifest/.github/workflows/ci.yml b/products/manifest/.github/workflows/ci.yml deleted file mode 100644 index b6ea5f812..000000000 --- a/products/manifest/.github/workflows/ci.yml +++ /dev/null @@ -1,210 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - main - -env: - REGISTRY_MANIFEST_COVERAGE_THRESHOLD: "80" - REGISTRY_PLATFORM_REPOSITORY: jeremi/registry-platform - REGISTRY_PLATFORM_REF: 1ff93500d4a42a09d67cc31e547d97ef1b5e5404 - -jobs: - rust: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Checkout registry-platform - uses: actions/checkout@v6 - with: - repository: ${{ env.REGISTRY_PLATFORM_REPOSITORY }} - ref: ${{ env.REGISTRY_PLATFORM_REF }} - token: ${{ secrets.REGISTRY_PLATFORM_TOKEN || github.token }} - path: registry-platform - - - name: Link registry-platform path dependency - run: ln -s "$GITHUB_WORKSPACE/registry-platform" "$GITHUB_WORKSPACE/../registry-platform" - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy,rustfmt - - - name: Cache Cargo - uses: Swatinem/rust-cache@v2 - - - name: Format - run: cargo fmt --all -- --check - - - name: Lint - run: cargo clippy --workspace --all-targets -- -D warnings - - - name: Test core - run: cargo test -p registry-manifest-core - - - name: Test CLI - run: cargo test -p registry-manifest-cli - - - name: Validate profile fixtures - run: cargo run -p registry-manifest-cli -- validate-profiles profiles - - - name: Build - run: cargo build --workspace --all-targets - - coverage: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Checkout registry-platform - uses: actions/checkout@v6 - with: - repository: ${{ env.REGISTRY_PLATFORM_REPOSITORY }} - ref: ${{ env.REGISTRY_PLATFORM_REF }} - token: ${{ secrets.REGISTRY_PLATFORM_TOKEN || github.token }} - path: registry-platform - - - name: Link registry-platform path dependency - run: ln -s "$GITHUB_WORKSPACE/registry-platform" "$GITHUB_WORKSPACE/../registry-platform" - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache Cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Run coverage - run: | - mkdir -p target/coverage - cargo llvm-cov --workspace | tee target/coverage/summary.txt - cat target/coverage/summary.txt >> "$GITHUB_STEP_SUMMARY" - - - name: Export coverage artifacts - run: | - cargo llvm-cov report --lcov --output-path target/coverage/lcov.info - cargo llvm-cov report --json --summary-only --output-path target/coverage/summary.json - test -s target/coverage/summary.txt - test -s target/coverage/lcov.info - test -s target/coverage/summary.json - - - name: Export coverage dashboard data - run: | - python3 - <<'PY' - import json - import os - from datetime import datetime, timezone - from pathlib import Path - - summary = json.loads(Path("target/coverage/summary.json").read_text()) - line_coverage = summary["data"][0]["totals"]["lines"]["percent"] - threshold = float(os.environ["REGISTRY_MANIFEST_COVERAGE_THRESHOLD"]) - run_url = f"{os.environ['GITHUB_SERVER_URL']}/{os.environ['GITHUB_REPOSITORY']}/actions/runs/{os.environ['GITHUB_RUN_ID']}" - dashboard = { - "repo": "registry-manifest", - "branch": os.environ.get("GITHUB_REF_NAME", ""), - "commit": os.environ.get("GITHUB_SHA", ""), - "workflow_run_url": run_url, - "generated_at_utc": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), - "line_coverage_percent": round(line_coverage, 2), - "threshold_percent": threshold, - "status": "pass" if line_coverage >= threshold else "fail", - "artifact_urls": [run_url], - } - Path("target/coverage/dashboard.json").write_text(json.dumps(dashboard, indent=2) + "\n") - PY - test -s target/coverage/dashboard.json - - - name: Upload coverage artifacts - uses: actions/upload-artifact@v4 - with: - name: registry-manifest-coverage - path: | - target/coverage/summary.txt - target/coverage/lcov.info - target/coverage/summary.json - target/coverage/dashboard.json - - - name: Enforce coverage threshold - run: | - python3 - <<'PY' - import json - from pathlib import Path - - dashboard = json.loads(Path("target/coverage/dashboard.json").read_text()) - if dashboard["status"] != "pass": - raise SystemExit( - f"line coverage {dashboard['line_coverage_percent']:.2f}% is below " - f"threshold {dashboard['threshold_percent']:.2f}%" - ) - PY - - dependency-policy: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Checkout registry-platform - uses: actions/checkout@v6 - with: - repository: ${{ env.REGISTRY_PLATFORM_REPOSITORY }} - ref: ${{ env.REGISTRY_PLATFORM_REF }} - token: ${{ secrets.REGISTRY_PLATFORM_TOKEN || github.token }} - path: registry-platform - - - name: Link registry-platform path dependency - run: ln -s "$GITHUB_WORKSPACE/registry-platform" "$GITHUB_WORKSPACE/../registry-platform" - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache Cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-deny - run: cargo install cargo-deny --version 0.19.7 --locked - - - name: Deny check - run: cargo deny check - - - name: Install cargo-audit - run: cargo install cargo-audit --version 0.22.1 --locked - - - name: Audit - run: | - cargo audit \ - --db "$RUNNER_TEMP/cargo-audit-advisory-db" \ - --deny warnings \ - --ignore RUSTSEC-2024-0388 \ - --ignore RUSTSEC-2024-0370 - - secret-scan: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install Gitleaks - run: | - mkdir -p "$RUNNER_TEMP/bin" - GOBIN="$RUNNER_TEMP/bin" go install github.com/zricethezav/gitleaks/v8@v8.30.1 - - - name: Scan tracked files for secrets - env: - GITLEAKS_CONFIG_TOML: | - [extend] - useDefault = true - - [[allowlists]] - description = "Ignore generated Rust build output." - paths = ["(^|/)target/"] - run: | - "$RUNNER_TEMP/bin/gitleaks" dir --no-banner --redact --verbose --timeout 120 . diff --git a/products/manifest/README.md b/products/manifest/README.md index 7784cf052..266572f36 100644 --- a/products/manifest/README.md +++ b/products/manifest/README.md @@ -4,19 +4,17 @@ Release label: pre-1.0 technical release for evaluation and integration pilots. -[Public test coverage dashboard](https://docs.registrystack.org/reference/test-coverage/) tracks the CI line-coverage signal for this repository. - Registry Manifest is the commons contract and schema kernel for registry -metadata. It is a portable Rust workspace for modeling, validating, and +metadata. It is a portable set of Rust crates for modeling, validating, and rendering standards-facing registry metadata without running Registry Relay. It owns metadata manifests, compiled metadata models, validation, vocabulary prefix expansion, and pure renderers for catalog JSON, DCAT JSON-LD, BRegDCAT-AP JSON-LD, CPSV-AP JSON-LD, SHACL, JSON Schema Draft 2020-12, form JSON Schema, OGC API Records item collections, policy documents, evidence-offering metadata, embedded SKOS-shaped codelist metadata, and public federation metadata for Registry Notary delegated evaluation. -## Workspace +## Monorepo layout -- [`crates/registry-manifest-core`](crates/registry-manifest-core/README.md): +- [`crates/registry-manifest-core`](../../crates/registry-manifest-core/README.md): manifest contracts, validation, compilation, and renderers. -- [`crates/registry-manifest-cli`](crates/registry-manifest-cli/README.md): +- [`crates/registry-manifest-cli`](../../crates/registry-manifest-cli/README.md): command-line validation, rendering, static publication, and profile fixture validation. - `profiles/`: non-normative profile descriptors and metadata fixtures. - `examples/`: runnable static publication examples and notes. @@ -26,6 +24,10 @@ The checked-in profiles are examples until reviewed against official OpenCRVS, O ## Commands +Run these commands from `products/manifest` in the Registry Stack monorepo. +Cargo discovers the root workspace from this directory. Root CI runs the same +Manifest tests and profile-fixture validation as part of the workspace gate. + Format: ```sh @@ -41,37 +43,37 @@ cargo clippy --workspace --all-targets -- -D warnings Unit and golden renderer tests: ```sh -cargo test -p registry-manifest-core +cargo test --locked -p registry-manifest-core ``` CLI tests: ```sh -cargo test -p registry-manifest-cli +cargo test --locked -p registry-manifest-cli ``` Validate profile descriptors and fixtures: ```sh -cargo run -p registry-manifest-cli -- validate-profiles profiles +cargo run --locked -p registry-manifest-cli -- validate-profiles profiles ``` Validate a metadata manifest: ```sh -cargo run -p registry-manifest-cli -- validate profiles/example-civil-registration/fixtures/metadata.yaml +cargo run --locked -p registry-manifest-cli -- validate profiles/example-civil-registration/fixtures/metadata.yaml ``` Render one artifact: ```sh -cargo run -p registry-manifest-cli -- render profiles/example-civil-registration/fixtures/metadata.yaml --format bregdcat-ap +cargo run --locked -p registry-manifest-cli -- render profiles/example-civil-registration/fixtures/metadata.yaml --format bregdcat-ap ``` Publish a static metadata directory: ```sh -cargo run -p registry-manifest-cli -- publish profiles/example-civil-registration/fixtures/metadata.yaml --out target/metadata/public +cargo run --locked -p registry-manifest-cli -- publish profiles/example-civil-registration/fixtures/metadata.yaml --out target/metadata/public ``` The generated bundle uses `/metadata/index.json` as its canonical metadata @@ -152,7 +154,7 @@ behavior. ## Boundary -This repository must stay portable. `registry-manifest-core` must not depend on +Registry Manifest must stay portable. `registry-manifest-core` must not depend on Registry Relay, Registry Notary, Axum, DataFusion, Postgres, auth, audit, observability, runtime row access, secret handling, `utoipa`, or `clap`. diff --git a/products/manifest/SECURITY.md b/products/manifest/SECURITY.md index 87b30677b..bf149bac2 100644 --- a/products/manifest/SECURITY.md +++ b/products/manifest/SECURITY.md @@ -2,27 +2,29 @@ ## Supported Versions -Registry Manifest is pre-1.0. Security fixes are targeted at the current `main` -branch until release branches are introduced. +Registry Manifest follows the Registry Stack supported-release and branch policy. ## Reporting a Vulnerability Please do not open a public issue for suspected vulnerabilities. -Use GitHub private vulnerability reporting for this repository: +Use GitHub private vulnerability reporting for the Registry Stack monorepo: -https://github.com/jeremi/registry-manifest/security/advisories/new +https://github.com/registrystack/registry-stack/security/advisories/new Include the affected version or commit, reproduction steps, impact, and any known workaround. We will acknowledge the report, assess severity, and coordinate a fix before public disclosure when appropriate. -## Dependency Advisory Posture +The root [security policy](../../SECURITY.md) defines the reporting scope and +release-verification guidance for all Registry Stack products. -CI runs `cargo audit` and `cargo deny check` on every push and pull request, so -new RustSec advisories surface immediately. Acceptable advisory ignores live in -`deny.toml`; each carries a scoped rationale and a review trigger, and the full -list is reviewed quarterly or whenever a transitive dependency upgrades. +## Dependency advisory posture + +Root CI runs `cargo deny check` for relevant pushes and pull requests, covering +the locked workspace dependency graph. Accepted advisory exceptions live in +the root [`deny.toml`](../../deny.toml); each carries a scoped rationale and a +review trigger. Unsound or memory-safety advisories on direct dependencies (for example, the `libyml` / `serde_yml` class of issues) escalate immediately and block the release; the maintained equivalent is preferred over ignoring the advisory. diff --git a/products/manifest/docs/profile-fixtures.md b/products/manifest/docs/profile-fixtures.md index 4f1ba5f1e..d76d51b8e 100644 --- a/products/manifest/docs/profile-fixtures.md +++ b/products/manifest/docs/profile-fixtures.md @@ -25,15 +25,21 @@ against official artifacts. ## Prerequisites - Rust toolchain installed. -- The `registry-manifest` repository cloned locally. +- The Registry Stack monorepo cloned locally. + +Run the commands on this page from the Manifest product directory: + +```sh +cd /path/to/registry-stack/products/manifest +``` Build the CLI before running commands: ```sh -cargo build --bin registry-manifest +cargo build --locked -p registry-manifest-cli ``` -The commands in this page use `cargo run --bin registry-manifest --` as a prefix. +The commands in this page use `cargo run --locked -p registry-manifest-cli --` as a prefix. If you have installed the binary directly, replace that prefix with `registry-manifest`. ## Steps @@ -69,7 +75,7 @@ Each subdirectory that contains a `profile.yaml` is a profile entry. ### 2. Run validate-profiles on the full directory ```sh -cargo run --bin registry-manifest -- validate-profiles profiles +cargo run --locked -p registry-manifest-cli -- validate-profiles profiles ``` The validator scans every subdirectory for `profile.yaml`, validates the descriptor schema, @@ -89,7 +95,7 @@ place it under a scratch directory and pass that directory: ```sh mkdir -p /tmp/profile-check cp -r profiles/example-civil-registration /tmp/profile-check/ -cargo run --bin registry-manifest -- validate-profiles /tmp/profile-check +cargo run --locked -p registry-manifest-cli -- validate-profiles /tmp/profile-check ``` This validates only the profiles in `/tmp/profile-check` rather than the full suite. @@ -140,7 +146,7 @@ for a complete example. After running `validate-profiles`, confirm the exit code: ```sh -cargo run --bin registry-manifest -- validate-profiles profiles +cargo run --locked -p registry-manifest-cli -- validate-profiles profiles echo "exit code: $?" ``` @@ -149,7 +155,7 @@ Exit code 0 means every profile descriptor and every referenced fixture passed. To confirm the test suite also covers all four example profiles, run: ```sh -cargo test -p registry-manifest-core validates_profile_fixtures +cargo test --locked -p registry-manifest-core validates_profile_fixtures ``` This test asserts that all four example profile fixtures pass manifest validation. @@ -180,4 +186,5 @@ These keys belong in Registry Relay runtime configuration, not in a portable met Confirm you are running against the same `profiles/` directory path as CI. The validator uses the path you pass as its argument. -Check whether CI is running `validate-profiles profiles` from the repository root. +Root CI enters `products/manifest` and runs +`cargo run --locked -p registry-manifest-cli -- validate-profiles profiles`. diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index fa066dcb6..fd7d1dae2 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -12,6 +12,10 @@ REQUIRED_GATES: tuple[tuple[str, str], ...] = ( ("Cargo metadata", "run: cargo metadata --locked --format-version 1"), + ( + "Manifest profile validation", + "run: cargo run --locked -p registry-manifest-cli -- validate-profiles profiles", + ), ("Format", "run: cargo fmt --check"), ("Workspace check", "run: cargo check --locked --workspace --all-targets"), ("Clippy", "run: cargo clippy --workspace --all-targets -- -D warnings"), diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index 9bc2c2e8a..ca9936058 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -42,6 +42,13 @@ def test_missing_registryctl_tutorial_execution_is_reported(self) -> None: "Registryctl tutorial source execution", self.module.missing_gates(text) ) + def test_missing_manifest_profile_validation_is_reported(self) -> None: + text = self.workflow.replace( + "cargo run --locked -p registry-manifest-cli -- validate-profiles profiles", + "cargo run --locked -p registry-manifest-cli -- skip-profile-validation", + ) + self.assertIn("Manifest profile validation", self.module.missing_gates(text)) + def test_missing_release_docset_validation_is_reported(self) -> None: text = self.workflow.replace( "release/scripts/registry-release validate-docsets", From c84b1b9288b925e7c9cc89c47c33cc1f50753d8c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 21:07:25 +0700 Subject: [PATCH 55/65] fix(docs): prevent lazy evidence fetches Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-links.mjs | 26 +++++---- .../scripts/check-evidence-links.test.mjs | 54 ++++++++++++++++++- products/manifest/docs/profile-fixtures.md | 2 +- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/docs/site/scripts/check-evidence-links.mjs b/docs/site/scripts/check-evidence-links.mjs index 7e382c50e..17f2550b9 100644 --- a/docs/site/scripts/check-evidence-links.mjs +++ b/docs/site/scripts/check-evidence-links.mjs @@ -114,11 +114,12 @@ function readEvidenceUrls(dataDir) { return urls; } -function gitObjectExists(repoRoot, object) { +function gitObjectExists(repoRoot, object, gitCommand) { return ( - spawnSync('git', ['cat-file', '-e', object], { + spawnSync(gitCommand, ['cat-file', '-e', object], { cwd: repoRoot, encoding: 'utf8', + env: { ...process.env, GIT_NO_LAZY_FETCH: '1' }, stdio: 'pipe', }).status === 0 ); @@ -147,7 +148,7 @@ function validRepositoryPath(parts) { ); } -function checkRepositoryEvidence(repoRoot, rawUrl) { +function checkRepositoryEvidence(repoRoot, rawUrl, gitCommand) { let url; try { url = new URL(rawUrl); @@ -187,11 +188,11 @@ function checkRepositoryEvidence(repoRoot, rawUrl) { return `uses ${ref}, but evidence refs must be semver tags or full 40-character commits`; } - if (!gitObjectExists(repoRoot, `${commitish}^{commit}`)) { + if (!gitObjectExists(repoRoot, `${commitish}^{commit}`, gitCommand)) { return `references missing Git commit or tag ${ref}`; } const path = repositoryPath.join('/'); - if (!gitObjectExists(repoRoot, `${commitish}^{commit}:${path}`)) { + if (!gitObjectExists(repoRoot, `${commitish}^{commit}:${path}`, gitCommand)) { return `references missing path ${path} at ${ref}`; } return undefined; @@ -220,15 +221,19 @@ function currentDocsCandidates(rawUrl) { ]; } -function checkCurrentDocsEvidence(repoRoot, sourceRef, rawUrl) { +function checkCurrentDocsEvidence(repoRoot, sourceRef, rawUrl, gitCommand) { const candidates = currentDocsCandidates(rawUrl); if (candidates.length === 0) { return 'contains an invalid current-docs route'; } - if (!gitObjectExists(repoRoot, `${sourceRef}^{commit}`)) { + if (!gitObjectExists(repoRoot, `${sourceRef}^{commit}`, gitCommand)) { return `cannot resolve release source ${sourceRef}`; } - if (!candidates.some((path) => gitObjectExists(repoRoot, `${sourceRef}^{commit}:${path}`))) { + if ( + !candidates.some((path) => + gitObjectExists(repoRoot, `${sourceRef}^{commit}:${path}`, gitCommand), + ) + ) { return `does not resolve to a documentation page at release source ${sourceRef}`; } return undefined; @@ -238,6 +243,7 @@ export function checkEvidenceLinks({ repoRoot = resolve(scriptDir, '../../..'), dataDir = resolve(scriptDir, '../src/data'), sourceRef = 'HEAD', + gitCommand = 'git', } = {}) { const errors = []; let evidence; @@ -249,8 +255,8 @@ export function checkEvidenceLinks({ for (const item of evidence) { const error = item.url.startsWith('/') - ? checkCurrentDocsEvidence(repoRoot, sourceRef, item.url) - : checkRepositoryEvidence(repoRoot, item.url); + ? checkCurrentDocsEvidence(repoRoot, sourceRef, item.url, gitCommand) + : checkRepositoryEvidence(repoRoot, item.url, gitCommand); if (error) { errors.push(`${item.location}: ${item.url}: ${error}`); } diff --git a/docs/site/scripts/check-evidence-links.test.mjs b/docs/site/scripts/check-evidence-links.test.mjs index 3493528fd..099badfe5 100644 --- a/docs/site/scripts/check-evidence-links.test.mjs +++ b/docs/site/scripts/check-evidence-links.test.mjs @@ -1,6 +1,13 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -160,6 +167,51 @@ test('rejects generated evidence data that is stale', (t) => { assert.match(result.errors[0], /run npm run generate/); }); +test('disables lazy fetching for every Git object lookup and fails closed', (t) => { + const { root, commit } = createRepository(t); + const dataDir = writeEvidenceData(root, { + contractUrls: [ + `https://github.com/registrystack/registry-stack/blob/${commit}/source/missing.md`, + ], + }); + const wrapper = resolve(root, 'controlled-git.mjs'); + const log = resolve(root, 'controlled-git.log'); + writeFileSync( + wrapper, + `#!${process.execPath} +import { appendFileSync } from 'node:fs'; + +const object = process.argv.at(-1); +appendFileSync(${JSON.stringify(log)}, JSON.stringify({ + noLazyFetch: process.env.GIT_NO_LAZY_FETCH, + object, +}) + '\\n'); +if (process.env.GIT_NO_LAZY_FETCH !== '1') { + appendFileSync(${JSON.stringify(log)}, 'lazy-fetch-attempted\\n'); + process.exit(0); +} +process.exit(object.includes(':') ? 1 : 0); +`, + ); + chmodSync(wrapper, 0o755); + + const result = checkEvidenceLinks({ + repoRoot: root, + dataDir, + sourceRef: commit, + gitCommand: wrapper, + }); + assert.equal(result.checked, 1); + assert.match(result.errors[0], /references missing path/); + const invocations = readFileSync(log, 'utf8').trim().split('\n'); + assert.equal(invocations.length, 2); + assert.equal(invocations.some((line) => line === 'lazy-fetch-attempted'), false); + assert.deepEqual( + invocations.map((line) => JSON.parse(line).noLazyFetch), + ['1', '1'], + ); +}); + test('extracts only policy-owned YAML fields', () => { const standards = `- id: test official_url: https://example.invalid/main diff --git a/products/manifest/docs/profile-fixtures.md b/products/manifest/docs/profile-fixtures.md index d76d51b8e..42040adf7 100644 --- a/products/manifest/docs/profile-fixtures.md +++ b/products/manifest/docs/profile-fixtures.md @@ -48,7 +48,7 @@ If you have installed the binary directly, replace that prefix with `registry-ma By default the `validate-profiles` subcommand expects a `profiles/` directory path as its argument. -In the repository root, the directory is `profiles/`. +Relative to the Manifest product directory, the directory is `profiles/`. List the available profiles: From da278889491bb36a8ecdb6a456b7c699973aca00 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 21:29:47 +0700 Subject: [PATCH 56/65] docs(security): specify replay protection contracts Signed-off-by: Jeremi Joslin --- .../replay-protection-contract.test.mjs | 50 +++++++++++++++ docs/site/src/content/docs/spec/rs-sec-g.mdx | 62 +++++++++++++++---- 2 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 docs/site/scripts/replay-protection-contract.test.mjs diff --git a/docs/site/scripts/replay-protection-contract.test.mjs b/docs/site/scripts/replay-protection-contract.test.mjs new file mode 100644 index 000000000..58e18d5c1 --- /dev/null +++ b/docs/site/scripts/replay-protection-contract.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +const here = dirname(fileURLToPath(import.meta.url)); +const spec = readFileSync( + resolve(here, '../src/content/docs/spec/rs-sec-g.mdx'), + 'utf8', +); +const replaySection = spec.match( + /^## 8[.] Replay-protection authority\n(?[\s\S]*?)(?=^## 9[.] )/m, +)?.groups?.body; + +assert.ok(replaySection, 'RS-SEC-G must contain the replay-protection authority section'); + +test('RS-SEC-G keeps the exact product replay matrix', () => { + const productRows = replaySection.match(/^\| Registry (?:Relay|Notary) \|.*$/gm) ?? []; + assert.equal(productRows.length, 2, 'expected one replay-contract row per product'); + + assert.match( + replaySection, + /\| Registry Relay \| Batch-child idempotent consultation execution[.] \|[\s\S]*?child identity[\s\S]*?exact canonical request[\s\S]*?\| `15 minutes` from reservation or terminal publication[.] \|/, + ); + assert.match( + replaySection, + /\| Registry Notary \| Scoped, domain-separated protocol one-time and completion domains,[\s\S]*?Product-specific scope and identifier hashes[\s\S]*?\| The product-specific absolute expiry or bounded retention for the domain,/, + ); +}); + +test('RS-SEC-G keeps replay authority product-owned and isolated', () => { + assert.match(replaySection, /production or multi-instance deployment MUST keep replay correctness state in\nthe PostgreSQL state owned by the product/); + assert.match(replaySection, /Replicas of one product authority MUST share only that authority's product state/); + assert.match(replaySection, /Separate federation authorities MUST NOT share replay state/); + assert.match( + replaySection, + /Registry Relay and Registry Notary MUST NOT share replay tables, schemas, database roles,\nmigrations, or correctness transactions/, + ); + assert.match(replaySection, /MUST NOT turn these boundaries into a shared correctness-state abstraction/); + assert.doesNotMatch(replaySection, /\bRedis\b/i); +}); + +test('RS-SEC-G links retention and recovery and requires fail-closed recovery', () => { + assert.match(replaySection, /\[retention and persistent-state reference\]\(\.\.\/\.\.\/operate\/retention-and-persistent-state\/\)/); + assert.match(replaySection, /\[backup and restore procedure\]\(\.\.\/\.\.\/operate\/backup-and-restore\/\)/); + assert.match(replaySection, /database-unavailable, read-only, timed-out, or transaction-uncertain result MUST fail closed/); + assert.match(replaySection, /potentially stale recovery point MUST remain offline until the product-specific recovery rules/); + assert.match(replaySection, /Expiry alone MUST NOT be treated as repair/); +}); 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 8b437245f..d08ab6f01 100644 --- a/docs/site/src/content/docs/spec/rs-sec-g.mdx +++ b/docs/site/src/content/docs/spec/rs-sec-g.mdx @@ -1,6 +1,6 @@ --- title: "RS-SEC-G: Registry family security model" -description: "The cross-cutting security model for the registry stack: the shared security primitives, authentication and authorization, verification-key publication, the audit obligation, federation trust, and the operator boundary that the protocol specifications refine." +description: "The cross-cutting security model for the registry stack: shared security primitives, authentication and authorization, verification-key publication, audit, federation trust, replay protection, and the operator boundary." status: draft owner: registry-docs source_repos: @@ -24,7 +24,7 @@ audience: - specification editor --- -This document defines the security model shared across the registry stack: where the security-critical primitives live, how a caller authenticates and is authorized, how a service publishes the keys a verifier needs, what every request that touches person-level data MUST record, how delegated evaluation is trusted, and where the operator's responsibility begins. It is the cross-cutting (general) security specification that the protocol specifications refine for their own surfaces. +This document defines the security model shared across the registry stack: where the security-critical primitives live, how a caller authenticates and is authorized, how a service publishes the keys a verifier needs, what every request that touches person-level data MUST record, how delegated evaluation is trusted, how replay decisions remain authoritative, and where the operator's responsibility begins. It is the cross-cutting (general) security specification that the protocol specifications refine for their own surfaces. It refines the security-relevant invariants of [RS-ARC-G](../rs-arc-g/) Section 5 (specifically REQ-ARC-G-004 and REQ-ARC-G-005) one level of detail down, from architectural invariant to security model. Where this document and RS-ARC-G state the same constraint, RS-ARC-G is the general invariant and this document is its security-model form. The wire-level form of these constraints lives in [RS-PR-NOTARY](../rs-pr-notary/) and [RS-PR-RELAY](../rs-pr-relay/); where this document and a protocol specification state the same constraint, this document is the cross-cutting model and the protocol specification is its surface-specific form. @@ -38,6 +38,7 @@ The key words in this document are interpreted per [RS-DOC](../rs-doc/) Section | 0.1.1 | 2026-06-21 | draft | Clarified key custody, readiness, public error detail, and principal-scoped cache boundaries. | | 0.2.0 | 2026-07-07 | draft | Enumerated the full unauthenticated surface in REQ-SEC-G-006 and anchored the evidence section to the enforcing code. | | 0.3.0 | 2026-07-13 | draft | Assigned registry source access to Relay and restated Notary authorization and federation gates in terms of compiler-pinned Relay consultations. | +| 0.4.0 | 2026-07-19 | draft | Defined the product-owned Relay and Notary replay-protection boundaries, retention references, failure posture, and stale-recovery invariant. | ## 1. Scope and references @@ -49,15 +50,16 @@ This specification covers the security model that holds across the runtime servi - Verification-key publication and the public/private key boundary. - The audit obligation and its fail-closed posture. - Federation trust: static peers and verify-before-read. +- Product-owned replay protection: authority, retention, transaction, failure, and recovery invariants. - The transport and error posture, and the outbound-call policy. - The operator boundary: what this model leaves to the deployment. This specification does not define: -- **Exact configuration schemas, algorithms, and parameters.** Concrete signing algorithms and key sizes, token and cache windows, entropy floors, the precise security-header set, and the precise outbound allow and deny rules are configuration and operational detail, not contract data. For the configurable surface, see the [Registry Notary operator configuration reference](../../products/registry-notary/operator-config-reference/), the [Registry Notary signing-key reference](../../products/registry-notary/signing-key-provider/), and the [Registry Relay client integration](../../products/registry-relay/client-integration/) guide. +- **Exact configuration schemas, algorithms, and parameters.** Concrete signing algorithms and key sizes, configurable token and cache windows, entropy floors, the precise security-header set, and the precise outbound allow and deny rules are configuration and operational detail, not contract data. Section 8 states the fixed Relay batch-child replay retention that is part of the product contract. For the configurable surface, see the [Registry Notary operator configuration reference](../../products/registry-notary/operator-config-reference/), the [Registry Notary signing-key reference](../../products/registry-notary/signing-key-provider/), and the [Registry Relay client integration](../../products/registry-relay/client-integration/) guide. - **Surface-specific protocol behavior.** How each service applies this model to its own routes, request shapes, and error codes belongs to [RS-PR-NOTARY](../rs-pr-notary/) and [RS-PR-RELAY](../rs-pr-relay/). -- **Key custody and secret provisioning.** How private keys and credentials are stored, injected, and rotated in a deployment (environment, file, hardware module) is an operator responsibility, addressed in Section 9. -- **Deeper service-internal security mechanisms.** The internal structure of credential fingerprints, the audit envelope, and the replay store, beyond the externally observable behavior stated here, is reserved for future deeper specifications. +- **Key custody and secret provisioning.** How private keys and credentials are stored, injected, and rotated in a deployment (environment, file, hardware module) is an operator responsibility, addressed in Section 10. +- **Deeper service-internal security mechanisms.** The internal structure of credential fingerprints, the audit envelope, and product replay tables beyond the authority, binding, retention, transaction, failure, and recovery behavior stated here is reserved for product specifications and operator guidance. For the components named here and their boundaries, see [RS-ARC-G](../rs-arc-g/) Section 3 and the [boundary map](../../map/boundaries-and-map/). For the narrative security context, see the [architecture overview](../../explanation/architecture/) and [Evidence issuance, end to end](../../explanation/evidence-issuance/). @@ -113,9 +115,9 @@ A verifier needs the issuer's public key to check a credential, and it needs tha 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 credentials it signed are still within their validity, so a verifier can check previously issued artifacts across a rotation. -The custody mechanism for the private key (environment, file, or hardware module) is a deployment choice, described in the [signing-key reference](../../products/registry-notary/signing-key-provider/) and out of scope here (Section 9). +The custody mechanism for the private key (environment, file, or hardware module) is a deployment choice, described in the [signing-key reference](../../products/registry-notary/signing-key-provider/) and out of scope here (Section 10). -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. +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 10. ## 6. Audit @@ -131,7 +133,42 @@ Delegated evaluation crosses a trust boundary between two services. The model ad REQ-SEC-G-010: Delegated evaluation MUST be static-peer only: peers are loaded from configuration at startup, and a request from an unconfigured peer MUST be rejected. Before any Relay consultation or claim evaluation, the serving service MUST verify the peer's identity and request signature, the request's freshness and single use, and its purpose, profile, and audience. This is the security-model form of REQ-ARC-G-009 and the cross-cutting form of REQ-PR-NOTARY-018 and REQ-PR-NOTARY-019. Dynamic trust-chain discovery, shared replay storage, and federated credential issuance are out of scope for this version and MUST NOT be implied by a conformance claim against this specification. -## 8. Transport and outbound-call posture +## 8. Replay-protection authority + +Replay protection is product correctness state, not a shared storage service. +Registry Platform can supply replay vocabulary and mechanism-only helpers, but Registry Relay and +Registry Notary define and persist their own replay decisions. +The [retention and persistent-state reference](../../operate/retention-and-persistent-state/) +defines the complete product retention inventory, and the +[backup and restore procedure](../../operate/backup-and-restore/) defines the recovery steps. + +| Product | Protected execution or domain | Identity and request binding | Retention | +| --- | --- | --- | --- | +| Registry Relay | Batch-child idempotent consultation execution. | A high-entropy Notary child identity selects the replay row and binds the exact canonical request to the authenticated consultation workload. The same child and request can replay the terminal result; a different binding conflicts. | `15 minutes` from reservation or terminal publication. | +| Registry Notary | Scoped, domain-separated protocol one-time and completion domains, including replay identifiers, nonce consumption, batch completion, and pre-authorized-code redemption. | Product-specific scope and identifier hashes bind each decision to its protocol authority and context. Typed Notary transactions enforce one-time use or immutable completion. | The product-specific absolute expiry or bounded retention for the domain, as listed in the retention reference. | + +REQ-SEC-G-014: A production or multi-instance deployment MUST keep replay correctness state in +the PostgreSQL state owned by the product making the decision. +Replicas of one product authority MUST share only that authority's product state so a retained +decision survives restart and is observed across replicas. +Separate federation authorities MUST NOT share replay state. +Registry Relay and Registry Notary MUST NOT share replay tables, schemas, database roles, +migrations, or correctness transactions, even when their databases run in one PostgreSQL +cluster. +An implementation MUST NOT turn these boundaries into a shared correctness-state abstraction. + +REQ-SEC-G-015: Each product MUST use its product-owned atomic reservation and completion +operations for replay-sensitive work. +A database-unavailable, read-only, timed-out, or transaction-uncertain result MUST fail closed: +the product MUST NOT assume the identifier is absent, report replay-sensitive success, or admit +traffic on state that it cannot attest. +A potentially stale recovery point MUST remain offline until the product-specific recovery rules +in the backup and restore procedure pass. +Expiry alone MUST NOT be treated as repair for a Relay recovery point missing durable audit, +quota, materialization, or keyring history, or for a Notary recovery point missing credential +status. + +## 9. Transport and outbound-call posture Services present a consistent error surface and constrain the calls they make outward. @@ -141,7 +178,7 @@ REQ-SEC-G-012: A runtime service SHOULD apply the Registry Platform HTTP-securit Problem details are a client-facing protocol surface, not an operator diagnostic channel. Stable problem codes and titles belong in responses; raw bearer tokens, private keys, source values, local filesystem paths, and unbounded internal error chains belong in protected operator logs. Principal-scoped responses, including scoped metadata and claim results, are not public cache entries: where a response varies by authenticated principal, scope, purpose, or authorization context, deployments use cache controls appropriate to that sensitivity, such as private or no-store semantics and `Vary: Authorization` where a response is cacheable. -## 9. The operator boundary +## 10. The operator boundary The security model ends where the deployment begins. The following are operator responsibilities, not behavior this specification defines: secret and key provisioning, key custody and the rotation schedule, audit retention and storage, tenant isolation, transport termination and certificate management, network rate limiting at the edge, deployment configuration, and incident response. Registry Platform and the runtime services provide the primitives; the operator provisions, configures, and operates them. @@ -159,6 +196,8 @@ A registry stack deployment conforms to this specification when it: - publishes only public verification keys and keeps private material unpublished (REQ-SEC-G-007); - audits every request touching person-level data and can run audit fail-closed (REQ-SEC-G-008, REQ-SEC-G-009); - admits delegated evaluation only from verified static peers, fully checked before any Relay consultation or claim evaluation (REQ-SEC-G-010); +- keeps replay decisions in separate product-owned PostgreSQL state, shares them only across replicas of the same authority, and preserves the product retention contract (REQ-SEC-G-014); +- fails closed when replay correctness state is unavailable or uncertain and keeps potentially stale recovery points offline until product recovery rules pass (REQ-SEC-G-015); - reports errors as problem+json outside route-specific error envelopes, and applies the shared HTTP-security and outbound-policy primitives (REQ-SEC-G-011, REQ-SEC-G-012); - keeps secrets out of distributable artifacts and leaves provisioning to the operator (REQ-SEC-G-013). @@ -168,13 +207,14 @@ Conformance to this specification does not imply conformance to any external sta This specification is `verified`: every requirement describes shipped behavior a reader can inspect, per RS-DOC REQ-DOC-014. -- The [boundary map](../../map/boundaries-and-map/) records that the security primitives (authentication, OIDC, audit envelopes, HTTP security, outbound HTTP policy, cryptography, SD-JWT VC helpers) are owned by Registry Platform and that secret provisioning, audit retention, tenant isolation, deployment configuration, and incident response are operator responsibilities, which Sections 2 and 9 make precise. +- The [boundary map](../../map/boundaries-and-map/) records that the security primitives (authentication, OIDC, audit envelopes, HTTP security, outbound HTTP policy, cryptography, SD-JWT VC helpers) are owned by Registry Platform and that secret provisioning, audit retention, tenant isolation, deployment configuration, and incident response are operator responsibilities, which Sections 2 and 10 make precise. - The [Registry Notary API reference](../../reference/apis/registry-notary/) and [Registry Relay API reference](../../reference/apis/registry-relay/) describe the two authentication modes, the constant-time fingerprint comparison, the OIDC trust inputs, the per-dataset and per-claim scopes, and the unauthenticated probes that Sections 3 and 4 state normatively. - The [Registry Notary signing-key reference](../../products/registry-notary/signing-key-provider/) describes JWKS publication, the public/private key boundary, and rotation, which Section 5 states normatively. - [RS-PR-NOTARY](../rs-pr-notary/) and [RS-PR-RELAY](../rs-pr-relay/) carry the surface-level form of the authentication, authorization, key-publication, audit, federation, and error requirements this document generalizes. - [RS-ARC-G](../rs-arc-g/) Section 5 holds the architectural invariants (REQ-ARC-G-004, REQ-ARC-G-005) that Sections 2 and 6 refine. +- The [retention and persistent-state reference](../../operate/retention-and-persistent-state/) and [backup and restore procedure](../../operate/backup-and-restore/) define the product-owned PostgreSQL retention and stale-recovery behavior that Section 8 states normatively. - The [standards register](../../reference/standards/) records the adoption mode for SD-JWT VC, OID4VCI, and the W3C DID method listed in `standards_referenced`. -- The enforcing code is inspectable in the workspace: the constant-time fingerprint comparison in `crates/registry-platform-authcommon` (REQ-SEC-G-003); OIDC verification in `crates/registry-platform-oidc` (REQ-SEC-G-004); scope-before-source enforcement in `crates/registry-notary-server/src/runtime.rs` (`require_claim_access`) and the Registry Relay route handlers (REQ-SEC-G-005); the unauthenticated-surface allow-lists in Registry Relay's router assembly (`crates/registry-relay/src/server.rs`) and Registry Notary's auth-exemption check (`is_auth_exempt_path` in `crates/registry-notary-server/src/standalone.rs`) (REQ-SEC-G-006); the audit envelope in `crates/registry-platform-audit` and the services' fail-closed audit paths, exercised by tests such as Registry Notary's federation audit-write-failure test (REQ-SEC-G-008, REQ-SEC-G-009); and the secret-material rejection lists in `crates/registry-manifest-core` (REQ-SEC-G-013). +- The enforcing code is inspectable in the workspace: the constant-time fingerprint comparison in `crates/registry-platform-authcommon` (REQ-SEC-G-003); OIDC verification in `crates/registry-platform-oidc` (REQ-SEC-G-004); scope-before-source enforcement in `crates/registry-notary-server/src/runtime.rs` (`require_claim_access`) and the Registry Relay route handlers (REQ-SEC-G-005); the unauthenticated-surface allow-lists in Registry Relay's router assembly (`crates/registry-relay/src/server.rs`) and Registry Notary's auth-exemption check (`is_auth_exempt_path` in `crates/registry-notary-server/src/standalone.rs`) (REQ-SEC-G-006); the audit envelope in `crates/registry-platform-audit` and the services' fail-closed audit paths, exercised by tests such as Registry Notary's federation audit-write-failure test (REQ-SEC-G-008, REQ-SEC-G-009); Relay's batch-child binding and PostgreSQL reservation functions in `crates/registry-relay/src/consultation` and `crates/registry-relay/src/state_plane`; Notary's typed PostgreSQL replay functions in `crates/registry-notary-server/src/state_plane` (REQ-SEC-G-014, REQ-SEC-G-015); and the secret-material rejection lists in `crates/registry-manifest-core` (REQ-SEC-G-013). ## Next From 99aad41cddfaebe2aedee43bd6f3052513f676fa Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 21:31:06 +0700 Subject: [PATCH 57/65] docs: specify operations posture contract Signed-off-by: Jeremi Joslin --- docs/site/scripts/ops-posture-spec.test.mjs | 71 ++++++ .../docs/reference/apis/registry-notary.mdx | 2 + .../docs/reference/apis/registry-relay.mdx | 3 +- .../docs/security/hardening-checklist.mdx | 6 +- .../src/content/docs/spec/rs-op-posture.mdx | 235 ++++++++++++++++++ docs/site/src/data/contracts.yaml | 13 + docs/site/src/data/generated/contracts.json | 12 + 7 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 docs/site/scripts/ops-posture-spec.test.mjs create mode 100644 docs/site/src/content/docs/spec/rs-op-posture.mdx diff --git a/docs/site/scripts/ops-posture-spec.test.mjs b/docs/site/scripts/ops-posture-spec.test.mjs new file mode 100644 index 000000000..981d6220e --- /dev/null +++ b/docs/site/scripts/ops-posture-spec.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import YAML from 'yaml'; + +const here = dirname(fileURLToPath(import.meta.url)); +const siteRoot = resolve(here, '..'); +const repositoryRoot = resolve(siteRoot, '../..'); +const specPath = resolve(siteRoot, 'src/content/docs/spec/rs-op-posture.mdx'); +const schemaPath = resolve( + repositoryRoot, + 'crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json', +); +const relayExamplePath = resolve( + repositoryRoot, + 'crates/registry-platform-ops/examples/registry-relay.posture.valid.json', +); +const notaryExamplePath = resolve( + repositoryRoot, + 'crates/registry-platform-ops/examples/registry-notary.posture.valid.json', +); + +function frontmatter(text) { + const end = text.indexOf('\n---\n', 4); + assert.notEqual(end, -1, 'expected complete YAML frontmatter'); + return YAML.parse(text.slice(4, end)); +} + +test('RS-OP-POSTURE has a stable formal-specification identifier', () => { + const page = readFileSync(specPath, 'utf8'); + const data = frontmatter(page); + + assert.equal(data.doc_id, 'RS-OP-POSTURE'); + assert.equal(data.doc_type, 'specification'); + assert.equal(data.category, 'normative'); + assert.equal(data.evidence, 'verified'); +}); + +test('RS-OP-POSTURE names the shipped Relay and Notary examples', () => { + const page = readFileSync(specPath, 'utf8'); + const relayExample = JSON.parse(readFileSync(relayExamplePath, 'utf8')); + const notaryExample = JSON.parse(readFileSync(notaryExamplePath, 'utf8')); + + assert.equal(relayExample.schema, 'registry.ops.posture.v1'); + assert.equal(relayExample.component, 'registry-relay'); + assert.equal(relayExample.tier, 'default'); + assert.equal(notaryExample.schema, 'registry.ops.posture.v1'); + assert.equal(notaryExample.component, 'registry-notary'); + assert.equal(notaryExample.tier, 'default'); + assert.match(page, /registry-relay\.posture\.valid\.json/); + assert.match(page, /registry-notary\.posture\.valid\.json/); + assert.match(page, /restricted-posture\.valid\.json/); +}); + +test('RS-OP-POSTURE records the closed v1 schema and new-identifier rule', () => { + const page = readFileSync(specPath, 'utf8'); + const schema = JSON.parse(readFileSync(schemaPath, 'utf8')); + + assert.equal( + schema.$id, + 'https://id.registrystack.org/schemas/registry-platform/registry.ops.posture.v1.schema.json', + ); + assert.equal(schema.additionalProperties, false); + assert.equal(schema.properties.schema.const, 'registry.ops.posture.v1'); + assert.deepEqual(schema.properties.component.enum, ['registry-relay', 'registry-notary']); + assert.match(page, /additionalProperties: false/); + assert.match(page, /Every post-1\.0 shape change, including an additive optional property/); + assert.match(page, /requires a new\s+schema identifier/); +}); diff --git a/docs/site/src/content/docs/reference/apis/registry-notary.mdx b/docs/site/src/content/docs/reference/apis/registry-notary.mdx index 4b31556bd..d77cd1977 100644 --- a/docs/site/src/content/docs/reference/apis/registry-notary.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-notary.mdx @@ -127,6 +127,8 @@ or execute direct registry source connectors. - Admin reload: the standalone router returns `501` with `registry.admin.capability.not_supported`; key and configuration changes require a service restart. +- Admin posture: `GET /admin/v1/posture` returns the shared versioned operations observation. + [RS-OP-POSTURE](../../spec/rs-op-posture/) defines its envelope, tiers, and consumer rules. ## Behavior the schema cannot express diff --git a/docs/site/src/content/docs/reference/apis/registry-relay.mdx b/docs/site/src/content/docs/reference/apis/registry-relay.mdx index 6a183212a..a1f7d00ef 100644 --- a/docs/site/src/content/docs/reference/apis/registry-relay.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-relay.mdx @@ -74,7 +74,8 @@ For setup steps, scope strings, and credential configuration, see - **Admin routes.** Reload and metrics routes are mounted on a separate, optional admin listener, require `registry_relay:admin` for reload/config mutation, `registry_relay:metrics_read` for metrics, and `registry_relay:ops_read` for operational posture reads. They do not appear in the - public OpenAPI document. + public OpenAPI document. [RS-OP-POSTURE](../../spec/rs-op-posture/) defines the versioned + posture document returned by `GET /admin/v1/posture`. - **Evidence-offering discovery.** Relay publishes evidence-offering metadata at `GET /metadata/evidence-offerings` and `GET /metadata/evidence-offerings/{offering_id}`. These routes describe where to reach a Notary for verification; Relay does not execute the diff --git a/docs/site/src/content/docs/security/hardening-checklist.mdx b/docs/site/src/content/docs/security/hardening-checklist.mdx index c076ddbf1..d55dbe55f 100644 --- a/docs/site/src/content/docs/security/hardening-checklist.mdx +++ b/docs/site/src/content/docs/security/hardening-checklist.mdx @@ -138,7 +138,9 @@ or the [Registry Notary operator configuration reference](../../products/registr YAML file trips `relay.config.unsigned` / `notary.config.unsigned` and the process refuses to start. - Review `GET /admin/v1/posture` regularly. It reports the declared profile, active findings, and - active waivers, so the deployment's actual state is inspectable rather than asserted. + active waivers, so the deployment's actual state is inspectable rather than asserted. Read + [RS-OP-POSTURE](../../spec/rs-op-posture/) before building operational tooling around its + default or restricted tier. - Give every waiver a non-empty, non-secret reason and a mandatory expiry date. `startup_fail` gates are never waivable; a waiver naming one is rejected. Waiver reasons appear only in the restricted posture tier. @@ -169,6 +171,8 @@ or the [Registry Notary operator configuration reference](../../products/registr expected for a deliberately unmet `startup_fail` gate). - Query `GET /admin/v1/posture` and read the `deployment` object: confirm the profile matches what you declared, and that no unwaived finding at or above your profile's threshold is outstanding. + Validate the response as [RS-OP-POSTURE](../../spec/rs-op-posture/) before an automated check + acts on it. - Send a request that should be audited and confirm a corresponding record lands in your configured sink, not only `stdout`. - Attempt an admin-listener request from outside the private network it is meant to be bound to, diff --git a/docs/site/src/content/docs/spec/rs-op-posture.mdx b/docs/site/src/content/docs/spec/rs-op-posture.mdx new file mode 100644 index 000000000..baac979db --- /dev/null +++ b/docs/site/src/content/docs/spec/rs-op-posture.mdx @@ -0,0 +1,235 @@ +--- +title: "RS-OP-POSTURE: Operations posture document" +description: "The normative contract for the registry.ops.posture.v1 operations posture document: its envelope, component sections, sensitivity tiers, producer and consumer duties, and immutable schema evolution." +status: draft +owner: registry-docs +source_repos: + - registry-platform + - registry-relay + - registry-notary +last_reviewed: "2026-07-19" +doc_type: specification +doc_id: RS-OP-POSTURE +category: normative +evidence: verified +locale: en +standards_referenced: + - json-schema +layer: + - administration + - operations +audience: + - integrator + - operator + - maintainer +--- + +This document defines the operations posture observation that Registry Relay and Registry Notary +return from their protected admin surfaces. It gives operators and tooling authors one stable, +versioned document for observing build, runtime, configuration, deployment, audit, and +component-specific status without treating that observation as an authorization decision or a +source of secrets. + +The key words in this document are interpreted per [RS-DOC](../rs-doc/) Section 2. +Defined terms are used per [RS-TERMS](../rs-terms/). +The [Registry Ops Posture v1 JSON Schema](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json) +is the source of truth for validation; this specification defines the operational meaning and +evolution rules that JSON Schema alone does not express. + +## Version history + +| Version | Date | Status | Change | +| --- | --- | --- | --- | +| 0.1.0 | 2026-07-19 | draft | Initial posture-document contract for `registry.ops.posture.v1`. | + +## 1. Scope and surface + +This specification covers a point-in-time operations observation returned by +`GET /admin/v1/posture`. +Registry Relay and Registry Notary expose that route on their protected admin surfaces, not in +the public Registry Relay API document. +The product route and authorization details remain with the +[Registry Relay API reference](../../reference/apis/registry-relay/) and the +[Registry Notary API reference](../../reference/apis/registry-notary/). + +This specification does not define deployment configuration, profile-gate policy, audit +retention, or a new authorization scope. +It also does not make a posture document a health guarantee, a conformance certificate, or proof +that an external artifact is safe to trust. + +## 2. Document envelope + +The schema identifier is `registry.ops.posture.v1`. +Its canonical JSON Schema identifier is +`https://id.registrystack.org/schemas/registry-platform/registry.ops.posture.v1.schema.json`. +The root object and named nested objects use `additionalProperties: false`, except for two +explicitly modeled maps. +`standards_artifacts` accepts producer-selected artifact labels, while each artifact-reference +value has the closed shape defined by the schema. +`notary.signing_keys.readiness` accepts signing-key identifiers, while each value is one of the +defined readiness states. + +REQ-OP-POSTURE-001: A producer MUST emit a JSON object that validates against +`registry.ops.posture.v1`. +It MUST include `schema`, `observed_at`, `component`, `instance`, `build`, `runtime`, +`configuration`, `standards_artifacts`, and `posture`. +`observed_at` MUST be an RFC 3339 date-time and `schema` MUST equal +`registry.ops.posture.v1`. +A producer MUST NOT add a root or nested property that the schema does not model. + +REQ-OP-POSTURE-002: A runtime endpoint producer MUST include `tier` as either `default` or +`restricted`, even though the v1 schema permits its omission. +Consumers of a runtime endpoint MUST treat an absent `tier` as invalid for that endpoint rather +than silently guessing the disclosure class. + +### Component rules + +`component` is exactly one of `registry-relay` and `registry-notary`. +The component selects one, and only one, component section. + +| `component` value | Required section | Forbidden section | +| --- | --- | --- | +| `registry-relay` | `relay` | `notary` | +| `registry-notary` | `notary` | `relay` | + +REQ-OP-POSTURE-003: A `registry-relay` document MUST contain `relay` and MUST NOT contain +`notary`. +A `registry-notary` document MUST contain `notary` and MUST NOT contain `relay`. +A consumer MUST reject a document that does not satisfy this pairing, even when the shared +envelope itself appears complete. + +The shared sections state the following categories of observation: + +- `instance`: deployment identity and environment, with optional owner, jurisdiction, and URL. +- `build`: package and version, with optional source revision and enabled features. +- `runtime`: authentication mode, admin enablement, and readiness state. +- `configuration`: configuration source, reload support, the latest applied state, and optional + emergency or trusted-root state. +- `deployment`: optional profile, findings, and waivers. +- `audit`: optional assurance characteristics for the configured audit path. +- `standards_artifacts`: observed published-artifact references keyed by artifact label. +- `posture`: warnings, actionable findings, and the observed audit-shipping state. + +The `relay` section reports configured surface counts, metadata-manifest state, and adapter state. +The `notary` section reports claim and credential-profile counts plus state, credential-status, +federation, OID4VCI, and optional subject-access observations. + +## 3. Examples + +The schema-valid [Registry Relay default example](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/examples/registry-relay.posture.valid.json) +and [Registry Notary default example](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/examples/registry-notary.posture.valid.json) +show the default projection for each component. +The [restricted-tier fixture](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/fixtures/posture/restricted-posture.valid.json) +shows fields that are valid but intentionally absent from the default projection. + +Examples are observations, not deployment templates. +An operator MUST supply actual deployment configuration through the product's configuration +surface, not by copying posture values into configuration. + +## 4. Sensitivity tiers and emit-only filtering + +The tier identifies the disclosure class of the document, not the caller's authorization level. +The product endpoint remains responsible for authentication and authorization before it returns +either tier. +A deployment MUST NOT assume that selecting `restricted` grants a caller any new permission. + +REQ-OP-POSTURE-004: A producer of the `default` tier MUST create its emitted document from the +default JSON-pointer allowlist. +It MUST use an emit-only projection, not a blacklist redaction pass over a complete operational +object. +If a leaf is not allowlisted, the producer MUST omit it rather than mask it, hash it, or preserve +it in an opaque nested value. + +The default projection includes operational status, counts, selected digests, finding identifiers +and severities, and non-secret audit-shipping observations. +It excludes values that can reveal topology or operational free text, including instance URLs, +build revisions and features, trusted roots, artifact URLs, waiver reasons, signing-key details, +and Notary federation identities or peers. +The checked-in sensitive fixture proves that the projection excludes credentials, private keys, +source URLs, subject identifiers, raw rows, claim values, and the restricted topology fields. + +REQ-OP-POSTURE-005: A `restricted` document MAY contain every field modeled by the v1 schema, +including fields excluded from the default allowlist. +It MUST still conform to the closed schema and MUST NOT contain secret material, raw registry +data, claim values, subject identifiers, bearer tokens, or private keys. +The restricted tier is not a substitute for a private admin listener, access control, or operator +review. + +Consumers MUST treat a missing field as unreported, not as `false`, `none`, or a failed check. +Consumers MUST NOT reconstruct excluded values from a default-tier document or compare a default +and restricted observation as though omission proves a configuration change. + +## 5. Schema evolution + +The v1 schema is a strict closed contract, not an extensible envelope. +Its `schema` field is a constant and the root and named nested objects reject unknown keys, apart +from the explicitly modeled maps in Section 2. +This lets consumers validate a complete known shape before making an operational decision. + +REQ-OP-POSTURE-006: The identifier `registry.ops.posture.v1` MUST continue to name exactly its +published v1 shape and meaning. +Every post-1.0 shape change, including an additive optional property, a new required property, a +removed or renamed property, a changed type or enum, or a changed field meaning, requires a new +schema identifier and new JSON Schema document. +A producer MUST NOT send a changed shape while retaining `registry.ops.posture.v1`. + +Adding an artifact label inside `standards_artifacts` does not change the v1 shape because that map +is the explicitly modeled extension point. +The new entry's value MUST remain a v1 `artifact_reference` object. + +REQ-OP-POSTURE-007: A consumer MUST dispatch on `schema` before interpreting the document. +It MUST validate the complete document against the corresponding schema, reject an unknown +schema identifier, and reject unknown properties in a v1 document. +A consumer that supports a later schema identifier MUST retain an explicit validation path for v1 +until its own compatibility policy retires v1. + +## 6. Producer and consumer expectations + +REQ-OP-POSTURE-008: A producer MUST observe the current runtime state, set `observed_at` at the +time of that observation, select the component section that matches the runtime, and emit a result +that conforms to the selected schema and tier. +It MUST select posture fields through typed, explicit construction and apply the selected tier's +emit-only projection. +This construction MUST prevent secret-bearing configuration and production data from being copied +into the output. + +REQ-OP-POSTURE-009: A consumer MUST use posture as an observation for operations and troubleshooting +only. +It MUST evaluate readiness, findings, audit status, and artifact observations according to its own +policy and MUST NOT treat any of them as an authorization grant, an external conformance claim, +or proof of remote receipt or retention. + +## Conformance + +A producer conforms to this specification when it emits a schema-valid, component-consistent, +tier-labeled observation; uses the emit-only allowlist for default output; keeps restricted output +within the same secret and source-data boundary; and assigns a new schema identifier for every +post-1.0 shape change (REQ-OP-POSTURE-001 through REQ-OP-POSTURE-006 and REQ-OP-POSTURE-008). + +A consumer conforms when it validates and dispatches by schema, enforces component pairing, +handles omissions as unreported, and does not elevate posture observations into proof or authority +(REQ-OP-POSTURE-002, REQ-OP-POSTURE-003, REQ-OP-POSTURE-007, and REQ-OP-POSTURE-009). + +## Evidence + +This specification is `verified`: its document shape, examples, component pairing, and tier +filter are shipped in Registry Platform and exercised by contract tests. + +- The [v1 JSON Schema](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json) + defines the closed envelope, schema constant, and Relay/Notary component pairing + (Sections 2 and 5). +- The [posture contract tests](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/tests/posture_contract.rs) + validate both examples, the default allowlist projection, the sensitive fixture, and exclusion + of restricted fields from default output (Sections 3, 4, and 6). +- The [Registry Relay admin handler](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-relay/src/api/admin.rs) + and [Registry Notary admin handler](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-notary-server/src/api/admin.rs) + select the requested tier and return the shared posture document (Sections 1, 4, and 6). + +## Next + +- [Harden a production deployment](../../security/hardening-checklist/) explains how an operator + uses posture findings while preparing a deployment. +- [Registry Relay protocol](../rs-pr-relay/) defines the Relay admin-surface boundary. +- [Registry Notary protocol](../rs-pr-notary/) defines Registry Notary's protected service surface. +- [RS-SEC-G](../rs-sec-g/) defines the security model and the operator boundary this observation + helps inspect. diff --git a/docs/site/src/data/contracts.yaml b/docs/site/src/data/contracts.yaml index b555a9f75..bf5b1d771 100644 --- a/docs/site/src/data/contracts.yaml +++ b/docs/site/src/data/contracts.yaml @@ -7,6 +7,19 @@ label: Registry Platform crates url: https://github.com/registrystack/registry-stack/tree/v0.11.0/crates consumer_note: Relay, Notary, and future registry services should consume these primitives instead of reimplementing security or operational behavior locally. +- id: registry-platform.ops-posture-v1 + name: Registry Ops Posture v1 + owner: registry-platform + status: current-source + surface: Versioned JSON Schema for protected Registry Relay and Registry Notary operations posture observations, including the shared envelope, component sections, default and restricted sensitivity tiers, deployment findings, audit state, and standards-artifact references. + source_of_truth: + label: Registry Ops Posture v1 JSON Schema + url: https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json + consumer_note: >- + Validate every document against `registry.ops.posture.v1` before use. The + schema is closed: every post-1.0 shape change, including an additive field, + requires a new schema identifier. Use the default tier for the emit-only + allowlisted projection; restricted output remains protected operational data. - id: registry-relay.openapi name: Registry Relay OpenAPI owner: registry-relay diff --git a/docs/site/src/data/generated/contracts.json b/docs/site/src/data/generated/contracts.json index a34ed7442..8a1256cb6 100644 --- a/docs/site/src/data/generated/contracts.json +++ b/docs/site/src/data/generated/contracts.json @@ -11,6 +11,18 @@ }, "consumer_note": "Relay, Notary, and future registry services should consume these primitives instead of reimplementing security or operational behavior locally." }, + { + "id": "registry-platform.ops-posture-v1", + "name": "Registry Ops Posture v1", + "owner": "registry-platform", + "status": "current-source", + "surface": "Versioned JSON Schema for protected Registry Relay and Registry Notary operations posture observations, including the shared envelope, component sections, default and restricted sensitivity tiers, deployment findings, audit state, and standards-artifact references.", + "source_of_truth": { + "label": "Registry Ops Posture v1 JSON Schema", + "url": "https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json" + }, + "consumer_note": "Validate every document against `registry.ops.posture.v1` before use. The schema is closed: every post-1.0 shape change, including an additive field, requires a new schema identifier. Use the default tier for the emit-only allowlisted projection; restricted output remains protected operational data." + }, { "id": "registry-relay.openapi", "name": "Registry Relay OpenAPI", From 6da9f1252f9bdf88308782edeeecd00e13d532dc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 21:43:37 +0700 Subject: [PATCH 58/65] docs(manifest): defer narrative keys to reference Signed-off-by: Jeremi Joslin --- .../manifest-narrative-reference.test.mjs | 34 +++++++++++++++++++ .../content/docs/explanation/architecture.mdx | 5 +-- .../src/content/docs/reference/glossary.mdx | 2 +- docs/site/src/content/docs/spec/rs-arc-g.mdx | 4 +-- docs/site/src/content/docs/spec/rs-terms.mdx | 2 +- 5 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 docs/site/scripts/manifest-narrative-reference.test.mjs diff --git a/docs/site/scripts/manifest-narrative-reference.test.mjs b/docs/site/scripts/manifest-narrative-reference.test.mjs new file mode 100644 index 000000000..6e05b3bbe --- /dev/null +++ b/docs/site/scripts/manifest-narrative-reference.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +const here = dirname(fileURLToPath(import.meta.url)); +const siteRoot = resolve(here, '..'); +const repositoryRoot = resolve(siteRoot, '../..'); +const manifestReference = readFileSync( + resolve(repositoryRoot, 'products/manifest/docs/reference.md'), + 'utf8', +); +const narrativePages = [ + 'src/content/docs/spec/rs-terms.mdx', + 'src/content/docs/reference/glossary.mdx', + 'src/content/docs/spec/rs-arc-g.mdx', + 'src/content/docs/explanation/architecture.mdx', +]; + +test('narrative manifest summaries defer the mutable key set to the Manifest reference', () => { + assert.match(manifestReference, /^## Manifest top-level keys$/m); + + for (const pagePath of narrativePages) { + const page = readFileSync(resolve(siteRoot, pagePath), 'utf8'); + + assert.match(page, /Registry Manifest reference/); + assert.match(page, /products\/registry-manifest\/reference/); + assert.doesNotMatch( + page, + /describes datasets, entities, fields, public services, forms, requirements, policies,/, + ); + } +}); diff --git a/docs/site/src/content/docs/explanation/architecture.mdx b/docs/site/src/content/docs/explanation/architecture.mdx index 0c900886f..deef031db 100644 --- a/docs/site/src/content/docs/explanation/architecture.mdx +++ b/docs/site/src/content/docs/explanation/architecture.mdx @@ -67,8 +67,9 @@ updated policy documents without touching deployment config. ## Data and contract flow 1. Registry Platform provides reusable security and operational primitives consumed by runtime services. -2. A metadata manifest (`registry-manifest/v1` schema) describes datasets, entities, fields, policies, - and evidence offerings. +2. A metadata manifest (`registry-manifest/v1` schema) describes a registry through its catalog and + defined collections. The [Registry Manifest reference](../../products/registry-manifest/reference/) + is authoritative for the current top-level keys and field schemas. 3. Registry Manifest compiles and validates the manifest, then renders a static discovery bundle (catalog, DCAT, BRegDCAT-AP, CPSV-AP, SHACL, JSON Schemas, OGC Records item collection, policies, evidence-offering metadata, embedded codelist metadata, and an `index.json`). diff --git a/docs/site/src/content/docs/reference/glossary.mdx b/docs/site/src/content/docs/reference/glossary.mdx index d39fedacb..9f7b3a6e3 100644 --- a/docs/site/src/content/docs/reference/glossary.mdx +++ b/docs/site/src/content/docs/reference/glossary.mdx @@ -145,7 +145,7 @@ Product names are always in English, including on future translated pages.
JSON-based linked data format. W3C recommendation (JSON-LD 1.1). Used for catalog, policy, and CCCEV render output.
metadata manifest
-
Portable `metadata.yaml` document (schema version `registry-manifest/v1`) that describes datasets, entities, fields, public services, forms, requirements, policies, evidence offerings, federation metadata, evaluation profiles, and governed evidence ecosystem bindings. Must not include Relay runtime bindings such as source paths, table names, or scopes.
+
Portable `metadata.yaml` document (schema version `registry-manifest/v1`) that describes a registry through its catalog and defined collections. The Registry Manifest reference is authoritative for the current top-level keys and field schemas. Must not include Relay runtime bindings such as source paths, table names, or scopes.
measures
The configured numeric computations in a Registry Relay aggregate definition (renamed from `indicators`). Each measure has an `id`, a label, an aggregation method (`count`, `sum`, `average`, among others), and a source column. Returned in the `AggregateResult.measures` field alongside `observations`. See also: `observations`.
diff --git a/docs/site/src/content/docs/spec/rs-arc-g.mdx b/docs/site/src/content/docs/spec/rs-arc-g.mdx index 780bdb7d0..6311dfc34 100644 --- a/docs/site/src/content/docs/spec/rs-arc-g.mdx +++ b/docs/site/src/content/docs/spec/rs-arc-g.mdx @@ -129,7 +129,7 @@ Registry Platform (`registry-platform`) is a shared Rust crate workspace that pr ### Registry Manifest -Registry Manifest (`registry-manifest`) is a pure Rust library and command-line interface (CLI) with no runtime data dependencies. It accepts a portable `metadata.yaml` document (schema version `registry-manifest/v1`) that describes datasets, entities, fields, public services, forms, requirements, policies, codelists, and evidence offerings. It validates the document and renders a static discovery bundle containing: a catalog (`catalog.json`), Data Catalog Vocabulary (DCAT) and BRegDCAT-AP JSON-LD, Core Public Service Vocabulary Application Profile (CPSV-AP) JSON-LD, Shapes Constraint Language (SHACL) node shapes, JSON Schema documents, OGC API Records item collection, Open Digital Rights Language (ODRL) policy documents, Core Criterion and Core Evidence Vocabulary (CCCEV) metadata, embedded SKOS-shaped codelist metadata, and an `index.json`. Registry Manifest does not serve HTTP, does not connect to databases, and does not handle authentication, audit, or secrets. Production source configuration (source paths, table identifiers, scopes) does not belong in the manifest; it belongs in Registry Relay configuration. +Registry Manifest (`registry-manifest`) is a pure Rust library and command-line interface (CLI) with no runtime data dependencies. It accepts a portable `metadata.yaml` document (schema version `registry-manifest/v1`) that describes a registry through its catalog and defined collections. The [Registry Manifest reference](../../products/registry-manifest/reference/) is authoritative for the current top-level keys and field schemas. It validates the document and renders a static discovery bundle containing: a catalog (`catalog.json`), Data Catalog Vocabulary (DCAT) and BRegDCAT-AP JSON-LD, Core Public Service Vocabulary Application Profile (CPSV-AP) JSON-LD, Shapes Constraint Language (SHACL) node shapes, JSON Schema documents, OGC API Records item collection, Open Digital Rights Language (ODRL) policy documents, Core Criterion and Core Evidence Vocabulary (CCCEV) metadata, embedded SKOS-shaped codelist metadata, and an `index.json`. Registry Manifest does not serve HTTP, does not connect to databases, and does not handle authentication, audit, or secrets. Production source configuration (source paths, table identifiers, scopes) does not belong in the manifest; it belongs in Registry Relay configuration. ### Registry Relay @@ -156,7 +156,7 @@ The following ordered flow describes how a request moves through the stack from 1. **Shared primitives.** Registry Platform provides reusable security and operational primitives (authentication, OIDC verification, audit envelopes, HTTP security, outbound HTTP policy, cryptography, SD-JWT VC helpers) consumed by both runtime services. -2. **Manifest authoring.** An operator authors a portable `metadata.yaml` document (schema `registry-manifest/v1`) describing datasets, entities, fields, public services, forms, requirements, policies, and evidence offerings. The manifest does not contain runtime bindings. +2. **Manifest authoring.** An operator authors a portable `metadata.yaml` document (schema `registry-manifest/v1`) describing a registry through its catalog and defined collections. The [Registry Manifest reference](../../products/registry-manifest/reference/) is authoritative for the current top-level keys and field schemas. The manifest does not contain runtime bindings. 3. **Compilation and bundle rendering.** Registry Manifest validates the manifest and renders a static discovery bundle. The bundle contains the catalog, DCAT and BRegDCAT-AP JSON-LD, CPSV-AP JSON-LD, SHACL node shapes, JSON Schema documents, OGC API Records item collection, ODRL policy documents, CCCEV metadata, evidence-offering metadata, embedded SKOS-shaped codelist metadata, and an `index.json`. The bundle can be hosted as static files without running any runtime service. diff --git a/docs/site/src/content/docs/spec/rs-terms.mdx b/docs/site/src/content/docs/spec/rs-terms.mdx index 0b6ab7c19..5192a006f 100644 --- a/docs/site/src/content/docs/spec/rs-terms.mdx +++ b/docs/site/src/content/docs/spec/rs-terms.mdx @@ -105,7 +105,7 @@ The registry stack comprises four formal products. Product names are Title Case. ## 3. Metadata and standards terms -**metadata manifest**: Portable `metadata.yaml` document using schema version `registry-manifest/v1` that describes datasets, entities, fields, public services, forms, requirements, policies, evidence offerings, federation metadata, evaluation profiles, and governed evidence ecosystem bindings. MUST NOT include Registry Relay runtime bindings such as source paths, table names, or scopes. Registry Stack product term. +**metadata manifest**: Portable `metadata.yaml` document using schema version `registry-manifest/v1` that describes a registry through its catalog and defined collections. The [Registry Manifest reference](../../products/registry-manifest/reference/) is authoritative for the current top-level keys and field schemas. A metadata manifest MUST NOT include Registry Relay runtime bindings such as source paths, table names, or scopes. Registry Stack product term. **runtime binding**: Registry Relay configuration that connects logical manifest concepts to actual files, database tables, scopes, and backend credentials. Lives in Relay config, not in the manifest. Registry Stack product term. From 8a1fcb1e18ec8df552eba69b2e61b3ebbb637c29 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 21:27:24 +0700 Subject: [PATCH 59/65] chore(ci): remove inert nested workflows Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 187 ++++++++++++++++++ .gitleaks.toml | 46 +++++ crates/registryctl/.github/workflows/ci.yml | 63 ------ .../registryctl/.github/workflows/release.yml | 148 -------------- products/platform/.github/workflows/ci.yml | 109 ---------- products/platform/.github/workflows/fuzz.yml | 110 ----------- products/platform/README.md | 33 ++-- products/platform/docs/SECURITY_PRINCIPLES.md | 8 +- .../platform/docs/config-drift-inventory.md | 119 +++-------- products/platform/docs/versioning.md | 99 +++++----- products/platform/fuzz/README.md | 35 ++-- products/platform/scripts/audit-configs.sh | 39 ++-- .../scripts/check-hygiene-alignment.sh | 41 +++- release/scripts/check-gates-inventory.py | 96 ++++++++- release/scripts/test_check_gates_inventory.py | 115 +++++++++++ 15 files changed, 610 insertions(+), 638 deletions(-) create mode 100644 .gitleaks.toml delete mode 100644 crates/registryctl/.github/workflows/ci.yml delete mode 100644 crates/registryctl/.github/workflows/release.yml delete mode 100644 products/platform/.github/workflows/ci.yml delete mode 100644 products/platform/.github/workflows/fuzz.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94e0399c5..2199f6c39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,10 @@ permissions: env: CARGO_TERM_COLOR: always + CARGO_FUZZ_VERSION: "0.13.2" + CARGO_LLVM_COV_VERSION: "0.8.7" + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_LINUX_X64_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" jobs: changes: @@ -21,6 +25,8 @@ jobs: runs-on: ubuntu-24.04 outputs: rust: ${{ steps.filter.outputs.rust }} + platform: ${{ steps.filter.outputs.platform }} + platform_hygiene: ${{ steps.filter.outputs.platform_hygiene }} release_tool: ${{ steps.filter.outputs.release_tool }} release_source_proof: ${{ steps.filter.outputs.release_source_proof }} docs: ${{ steps.filter.outputs.docs }} @@ -39,6 +45,8 @@ jobs: run: | set -euo pipefail rust=false + platform=false + platform_hygiene=false release_tool=false release_source_proof=false docs=false @@ -59,6 +67,8 @@ jobs: mark_all() { rust=true + platform=true + platform_hygiene=true release_tool=true release_source_proof=true docs=true @@ -92,6 +102,16 @@ jobs: rust=true ;; esac + case "${path}" in + Cargo.toml|Cargo.lock|clippy.toml|deny.toml|rustfmt.toml|rust-toolchain*|crates/registry-config-report/*|crates/registry-platform-*|products/platform/Cargo.toml|products/platform/fuzz/*|products/platform/rust-toolchain.toml) + platform=true + ;; + esac + case "${path}" in + clippy.toml|deny.toml|rustfmt.toml|crates/registry-relay/clippy.toml|crates/registry-relay/deny.toml|crates/registry-relay/rustfmt.toml|crates/registry-relay/config/*|crates/registry-relay/demo/config/*|crates/registry-relay/perf/config/*|crates/registry-relay/profiles/*|crates/registry-relay/tests/fixtures/config/*|products/platform/clippy.toml|products/platform/deny.toml|products/platform/rustfmt.toml|products/platform/scripts/*|products/platform/templates/*) + platform_hygiene=true + ;; + esac case "${path}" in release/*) release_tool=true @@ -125,6 +145,8 @@ jobs: { echo "rust=${rust}" + echo "platform=${platform}" + echo "platform_hygiene=${platform_hygiene}" echo "release_tool=${release_tool}" echo "release_source_proof=${release_source_proof}" echo "docs=${docs}" @@ -132,6 +154,171 @@ jobs: echo "registryctl_tutorial=${registryctl_tutorial}" } >> "${GITHUB_OUTPUT}" + secrets: + name: Secret scan + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + persist-credentials: false + submodules: false + + - name: Install pinned Gitleaks + shell: bash + run: | + set -euo pipefail + mkdir -p "${RUNNER_TEMP}/bin" + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/gitleaks.tar.gz" \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + echo "${GITLEAKS_LINUX_X64_SHA256} ${RUNNER_TEMP}/gitleaks.tar.gz" | sha256sum -c - + tar -xzf "${RUNNER_TEMP}/gitleaks.tar.gz" -C "${RUNNER_TEMP}/bin" gitleaks + chmod +x "${RUNNER_TEMP}/bin/gitleaks" + + - name: Scan tracked source for secrets + run: >- + "${RUNNER_TEMP}/bin/gitleaks" dir + --config .gitleaks.toml + --no-banner + --redact + --timeout 120 + . + + platform-quality: + name: Platform all-features + needs: changes + if: needs.changes.outputs.platform == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + submodules: false + + - name: Cache Cargo registry and build artifacts + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: platform-all-features + + - name: Build platform crates with all features + run: cargo build --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features + + - name: Lint platform crates with all features + run: cargo clippy --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features -- -D warnings + + - name: Test platform crates with all features + run: cargo test --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features + + platform-coverage: + name: Platform line coverage + needs: changes + if: needs.changes.outputs.platform == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + submodules: false + + - name: Cache Cargo registry and build artifacts + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: platform-coverage + + - name: Install pinned coverage tool + uses: taiki-e/install-action@25435dc8dd3baed7417e0c96d3fe89013a5b2e09 # v2.81.3 + with: + tool: cargo-llvm-cov@${{ env.CARGO_LLVM_COV_VERSION }} + + - name: Enforce platform line coverage + run: >- + cargo llvm-cov --locked + -p registry-config-report + -p 'registry-platform-*' + --all-features + --fail-under-lines 80 + + platform-hygiene: + name: Platform hygiene and config inventory + needs: changes + if: needs.changes.outputs.platform_hygiene == 'true' + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + submodules: false + + - name: Check shared Rust hygiene files + run: products/platform/scripts/check-hygiene-alignment.sh + + - name: Audit monorepo config roots + run: >- + products/platform/scripts/audit-configs.sh + --check + --format paths + > "${RUNNER_TEMP}/registry-config-inventory.txt" + + platform-fuzz: + name: Platform fuzz smoke (${{ matrix.target }}) + needs: changes + if: needs.changes.outputs.platform == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + target: + - authcommon_parsers + - oid4vci_request_and_proof + - sdjwt_holder_proof + - sdjwt_issuance + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + submodules: false + + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # nightly + with: + toolchain: nightly + + - name: Cache Cargo registry and build artifacts + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: platform-fuzz-${{ matrix.target }} + + - name: Install pinned cargo-fuzz + uses: taiki-e/install-action@25435dc8dd3baed7417e0c96d3fe89013a5b2e09 # v2.81.3 + with: + tool: cargo-fuzz@${{ env.CARGO_FUZZ_VERSION }} + + - name: Smoke fuzz target + working-directory: products/platform + run: | + set -euo pipefail + mkdir -p "fuzz/artifacts/${{ matrix.target }}" + cargo +nightly fuzz run --fuzz-dir fuzz --target x86_64-unknown-linux-gnu "${{ matrix.target }}" -- \ + -max_total_time=60 \ + -rss_limit_mb=1024 \ + -artifact_prefix="fuzz/artifacts/${{ matrix.target }}/" \ + -print_final_stats=1 + + - name: Upload fuzz failures + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: platform-fuzz-${{ matrix.target }} + path: products/platform/fuzz/artifacts/${{ matrix.target }}/ + if-no-files-found: ignore + rust: name: Rust workspace needs: changes diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..7d74a9772 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,46 @@ +[extend] +useDefault = true + +[[allowlists]] +description = "Ignore generated Rust build output." +paths = ["(^|/)target/"] + +[[allowlists]] +description = "Allow synthetic JWT fixtures used only by the platform fuzz harnesses." +paths = [ + "^products/platform/fuzz/corpus/oid4vci_request_and_proof/credential_request\\.json$", + "^products/platform/fuzz/corpus/oid4vci_request_and_proof/valid-proof-jwt$", + "^products/platform/fuzz/corpus/sdjwt_holder_proof/holder_proof\\.jwt$", + "^products/platform/fuzz/corpus/sdjwt_holder_proof/valid-holder-proof-jwt$", +] + +[[allowlists]] +description = "Allow fixed identifiers and JWTs in the public Notary OpenAPI example." +regexTarget = "match" +regexes = [ + '''credential:01HX7Y5F2WAJ7ZP0Q4M5K9E8NC''', + '''eyJhbGciOiJFZERTQSIsInR5cCI6ImRjK3NkLWp3dCIsImtpZCI6ImRpZDp3ZWI6YWdyaWN1bHR1cmUuZGVtby5leGFtcGxlLmdvdi''', +] + +[[allowlists]] +description = "Allow fixed non-secret values used by tests and configuration examples." +regexTarget = "line" +regexes = [ + '''oid4vci_preauth_enabled:\s*self\.oid4vci\.enabled''', + '''std::env::set_var\(SECRET_ENV,\s*"0123456789abcdef0123456789abcdef"\)''', + '''let key = Pkcs1RsaPrivateKey''', + '''chain_key_epoch_id:\s*[a-z0-9-]+-chain-1''', + '''"dci_crvs_api":\s*"5e31d1e381d4bd8c7c74112d714fd49d263c6df7"''', +] + +[[allowlists]] +description = "Allow synthetic OpenCRVS response JWTs used by registryctl project-authoring tests." +paths = [ + "^crates/registryctl/tests/fixtures/project-authoring/opencrvs(?:-country-variant)?/integrations/birth-record/fixtures/bodies/(?:ambiguous|match|no-match)\\.json$", +] + +[[allowlists]] +description = "Allow synthetic Notary holder-proof fixtures generated from repository test keys." +paths = [ + "^products/notary/tests/fixtures/sd_jwt_vc/holder-proof-(?:eddsa|es256-unsupported|mismatch)\\.(?:jwt|sd-jwt)$", +] diff --git a/crates/registryctl/.github/workflows/ci.yml b/crates/registryctl/.github/workflows/ci.yml deleted file mode 100644 index f7426fe1a..000000000 --- a/crates/registryctl/.github/workflows/ci.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: CI - -on: - pull_request: - -permissions: - contents: read - -env: - CARGO_TERM_COLOR: always - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - RUST_TOOLCHAIN: 1.95.0 - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - rust: - name: fmt, clippy, tests - runs-on: ubuntu-24.04 - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install Rust - shell: bash - run: | - set -euo pipefail - rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal - rustup component add rustfmt clippy --toolchain "$RUST_TOOLCHAIN" - rustup default "$RUST_TOOLCHAIN" - - - name: Check formatting - run: cargo fmt --all -- --check - - - name: Lint - run: cargo clippy --workspace --all-targets --locked -- -D warnings - - - name: Test - run: cargo test --workspace --locked - - contract-smoke: - name: contract smoke compile gate - runs-on: ubuntu-24.04 - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install Rust - shell: bash - run: | - set -euo pipefail - rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal - rustup default "$RUST_TOOLCHAIN" - - # registryctl smoke and registryctl notary smoke require a generated - # project plus a live local stack. PR CI keeps this as a compile-only gate. - - name: Compile registryctl smoke commands - run: | - cargo build --locked --bin registryctl - cargo run --locked -- smoke --help - cargo run --locked -- notary smoke --help diff --git a/crates/registryctl/.github/workflows/release.yml b/crates/registryctl/.github/workflows/release.yml deleted file mode 100644 index 77bb1c859..000000000 --- a/crates/registryctl/.github/workflows/release.yml +++ /dev/null @@ -1,148 +0,0 @@ -name: Release Binaries - -on: - push: - tags: - - "v*.*.*" - workflow_dispatch: - inputs: - release_tag: - description: "Semver tag to validate, for example v0.1.0" - required: true - default: "v0.1.0" - dry_run: - description: "Build and verify artifacts without creating a GitHub release" - required: true - type: boolean - default: true - -permissions: - contents: read - -env: - CARGO_TERM_COLOR: always - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} - RUST_TOOLCHAIN: 1.95.0 - -concurrency: - group: release-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -jobs: - build: - name: Build ${{ matrix.asset }} - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - asset: linux-x86_64 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - asset: linux-aarch64 - - runner: macos-14 - target: aarch64-apple-darwin - asset: macos-aarch64 - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install Rust - shell: bash - run: | - set -euo pipefail - rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal --target "${{ matrix.target }}" - rustup default "$RUST_TOOLCHAIN" - - - name: Validate release tag matches crate version - shell: bash - run: | - set -euo pipefail - case "$RELEASE_TAG" in - v[0-9]*.[0-9]*.[0-9]*) ;; - *) - echo "Release tag must be semver-like, for example v0.1.0: $RELEASE_TAG" >&2 - exit 1 - ;; - esac - - crate_version="$(python3 -c 'import pathlib, tomllib; print(tomllib.load(pathlib.Path("Cargo.toml").open("rb"))["package"]["version"])')" - if [[ "$RELEASE_TAG" != "v${crate_version}" ]]; then - echo "Release tag ${RELEASE_TAG} does not match Cargo.toml package version ${crate_version}" >&2 - exit 1 - fi - - - name: Build - run: cargo build --release --locked --target "${{ matrix.target }}" - - - name: Package - shell: bash - run: | - set -euo pipefail - asset="registryctl-${{ matrix.asset }}.tar.gz" - mkdir -p dist - cp "target/${{ matrix.target }}/release/registryctl" dist/registryctl - tar -C dist -czf "$asset" registryctl - shasum -a 256 "$asset" > "$asset.sha256" - shasum -a 256 -c "$asset.sha256" - - - name: Upload artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: registryctl-${{ matrix.asset }} - path: | - registryctl-${{ matrix.asset }}.tar.gz - registryctl-${{ matrix.asset }}.tar.gz.sha256 - if-no-files-found: error - - publish: - name: Publish release - runs-on: ubuntu-24.04 - needs: build - permissions: - contents: write - env: - DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Download artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - path: release-assets - merge-multiple: true - - - name: Verify downloaded checksums - shell: bash - run: | - set -euo pipefail - find release-assets -name '*.sha256' -print | sort | while read -r sum_file; do - ( - cd "$(dirname "$sum_file")" - shasum -a 256 -c "$(basename "$sum_file")" - ) - done - - - name: Dry-run release - if: env.DRY_RUN == 'true' - shell: bash - run: | - set -euo pipefail - echo "Dry run for ${RELEASE_TAG}; release creation skipped." - find release-assets -maxdepth 1 -type f -print | sort - - - name: Publish tagged release - if: env.DRY_RUN != 'true' - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - set -euo pipefail - gh release create "$RELEASE_TAG" release-assets/* \ - --verify-tag \ - --title "$RELEASE_TAG" \ - --notes "registryctl ${RELEASE_TAG}" diff --git a/products/platform/.github/workflows/ci.yml b/products/platform/.github/workflows/ci.yml deleted file mode 100644 index 271e00944..000000000 --- a/products/platform/.github/workflows/ci.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: ci - -on: - push: - branches: [main] - pull_request: - -permissions: - contents: read - -env: - RUST_TOOLCHAIN: "1.95.0" - CARGO_LLVM_COV_VERSION: "0.8.7" - CARGO_DENY_VERSION: "0.19.7" - GITLEAKS_VERSION: "8.30.1" - GITLEAKS_LINUX_X64_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} - -jobs: - rust: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - components: clippy, rustfmt - - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - - run: cargo fmt --all -- --check - - run: cargo build --workspace --all-targets --all-features - - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - - run: cargo test --workspace --all-targets --all-features - - coverage: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - - uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2 - with: - tool: cargo-llvm-cov@${{ env.CARGO_LLVM_COV_VERSION }} - - run: cargo llvm-cov --workspace --all-features --fail-under-lines 80 - - hygiene: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - run: ./scripts/check-hygiene-alignment.sh . . - - run: ./scripts/audit-configs.sh --base .. --format paths >/tmp/config-inventory.txt - - secrets: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - name: Install Gitleaks - run: | - mkdir -p "$RUNNER_TEMP/bin" - curl -sSfL \ - -o "$RUNNER_TEMP/gitleaks.tar.gz" \ - "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" - echo "${GITLEAKS_LINUX_X64_SHA256} $RUNNER_TEMP/gitleaks.tar.gz" | sha256sum -c - - tar -xzf "$RUNNER_TEMP/gitleaks.tar.gz" -C "$RUNNER_TEMP/bin" gitleaks - chmod +x "$RUNNER_TEMP/bin/gitleaks" - - name: Scan tracked files for secrets - env: - GITLEAKS_CONFIG_TOML: | - [extend] - useDefault = true - - [[allowlists]] - description = "Ignore generated Rust build output." - paths = ["(^|/)target/"] - - [[allowlists]] - description = "Allow published tough test signing keys vendored as fixtures, not credentials." - paths = [ - "^crates/registry-platform-config/tests/fixtures/tough-data/snakeoil\\.pem$", - "^crates/registry-platform-config/tests/fixtures/tough-data/snakeoil_2\\.pem$", - ] - - [[allowlists]] - description = "Allow synthetic test JWTs in fuzz corpus (example.test/issuer.example hosts, did:example subjects, repo test JWK only)." - paths = [ - "^fuzz/corpus/oid4vci_request_and_proof/valid-proof-jwt$", - "^fuzz/corpus/oid4vci_request_and_proof/credential_request\\.json$", - "^fuzz/corpus/sdjwt_holder_proof/holder_proof\\.jwt$", - "^fuzz/corpus/sdjwt_holder_proof/valid-holder-proof-jwt$", - ] - run: | - "$RUNNER_TEMP/bin/gitleaks" dir --no-banner --redact --verbose --timeout 120 . - - dependency-policy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - - uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2 - with: - tool: cargo-deny@${{ env.CARGO_DENY_VERSION }} - - run: cargo deny check diff --git a/products/platform/.github/workflows/fuzz.yml b/products/platform/.github/workflows/fuzz.yml deleted file mode 100644 index 4852282bb..000000000 --- a/products/platform/.github/workflows/fuzz.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: fuzz - -on: - pull_request: - paths: - - "crates/registry-platform-authcommon/**" - - "crates/registry-platform-crypto/**" - - "crates/registry-platform-oid4vci/**" - - "crates/registry-platform-sdjwt/**" - - "fuzz/**" - - ".github/workflows/fuzz.yml" - - "Cargo.toml" - - "Cargo.lock" - schedule: - - cron: "23 3 * * *" - workflow_dispatch: - -permissions: - contents: read - -env: - RUST_FUZZ_TOOLCHAIN: nightly - RUSTUP_TOOLCHAIN: nightly - CARGO_FUZZ_VERSION: "0.13.2" - -concurrency: - group: fuzz-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -jobs: - smoke: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - target: - - authcommon_parsers - - oid4vci_request_and_proof - - sdjwt_holder_proof - - sdjwt_issuance - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - with: - toolchain: ${{ env.RUST_FUZZ_TOOLCHAIN }} - - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - - uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2 - with: - tool: cargo-fuzz@${{ env.CARGO_FUZZ_VERSION }} - - name: Smoke fuzz target - run: | - export PATH="$HOME/.cargo/bin:$PATH" - mkdir -p "fuzz/artifacts/${{ matrix.target }}" - cargo fuzz run "${{ matrix.target }}" --target x86_64-unknown-linux-gnu -- \ - -max_total_time=60 \ - -rss_limit_mb=1024 \ - -artifact_prefix="fuzz/artifacts/${{ matrix.target }}/" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - if: failure() - with: - name: fuzz-crashes-${{ matrix.target }} - path: fuzz/artifacts/${{ matrix.target }}/ - if-no-files-found: ignore - - nightly: - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - target: - - authcommon_parsers - - oid4vci_request_and_proof - - sdjwt_holder_proof - - sdjwt_issuance - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - with: - toolchain: ${{ env.RUST_FUZZ_TOOLCHAIN }} - - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - - uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2 - with: - tool: cargo-fuzz@${{ env.CARGO_FUZZ_VERSION }} - - uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 - with: - path: fuzz/corpus/${{ matrix.target }} - key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }} - restore-keys: | - fuzz-corpus-${{ matrix.target }}- - - name: Run fuzz target - run: | - export PATH="$HOME/.cargo/bin:$PATH" - mkdir -p "fuzz/artifacts/${{ matrix.target }}" - cargo fuzz run "${{ matrix.target }}" --target x86_64-unknown-linux-gnu -- \ - -max_total_time=900 \ - -rss_limit_mb=2048 \ - -artifact_prefix="fuzz/artifacts/${{ matrix.target }}/" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - if: failure() - with: - name: fuzz-crashes-${{ matrix.target }} - path: fuzz/artifacts/${{ matrix.target }}/ - if-no-files-found: ignore - - uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 - if: always() - with: - path: fuzz/corpus/${{ matrix.target }} - key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }} diff --git a/products/platform/README.md b/products/platform/README.md index d7d5a6d66..82faf0ebf 100644 --- a/products/platform/README.md +++ b/products/platform/README.md @@ -75,10 +75,11 @@ The `registry-platform-httputil` crate uses `rustls` for outbound HTTPS. ### Toolchain pins - Rust: pinned in [`rust-toolchain.toml`](rust-toolchain.toml) (currently `1.95.0`). -- `cargo-deny`: install `0.19.7` or newer to match CI; older versions (≤ `0.14.x`) cannot parse the `[graph]` syntax used in [`deny.toml`](deny.toml). +- `cargo-deny`: install `0.19.8` to match CI; older versions (≤ `0.14.x`) + cannot parse the `[graph]` syntax used in [`deny.toml`](deny.toml). ```sh -cargo install --locked cargo-deny@0.19.7 +cargo install --locked cargo-deny@0.19.8 ``` ### Common commands @@ -87,21 +88,20 @@ Run checks from the workspace root: ```sh cargo fmt --check -cargo build --workspace --all-targets --all-features -cargo clippy --workspace --all-targets --all-features -- -D warnings -cargo test --workspace --all-targets --all-features +cargo build --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features +cargo clippy --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features -- -D warnings +cargo test --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features +cargo llvm-cov --locked -p registry-config-report -p 'registry-platform-*' --all-features --fail-under-lines 80 cargo deny check -GITLEAKS_CONFIG_TOML="$(cat <<'TOML' -[extend] -useDefault = true - -[[allowlists]] -description = "Ignore generated Rust build output." -paths = ["(^|/)target/"] -TOML -)" gitleaks dir --no-banner --redact --verbose --timeout 120 . +products/platform/scripts/check-hygiene-alignment.sh +products/platform/scripts/audit-configs.sh --check --format paths +gitleaks dir --config .gitleaks.toml --no-banner --redact --timeout 120 . ``` +CI pins `cargo-llvm-cov` 0.8.7, Gitleaks 8.30.1, and `cargo-fuzz` +0.13.2. Pull requests that change platform crates or their shared dependency +graph run the all-feature, coverage, and one-minute-per-target fuzz gates. + For focused work on one crate: ```sh @@ -111,8 +111,9 @@ cargo test -p registry-platform-oidc ## Repository Files - [`CHANGELOG.md`](CHANGELOG.md) records release-level changes. -- [`deny.toml`](deny.toml) defines dependency, license, advisory, and source - policy. +- The root [`deny.toml`](../../deny.toml) defines dependency, license, + advisory, and source policy for the monorepo dependency graph. The platform + copy remains a reusable consumer template. - [`rustfmt.toml`](rustfmt.toml) and [`clippy.toml`](clippy.toml) define shared Rust hygiene defaults. diff --git a/products/platform/docs/SECURITY_PRINCIPLES.md b/products/platform/docs/SECURITY_PRINCIPLES.md index c2000e834..28a894247 100644 --- a/products/platform/docs/SECURITY_PRINCIPLES.md +++ b/products/platform/docs/SECURITY_PRINCIPLES.md @@ -54,7 +54,13 @@ Audit chain verification (`registry_platform_audit::verify_chain`) is a consiste ## 10. Keep Workspace Hygiene Canonical -`clippy.toml`, `rustfmt.toml`, and `deny.toml` in consumer repos come from `registry-platform/templates/`. Consumer CI must run `scripts/check-hygiene-alignment.sh` so lint, formatting, dependency license policy, and advisory posture do not drift. +Reusable `clippy.toml`, `rustfmt.toml`, and `deny.toml` defaults live in +`products/platform/templates/`. Root CI runs +`products/platform/scripts/check-hygiene-alignment.sh` to keep the shared lint +and formatting files aligned and the platform dependency-policy template +self-consistent. The root, Relay, Manifest, and Notary `deny.toml` files govern +different dependency graphs and are reviewed as product-specific policies; +they are not required to be byte-for-byte copies of the platform template. ## Telemetry Convention For v0.1.0 diff --git a/products/platform/docs/config-drift-inventory.md b/products/platform/docs/config-drift-inventory.md index 7a263f048..12407bafd 100644 --- a/products/platform/docs/config-drift-inventory.md +++ b/products/platform/docs/config-drift-inventory.md @@ -1,101 +1,36 @@ -# Config Drift Inventory +# Registry configuration inventory -This inventory tracks files likely to change during the big-bang migration to `registry-platform v0.1.0`. The authoritative file list is generated by: +The Registry Stack monorepo keeps a lightweight inventory of Relay operator, +demo, performance, profile, and parser-test configuration. It helps reviewers +find configuration surfaces that may need coordinated changes when a shared +platform type or security invariant changes. + +Run the inventory from the monorepo root: ```sh -scripts/audit-configs.sh --base .. +products/platform/scripts/audit-configs.sh --check --format markdown ``` -Use `--check` in CI to fail when no consumer config roots are found. - -## Notary Drift - -Notary has breaking config changes: - -| Area | Required change | -| --- | --- | -| Static auth | `auth.api_keys[].token_env` becomes `auth.api_keys[].hash_env`. | -| Bearer auth | `auth.bearer_tokens[].token_env` becomes `auth.bearer_tokens[].hash_env`. | -| Auth mode | Add `auth.mode = "oidc"` or `auth.mode = "api_key"`. | -| Audit | Add `audit.hash_secret_env`; fail closed when the env var is absent outside explicit dev fixtures. | -| Admin | Add the admin reload block and scopes needed by `POST /admin/reload`. | -| HTTP security | Adopt CORS, CSP, CORP, body-limit, and RFC 9457 Problem Details settings where exposed by config. | -| Fixtures | Update demo config, perf config, and server test fixtures atomically. | - -Current expected Notary roots: - -| Root | Purpose | -| --- | --- | -| `registry-notary/demo/config/` | Demo operator config. | -| `registry-notary/perf/config/` | Performance-smoke configs. | -| `registry-notary/crates/registry-notary-server/tests/` | Server integration fixtures, when present. | - -## Relay Drift - -Relay has no schema rename planned for Track 1. The likely drift is fixture and construction churn: +The `--check` gate fails when an expected configuration root moves or +disappears without the inventory being updated. The generated output is the +authoritative current file list, so this document does not carry a dated copy +that can silently become stale. -| Area | Required change | -| --- | --- | -| OIDC fixtures | Tests that construct internal OIDC verifier/fetcher types move to `registry-platform-oidc`. | -| Audit fixtures | Tests that inspect chain envelopes move to `registry-platform-audit` types. | -| Workspace hygiene | `clippy.toml`, `rustfmt.toml`, and `deny.toml` must match platform templates byte-for-byte. | -| SD-JWT fixtures | Holder-proof and issuance fixtures must reflect platform invariants, including `vct`, header `kid`, holder `cnf.kid`, and `jti == credential_id`. | +The inventory annotates files that mention static authentication, fingerprints, +OIDC, audit configuration, or SD-JWT credentials. Those annotations are review +prompts, not proof that the configuration is secure or migration-complete. -Current expected Relay roots: +The current roots are: -| Root | Purpose | +| Surface | Monorepo path | | --- | --- | -| `registry-relay/config/` | Operator examples. | -| `registry-relay/demo/config/` | Demo registry configs. | -| `registry-relay/tests/fixtures/config/` | Config parser and validation fixtures. | - -## Review Procedure - -1. Run `scripts/audit-configs.sh --base ..`. -2. For every row marked `token_env`, update the file or remove the obsolete fixture. -3. For every row marked `oidc`, verify the fixture still exercises the intended platform verifier behavior. -4. For every row under a hygiene root, run `scripts/check-hygiene-alignment.sh` in the consumer repo. -5. Commit config fixture updates with the consumer migration, not with the platform tag, unless the file lives in this repo. - -## Current Generated Inventory - -Generated on 2026-05-24 with `scripts/audit-configs.sh --check --base .. --format markdown`. - -| App | Category | File | Notes | -| --- | --- | --- | --- | -| notary | demo config | `registry-notary/demo/config/registry-notary.yaml` | static-auth,token_env->hash_env,fingerprint,oidc,audit,sd-jwt | -| notary | perf config | `registry-notary/perf/config/medium.yaml` | static-auth,token_env->hash_env,fingerprint,oidc,audit,sd-jwt | -| notary | perf config | `registry-notary/perf/config/small.yaml` | static-auth,token_env->hash_env,fingerprint,oidc,audit,sd-jwt | -| relay | operator examples | `registry-relay/config/example.oidc.yaml` | oidc,audit,sd-jwt | -| relay | operator examples | `registry-relay/config/example.yaml` | static-auth,fingerprint,oidc,audit,sd-jwt | -| relay | operator examples | `registry-relay/config/spdci_disability_registry.example.yaml` | static-auth,fingerprint,audit | -| relay | demo config | `registry-relay/demo/config/all_demos.metadata.yaml` | audit | -| relay | demo config | `registry-relay/demo/config/all_demos.yaml` | static-auth,fingerprint,audit | -| relay | demo config | `registry-relay/demo/config/all_standards.metadata.yaml` | audit | -| relay | demo config | `registry-relay/demo/config/all_standards.yaml` | static-auth,fingerprint,audit | -| relay | demo config | `registry-relay/demo/config/benefits_casework.metadata.yaml` | review | -| relay | demo config | `registry-relay/demo/config/benefits_casework.yaml` | static-auth,fingerprint,audit | -| relay | demo config | `registry-relay/demo/config/clinic_capacity.metadata.yaml` | review | -| relay | demo config | `registry-relay/demo/config/clinic_capacity.yaml` | static-auth,fingerprint,audit | -| relay | demo config | `registry-relay/demo/config/disability_registry.metadata.yaml` | review | -| relay | demo config | `registry-relay/demo/config/disability_registry.yaml` | static-auth,fingerprint,audit | -| relay | demo config | `registry-relay/demo/config/education_registry.metadata.yaml` | review | -| relay | demo config | `registry-relay/demo/config/education_registry.yaml` | static-auth,fingerprint,audit | -| relay | demo config | `registry-relay/demo/config/evidence_registries.metadata.yaml` | sd-jwt | -| relay | demo config | `registry-relay/demo/config/evidence_registries.yaml` | static-auth,fingerprint,audit | -| relay | demo config | `registry-relay/demo/config/public_works_projects.metadata.yaml` | review | -| relay | demo config | `registry-relay/demo/config/public_works_projects.yaml` | static-auth,fingerprint,audit | -| relay | demo config | `registry-relay/demo/config/subject_registry.metadata.yaml` | audit | -| relay | demo config | `registry-relay/demo/config/subject_registry.yaml` | static-auth,fingerprint,audit | -| relay | config test fixtures | `registry-relay/tests/fixtures/config/allowed_filter_unknown_field.yaml` | static-auth,fingerprint,audit | -| relay | config test fixtures | `registry-relay/tests/fixtures/config/duplicate_dataset_id.yaml` | static-auth,fingerprint,audit | -| relay | config test fixtures | `registry-relay/tests/fixtures/config/interval_refresh.yaml` | static-auth,fingerprint,audit | -| relay | config test fixtures | `registry-relay/tests/fixtures/config/invalid_api_key_fingerprint.yaml` | static-auth,fingerprint,audit | -| relay | config test fixtures | `registry-relay/tests/fixtures/config/invalid_authority_type.yaml` | static-auth,fingerprint,audit | -| relay | config test fixtures | `registry-relay/tests/fixtures/config/invalid_dataset_spatial_coverage.yaml` | static-auth,fingerprint,audit | -| relay | config test fixtures | `registry-relay/tests/fixtures/config/invalid_default_spatial_coverage.yaml` | static-auth,fingerprint,audit | -| relay | config test fixtures | `registry-relay/tests/fixtures/config/invalid_publisher_iri.yaml` | static-auth,fingerprint,audit | -| relay | config test fixtures | `registry-relay/tests/fixtures/config/invalid_scope.yaml` | static-auth,fingerprint,audit | -| relay | config test fixtures | `registry-relay/tests/fixtures/config/missing_env.yaml` | static-auth,fingerprint,audit | -| relay | config test fixtures | `registry-relay/tests/fixtures/config/unknown_field.yaml` | static-auth,audit | -| relay | config test fixtures | `registry-relay/tests/fixtures/config/unknown_vocab_prefix.yaml` | static-auth,fingerprint,audit | +| Relay operator examples | `crates/registry-relay/config/` | +| Relay demos | `crates/registry-relay/demo/config/` | +| Relay performance fixtures | `crates/registry-relay/perf/config/` | +| Relay integration profiles | `crates/registry-relay/profiles/` | +| Relay parser fixtures | `crates/registry-relay/tests/fixtures/config/` | + +Notary currently has no equivalent checked-in operator configuration tree. +Its product policy, API contracts, test vectors, and deployment configuration +are verified by their owning root CI gates rather than being mislabeled as a +platform config inventory. diff --git a/products/platform/docs/versioning.md b/products/platform/docs/versioning.md index a99363092..01e5ba00e 100644 --- a/products/platform/docs/versioning.md +++ b/products/platform/docs/versioning.md @@ -1,55 +1,44 @@ -# Versioning - -`registry-platform` is released as one workspace tag and consumed by Registry Relay and Registry Notary through that tag. - -## Tag Policy - -- Tags use `vMAJOR.MINOR.PATCH`. -- Initial release target: `v0.1.0`. -- Consumers pin git tags, not branches. -- All crates in this workspace share the same version in `[workspace.package]`. -- A tag is not cut until CI is green for format, clippy, build, tests, dependency policy, secret scanning, and hygiene checks. - -## Semver Before 1.0 - -Before `v1.0.0`, minor bumps may contain breaking API or config changes. Patch bumps must be backward compatible for the latest minor line. - -Examples: - -- `v0.1.1` can fix `FetchUrlPolicy` behavior without changing public signatures. -- `v0.2.0` can change a public type or consumer config migration requirement. -- Backports to an older minor line happen only when Jeremi explicitly opens a backport lane. - -## v0.1.0 Algorithm Scope - -Platform-owned signing and verification supports EdDSA with Ed25519 JWKs. ES256, -RS256, PS256, and other JWK algorithms are intentionally rejected as unsupported -until a production consumer needs them. This keeps the first tag narrow while -making unsupported algorithms fail closed instead of silently falling back. -OIDC JWT verification is separate from platform-owned signing and remains -caller-configurable so consumers can interoperate with providers that do not -offer EdDSA, as long as their algorithm allowlist is explicit. - -## Consumer Alignment - -Relay and Notary must pin the same `registry-platform` tag during coordinated migrations. CI in each consumer should fail when: - -- The pinned platform tag differs from the approved migration tag. -- `clippy.toml`, `rustfmt.toml`, or `deny.toml` differs from `registry-platform/templates/`. -- A consumer keeps duplicate in-tree security primitives that the migration DoD says should be removed. - -## Release Checklist - -1. Update `CHANGELOG.md` with breaking changes, migration notes, and security fixes. -2. Regenerate the config drift inventory with `scripts/audit-configs.sh --base ..`. -3. Run the Track 0 verification commands from the spec: - - `cargo build --workspace --all-targets --all-features` - - `cargo clippy --workspace --all-targets --all-features -- -D warnings` - - `cargo install cargo-deny --version 0.19.7 --locked` - - `cargo deny check` - - `cargo test --workspace --all-targets --all-features` - - `gitleaks dir --no-banner --redact --verbose --timeout 120 .` with the CI `target/` allowlist -4. Confirm `scripts/check-hygiene-alignment.sh . .` is green. -5. Confirm consumer PRs are ready to pin the new tag. -6. Create and push the signed tag. -7. Land consumer tag bumps and publish operator-facing migration notes. +# Platform versioning in the Registry Stack monorepo + +The `registry-platform-*` crates and `registry-config-report` are workspace +members of Registry Stack. They share the version in the root `Cargo.toml`, are +verified from the root, and are released with the Registry Stack source tree. +There is no separate platform workspace tag or consumer pin to coordinate +inside this repository. + +## Release policy + +- Workspace release tags use the Registry Stack release version. +- All published platform crates use the root `[workspace.package]` version. +- A release candidate is not promoted until the active root workflows are + green for formatting, normal workspace checks, platform all-feature checks, + platform line coverage, dependency policy, secret scanning, hygiene + alignment, and the relevant fuzz smoke. +- Before 1.0, minor releases may include documented breaking API or config + changes. Patch releases remain compatible with the latest minor line. +- Backports to an older minor line require an explicitly opened backport lane. + +Platform-owned signing and verification supports the algorithms documented in +the public Registry Stack security and compatibility specifications. OIDC JWT +verification remains a separate caller-configurable trust surface, and callers +must use an explicit algorithm allowlist. + +## Release checks + +Run from the monorepo root: + +```sh +cargo fmt --check +cargo build --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features +cargo clippy --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features -- -D warnings +cargo test --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features +cargo llvm-cov --locked -p registry-config-report -p 'registry-platform-*' --all-features --fail-under-lines 80 +cargo deny check +products/platform/scripts/check-hygiene-alignment.sh +products/platform/scripts/audit-configs.sh --check --format paths +gitleaks dir --config .gitleaks.toml --no-banner --redact --timeout 120 . +``` + +CI pins the non-Rust tools and verifies the same commands. The nightly workflow +runs the longer fuzz campaign; pull requests that affect the platform surface +run a bounded 60-second smoke for each platform fuzz target. diff --git a/products/platform/fuzz/README.md b/products/platform/fuzz/README.md index 52c6c3e5b..23919208e 100644 --- a/products/platform/fuzz/README.md +++ b/products/platform/fuzz/README.md @@ -21,6 +21,8 @@ product type. ## Running locally +From `products/platform/`: + ```bash cargo +nightly fuzz run --fuzz-dir fuzz -- -max_total_time=60 -rss_limit_mb=1024 ``` @@ -40,27 +42,16 @@ filename. ## CI wiring -`.github/workflows/nightly-security.yml` runs a smoke pass for every committed -target when the nightly security workflow runs. The job uses the nightly -toolchain, `cargo-fuzz` 0.13.2, `-max_total_time=60`, `-rss_limit_mb=1024`, and -uploads `fuzz/artifacts/` on failure. - -Issue #26 tracks the fuller crash/corpus regression pattern (persisted corpus -plus previous-crash replay) for the manifest fuzz work and shared CI shape. Once -that pattern lands, the intended shape is: +The active root workflows provide two event-specific checks: -- **Per-PR smoke** (fast, required): for each target, - `cargo +nightly fuzz run --fuzz-dir fuzz -- -max_total_time=60 -rss_limit_mb=1024` - against the committed seed corpus only, no persisted state. Catches build - breakage and obvious crashes on every PR that touches a fuzzed crate. -- **Nightly long run** (scheduled, best-effort): each target run for longer - (10-30 minutes) against a corpus directory persisted across runs, so - coverage accumulates instead of resetting every run. On crash, upload the - crash artifact and the minimized failing input as a workflow artifact and - fail the run loudly. Do not auto-file public issues from a fuzz crash; a - crash in these boundaries may be a security finding and should route - through `SECURITY.md` like any other suspected vulnerability. +- `.github/workflows/ci.yml` runs a required one-minute smoke for each target + when a pull request changes a platform crate, fuzz harness, or shared Cargo + dependency input. +- `.github/workflows/nightly-security.yml` runs the platform smoke as part of + the scheduled security suite. -The nightly smoke is not a replacement for the persisted-corpus regression -track; it proves the committed targets and corpora keep building and do not -crash immediately. +Both use the nightly toolchain, pinned `cargo-fuzz` 0.13.2, the committed seed +corpus, `-max_total_time=60`, and `-rss_limit_mb=1024`, and upload crash +artifacts only on failure. A crash at these trust boundaries may be a security +finding and should route through `SECURITY.md`; automation must not file a +public issue containing the input. diff --git a/products/platform/scripts/audit-configs.sh b/products/platform/scripts/audit-configs.sh index e5e7dc41b..9c53d5b6d 100755 --- a/products/platform/scripts/audit-configs.sh +++ b/products/platform/scripts/audit-configs.sh @@ -3,21 +3,22 @@ set -euo pipefail usage() { cat <<'USAGE' -Usage: scripts/audit-configs.sh [--base DIR] [--format markdown|tsv|paths] [--check] +Usage: products/platform/scripts/audit-configs.sh [--base DIR] [--format markdown|tsv|paths] [--check] -Enumerates Registry Notary and Registry Relay config files that are likely to -drift during the registry-platform v0.1.0 migration. +Enumerates Registry Relay configuration surfaces governed alongside the +shared platform crates in the Registry Stack monorepo. Defaults: - --base .. parent directory containing registry-notary/relay + --base REPO_ROOT detected from this script's location --format markdown human-readable inventory table Flags: - --check exit non-zero when no expected consumer roots exist + --check exit non-zero when an expected config root is missing USAGE } -base=".." +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +base="$(cd "${script_dir}/../../.." && pwd)" format="markdown" check=false @@ -58,15 +59,15 @@ esac base="${base%/}" declare -a roots=( - "notary|demo config|registry-notary/demo/config" - "notary|perf config|registry-notary/perf/config" - "notary|server test fixtures|registry-notary/crates/registry-notary-server/tests" - "relay|operator examples|registry-relay/config" - "relay|demo config|registry-relay/demo/config" - "relay|config test fixtures|registry-relay/tests/fixtures/config" + "relay|operator examples|crates/registry-relay/config" + "relay|demo config|crates/registry-relay/demo/config" + "relay|performance config|crates/registry-relay/perf/config" + "relay|integration profiles|crates/registry-relay/profiles" + "relay|config test fixtures|crates/registry-relay/tests/fixtures/config" ) found_root=false +missing_roots=() rows=() notes_for_file() { @@ -105,6 +106,7 @@ for root_spec in "${roots[@]}"; do abs_root="$base/$rel_root" if [[ ! -d "$abs_root" ]]; then + missing_roots+=("$rel_root") continue fi @@ -123,9 +125,16 @@ for root_spec in "${roots[@]}"; do ) done -if [[ "$check" == true && "$found_root" == false ]]; then - echo "no registry-notary or registry-relay config roots found under $base" >&2 - exit 1 +if [[ "$check" == true ]]; then + if [[ "$found_root" == false ]]; then + echo "no Registry Relay config roots found under $base" >&2 + exit 1 + fi + if [[ ${#missing_roots[@]} -gt 0 ]]; then + echo "expected monorepo config roots are missing under $base:" >&2 + printf ' %s\n' "${missing_roots[@]}" >&2 + exit 1 + fi fi case "$format" in diff --git a/products/platform/scripts/check-hygiene-alignment.sh b/products/platform/scripts/check-hygiene-alignment.sh index f8106207c..9ace10c71 100755 --- a/products/platform/scripts/check-hygiene-alignment.sh +++ b/products/platform/scripts/check-hygiene-alignment.sh @@ -1,12 +1,43 @@ #!/usr/bin/env bash set -euo pipefail -root="${1:-.}" -platform="${2:-${REGISTRY_PLATFORM_DIR:-../registry-platform}}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="${1:-$(cd "${script_dir}/../../.." && pwd)}" +platform_root="${2:-${repo_root}/products/platform}" +template_root="${platform_root}/templates" -for file in clippy.toml rustfmt.toml deny.toml; do - cmp -s "$root/$file" "$platform/templates/$file" || { - echo "$file differs from registry-platform/templates/$file" >&2 +compare() { + local actual="$1" + local expected="$2" + local label="$3" + + cmp -s "$actual" "$expected" || { + echo "$label differs from ${expected#"${repo_root}/"}" >&2 exit 1 } +} + +for file in clippy.toml rustfmt.toml; do + compare \ + "${repo_root}/${file}" \ + "${template_root}/${file}" \ + "${file}" + compare \ + "${repo_root}/crates/registry-relay/${file}" \ + "${template_root}/${file}" \ + "crates/registry-relay/${file}" + compare \ + "${platform_root}/${file}" \ + "${template_root}/${file}" \ + "products/platform/${file}" done + +# Dependency policy is workspace-specific in the monorepo. Keep the reusable +# platform copy aligned with its published template, while the root, Relay, +# Manifest, and Notary policies remain free to describe their actual graphs. +compare \ + "${platform_root}/deny.toml" \ + "${template_root}/deny.toml" \ + "products/platform/deny.toml" + +echo "shared Rust hygiene files are aligned" diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index fd7d1dae2..80b1cb734 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -3,6 +3,7 @@ from __future__ import annotations +import subprocess import sys from pathlib import Path @@ -25,6 +26,58 @@ "run: cargo test --locked -p registry-relay --all-features", ), ("Cargo deny", "run: cargo deny check"), + ( + "Platform path filter", + "platform: ${{ steps.filter.outputs.platform }}", + ), + ( + "Config report platform path", + "crates/registry-config-report/*|crates/registry-platform-*", + ), + ( + "Platform hygiene path filter", + "platform_hygiene: ${{ steps.filter.outputs.platform_hygiene }}", + ), + ( + "Platform all-features build", + "run: cargo build --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features", + ), + ( + "Platform all-features clippy", + "run: cargo clippy --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features -- -D warnings", + ), + ( + "Platform all-features tests", + "run: cargo test --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features", + ), + ("Platform coverage job", "platform-coverage:"), + ("Platform coverage version pin", 'CARGO_LLVM_COV_VERSION: "0.8.7"'), + ("Platform coverage threshold", "--fail-under-lines 80"), + ( + "Config report platform coverage", + "cargo llvm-cov --locked\n -p registry-config-report\n -p 'registry-platform-*'", + ), + ( + "Platform hygiene alignment", + "run: products/platform/scripts/check-hygiene-alignment.sh", + ), + ( + "Platform config inventory", + "products/platform/scripts/audit-configs.sh", + ), + ("Platform config inventory check", "--check"), + ("Secret scan job", "secrets:"), + ("Gitleaks version pin", 'GITLEAKS_VERSION: "8.30.1"'), + ("Gitleaks archive checksum", "GITLEAKS_LINUX_X64_SHA256:"), + ("Gitleaks root config", "--config .gitleaks.toml"), + ("Gitleaks redaction", "--redact"), + ("Platform fuzz job", "platform-fuzz:"), + ("Platform fuzz version pin", 'CARGO_FUZZ_VERSION: "0.13.2"'), + ("Platform fuzz bounded runtime", "-max_total_time=60"), + ( + "Platform fuzz directory", + "cargo +nightly fuzz run --fuzz-dir fuzz", + ), ("Notary OpenAPI baseline", "run: just openapi-check"), ("Notary OpenAPI contract", "name: Notary OpenAPI contract"), ("Notary exposure check", "name: Notary exposure check"), @@ -129,15 +182,54 @@ def missing_gates(workflow_text: str) -> list[str]: return [name for name, snippet in REQUIRED_GATES if snippet not in workflow_text] +def nested_workflow_paths(paths: list[str]) -> list[str]: + """Return tracked workflows that GitHub cannot run from the repository root.""" + + return sorted( + path + for path in paths + if "/.github/workflows/" in f"/{path}" + and not path.startswith(".github/workflows/") + ) + + +def tracked_paths(root: Path) -> list[str]: + completed = subprocess.run( + ["git", "ls-files", "-z"], + cwd=root, + check=True, + capture_output=True, + ) + return [ + path.decode("utf-8") + for path in completed.stdout.split(b"\0") + if path + ] + + def main() -> int: workflow_text = CI_WORKFLOW.read_text(encoding="utf-8") missing = missing_gates(workflow_text) + nested = nested_workflow_paths(tracked_paths(ROOT)) + if missing or nested: + print("gate inventory check failed", file=sys.stderr) if missing: - print("gate inventory check failed: missing CI wiring", file=sys.stderr) + print("missing root CI wiring:", file=sys.stderr) for gate in missing: print(f"- {gate}", file=sys.stderr) + if nested: + print( + "nested workflows are inert and must move to root CI or be removed:", + file=sys.stderr, + ) + for path in nested: + print(f"- {path}", file=sys.stderr) + if missing or nested: return 1 - print(f"gate inventory check passed for {len(REQUIRED_GATES)} gates") + print( + f"gate inventory check passed for {len(REQUIRED_GATES)} gates; " + "no inert nested workflows" + ) return 0 diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index ca9936058..8086ad731 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib.util +import tomllib import unittest from pathlib import Path @@ -25,14 +26,128 @@ def setUp(self) -> None: self.workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text( encoding="utf-8" ) + self.gitleaks_config = (ROOT / ".gitleaks.toml").read_text(encoding="utf-8") + parsed_gitleaks = tomllib.loads(self.gitleaks_config) + self.gitleaks_paths = { + path + for allowlist in parsed_gitleaks["allowlists"] + for path in allowlist.get("paths", []) + } def test_real_ci_workflow_declares_inventory(self) -> None: self.assertEqual([], self.module.missing_gates(self.workflow)) + def test_real_repository_has_no_tracked_nested_workflows(self) -> None: + self.assertEqual( + [], + self.module.nested_workflow_paths(self.module.tracked_paths(ROOT)), + ) + + def test_root_workflows_are_allowed(self) -> None: + self.assertEqual( + [], + self.module.nested_workflow_paths( + [ + ".github/workflows/ci.yml", + ".github/workflows/release.yml", + ] + ), + ) + + def test_nested_workflow_is_reported(self) -> None: + self.assertEqual( + ["products/example/.github/workflows/ci.yml"], + self.module.nested_workflow_paths( + [ + ".github/workflows/ci.yml", + "products/example/.github/workflows/ci.yml", + ] + ), + ) + def test_missing_relay_exposure_gate_is_reported(self) -> None: text = self.workflow.replace("name: Relay exposure check", "name: Relay exposure") self.assertIn("Relay exposure check", self.module.missing_gates(text)) + def test_missing_platform_all_features_gate_is_reported(self) -> None: + text = self.workflow.replace( + "cargo test --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features", + "cargo test --locked -p registry-config-report -p 'registry-platform-*' --all-targets", + ) + self.assertIn( + "Platform all-features tests", self.module.missing_gates(text) + ) + + def test_missing_config_report_platform_path_is_reported(self) -> None: + text = self.workflow.replace( + "crates/registry-config-report/*|crates/registry-platform-*", + "crates/registry-platform-*", + ) + self.assertIn("Config report platform path", self.module.missing_gates(text)) + + def test_missing_config_report_platform_test_is_reported(self) -> None: + text = self.workflow.replace( + "cargo test --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features", + "cargo test --locked -p 'registry-platform-*' --all-targets --all-features", + ) + self.assertIn( + "Platform all-features tests", self.module.missing_gates(text) + ) + + def test_missing_config_report_platform_build_is_reported(self) -> None: + text = self.workflow.replace( + "cargo build --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features", + "cargo build --locked -p 'registry-platform-*' --all-targets --all-features", + ) + self.assertIn( + "Platform all-features build", self.module.missing_gates(text) + ) + + def test_missing_config_report_platform_clippy_is_reported(self) -> None: + text = self.workflow.replace( + "cargo clippy --locked -p registry-config-report -p 'registry-platform-*' --all-targets --all-features -- -D warnings", + "cargo clippy --locked -p 'registry-platform-*' --all-targets --all-features -- -D warnings", + ) + self.assertIn( + "Platform all-features clippy", self.module.missing_gates(text) + ) + + def test_missing_platform_coverage_threshold_is_reported(self) -> None: + text = self.workflow.replace("--fail-under-lines 80", "--summary-only") + self.assertIn("Platform coverage threshold", self.module.missing_gates(text)) + + def test_missing_config_report_platform_coverage_is_reported(self) -> None: + text = self.workflow.replace( + "cargo llvm-cov --locked\n -p registry-config-report\n -p 'registry-platform-*'", + "cargo llvm-cov --locked\n -p 'registry-platform-*'", + ) + self.assertIn( + "Config report platform coverage", self.module.missing_gates(text) + ) + + def test_missing_secret_scan_redaction_is_reported(self) -> None: + text = self.workflow.replace("--redact", "--verbose") + self.assertIn("Gitleaks redaction", self.module.missing_gates(text)) + + def test_root_secret_scan_names_all_synthetic_platform_jwt_fixtures(self) -> None: + for fixture_path in ( + r"^products/platform/fuzz/corpus/oid4vci_request_and_proof/credential_request\.json$", + r"^products/platform/fuzz/corpus/oid4vci_request_and_proof/valid-proof-jwt$", + r"^products/platform/fuzz/corpus/sdjwt_holder_proof/holder_proof\.jwt$", + r"^products/platform/fuzz/corpus/sdjwt_holder_proof/valid-holder-proof-jwt$", + ): + with self.subTest(fixture_path=fixture_path): + self.assertIn(fixture_path, self.gitleaks_paths) + + def test_root_secret_scan_does_not_keep_pre_monorepo_fuzz_paths(self) -> None: + self.assertFalse(any(path.startswith("^fuzz/") for path in self.gitleaks_paths)) + + def test_missing_platform_fuzz_bound_is_reported(self) -> None: + text = self.workflow.replace("-max_total_time=60", "-runs=0") + self.assertIn( + "Platform fuzz bounded runtime", self.module.missing_gates(text) + ) + def test_missing_registryctl_tutorial_execution_is_reported(self) -> None: text = self.workflow.replace( "run: npm run check:tutorial:registryctl", From 6067918b514277e4ef8bdfa4155110bb4f94b72d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 21:59:52 +0700 Subject: [PATCH 60/65] chore(registryctl): use workspace YAML parser Signed-off-by: Jeremi Joslin --- CONTRIBUTING.md | 4 + Cargo.lock | 2 +- Cargo.toml | 1 - crates/registryctl/CHANGELOG.md | 6 + crates/registryctl/Cargo.toml | 2 +- crates/registryctl/src/lib.rs | 71 ++-- crates/registryctl/src/main.rs | 11 +- .../src/project_authoring/commands.rs | 20 +- .../project_authoring/compiler/artifacts.rs | 4 +- .../src/project_authoring/compiler/notary.rs | 2 +- .../src/project_authoring/diagnostics.rs | 6 +- .../src/project_authoring/fixtures.rs | 2 +- .../src/project_authoring/output.rs | 14 +- .../src/project_authoring/tests.rs | 40 +- crates/registryctl/tests/project_authoring.rs | 349 +++++++++--------- 15 files changed, 276 insertions(+), 258 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index baba9d731..76656a089 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,6 +72,10 @@ Git dependencies require an immutable commit pin, a rationale, and a documented review trigger. Crosswalk's current pin rationale lives in [`external/README.md`](external/README.md). +For new Rust YAML parsing or serialization, use the workspace-pinned +`serde_norway` dependency. Do not introduce a direct `serde_yaml` dependency +without an approved compatibility or security rationale. + ## Scope Keep changes focused on the owning area: diff --git a/Cargo.lock b/Cargo.lock index 6122e70a0..c2457e0d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5755,7 +5755,7 @@ dependencies = [ "rustix", "serde", "serde_json", - "serde_yaml", + "serde_norway", "sha2 0.11.0", "tempfile", "time", diff --git a/Cargo.toml b/Cargo.toml index 0a95998a9..1bfa5590f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -135,7 +135,6 @@ schemars = { version = "=1.2.1" } serde_norway = { version = "0.9" } serde_path_to_error = { version = "0.1" } serde-saphyr = { version = "0.0.26" } -serde_yaml = { version = "0.9" } serde_yaml_ng = { version = "0.10.0" } sha2 = { version = "0.11" } subtle = { version = "2" } diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md index 0842d6a5a..8dd00ece8 100644 --- a/crates/registryctl/CHANGELOG.md +++ b/crates/registryctl/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Changed + +- Registryctl now uses the workspace-pinned `serde_norway` YAML parser and + serializer, consistent with the supported configuration boundary used by + Relay and Notary. + ## [0.11.0] - 2026-07-18 ### Added diff --git a/crates/registryctl/Cargo.toml b/crates/registryctl/Cargo.toml index 24df7af69..9b609033b 100644 --- a/crates/registryctl/Cargo.toml +++ b/crates/registryctl/Cargo.toml @@ -35,7 +35,7 @@ regex = "1" rustix.workspace = true serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_yaml = "0.9" +serde_norway.workspace = true sha2.workspace = true time = { workspace = true } tokio = { workspace = true, features = ["io-util", "process"] } diff --git a/crates/registryctl/src/lib.rs b/crates/registryctl/src/lib.rs index 733049205..ec26fd3c0 100644 --- a/crates/registryctl/src/lib.rs +++ b/crates/registryctl/src/lib.rs @@ -1637,7 +1637,7 @@ fn relay_doctor_config_path( let raw = fs::read_to_string(&config_path) .with_context(|| format!("failed to read {}", config_path.display()))?; - let mut value: serde_yaml::Value = serde_yaml::from_str(&raw) + let mut value: serde_norway::Value = serde_norway::from_str(&raw) .with_context(|| format!("failed to parse {}", config_path.display()))?; if let Some(metadata) = &relay.metadata { @@ -1653,18 +1653,19 @@ fn relay_doctor_config_path( fs::create_dir_all(&output_dir) .with_context(|| format!("failed to create {}", output_dir.display()))?; let doctor_config = output_dir.join("relay.config.yaml"); - let rendered = serde_yaml::to_string(&value).context("failed to render Relay doctor config")?; + let rendered = + serde_norway::to_string(&value).context("failed to render Relay doctor config")?; write_text(doctor_config.clone(), &rendered)?; Ok(doctor_config) } -fn set_yaml_path_string(value: &mut serde_yaml::Value, path: &[&str], replacement: String) { +fn set_yaml_path_string(value: &mut serde_norway::Value, path: &[&str], replacement: String) { let mut current = value; for segment in &path[..path.len().saturating_sub(1)] { - let serde_yaml::Value::Mapping(map) = current else { + let serde_norway::Value::Mapping(map) = current else { return; }; - let key = serde_yaml::Value::String((*segment).to_string()); + let key = serde_norway::Value::String((*segment).to_string()); let Some(next) = map.get_mut(&key) else { return; }; @@ -1673,21 +1674,21 @@ fn set_yaml_path_string(value: &mut serde_yaml::Value, path: &[&str], replacemen let Some(last) = path.last() else { return; }; - if let serde_yaml::Value::Mapping(map) = current { + if let serde_norway::Value::Mapping(map) = current { map.insert( - serde_yaml::Value::String((*last).to_string()), - serde_yaml::Value::String(replacement), + serde_norway::Value::String((*last).to_string()), + serde_norway::Value::String(replacement), ); } } fn rewrite_relay_container_data_paths( - value: &mut serde_yaml::Value, + value: &mut serde_norway::Value, project_dir: &Path, relay: &ProjectRelay, ) { match value { - serde_yaml::Value::String(text) => { + serde_norway::Value::String(text) => { const PREFIX: &str = "/var/lib/registry-relay/data/"; if let Some(relative) = text.strip_prefix(PREFIX) { let host_path = relay @@ -1699,12 +1700,12 @@ fn rewrite_relay_container_data_paths( *text = project_dir.join(host_path).display().to_string(); } } - serde_yaml::Value::Sequence(items) => { + serde_norway::Value::Sequence(items) => { for item in items { rewrite_relay_container_data_paths(item, project_dir, relay); } } - serde_yaml::Value::Mapping(map) => { + serde_norway::Value::Mapping(map) => { for value in map.values_mut() { rewrite_relay_container_data_paths(value, project_dir, relay); } @@ -2006,7 +2007,7 @@ pub fn add_notary_to_project( claim_file: PathBuf::from(NOTARY_CLAIM_FILE), workload_token: PathBuf::from(NOTARY_RELAY_TOKEN_PATH), }); - let manifest = serde_yaml::to_string(&project) + let manifest = serde_norway::to_string(&project) .context("failed to render registryctl manifest with Notary")?; write_text(project_dir.join("registryctl.yaml"), &manifest)?; Ok(()) @@ -2308,38 +2309,38 @@ fn merge_notary_compose(project_dir: &Path, image_lock: &RegistryctlImageLock) - let path = project_dir.join("compose.yaml"); let contents = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; - let mut compose: serde_yaml::Value = serde_yaml::from_str(&contents) + let mut compose: serde_norway::Value = serde_norway::from_str(&contents) .with_context(|| format!("failed to parse {}", path.display()))?; let fragment = include_str!("templates/notary_addon/compose-fragment.yaml.tmpl") .replace("{{relay_image}}", image_lock.relay_image()) .replace("{{notary_image}}", image_lock.notary_image()); - let fragment: serde_yaml::Value = - serde_yaml::from_str(&fragment).context("failed to parse Notary Compose fragment")?; + let fragment: serde_norway::Value = + serde_norway::from_str(&fragment).context("failed to parse Notary Compose fragment")?; merge_yaml_mapping(&mut compose, &fragment, "services")?; merge_yaml_mapping(&mut compose, &fragment, "volumes")?; merge_yaml_mapping(&mut compose, &fragment, "networks")?; - let rendered = serde_yaml::to_string(&compose).context("failed to render Compose file")?; + let rendered = serde_norway::to_string(&compose).context("failed to render Compose file")?; write_text(path, &format!("# Generated by registryctl.\n{rendered}")) } fn merge_yaml_mapping( - target: &mut serde_yaml::Value, - source: &serde_yaml::Value, + target: &mut serde_norway::Value, + source: &serde_norway::Value, key: &str, ) -> Result<()> { let target_root = target .as_mapping_mut() .ok_or_else(|| anyhow!("Compose document must be a mapping"))?; - let yaml_key = serde_yaml::Value::String(key.to_owned()); + let yaml_key = serde_norway::Value::String(key.to_owned()); if !target_root.contains_key(&yaml_key) { target_root.insert( yaml_key.clone(), - serde_yaml::Value::Mapping(serde_yaml::Mapping::new()), + serde_norway::Value::Mapping(serde_norway::Mapping::new()), ); } let target_mapping = target_root .get_mut(&yaml_key) - .and_then(serde_yaml::Value::as_mapping_mut) + .and_then(serde_norway::Value::as_mapping_mut) .ok_or_else(|| anyhow!("Compose {key} must be a mapping"))?; let source_mapping = source[key] .as_mapping() @@ -3174,7 +3175,7 @@ impl Project { let path = project_dir.join("registryctl.yaml"); let contents = fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; - serde_yaml::from_str(&contents) + serde_norway::from_str(&contents) .with_context(|| format!("failed to parse {}", path.display())) } } @@ -3447,7 +3448,7 @@ fn validate_config_api_key_fingerprints( ) -> Result<()> { let config = fs::read_to_string(config_path) .with_context(|| format!("failed to read {}", config_path.display()))?; - let config: serde_yaml::Value = serde_yaml::from_str(&config) + let config: serde_norway::Value = serde_norway::from_str(&config) .with_context(|| format!("failed to parse {}", config_path.display()))?; let api_keys = config["auth"]["api_keys"] .as_sequence() @@ -3639,7 +3640,7 @@ fn registryctl_manifest(dir: &Path, image_lock: &RegistryctlImageLock) -> Result output_dir: "output", }, }; - serde_yaml::to_string(&manifest).context("failed to render registryctl manifest") + serde_norway::to_string(&manifest).context("failed to render registryctl manifest") } fn generated_project_name(dir: &Path) -> String { @@ -3980,7 +3981,7 @@ mod tests { use registry_config_report::REGISTRYCTL_VALIDATION_REPORT_SCHEMA_V1; use serde_json::Value as JsonValue; - use serde_yaml::Value; + use serde_norway::Value; use tempfile::TempDir; use super::*; @@ -4536,9 +4537,9 @@ mod tests { assert!(config_text.contains("# Tables describe the source workbook.")); assert!(config_text.contains("# Aggregates expose predeclared grouped statistics.")); assert!(config_text.contains("# Entities are API projections.")); - let config: Value = serde_yaml::from_str(&config_text).unwrap(); + let config: Value = serde_norway::from_str(&config_text).unwrap(); let manifest: Value = - serde_yaml::from_str(&fs::read_to_string(project.join("registryctl.yaml")).unwrap()) + serde_norway::from_str(&fs::read_to_string(project.join("registryctl.yaml")).unwrap()) .unwrap(); let compose = fs::read_to_string(project.join("compose.yaml")).unwrap(); assert!(config.get("metadata").is_none()); @@ -4648,7 +4649,7 @@ mod tests { assert!(project.join(path).is_file(), "{path} should exist"); } let manifest: Value = - serde_yaml::from_str(&fs::read_to_string(project.join("registryctl.yaml")).unwrap()) + serde_norway::from_str(&fs::read_to_string(project.join("registryctl.yaml")).unwrap()) .unwrap(); assert_eq!(manifest["runtime"]["notary_base_url"], NOTARY_BASE_URL); assert_eq!(manifest["notary"]["claim_file"], NOTARY_CLAIM_FILE); @@ -4668,7 +4669,7 @@ mod tests { assert_private_file(&project, CONSULTATION_RELAY_CONFIG_PATH); assert_notary_runtime_input_owners_match_project(&project); let compose_text = fs::read_to_string(project.join("compose.yaml")).unwrap(); - let compose: Value = serde_yaml::from_str(&compose_text).unwrap(); + let compose: Value = serde_norway::from_str(&compose_text).unwrap(); let services = &compose["services"]; let runtime_user = "${REGISTRY_STACK_RUNTIME_UID:-65532}:${REGISTRY_STACK_RUNTIME_GID:-65532}"; @@ -4781,11 +4782,11 @@ mod tests { let notary_config_text = fs::read_to_string(project.join(NOTARY_CONFIG_PATH)).unwrap(); assert!(notary_config_text.contains("person-registration-accepted")); assert!(notary_config_text.contains("pending")); - let notary_config: Value = serde_yaml::from_str(¬ary_config_text).unwrap(); + let notary_config: Value = serde_norway::from_str(¬ary_config_text).unwrap(); assert_eq!(notary_config["state"]["storage"], "in_memory"); assert!(notary_config["evidence"]["credential_profiles"] .as_mapping() - .is_some_and(serde_yaml::Mapping::is_empty)); + .is_some_and(serde_norway::Mapping::is_empty)); let claims = notary_config["evidence"]["claims"].as_sequence().unwrap(); assert!(!claims.is_empty()); assert!(claims @@ -4949,7 +4950,7 @@ mod tests { init_spreadsheet_api(&project, Sample::Benefits, &test_image_lock()).unwrap(); let manifest: Value = - serde_yaml::from_str(&fs::read_to_string(project.join("registryctl.yaml")).unwrap()) + serde_norway::from_str(&fs::read_to_string(project.join("registryctl.yaml")).unwrap()) .unwrap(); let compose = fs::read_to_string(project.join("compose.yaml")).unwrap(); @@ -5008,7 +5009,7 @@ mod tests { Project::load(&project).unwrap(); let manifest: Value = - serde_yaml::from_str(&fs::read_to_string(project.join("registryctl.yaml")).unwrap()) + serde_norway::from_str(&fs::read_to_string(project.join("registryctl.yaml")).unwrap()) .unwrap(); let products = manifest["project"]["products"] .as_sequence() @@ -5182,7 +5183,7 @@ mod tests { let env = fs::read_to_string(project.join("secrets/local.env")).unwrap(); let config = fs::read_to_string(project.join("relay/config.yaml")).unwrap(); - let config_yaml: Value = serde_yaml::from_str(&config).unwrap(); + let config_yaml: Value = serde_norway::from_str(&config).unwrap(); assert_eq!(config_yaml["server"]["openapi_requires_auth"], false); assert!(!config.contains("commitment:")); diff --git a/crates/registryctl/src/main.rs b/crates/registryctl/src/main.rs index 4bb8720ef..01a6ba259 100644 --- a/crates/registryctl/src/main.rs +++ b/crates/registryctl/src/main.rs @@ -1738,7 +1738,7 @@ mod tests { let manifest_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let repository_root = manifest_root.join("../.."); - let catalog: serde_yaml::Value = serde_yaml::from_slice( + let catalog: serde_norway::Value = serde_norway::from_slice( &std::fs::read(manifest_root.join("tests/fixtures/project-authoring-journeys.yaml")) .expect("project-authoring journey catalog reads"), ) @@ -1779,7 +1779,7 @@ mod tests { .as_str() .expect("catalog workspace source"), ); - let project: serde_yaml::Value = serde_yaml::from_slice( + let project: serde_norway::Value = serde_norway::from_slice( &std::fs::read(source.join("registry-stack.yaml")).expect("catalog project reads"), ) .expect("catalog project parses"); @@ -1799,9 +1799,10 @@ mod tests { .expect("integration directory") .join("fixtures") .join(fixture_file); - let fixture: serde_yaml::Value = - serde_yaml::from_slice(&std::fs::read(fixture_path).expect("watch fixture reads")) - .expect("watch fixture parses"); + let fixture: serde_norway::Value = serde_norway::from_slice( + &std::fs::read(fixture_path).expect("watch fixture reads"), + ) + .expect("watch fixture parses"); journeys.push(( id, starter, diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index 4e6e6a35a..e4b948ee4 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -823,17 +823,17 @@ fn apply_local_tutorial_runtime_overrides(compiled: &mut CompiledProject) -> Res .notary_private .get_mut(Path::new("config/notary.yaml")) .ok_or_else(|| anyhow!("generated local Notary config is absent"))?; - let mut notary_config: serde_yaml::Value = serde_yaml::from_slice(notary) + let mut notary_config: serde_norway::Value = serde_norway::from_slice(notary) .context("generated local Notary config did not parse")?; notary_config["server"]["bind"] = - serde_yaml::Value::String("0.0.0.0:8081".to_string()); - notary_config["state"] = serde_yaml::from_str("storage: in_memory\n")?; - notary_config["evidence"]["signing_keys"] = serde_yaml::from_str(&format!( + serde_norway::Value::String("0.0.0.0:8081".to_string()); + notary_config["state"] = serde_norway::from_str("storage: in_memory\n")?; + notary_config["evidence"]["signing_keys"] = serde_norway::from_str(&format!( "relay-workload:\n provider: local_jwk_env\n private_jwk_env: {}\n alg: EdDSA\n kid: {}\n status: active\n", super::NOTARY_RELAY_WORKLOAD_JWK_ENV, super::NOTARY_RELAY_WORKLOAD_KID, ))?; - *notary = serde_yaml::to_string(¬ary_config) + *notary = serde_norway::to_string(¬ary_config) .context("failed to render local Notary config")? .into_bytes() .into_boxed_slice(); @@ -842,15 +842,15 @@ fn apply_local_tutorial_runtime_overrides(compiled: &mut CompiledProject) -> Res .relay_private .get_mut(Path::new("config/relay.yaml")) .ok_or_else(|| anyhow!("generated local consultation Relay config is absent"))?; - let mut relay_config: serde_yaml::Value = serde_yaml::from_slice(relay) + let mut relay_config: serde_norway::Value = serde_norway::from_slice(relay) .context("generated local consultation Relay config did not parse")?; relay_config["server"]["bind"] = - serde_yaml::Value::String("0.0.0.0:8082".to_string()); + serde_norway::Value::String("0.0.0.0:8082".to_string()); relay_config["auth"]["oidc"]["allow_dev_insecure_fetch_urls"] = - serde_yaml::Value::Bool(true); + serde_norway::Value::Bool(true); relay_config["consultation"]["state_plane"]["root_certificate_path"] = - serde_yaml::Value::String("/run/registry-tls/state-plane-ca.pem".to_string()); - *relay = serde_yaml::to_string(&relay_config) + serde_norway::Value::String("/run/registry-tls/state-plane-ca.pem".to_string()); + *relay = serde_norway::to_string(&relay_config) .context("failed to render local consultation Relay config")? .into_bytes() .into_boxed_slice(); diff --git a/crates/registryctl/src/project_authoring/compiler/artifacts.rs b/crates/registryctl/src/project_authoring/compiler/artifacts.rs index 71fe7d45b..185a9329a 100644 --- a/crates/registryctl/src/project_authoring/compiler/artifacts.rs +++ b/crates/registryctl/src/project_authoring/compiler/artifacts.rs @@ -142,7 +142,7 @@ fn compile_project_for_environment( generated_relay_config(loaded, environment_name, environment, &packs, &profiles)?; relay_private.insert( PathBuf::from("config/relay.yaml"), - serde_yaml::to_string(&relay_config)? + serde_norway::to_string(&relay_config)? .into_bytes() .into_boxed_slice(), ); @@ -168,7 +168,7 @@ fn compile_project_for_environment( generated_notary_config(loaded, environment_name, environment, &profiles)?; notary_private.insert( PathBuf::from("config/notary.yaml"), - serde_yaml::to_string(¬ary_config)? + serde_norway::to_string(¬ary_config)? .into_bytes() .into_boxed_slice(), ); diff --git a/crates/registryctl/src/project_authoring/compiler/notary.rs b/crates/registryctl/src/project_authoring/compiler/notary.rs index f949050a1..343d485d1 100644 --- a/crates/registryctl/src/project_authoring/compiler/notary.rs +++ b/crates/registryctl/src/project_authoring/compiler/notary.rs @@ -1338,7 +1338,7 @@ mod notary_compiler_tests { #[test] fn compiler_rejects_source_free_credential_profiles_without_project_validation() { - let project: RegistryProject = serde_yaml::from_str( + let project: RegistryProject = serde_norway::from_str( r#"version: 1 registry: { id: compiler-boundary-test } services: diff --git a/crates/registryctl/src/project_authoring/diagnostics.rs b/crates/registryctl/src/project_authoring/diagnostics.rs index 22972a413..1e04a0116 100644 --- a/crates/registryctl/src/project_authoring/diagnostics.rs +++ b/crates/registryctl/src/project_authoring/diagnostics.rs @@ -1073,7 +1073,7 @@ fn diagnostic_parse_yaml Deserialize<'de>>( kind: &'static str, schema_hint: &'static str, ) -> DiagnosticResult { - serde_yaml::from_slice(bytes).map_err(|error| { + serde_norway::from_slice(bytes).map_err(|error| { let unknown_field = error.to_string().contains("unknown field"); let location = error.location(); Box::new(ProjectAuthoringDiagnostic { @@ -1084,8 +1084,8 @@ fn diagnostic_parse_yaml Deserialize<'de>>( }, file: file.to_string(), field: None, - line: location.as_ref().map(serde_yaml::Location::line), - column: location.as_ref().map(serde_yaml::Location::column), + line: location.as_ref().map(serde_norway::Location::line), + column: location.as_ref().map(serde_norway::Location::column), schema_hint: Some(schema_hint), suggestion: None, cause: if unknown_field { diff --git a/crates/registryctl/src/project_authoring/fixtures.rs b/crates/registryctl/src/project_authoring/fixtures.rs index a15f6ed51..9bcfa67b3 100644 --- a/crates/registryctl/src/project_authoring/fixtures.rs +++ b/crates/registryctl/src/project_authoring/fixtures.rs @@ -649,7 +649,7 @@ fn evaluate_product_claims( .notary_private .get(Path::new("config/notary.yaml")) .ok_or_else(|| anyhow!("generated Notary config is absent"))?; - let notary_config: StandaloneRegistryNotaryConfig = serde_yaml::from_slice(notary_config) + let notary_config: StandaloneRegistryNotaryConfig = serde_norway::from_slice(notary_config) .context("generated Notary config did not parse for offline evaluation")?; let harness = OfflineNotaryHarness::compile(notary_config, relay_evidence, project_cel_worker_config()?) diff --git a/crates/registryctl/src/project_authoring/output.rs b/crates/registryctl/src/project_authoring/output.rs index f751bd450..bf040ab4e 100644 --- a/crates/registryctl/src/project_authoring/output.rs +++ b/crates/registryctl/src/project_authoring/output.rs @@ -23,7 +23,7 @@ fn validate_generated_notary(compiled: &CompiledProject) -> Result<()> { .get(Path::new("config/notary.yaml")) .ok_or_else(|| anyhow!("generated Notary config is absent"))?; let notary: StandaloneRegistryNotaryConfig = - serde_yaml::from_slice(notary_config).context("generated Notary config did not parse")?; + serde_norway::from_slice(notary_config).context("generated Notary config did not parse")?; notary .validate() .context("generated Notary config failed the production validator")?; @@ -35,7 +35,7 @@ fn validate_generated_relay( files: &BTreeMap>, ) -> Result<()> { validate_generated_relay_activation(relay_config, files)?; - let config: Value = serde_yaml::from_slice(relay_config) + let config: Value = serde_norway::from_slice(relay_config) .context("generated Relay config did not parse as strict YAML")?; if config .pointer("/consultation/artifacts/public_contracts") @@ -54,14 +54,14 @@ fn validate_generated_relay_activation( let validation_root = GeneratedValidationDirectory::create()?; write_file_map(&validation_root.path, files)?; let config_path = validation_root.path.join("config/relay.yaml"); - let mut local_config: Value = serde_yaml::from_slice(relay_config) + let mut local_config: Value = serde_norway::from_slice(relay_config) .context("generated Relay config did not parse for activation validation")?; local_config["deployment"]["profile"] = Value::String("local".to_string()); fs::remove_file(&config_path) .context("failed to stage generated Relay activation validation")?; write_private_file( &config_path, - serde_yaml::to_string(&local_config)?.as_bytes(), + serde_norway::to_string(&local_config)?.as_bytes(), )?; let mut loaded = registry_relay::config::load_with_metadata(&config_path) .map_err(|error| anyhow!("generated Relay config failed production loading: {error:?}"))?; @@ -118,12 +118,12 @@ fn compile_generated_relay_fixture( relay_config: &[u8], files: &BTreeMap>, ) -> Result { - let runtime: registry_relay::config::Config = serde_yaml::from_slice(relay_config) + let runtime: registry_relay::config::Config = serde_norway::from_slice(relay_config) .context("generated Relay config did not parse with the production model")?; registry_relay::config::validate::run(&runtime).map_err(|error| { anyhow!("generated Relay config failed the production startup validator: {error:?}") })?; - let config: Value = serde_yaml::from_slice(relay_config) + let config: Value = serde_norway::from_slice(relay_config) .context("generated Relay config did not parse as strict YAML")?; let artifacts = config .pointer("/consultation/artifacts") @@ -1227,7 +1227,7 @@ fn read_bounded_fixture_body(root: &Path, path: &Path, max_bytes: u64) -> Result } fn parse_yaml Deserialize<'de>>(bytes: &[u8], label: &str) -> Result { - serde_yaml::from_slice(bytes).map_err(|error| { + serde_norway::from_slice(bytes).map_err(|error| { let location = error .location() .map(|location| format!(":{}:{}", location.line(), location.column())) diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 20222f603..750ec2394 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -45,7 +45,7 @@ mod tests { #[test] fn corrected_http_authoring_lowers_to_one_product_neutral_request() { - let authored: AuthoredIntegrationDocument = serde_yaml::from_str( + let authored: AuthoredIntegrationDocument = serde_norway::from_str( r#" version: 1 id: person-status @@ -99,7 +99,7 @@ outputs: #[test] fn date_outputs_keep_the_typed_contract_without_a_string_bound() { - let entity_field: EntityFieldSchema = serde_yaml::from_str( + let entity_field: EntityFieldSchema = serde_norway::from_str( r#"type: string format: date maxLength: 10 @@ -125,7 +125,7 @@ maxLength: 10 ) .expect("typed snapshot date validates"); - let authored: AuthoredIntegrationDocument = serde_yaml::from_str( + let authored: AuthoredIntegrationDocument = serde_norway::from_str( r#" version: 1 id: person-birth-date @@ -155,7 +155,7 @@ outputs: #[test] fn corrected_authoring_rejects_the_superseded_operation_graph() { - serde_yaml::from_str::( + serde_norway::from_str::( r#" version: 1 id: obsolete-flow @@ -176,7 +176,7 @@ outputs: #[test] fn typed_authoring_preserves_roles_scalar_contracts_and_conservative_bounds() { - let authored: AuthoredIntegrationDocument = serde_yaml::from_str( + let authored: AuthoredIntegrationDocument = serde_norway::from_str( r#" version: 1 id: typed-person-status @@ -296,7 +296,7 @@ outputs: "type: [string, \"null\"], maxLength: 64", ); let authored: AuthoredIntegrationDocument = - serde_yaml::from_str(&nullable_selector).expect("nullable selector parses"); + serde_norway::from_str(&nullable_selector).expect("nullable selector parses"); assert!(lower_authored_integration(&authored) .expect_err("nullable selector rejects") .to_string() @@ -307,7 +307,7 @@ outputs: "type: integer, minimum: -9007199254740992, maximum: 1", ); let authored: AuthoredIntegrationDocument = - serde_yaml::from_str(&unsafe_integer).expect("unsafe integer parses"); + serde_norway::from_str(&unsafe_integer).expect("unsafe integer parses"); assert!(lower_authored_integration(&authored) .expect_err("unsafe integer rejects") .to_string() @@ -315,7 +315,7 @@ outputs: let oversized_selector = base.replace("maxLength: 64", "maxLength: 1025"); let authored: AuthoredIntegrationDocument = - serde_yaml::from_str(&oversized_selector).expect("oversized selector parses"); + serde_norway::from_str(&oversized_selector).expect("oversized selector parses"); assert!(lower_authored_integration(&authored) .expect_err("aggregate selector bytes reject") .to_string() @@ -353,7 +353,7 @@ outputs: } let authored: AuthoredIntegrationDocument = - serde_yaml::from_str(&authored_with_inputs(8, 8)) + serde_norway::from_str(&authored_with_inputs(8, 8)) .expect("maximum typed input map parses"); assert_eq!( lower_authored_integration(&authored) @@ -364,14 +364,14 @@ outputs: ); let authored: AuthoredIntegrationDocument = - serde_yaml::from_str(&authored_with_inputs(9, 0)).expect("nine-selector map parses"); + serde_norway::from_str(&authored_with_inputs(9, 0)).expect("nine-selector map parses"); assert!(lower_authored_integration(&authored) .expect_err("nine selectors reject") .to_string() .contains("between one and eight selectors")); let authored: AuthoredIntegrationDocument = - serde_yaml::from_str(&authored_with_inputs(8, 9)).expect("seventeen-input map parses"); + serde_norway::from_str(&authored_with_inputs(8, 9)).expect("seventeen-input map parses"); assert!(lower_authored_integration(&authored) .expect_err("seventeen inputs reject") .to_string() @@ -417,7 +417,7 @@ outputs: let compiled = compile_project(&loaded, None).expect("NIA release project compiles"); validate_generated_product_configs(&compiled) .expect("generated NIA Relay config passes the product validator"); - let relay: Value = serde_yaml::from_slice( + let relay: Value = serde_norway::from_slice( compiled .relay_private .get(Path::new("config/relay.yaml")) @@ -509,7 +509,7 @@ outputs: validate_entity_definition(&loaded.entities["population"].document) .expect("one-minute materialization refresh is supported"); let compiled = compile_project(&loaded, None).expect("interval project compiles"); - let relay: Value = serde_yaml::from_slice( + let relay: Value = serde_norway::from_slice( compiled .relay_private .get(Path::new("config/relay.yaml")) @@ -638,13 +638,13 @@ outputs: .relay_private .get(Path::new("config/relay.yaml")) .expect("Relay config exists"); - let original: Value = serde_yaml::from_slice(relay).expect("Relay config parses"); + let original: Value = serde_norway::from_slice(relay).expect("Relay config parses"); for field in ["sha256", "hash"] { let mut tampered = original.clone(); tampered["consultation"]["artifacts"]["private_bindings"][0][field] = Value::String(format!("sha256:{}", "0".repeat(64))); - let bytes = serde_yaml::to_string(&tampered).expect("tampered config serializes"); + let bytes = serde_norway::to_string(&tampered).expect("tampered config serializes"); let error = validate_generated_relay(bytes.as_bytes(), &compiled.relay_private) .expect_err("tampered binding pin must fail closed"); let diagnostic = format!("{error:#}"); @@ -815,7 +815,7 @@ outputs: .expect("golden project loads"); let mut compiled = compile_project(&loaded, None).expect("golden project compiles"); let config_path = Path::new("config/notary.yaml").to_path_buf(); - let mut notary: Value = serde_yaml::from_slice( + let mut notary: Value = serde_norway::from_slice( compiled .notary_private .get(&config_path) @@ -825,7 +825,7 @@ outputs: notary["evidence"]["claims"][0]["formats"] = Value::Array(Vec::new()); compiled.notary_private.insert( config_path, - serde_yaml::to_string(¬ary) + serde_norway::to_string(¬ary) .expect("tampered Notary config serializes") .into_bytes() .into_boxed_slice(), @@ -1021,7 +1021,7 @@ fn fixture_input_validation_uses_typed_values_and_explicit_null() { #[test] fn oauth_authoring_lowers_host_owned_form_exchange_with_expiry_cache() { - let authored: AuthoredIntegrationDocument = serde_yaml::from_str( + let authored: AuthoredIntegrationDocument = serde_norway::from_str( r#" version: 1 id: generic-status @@ -1061,7 +1061,7 @@ outputs: #[test] fn environment_source_binding_has_no_legacy_destination_or_credential_type_aliases() { - let source: EnvironmentIntegration = serde_yaml::from_str( + let source: EnvironmentIntegration = serde_norway::from_str( r#" source: origin: https://registry.invalid @@ -1091,6 +1091,6 @@ source: "source: { origin: https://registry.invalid, advanced_capabilities: {} }", "source: { origin: https://registry.invalid, credential: { type: basic, generation: 1 } }", ] { - assert!(serde_yaml::from_str::(legacy).is_err()); + assert!(serde_norway::from_str::(legacy).is_err()); } } diff --git a/crates/registryctl/tests/project_authoring.rs b/crates/registryctl/tests/project_authoring.rs index af1120bc8..ae2468c94 100644 --- a/crates/registryctl/tests/project_authoring.rs +++ b/crates/registryctl/tests/project_authoring.rs @@ -58,7 +58,7 @@ fn repository_root() -> PathBuf { } fn project_authoring_journey_catalog() -> ProjectAuthoringJourneyCatalog { - serde_yaml::from_slice( + serde_norway::from_slice( &std::fs::read( Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/project-authoring-journeys.yaml"), @@ -115,7 +115,7 @@ fn catalog_focused_selection(journey: &ProjectAuthoringJourney) -> (String, Stri fn catalog_has_authored_fixtures( journey: &ProjectAuthoringJourney, - project: &serde_yaml::Value, + project: &serde_norway::Value, ) -> bool { let Some(integrations) = project["integrations"].as_mapping() else { return false; @@ -231,17 +231,17 @@ fn project_check_aggregates_script_host_call_and_environment_diagnostics_safely( let environment_path = project.join("environments/local.yaml"); let mut environment = read_yaml(&environment_path); environment["integrations"]["health-record"]["source"]["credential"]["generation"] = - serde_yaml::Value::Number(0.into()); + serde_norway::Value::Number(0.into()); environment["integrations"]["health-record"]["source"]["credential"]["username"]["secret"] = - serde_yaml::Value::String(ENVIRONMENT_MARKER.to_string()); + serde_norway::Value::String(ENVIRONMENT_MARKER.to_string()); write_yaml(&environment_path, &environment); let fixture_path = project.join("integrations/health-record/fixtures/match.yaml"); let mut fixture = read_yaml(&fixture_path); fixture["variables"]["diagnostic_marker"] = - serde_yaml::Value::String(FIXTURE_MARKER.to_string()); + serde_norway::Value::String(FIXTURE_MARKER.to_string()); fixture["interactions"][0]["respond"]["body"]["diagnostic_marker"] = - serde_yaml::Value::String(RESPONSE_MARKER.to_string()); + serde_norway::Value::String(RESPONSE_MARKER.to_string()); write_yaml(&fixture_path, &fixture); let report = authoring_diagnostics(&project); @@ -351,9 +351,9 @@ fn project_check_keeps_script_probe_stable_across_metadata_and_ignores_non_calls let integration_path = project.join("integrations/health-record/integration.yaml"); let mut integration = read_yaml(&integration_path); integration["source"]["product"] = - serde_yaml::Value::String("unrelated-product-metadata".to_string()); + serde_norway::Value::String("unrelated-product-metadata".to_string()); integration["source"]["versions"] = - serde_yaml::from_str("unverified: [9.9]\n").expect("version metadata"); + serde_norway::from_str("unverified: [9.9]\n").expect("version metadata"); write_yaml(&integration_path, &integration); assert_eq!(authoring_diagnostics(&project), baseline); } @@ -389,9 +389,9 @@ fn project_check_reports_two_independent_environment_errors_once_each() { let environment_path = project.join("environments/local.yaml"); let mut environment = read_yaml(&environment_path); environment["integrations"]["eligibility"]["source"]["origin"] = - serde_yaml::Value::String("http://unsafe-origin-marker.invalid".to_string()); + serde_norway::Value::String("http://unsafe-origin-marker.invalid".to_string()); environment["integrations"]["eligibility"]["source"]["credential"]["generation"] = - serde_yaml::Value::Number(0.into()); + serde_norway::Value::Number(0.into()); write_yaml(&environment_path, &environment); let report = authoring_diagnostics(&project); @@ -607,7 +607,7 @@ fn project_check_cli_rejects_an_unselected_environment_symlink_with_typed_output let fixture_path = project.join("integrations/eligibility/fixtures/eligible.yaml"); let mut fixture = read_yaml(&fixture_path); - fixture["expect"]["outputs"]["approved"] = serde_yaml::Value::Bool(false); + fixture["expect"]["outputs"]["approved"] = serde_norway::Value::Bool(false); write_yaml(&fixture_path, &fixture); let run = |format: &str| { @@ -755,13 +755,13 @@ fn project_check_collects_all_safe_missing_integration_references_without_cascad let project_path = project.join("registry-stack.yaml"); let mut authored = read_yaml(&project_path); authored["integrations"]["eligibility"]["file"] = - serde_yaml::Value::String("integrations/zeta/missing.yaml".to_string()); + serde_norway::Value::String("integrations/zeta/missing.yaml".to_string()); authored["integrations"] .as_mapping_mut() .expect("integration map") .insert( - serde_yaml::Value::String("alpha".to_string()), - serde_yaml::from_str("file: integrations/alpha/missing.yaml\n") + serde_norway::Value::String("alpha".to_string()), + serde_norway::from_str("file: integrations/alpha/missing.yaml\n") .expect("missing integration reference"), ); write_yaml(&project_path, &authored); @@ -834,7 +834,7 @@ fn project_check_unsafe_inputs_are_terminal_and_value_free() { let project_path = traversal.join("registry-stack.yaml"); let mut project = read_yaml(&project_path); project["integrations"]["eligibility"]["file"] = - serde_yaml::Value::String("../unsafe-marker/integration.yaml".to_string()); + serde_norway::Value::String("../unsafe-marker/integration.yaml".to_string()); write_yaml(&project_path, &project); let traversal_report = authoring_diagnostics(&traversal); assert_eq!(traversal_report.diagnostics.len(), 1); @@ -1237,7 +1237,7 @@ fn every_cataloged_supported_project_authoring_command_is_automated() { "{} compiler-pinned Relay consultation", journey.id ); - let rendered = serde_yaml::to_string(¬ary_config) + let rendered = serde_norway::to_string(¬ary_config) .expect("generated Notary config serializes"); assert!( rendered.contains("contract_hash:"), @@ -1723,7 +1723,7 @@ fn local_rhai_modules_are_a_static_hash_covered_closure() { let integration_path = integration_directory.join("integration.yaml"); let mut integration = read_yaml(&integration_path); integration["capability"]["script"]["modules"] = - serde_yaml::from_str("[lib/normalize.rhai]").expect("module list"); + serde_norway::from_str("[lib/normalize.rhai]").expect("module list"); write_yaml(&integration_path, &integration); let options = ProjectBuildOptions { @@ -1782,8 +1782,8 @@ fn public_rhai_commands_accept_the_released_contract_for_an_unknown_product() { let source = integration["source"] .as_mapping_mut() .expect("Rhai source mapping"); - source.remove(serde_yaml::Value::String("product".to_string())); - source.remove(serde_yaml::Value::String("versions".to_string())); + source.remove(serde_norway::Value::String("product".to_string())); + source.remove(serde_norway::Value::String("versions".to_string())); write_yaml(&metadata_free_integration, &integration); let exercise = |project_directory: PathBuf| { @@ -2669,21 +2669,21 @@ expect: { outcome: ambiguous, outputs: {}, claims: {} } let project_file = project.join("registry-stack.yaml"); let mut project_document = read_yaml(&project_file); let service = &mut project_document["services"]["person-verification"]; - service["purpose"] = serde_yaml::Value::String("municipal-benefit-screening".to_string()); - service["consultations"]["person_record"]["input"] = serde_yaml::from_str( + service["purpose"] = serde_norway::Value::String("municipal-benefit-screening".to_string()); + service["consultations"]["person_record"]["input"] = serde_norway::from_str( "municipal_reference: request.target.identifiers.registry_person_id\n", ) .expect("adapted consultation input"); service["claims"] .as_mapping_mut() .expect("starter claims") - .remove(serde_yaml::Value::String("person-active".to_string())); + .remove(serde_norway::Value::String("person-active".to_string())); service["claims"] .as_mapping_mut() .expect("starter claims") .insert( - serde_yaml::Value::String("person-status".to_string()), - serde_yaml::from_str("output: person_record.status\ndisclosure: value\n") + serde_norway::Value::String("person-status".to_string()), + serde_norway::from_str("output: person_record.status\ndisclosure: value\n") .expect("adapted status claim"), ); service["credential_profiles"]["person-status"]["claims"] @@ -2692,7 +2692,7 @@ expect: { outcome: ambiguous, outputs: {}, claims: {} } .iter_mut() .for_each(|claim| { if claim.as_str() == Some("person-active") { - *claim = serde_yaml::Value::String("person-status".to_string()); + *claim = serde_norway::Value::String("person-status".to_string()); } }); write_yaml(&project_file, &project_document); @@ -2792,8 +2792,8 @@ fn source_product_is_metadata_not_runtime_dispatch() { let source = integration["source"] .as_mapping_mut() .expect("authored source mapping"); - source.remove(serde_yaml::Value::String("product".to_string())); - source.remove(serde_yaml::Value::String("versions".to_string())); + source.remove(serde_norway::Value::String("product".to_string())); + source.remove(serde_norway::Value::String("versions".to_string())); write_yaml(&integration_path, &integration); let report = test_registry_project(&ProjectTestOptions { project_directory: metadata_free, @@ -2841,7 +2841,7 @@ fn project_integrations_share_one_logical_source_without_conflating_protocol_hel let environment_path = independent_origin.join("environments/local.yaml"); let mut environment = read_yaml(&environment_path); environment["integrations"]["secondary"]["source"]["origin"] = - serde_yaml::Value::String("https://unrelated-registry.invalid".to_string()); + serde_norway::Value::String("https://unrelated-registry.invalid".to_string()); write_yaml(&environment_path, &environment); let error = check_registry_project(&ProjectCheckOptions { project_directory: independent_origin, @@ -2859,7 +2859,7 @@ fn project_integrations_share_one_logical_source_without_conflating_protocol_hel let environment_path = protocol_helper.join("environments/local.yaml"); let mut environment = read_yaml(&environment_path); environment["integrations"]["secondary"]["source"]["oauth"]["origin"] = - serde_yaml::Value::String("https://oauth-helper.invalid".to_string()); + serde_norway::Value::String("https://oauth-helper.invalid".to_string()); write_yaml(&environment_path, &environment); check_registry_project(&ProjectCheckOptions { project_directory: protocol_helper, @@ -2970,7 +2970,7 @@ fn authored_unknown_fields_and_traversal_fail_closed() { let conformance_escape = copy_project("dhis2-script", temporary.path()); let fixture_path = conformance_escape.join("integrations/health-record/fixtures/match.yaml"); let mut fixture = read_yaml(&fixture_path); - fixture["worker_probe"] = serde_yaml::Value::String("network".to_string()); + fixture["worker_probe"] = serde_norway::Value::String("network".to_string()); write_yaml(&fixture_path, &fixture); let error = test_registry_project(&ProjectTestOptions { project_directory: conformance_escape, @@ -3712,7 +3712,7 @@ fn relay_authorization_bindings_follow_authored_service_topology() { environment .as_mapping_mut() .expect("environment mapping") - .remove(serde_yaml::Value::String("notary_relay".to_string())); + .remove(serde_norway::Value::String("notary_relay".to_string())); write_yaml(&environment_path, &environment); let error = check_registry_project(&ProjectCheckOptions { project_directory: missing_workload, @@ -3730,7 +3730,7 @@ fn relay_authorization_bindings_follow_authored_service_topology() { let environment_path = missing_records_client.join("environments/local.yaml"); let mut environment = read_yaml(&environment_path); environment["relay"]["allowed_clients"] = - serde_yaml::from_str("[]\n").expect("empty allowed client list"); + serde_norway::from_str("[]\n").expect("empty allowed client list"); write_yaml(&environment_path, &environment); let error = check_registry_project(&ProjectCheckOptions { project_directory: missing_records_client, @@ -3883,7 +3883,7 @@ fn integration_input_pattern_schema_matches_the_wire_limit() { .with_draft(jsonschema::Draft::Draft202012) .compile(&schema) .expect("integration schema compiles"); - let authored: serde_yaml::Value = serde_yaml::from_slice( + let authored: serde_norway::Value = serde_norway::from_slice( &std::fs::read(golden("custom-system").join("integrations/eligibility/integration.yaml")) .expect("integration reads"), ) @@ -3963,7 +3963,7 @@ fn api_key_interfaces_keep_values_environment_only_and_use_the_stable_auth_type( remove_custom_cel_claim(&project); let integration = project.join("integrations/eligibility/integration.yaml"); let mut document = read_yaml(&integration); - document["source"]["auth"] = serde_yaml::from_str(&format!( + document["source"]["auth"] = serde_norway::from_str(&format!( "type: {credential_type}\nname: {name}\nmax_value_bytes: 128\n" )) .expect("API-key interface YAML"); @@ -3972,7 +3972,7 @@ fn api_key_interfaces_keep_values_environment_only_and_use_the_stable_auth_type( let environment = project.join("environments/local.yaml"); let mut document = read_yaml(&environment); document["integrations"]["eligibility"]["source"]["credential"] = - serde_yaml::from_str("value: { secret: PROJECT_SOURCE_API_KEY }\ngeneration: 1\n") + serde_norway::from_str("value: { secret: PROJECT_SOURCE_API_KEY }\ngeneration: 1\n") .expect("API-key environment YAML"); write_yaml(&environment, &document); @@ -4000,7 +4000,7 @@ fn api_key_interfaces_keep_values_environment_only_and_use_the_stable_auth_type( let integration = project.join("integrations/eligibility/integration.yaml"); let mut document = read_yaml(&integration); document["source"]["auth"] = - serde_yaml::from_str("type: api_key_header\nname: authorization\nmax_value_bytes: 128\n") + serde_norway::from_str("type: api_key_header\nname: authorization\nmax_value_bytes: 128\n") .expect("invalid API-key header interface"); write_yaml(&integration, &document); let error = test_registry_project(&ProjectTestOptions { @@ -4016,7 +4016,7 @@ fn api_key_interfaces_keep_values_environment_only_and_use_the_stable_auth_type( let integration = project.join("integrations/eligibility/integration.yaml"); let mut document = read_yaml(&integration); document["source"]["auth"] = - serde_yaml::from_str("type: api_key_query\nname: fields\nmax_value_bytes: 128\n") + serde_norway::from_str("type: api_key_query\nname: fields\nmax_value_bytes: 128\n") .expect("colliding API-key query interface"); write_yaml(&integration, &document); let error = test_registry_project(&ProjectTestOptions { @@ -4032,7 +4032,7 @@ fn api_key_interfaces_keep_values_environment_only_and_use_the_stable_auth_type( let integration = project.join("integrations/eligibility/integration.yaml"); let mut document = read_yaml(&integration); document["source"]["auth"] = - serde_yaml::from_str("type: api_key_query\nname: apiKey\nmax_value_bytes: 128\n") + serde_norway::from_str("type: api_key_query\nname: apiKey\nmax_value_bytes: 128\n") .expect("API-key query interface"); write_yaml(&integration, &document); let environment = project.join("environments/local.yaml"); @@ -4091,9 +4091,9 @@ fn dci_exact_and_and_full_date_inputs_fail_closed_before_source_access() { .as_mapping_mut() .expect("DCI selectors"); let uin = selectors - .remove(serde_yaml::Value::String("uin".to_string())) + .remove(serde_norway::Value::String("uin".to_string())) .expect("UIN selector"); - selectors.insert(serde_yaml::Value::String("other".to_string()), uin); + selectors.insert(serde_norway::Value::String("other".to_string()), uin); write_yaml(&integration_path, &integration); let error = test_registry_project(&ProjectTestOptions { project_directory: project, @@ -4124,7 +4124,7 @@ fn dci_exact_and_and_full_date_inputs_fail_closed_before_source_access() { document["input"] .as_mapping_mut() .expect("fixture inputs") - .remove(serde_yaml::Value::String("selector_3".to_string())); + .remove(serde_norway::Value::String("selector_3".to_string())); write_yaml(&fixture, &document); let error = test_registry_project(&ProjectTestOptions { project_directory: project, @@ -4196,7 +4196,7 @@ fn opencrvs_composite_dci_uses_unified_exact_predicates_canonically() { } fn validate_yaml(schema: &jsonschema::JSONSchema, path: &Path) { - let authored: serde_yaml::Value = serde_yaml::from_slice( + let authored: serde_norway::Value = serde_norway::from_slice( &std::fs::read(path).unwrap_or_else(|error| panic!("{}: {error}", path.display())), ) .unwrap_or_else(|error| panic!("{}: {error}", path.display())); @@ -4277,8 +4277,8 @@ fn check_and_build_produce_deterministic_product_inputs() { let output = PathBuf::from(first.output.expect("build output")); let notary_config = std::fs::read_to_string(output.join("private/notary/config/notary.yaml")) .expect("generated Notary config"); - let notary_document: serde_yaml::Value = - serde_yaml::from_str(¬ary_config).expect("generated Notary config parses"); + let notary_document: serde_norway::Value = + serde_norway::from_str(¬ary_config).expect("generated Notary config parses"); assert!( notary_document.get("cel").is_none(), "absent authoring must preserve the Notary product default" @@ -4332,7 +4332,7 @@ fn generated_relay_contract_activates_through_notary_exactly_and_rejects_a_stale "private/relay/config/artifacts/consultation-contracts/household-eligibility-household.json", ); let contract_bytes = std::fs::read(&contract_path).expect("Relay contract artifact reads"); - let notary: StandaloneRegistryNotaryConfig = serde_yaml::from_slice( + let notary: StandaloneRegistryNotaryConfig = serde_norway::from_slice( &std::fs::read(output.join("private/notary/config/notary.yaml")) .expect("Notary config reads"), ) @@ -4404,7 +4404,7 @@ fn generated_snapshot_contracts_activate_through_notary_at_the_authoring_bound() let entity_path = project.join("entities/people.yaml"); let mut entity = read_yaml(&entity_path); entity["materialization"]["max_bytes"] = - serde_yaml::Value::String(authored_max_bytes.to_string()); + serde_norway::Value::String(authored_max_bytes.to_string()); write_yaml(&entity_path, &entity); let build = build_registry_project(&ProjectBuildOptions { @@ -4426,7 +4426,7 @@ fn generated_snapshot_contracts_activate_through_notary_at_the_authoring_bound() Some(expected_max_bytes) ); - let notary: StandaloneRegistryNotaryConfig = serde_yaml::from_slice( + let notary: StandaloneRegistryNotaryConfig = serde_norway::from_slice( &std::fs::read(output.join("private/notary/config/notary.yaml")) .expect("Notary config reads"), ) @@ -4488,7 +4488,7 @@ fn script_only_change_moves_the_relay_closure_without_forking_the_public_contrac std::fs::read(first_output.join(pack_relative)).expect("initial integration pack reads"); let first_binding = std::fs::read(first_output.join(binding_relative)).expect("initial private binding reads"); - let notary: StandaloneRegistryNotaryConfig = serde_yaml::from_slice( + let notary: StandaloneRegistryNotaryConfig = serde_norway::from_slice( &std::fs::read(first_output.join("private/notary/config/notary.yaml")) .expect("initial Notary config reads"), ) @@ -4532,7 +4532,7 @@ fn script_only_change_moves_the_relay_closure_without_forking_the_public_contrac std::fs::read(second_output.join(pack_relative)).expect("changed integration pack reads"); let second_binding = std::fs::read(second_output.join(binding_relative)).expect("changed private binding reads"); - let second_notary: StandaloneRegistryNotaryConfig = serde_yaml::from_slice( + let second_notary: StandaloneRegistryNotaryConfig = serde_norway::from_slice( &std::fs::read(second_output.join("private/notary/config/notary.yaml")) .expect("changed Notary config reads"), ) @@ -4599,7 +4599,7 @@ fn records_and_snapshot_share_one_generated_materialization() { .expect("records plus evidence golden builds through production validation"); let output = PathBuf::from(build.output.expect("build output")); let relay_root = output.join("private/relay"); - let relay: serde_json::Value = serde_yaml::from_slice( + let relay: serde_json::Value = serde_norway::from_slice( &std::fs::read(relay_root.join("config/relay.yaml")).expect("Relay config reads"), ) .expect("Relay config parses"); @@ -4786,22 +4786,22 @@ fn local_loopback_relay_topology_is_explicit_and_nonportable() { let environment_path = project.join("environments/local.yaml"); let mut environment = read_yaml(&environment_path); environment["relay"]["origin"] = - serde_yaml::Value::String("HTTP://127.0.0.1:18080".to_string()); + serde_norway::Value::String("HTTP://127.0.0.1:18080".to_string()); environment["relay"]["issuer"] = - serde_yaml::Value::String("HTTP://127.0.0.1:18090".to_string()); + serde_norway::Value::String("HTTP://127.0.0.1:18090".to_string()); environment["relay"]["jwks_url"] = - serde_yaml::Value::String("HTTP://127.0.0.1:18090/jwks.json".to_string()); + serde_norway::Value::String("HTTP://127.0.0.1:18090/jwks.json".to_string()); environment["notary_relay"]["base_url"] = - serde_yaml::Value::String("HTTP://127.0.0.1:18081".to_string()); - environment["notary_state"] = serde_yaml::from_str( + serde_norway::Value::String("HTTP://127.0.0.1:18081".to_string()); + environment["notary_state"] = serde_norway::from_str( "postgresql:\n root_certificate_path: /run/secrets/notary-postgres-ca.pem\n", ) .expect("Notary state binding parses"); - environment["relay_state"] = serde_yaml::from_str( + environment["relay_state"] = serde_norway::from_str( "postgresql:\n root_certificate_path: /run/secrets/relay-postgres-ca.pem\n", ) .expect("Relay state binding parses"); - environment["notary_cel"] = serde_yaml::from_str("worker_memory_bytes: 1073741824\n") + environment["notary_cel"] = serde_norway::from_str("worker_memory_bytes: 1073741824\n") .expect("Notary CEL binding parses"); write_yaml(&environment_path, &environment); @@ -4876,10 +4876,10 @@ fn local_loopback_relay_topology_is_explicit_and_nonportable() { let rejected = copy_project("custom-system", rejected_root.path()); let environment_path = rejected.join("environments/local.yaml"); let mut environment = read_yaml(&environment_path); - environment["deployment"]["profile"] = serde_yaml::Value::String(profile.to_string()); - environment["relay"]["origin"] = serde_yaml::Value::String(origin.to_string()); - environment["relay"]["issuer"] = serde_yaml::Value::String(issuer.to_string()); - environment["relay"]["jwks_url"] = serde_yaml::Value::String(jwks_url.to_string()); + environment["deployment"]["profile"] = serde_norway::Value::String(profile.to_string()); + environment["relay"]["origin"] = serde_norway::Value::String(origin.to_string()); + environment["relay"]["issuer"] = serde_norway::Value::String(issuer.to_string()); + environment["relay"]["jwks_url"] = serde_norway::Value::String(jwks_url.to_string()); write_yaml(&environment_path, &environment); let error = check_registry_project(&ProjectCheckOptions { project_directory: rejected, @@ -4900,9 +4900,9 @@ fn hosted_notary_can_use_an_explicit_loopback_relay_connection() { let project = copy_project("custom-system", temporary.path()); let environment_path = project.join("environments/local.yaml"); let mut environment = read_yaml(&environment_path); - environment["deployment"]["profile"] = serde_yaml::Value::String("hosted_lab".to_string()); + environment["deployment"]["profile"] = serde_norway::Value::String("hosted_lab".to_string()); environment["notary_relay"]["base_url"] = - serde_yaml::Value::String("http://127.0.0.1:18080".to_string()); + serde_norway::Value::String("http://127.0.0.1:18080".to_string()); write_yaml(&environment_path, &environment); let build = build_registry_project(&ProjectBuildOptions { @@ -4933,7 +4933,7 @@ fn hosted_notary_can_use_an_explicit_loopback_relay_connection() { let rejected_environment_path = rejected.join("environments/local.yaml"); let mut rejected_environment = read_yaml(&rejected_environment_path); rejected_environment["notary_relay"]["base_url"] = - serde_yaml::Value::String("http://10.42.0.8:8080".to_string()); + serde_norway::Value::String("http://10.42.0.8:8080".to_string()); write_yaml(&rejected_environment_path, &rejected_environment); let error = check_registry_project(&ProjectCheckOptions { project_directory: rejected, @@ -4953,7 +4953,7 @@ fn issuance_accepts_a_full_verification_method_kid() { let environment_path = project.join("environments/local.yaml"); let mut environment = read_yaml(&environment_path); let kid = "did:web:household-notary.invalid#issuer-key-1"; - environment["issuance"]["signing_kid"] = serde_yaml::Value::String(kid.to_string()); + environment["issuance"]["signing_kid"] = serde_norway::Value::String(kid.to_string()); write_yaml(&environment_path, &environment); let build = build_registry_project(&ProjectBuildOptions { @@ -4975,7 +4975,7 @@ fn issuance_accepts_a_full_verification_method_kid() { let environment_path = rejected.join("environments/local.yaml"); let mut environment = read_yaml(&environment_path); environment["issuance"]["signing_kid"] = - serde_yaml::Value::String("did:web:issuer.invalid#bad kid".to_string()); + serde_norway::Value::String("did:web:issuer.invalid#bad kid".to_string()); write_yaml(&environment_path, &environment); let error = check_registry_project(&ProjectCheckOptions { project_directory: rejected, @@ -5026,12 +5026,12 @@ fn credential_profiles_reject_mixed_registry_backed_and_source_free_evidence() { let mut document = read_yaml(&project_path); let service = &mut document["services"]["household-eligibility"]; service["claims"]["applicant-declaration"] = - serde_yaml::from_str("cel: 'true'\nvalue: { type: boolean }\ndisclosure: predicate\n") + serde_norway::from_str("cel: 'true'\nvalue: { type: boolean }\ndisclosure: predicate\n") .expect("source-free claim"); service["credential_profiles"]["household-eligibility"]["claims"] .as_sequence_mut() .expect("credential profile claims") - .push(serde_yaml::Value::String( + .push(serde_norway::Value::String( "applicant-declaration".to_string(), )); write_yaml(&project_path, &document); @@ -5077,7 +5077,7 @@ fn authored_oid4vci_binding_generates_the_complete_notary_owned_issuer() { let project_path = project.join("registry-stack.yaml"); let mut document = read_yaml(&project_path); document["services"]["household-eligibility"]["credential_profiles"]["household-eligibility"] - ["claims"] = serde_yaml::from_str("[household-record-exists]") + ["claims"] = serde_norway::from_str("[household-record-exists]") .expect("single registry-backed credential claim"); write_yaml(&project_path, &document); author_oid4vci_binding( @@ -5162,7 +5162,7 @@ fn authored_oid4vci_binding_generates_the_complete_notary_owned_issuer() { ); assert_eq!( notary["subject_access"]["allowed_formats"], - serde_yaml::from_str::( + serde_norway::from_str::( "[application/vnd.registry-notary.claim-result+json]" ) .expect("canonical evaluation format parses") @@ -5230,7 +5230,7 @@ fn authored_oid4vci_walt_profile_is_explicit_and_keeps_the_bearer_window_bounded let mut document = read_yaml(&project_path); document["services"]["household-eligibility"]["credential_profiles"]["household-eligibility"] ["claims"] = - serde_yaml::from_str("[household-record-exists]").expect("single registry-backed claim"); + serde_norway::from_str("[household-record-exists]").expect("single registry-backed claim"); write_yaml(&project_path, &document); author_oid4vci_binding( &project, @@ -5300,8 +5300,9 @@ fn authored_oid4vci_binding_rejects_open_or_incoherent_trust_topologies() { let project_path = project.join("registry-stack.yaml"); let mut document = read_yaml(&project_path); document["services"]["household-eligibility"]["credential_profiles"] - ["household-eligibility"]["claims"] = serde_yaml::from_str("[household-record-exists]") - .expect("single registry-backed credential claim"); + ["household-eligibility"]["claims"] = + serde_norway::from_str("[household-record-exists]") + .expect("single registry-backed credential claim"); write_yaml(&project_path, &document); author_oid4vci_binding( &project, @@ -5339,10 +5340,11 @@ fn authored_oid4vci_binding_rejects_open_or_incoherent_trust_topologies() { let project_path = project.join("registry-stack.yaml"); let mut document = read_yaml(&project_path); document["services"]["household-eligibility"]["credential_profiles"] - ["household-eligibility"]["claims"] = serde_yaml::from_str("[household-record-exists]") - .expect("single registry-backed claim"); + ["household-eligibility"]["claims"] = + serde_norway::from_str("[household-record-exists]") + .expect("single registry-backed claim"); document["services"]["household-eligibility"]["access"]["scopes"] = - serde_yaml::from_str(scopes).expect("access scopes"); + serde_norway::from_str(scopes).expect("access scopes"); write_yaml(&project_path, &document); author_oid4vci_binding( &project, @@ -5355,7 +5357,7 @@ fn authored_oid4vci_binding_rejects_open_or_incoherent_trust_topologies() { environment .as_mapping_mut() .expect("environment mapping") - .remove(serde_yaml::Value::String("callers".to_string())); + .remove(serde_norway::Value::String("callers".to_string())); write_yaml(&environment_path, &environment); let error = check_registry_project(&ProjectCheckOptions { project_directory: project, @@ -5383,7 +5385,7 @@ fn combined_project_without_relay_consultations_needs_no_notary_relay_workload() let project = copy_project("relay-only-records", temporary.path()); let project_path = project.join("registry-stack.yaml"); let mut authored_project = read_yaml(&project_path); - authored_project["services"]["applicant-declaration"] = serde_yaml::from_str( + authored_project["services"]["applicant-declaration"] = serde_norway::from_str( r#"kind: evidence version: 1 purpose: application-processing @@ -5403,12 +5405,12 @@ credential_profiles: {} let environment_path = project.join("environments/local.yaml"); let mut environment = read_yaml(&environment_path); - environment["callers"] = serde_yaml::from_str( + environment["callers"] = serde_norway::from_str( "application-service:\n api_key_fingerprint: { secret: APPLICATION_SERVICE_TOKEN_HASH }\n scopes: ['evidence:declaration:read']\n", ) .expect("Notary caller binding"); environment["deployment"]["notary"] = - serde_yaml::from_str("service: declaration-notary\n").expect("Notary deployment"); + serde_norway::from_str("service: declaration-notary\n").expect("Notary deployment"); write_yaml(&environment_path, &environment); let build = build_registry_project(&ProjectBuildOptions { @@ -5454,9 +5456,9 @@ fn source_free_evaluation_without_credential_profiles_omits_issuance_and_signing assert!(notary["evidence"].get("signing_keys").is_none()); assert!(notary["evidence"]["credential_profiles"] .as_mapping() - .is_some_and(serde_yaml::Mapping::is_empty)); + .is_some_and(serde_norway::Mapping::is_empty)); assert!(notary.get("oid4vci").is_none()); - let rendered = serde_yaml::to_string(¬ary).expect("Notary-only config serializes"); + let rendered = serde_norway::to_string(¬ary).expect("Notary-only config serializes"); for forbidden in [ "redis:", "direct_source:", @@ -5473,7 +5475,7 @@ fn source_free_evaluation_without_credential_profiles_omits_issuance_and_signing environment .as_mapping_mut() .expect("environment mapping") - .remove(serde_yaml::Value::String("issuance".to_string())); + .remove(serde_norway::Value::String("issuance".to_string())); write_yaml(&missing_issuance_environment, &environment); let error = check_registry_project(&ProjectCheckOptions { project_directory: missing_issuance, @@ -5490,7 +5492,7 @@ fn source_free_evaluation_without_credential_profiles_omits_issuance_and_signing let project_path = unexpected_issuance.join("registry-stack.yaml"); let mut authored_project = read_yaml(&project_path); authored_project["services"]["household-eligibility"]["credential_profiles"] = - serde_yaml::from_str("{}\n").expect("empty credential profiles"); + serde_norway::from_str("{}\n").expect("empty credential profiles"); write_yaml(&project_path, &authored_project); let error = check_registry_project(&ProjectCheckOptions { project_directory: unexpected_issuance, @@ -5510,24 +5512,24 @@ fn records_standards_share_the_validated_materialization() { let entity_path = project.join("entities/people.yaml"); let mut entity = read_yaml(&entity_path); entity["schema"]["properties"]["longitude"] = - serde_yaml::from_str("type: [integer, 'null']\nminimum: -180\nmaximum: 180\n") + serde_norway::from_str("type: [integer, 'null']\nminimum: -180\nmaximum: 180\n") .expect("longitude field"); entity["schema"]["properties"]["latitude"] = - serde_yaml::from_str("type: [integer, 'null']\nminimum: -90\nmaximum: 90\n") + serde_norway::from_str("type: [integer, 'null']\nminimum: -90\nmaximum: 90\n") .expect("latitude field"); entity["schema"]["required"] .as_sequence_mut() .expect("entity required fields") .extend([ - serde_yaml::Value::String("longitude".to_string()), - serde_yaml::Value::String("latitude".to_string()), + serde_norway::Value::String("longitude".to_string()), + serde_norway::Value::String("latitude".to_string()), ]); write_yaml(&entity_path, &entity); let project_path = project.join("registry-stack.yaml"); let mut authored_project = read_yaml(&project_path); authored_project["services"]["people-records"]["api"]["standards"]["ogc_features"] = - serde_yaml::from_str( + serde_norway::from_str( r#"collection_id: people title: Population locations geometry: @@ -5541,7 +5543,7 @@ max_geometry_vertices: 1 ) .expect("OGC spatial mapping"); authored_project["services"]["people-records"]["api"]["standards"]["sp_dci"] = - serde_yaml::from_str( + serde_norway::from_str( r#"registry: population registry_type: civil-registry record_type: person @@ -5567,19 +5569,19 @@ response_fields: { eligible: eligible } .as_sequence_mut() .expect("records projection") .extend([ - serde_yaml::Value::String("longitude".to_string()), - serde_yaml::Value::String("latitude".to_string()), + serde_norway::Value::String("longitude".to_string()), + serde_norway::Value::String("latitude".to_string()), ]); authored_project["services"]["people-records"]["api"]["filters"]["registration_status"] = - serde_yaml::from_str("[eq]").expect("SP DCI expression filter"); + serde_norway::from_str("[eq]").expect("SP DCI expression filter"); write_yaml(&project_path, &authored_project); let environment_path = project.join("environments/local.yaml"); let mut environment = read_yaml(&environment_path); environment["entities"]["people"]["columns"]["longitude"] = - serde_yaml::Value::String("longitude_deg".to_string()); + serde_norway::Value::String("longitude_deg".to_string()); environment["entities"]["people"]["columns"]["latitude"] = - serde_yaml::Value::String("latitude_deg".to_string()); + serde_norway::Value::String("latitude_deg".to_string()); write_yaml(&environment_path, &environment); let build = build_registry_project(&ProjectBuildOptions { @@ -5590,7 +5592,7 @@ response_fields: { eligible: eligible } }) .expect("enabled records standards build through Relay production validation"); let output = PathBuf::from(build.output.expect("build output")); - let relay: serde_json::Value = serde_yaml::from_slice( + let relay: serde_json::Value = serde_norway::from_slice( &std::fs::read(output.join("private/relay/config/relay.yaml")).expect("Relay config reads"), ) .expect("Relay config parses"); @@ -5943,7 +5945,7 @@ fn authored_request_literals_cannot_smuggle_secret_material() { let integration_path = project.join("integrations/eligibility/integration.yaml"); let mut integration = read_yaml(&integration_path); integration["capability"]["http"]["request"]["query"]["password"] = - serde_yaml::Value::String(SECRET_SENTINEL.to_string()); + serde_norway::Value::String(SECRET_SENTINEL.to_string()); write_yaml(&integration_path, &integration); let error = check_registry_project(&ProjectCheckOptions { project_directory: project.clone(), @@ -5964,7 +5966,7 @@ fn authored_request_literals_cannot_smuggle_secret_material() { let integration_path = project.join("integrations/eligibility/integration.yaml"); let mut integration = read_yaml(&integration_path); integration["capability"]["http"]["request"]["headers"][header] = - serde_yaml::Value::String(SECRET_SENTINEL.to_string()); + serde_norway::Value::String(SECRET_SENTINEL.to_string()); write_yaml(&integration_path, &integration); let error = check_registry_project(&ProjectCheckOptions { project_directory: project.clone(), @@ -6280,7 +6282,7 @@ fn verified_signed_baseline_classifies_semantic_review_dimensions_independently( let notary_cel_environment = notary_cel_project.join("environments/local.yaml"); let mut environment = read_yaml(¬ary_cel_environment); - environment["notary_cel"] = serde_yaml::from_str("worker_memory_bytes: 1073741824\n") + environment["notary_cel"] = serde_norway::from_str("worker_memory_bytes: 1073741824\n") .expect("Notary CEL binding parses"); write_yaml(¬ary_cel_environment, &environment); assert_change_dimensions( @@ -6403,12 +6405,12 @@ fn extend_exact_selector(project: &Path, golden_name: &str, size: usize) { for component in 2..=size { let name = format!("selector_{component}"); let declaration = if component == 4 { - serde_yaml::from_str( + serde_norway::from_str( "role: selector\ntype: string\nformat: date\nminLength: 10\nmaxLength: 10\n", ) .expect("full-date input declaration") } else { - serde_yaml::from_str(&format!( + serde_norway::from_str(&format!( "role: selector\ntype: string\nmaxLength: 8\npattern: '^S{component}$'\n" )) .expect("string input declaration") @@ -6416,14 +6418,14 @@ fn extend_exact_selector(project: &Path, golden_name: &str, size: usize) { integration["input"] .as_mapping_mut() .expect("integration input mapping") - .insert(serde_yaml::Value::String(name.clone()), declaration); + .insert(serde_norway::Value::String(name.clone()), declaration); if golden_name == "custom-system" { integration["capability"]["http"]["request"]["query"] .as_mapping_mut() .expect("HTTP query mapping") .insert( - serde_yaml::Value::String(name.clone()), - serde_yaml::from_str(&format!("input: {name}\n")) + serde_norway::Value::String(name.clone()), + serde_norway::from_str(&format!("input: {name}\n")) .expect("query input expression"), ); } else { @@ -6431,8 +6433,8 @@ fn extend_exact_selector(project: &Path, golden_name: &str, size: usize) { .as_mapping_mut() .expect("snapshot exact mapping") .insert( - serde_yaml::Value::String(name.clone()), - serde_yaml::from_str(&format!("input: {name}\n")) + serde_norway::Value::String(name.clone()), + serde_norway::from_str(&format!("input: {name}\n")) .expect("snapshot input expression"), ); } @@ -6458,8 +6460,8 @@ fn extend_exact_selector(project: &Path, golden_name: &str, size: usize) { .as_mapping_mut() .expect("consultation input mapping") .insert( - serde_yaml::Value::String(name.clone()), - serde_yaml::Value::String(format!("request.target.identifiers.{name}")), + serde_norway::Value::String(name.clone()), + serde_norway::Value::String(format!("request.target.identifiers.{name}")), ); } } @@ -6485,21 +6487,21 @@ fn extend_exact_selector(project: &Path, golden_name: &str, size: usize) { .as_mapping_mut() .expect("fixture input mapping") .insert( - serde_yaml::Value::String(format!("selector_{component}")), - serde_yaml::Value::String(value.clone()), + serde_norway::Value::String(format!("selector_{component}")), + serde_norway::Value::String(value.clone()), ); if golden_name == "custom-system" { if let Some(interactions) = document .get_mut("interactions") - .and_then(serde_yaml::Value::as_sequence_mut) + .and_then(serde_norway::Value::as_sequence_mut) { for interaction in interactions { let query = interaction["expect"]["query"] .as_mapping_mut() .expect("fixture expected query mapping"); query.insert( - serde_yaml::Value::String(format!("selector_{component}")), - serde_yaml::Value::String(value.clone()), + serde_norway::Value::String(format!("selector_{component}")), + serde_norway::Value::String(value.clone()), ); } } @@ -6519,27 +6521,27 @@ fn extend_exact_selector(project: &Path, golden_name: &str, size: usize) { .as_mapping_mut() .expect("entity properties") .insert( - serde_yaml::Value::String(name.clone()), + serde_norway::Value::String(name.clone()), if component == 4 { // Full-date canonicalization belongs to the consultation input. // Snapshot exact keys remain physical UTF-8 binary values. - serde_yaml::from_str("type: string\nmaxLength: 10\n") + serde_norway::from_str("type: string\nmaxLength: 10\n") .expect("full-date snapshot key field") } else { - serde_yaml::from_str("type: string\nmaxLength: 8\n") + serde_norway::from_str("type: string\nmaxLength: 8\n") .expect("string entity selector field") }, ); entity["schema"]["required"] .as_sequence_mut() .expect("entity required fields") - .push(serde_yaml::Value::String(name.clone())); + .push(serde_norway::Value::String(name.clone())); environment["entities"]["people"]["columns"] .as_mapping_mut() .expect("entity columns") .insert( - serde_yaml::Value::String(name), - serde_yaml::Value::String(format!("selector_col_{component}")), + serde_norway::Value::String(name), + serde_norway::Value::String(format!("selector_col_{component}")), ); } write_yaml(&entity_path, &entity); @@ -6560,7 +6562,7 @@ fn duplicate_project_integration(project: &Path, source_alias: &str, target_alia .join(target_alias) .join("integration.yaml"); let mut integration = read_yaml(&integration_path); - integration["id"] = serde_yaml::Value::String(format!("{target_alias}-integration")); + integration["id"] = serde_norway::Value::String(format!("{target_alias}-integration")); write_yaml(&integration_path, &integration); let project_path = project.join("registry-stack.yaml"); @@ -6569,8 +6571,8 @@ fn duplicate_project_integration(project: &Path, source_alias: &str, target_alia .as_mapping_mut() .expect("project integrations mapping") .insert( - serde_yaml::Value::String(target_alias.to_string()), - serde_yaml::from_str(&format!( + serde_norway::Value::String(target_alias.to_string()), + serde_norway::from_str(&format!( "file: integrations/{target_alias}/integration.yaml\n" )) .expect("project integration reference"), @@ -6600,7 +6602,7 @@ fn duplicate_project_integration(project: &Path, source_alias: &str, target_alia }) .expect("source integration consultation"); let mut duplicated_consultation = duplicated_consultation; - duplicated_consultation["integration"] = serde_yaml::Value::String(target_alias.to_string()); + duplicated_consultation["integration"] = serde_norway::Value::String(target_alias.to_string()); let service = project_document["services"] .as_mapping_mut() .and_then(|services| services.get_mut(&service_name)) @@ -6609,7 +6611,7 @@ fn duplicate_project_integration(project: &Path, source_alias: &str, target_alia .as_mapping_mut() .expect("project consultations mapping") .insert( - serde_yaml::Value::String(target_alias.to_string()), + serde_norway::Value::String(target_alias.to_string()), duplicated_consultation, ); let consultation_name = consultation_name @@ -6637,7 +6639,7 @@ fn duplicate_project_integration(project: &Path, source_alias: &str, target_alia .as_mapping_mut() .expect("project claims mapping") .insert( - serde_yaml::Value::String(claim_name.clone()), + serde_norway::Value::String(claim_name.clone()), duplicated_claim, ); for credential in service["credential_profiles"] @@ -6648,7 +6650,7 @@ fn duplicate_project_integration(project: &Path, source_alias: &str, target_alia credential["claims"] .as_sequence_mut() .expect("credential profile claims") - .push(serde_yaml::Value::String(claim_name.clone())); + .push(serde_norway::Value::String(claim_name.clone())); } write_yaml(&project_path, &project_document); rewrite_duplicated_fixture_claims( @@ -6670,7 +6672,7 @@ fn duplicate_project_integration(project: &Path, source_alias: &str, target_alia .as_mapping_mut() .expect("environment integrations mapping") .insert( - serde_yaml::Value::String(target_alias.to_string()), + serde_norway::Value::String(target_alias.to_string()), source_binding, ); write_yaml(&environment_path, &environment); @@ -6686,41 +6688,41 @@ fn rewrite_duplicated_fixture_claims(fixtures: &Path, source_claim: &str, target let Some(claims) = fixture["expect"]["claims"].as_mapping_mut() else { continue; }; - let source_key = serde_yaml::Value::String(source_claim.to_string()); + let source_key = serde_norway::Value::String(source_claim.to_string()); let Some(expected) = claims.get(&source_key).cloned() else { continue; }; claims.clear(); claims.insert( - serde_yaml::Value::String(target_claim.to_string()), + serde_norway::Value::String(target_claim.to_string()), expected, ); write_yaml(&path, &fixture); } } -fn yaml_contains_string(value: &serde_yaml::Value, needle: &str) -> bool { +fn yaml_contains_string(value: &serde_norway::Value, needle: &str) -> bool { match value { - serde_yaml::Value::String(value) => value.contains(needle), - serde_yaml::Value::Mapping(mapping) => mapping.iter().any(|(key, value)| { + serde_norway::Value::String(value) => value.contains(needle), + serde_norway::Value::Mapping(mapping) => mapping.iter().any(|(key, value)| { yaml_contains_string(key, needle) || yaml_contains_string(value, needle) }), - serde_yaml::Value::Sequence(sequence) => sequence + serde_norway::Value::Sequence(sequence) => sequence .iter() .any(|value| yaml_contains_string(value, needle)), _ => false, } } -fn replace_yaml_strings(value: &mut serde_yaml::Value, from: &str, to: &str) { +fn replace_yaml_strings(value: &mut serde_norway::Value, from: &str, to: &str) { match value { - serde_yaml::Value::String(value) => *value = value.replace(from, to), - serde_yaml::Value::Mapping(mapping) => { + serde_norway::Value::String(value) => *value = value.replace(from, to), + serde_norway::Value::Mapping(mapping) => { for value in mapping.values_mut() { replace_yaml_strings(value, from, to); } } - serde_yaml::Value::Sequence(sequence) => { + serde_norway::Value::Sequence(sequence) => { for value in sequence { replace_yaml_strings(value, from, to); } @@ -6729,21 +6731,21 @@ fn replace_yaml_strings(value: &mut serde_yaml::Value, from: &str, to: &str) { } } -fn namespace_secret_references(value: &mut serde_yaml::Value, namespace: &str) { +fn namespace_secret_references(value: &mut serde_norway::Value, namespace: &str) { let namespace = namespace.replace('-', "_").to_ascii_uppercase(); namespace_secret_references_with_suffix(value, &namespace); } -fn namespace_secret_references_with_suffix(value: &mut serde_yaml::Value, namespace: &str) { +fn namespace_secret_references_with_suffix(value: &mut serde_norway::Value, namespace: &str) { match value { - serde_yaml::Value::Mapping(mapping) => { + serde_norway::Value::Mapping(mapping) => { if let Some(secret) = mapping - .get_mut(serde_yaml::Value::String("secret".to_string())) + .get_mut(serde_norway::Value::String("secret".to_string())) .and_then(|value| value.as_str().map(ToOwned::to_owned)) { mapping.insert( - serde_yaml::Value::String("secret".to_string()), - serde_yaml::Value::String(format!("{secret}_{namespace}")), + serde_norway::Value::String("secret".to_string()), + serde_norway::Value::String(format!("{secret}_{namespace}")), ); return; } @@ -6751,7 +6753,7 @@ fn namespace_secret_references_with_suffix(value: &mut serde_yaml::Value, namesp namespace_secret_references_with_suffix(nested, namespace); } } - serde_yaml::Value::Sequence(sequence) => { + serde_norway::Value::Sequence(sequence) => { for nested in sequence { namespace_secret_references_with_suffix(nested, namespace); } @@ -6760,14 +6762,14 @@ fn namespace_secret_references_with_suffix(value: &mut serde_yaml::Value, namesp } } -fn read_yaml(path: &Path) -> serde_yaml::Value { - serde_yaml::from_slice(&std::fs::read(path).expect("YAML reads")).expect("YAML parses") +fn read_yaml(path: &Path) -> serde_norway::Value { + serde_norway::from_slice(&std::fs::read(path).expect("YAML reads")).expect("YAML parses") } -fn write_yaml(path: &Path, document: &serde_yaml::Value) { +fn write_yaml(path: &Path, document: &serde_norway::Value) { std::fs::write( path, - serde_yaml::to_string(document).expect("YAML serializes"), + serde_norway::to_string(document).expect("YAML serializes"), ) .expect("YAML writes"); } @@ -6795,7 +6797,9 @@ fn remove_custom_cel_claim(project: &Path) { service["claims"] .as_mapping_mut() .expect("custom claims") - .remove(serde_yaml::Value::String("household-eligible".to_string())); + .remove(serde_norway::Value::String( + "household-eligible".to_string(), + )); service["credential_profiles"]["household-eligibility"]["claims"] .as_sequence_mut() .expect("custom credential claims") @@ -6808,11 +6812,13 @@ fn remove_custom_cel_claim(project: &Path) { let mut document = read_yaml(&path); let claims = document .get_mut("expect") - .and_then(serde_yaml::Value::as_mapping_mut) + .and_then(serde_norway::Value::as_mapping_mut) .and_then(|expect| expect.get_mut("claims")) - .and_then(serde_yaml::Value::as_mapping_mut); + .and_then(serde_norway::Value::as_mapping_mut); if let Some(claims) = claims { - claims.remove(serde_yaml::Value::String("household-eligible".to_string())); + claims.remove(serde_norway::Value::String( + "household-eligible".to_string(), + )); } write_yaml(&path, &document); } @@ -6821,7 +6827,7 @@ fn remove_custom_cel_claim(project: &Path) { fn make_opencrvs_composite_dci(project: &Path) { let integration_path = project.join("integrations/birth-record/integration.yaml"); let mut integration = read_yaml(&integration_path); - integration["input"] = serde_yaml::from_str( + integration["input"] = serde_norway::from_str( r#"uin: role: selector type: string @@ -6840,7 +6846,7 @@ place: "#, ) .expect("composite DCI inputs"); - integration["source"]["protocol"]["signed_dci"]["selectors"] = serde_yaml::from_str( + integration["source"]["protocol"]["signed_dci"]["selectors"] = serde_norway::from_str( r#"uin: { field: identifier_value, response_pointer: /identifier/0/identifier_value } family: { field: family_name, response_pointer: /child/family_name } place: { field: place_of_birth, response_pointer: /place_of_birth } @@ -6857,7 +6863,7 @@ place: { field: place_of_birth, response_pointer: /place_of_birth } let project_path = project.join("registry-stack.yaml"); let mut project_document = read_yaml(&project_path); project_document["services"]["birth-verification"]["consultations"]["birth"]["input"] = - serde_yaml::from_str( + serde_norway::from_str( r#"uin: request.target.identifiers.uin family: request.target.identifiers.family place: request.target.identifiers.place @@ -6868,7 +6874,7 @@ place: request.target.identifiers.place service["claims"] .as_mapping_mut() .expect("OpenCRVS claims") - .remove(serde_yaml::Value::String("age-band".to_string())); + .remove(serde_norway::Value::String("age-band".to_string())); service["credential_profiles"]["birth-summary"]["claims"] .as_sequence_mut() .expect("OpenCRVS credential claims") @@ -6890,9 +6896,10 @@ place: request.target.identifiers.place continue; } let mut fixture = read_yaml(&path); - fixture["input"] = - serde_yaml::from_str("uin: '0000000001'\nfamily: Example\nplace: Fictional District\n") - .expect("composite DCI fixture inputs"); + fixture["input"] = serde_norway::from_str( + "uin: '0000000001'\nfamily: Example\nplace: Fictional District\n", + ) + .expect("composite DCI fixture inputs"); let data_interaction = fixture["interactions"] .as_sequence_mut() .and_then(|interactions| { @@ -6902,7 +6909,7 @@ place: request.target.identifiers.place }) .expect("DCI data interaction"); data_interaction["expect"]["body"]["message"]["search_request"][0]["search_criteria"] - ["query"]["predicates"] = serde_yaml::from_str( + ["query"]["predicates"] = serde_norway::from_str( r#"- { field: family_name, operator: eq, value: Example } - { field: place_of_birth, operator: eq, value: Fictional District } - { field: identifier_value, operator: eq, value: "0000000001" } @@ -6911,11 +6918,11 @@ place: request.target.identifiers.place .expect("composite DCI request predicates"); if let Some(claims) = fixture .get_mut("expect") - .and_then(serde_yaml::Value::as_mapping_mut) + .and_then(serde_norway::Value::as_mapping_mut) .and_then(|expect| expect.get_mut("claims")) - .and_then(serde_yaml::Value::as_mapping_mut) + .and_then(serde_norway::Value::as_mapping_mut) { - claims.remove(serde_yaml::Value::String("age-band".to_string())); + claims.remove(serde_norway::Value::String("age-band".to_string())); } write_yaml(&path, &fixture); } @@ -6935,7 +6942,7 @@ fn add_source_free_credential_capability(project: &Path) { let project_path = project.join("registry-stack.yaml"); let mut authored_project = read_yaml(&project_path); authored_project["services"]["applicant-evaluation"]["credential_profiles"] = - serde_yaml::from_str( + serde_norway::from_str( r#"application-declaration: format: dc+sd-jwt type: https://credentials.invalid/application-declaration/v1 @@ -6948,7 +6955,7 @@ fn add_source_free_credential_capability(project: &Path) { let environment_path = project.join("environments/local.yaml"); let mut environment = read_yaml(&environment_path); - environment["issuance"] = serde_yaml::from_str( + environment["issuance"] = serde_norway::from_str( r#"issuer: did:web:evaluation-notary.invalid signing_kid: project-issuer-key signing_key: { secret: REGISTRY_NOTARY_ISSUER_JWK } @@ -6963,18 +6970,18 @@ fn author_oid4vci_binding(project: &Path, service: &str, profile: &str, id_type: let project_path = project.join("registry-stack.yaml"); let mut authored_project = read_yaml(&project_path); authored_project["services"][service]["credential_profiles"][profile]["type"] = - serde_yaml::Value::String(format!( + serde_norway::Value::String(format!( "https://notary.example.invalid/credentials/{profile}/v1" )); write_yaml(&project_path, &authored_project); let environment_path = project.join("environments/local.yaml"); let mut environment = read_yaml(&environment_path); - environment["notary_state"] = serde_yaml::from_str( + environment["notary_state"] = serde_norway::from_str( "postgresql:\n root_certificate_path: /run/secrets/notary-postgres-ca.pem\n", ) .expect("Notary PostgreSQL state binding"); - environment["oid4vci"] = serde_yaml::from_str(&format!( + environment["oid4vci"] = serde_norway::from_str(&format!( r#"public_base_url: https://notary.example.invalid credential: service: {service} @@ -7005,9 +7012,9 @@ allowed_wallet_origins: [https://wallet.example.invalid] } fn merge_environment_yaml(path: &Path, patch: &str) { - fn merge(target: &mut serde_yaml::Value, patch: serde_yaml::Value) { + fn merge(target: &mut serde_norway::Value, patch: serde_norway::Value) { match (target, patch) { - (serde_yaml::Value::Mapping(target), serde_yaml::Value::Mapping(patch)) => { + (serde_norway::Value::Mapping(target), serde_norway::Value::Mapping(patch)) => { for (key, value) in patch { if let Some(target) = target.get_mut(&key) { merge(target, value); @@ -7023,7 +7030,7 @@ fn merge_environment_yaml(path: &Path, patch: &str) { let mut environment = read_yaml(path); merge( &mut environment, - serde_yaml::from_str(patch).expect("environment patch"), + serde_norway::from_str(patch).expect("environment patch"), ); write_yaml(path, &environment); } From 563f57029d2cc9d5d38e70ee74481c75157571e6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 22:06:46 +0700 Subject: [PATCH 61/65] fix(ci): ignore generated secret-scan caches Signed-off-by: Jeremi Joslin --- .gitleaks.toml | 8 +++++++ release/scripts/test_check_gates_inventory.py | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/.gitleaks.toml b/.gitleaks.toml index 7d74a9772..191f78afa 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -5,6 +5,14 @@ useDefault = true description = "Ignore generated Rust build output." paths = ["(^|/)target/"] +[[allowlists]] +description = "Ignore the generated, gitignored docs repository cache." +paths = ["^docs/site/\\.repo-docs-cache/"] + +[[allowlists]] +description = "Ignore the generated, gitignored VS Code test installation." +paths = ["^editors/vscode/\\.vscode-test/"] + [[allowlists]] description = "Allow synthetic JWT fixtures used only by the platform fuzz harnesses." paths = [ diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index 8086ad731..74c2e218b 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib.util +import subprocess import tomllib import unittest from pathlib import Path @@ -142,6 +143,27 @@ def test_root_secret_scan_names_all_synthetic_platform_jwt_fixtures(self) -> Non def test_root_secret_scan_does_not_keep_pre_monorepo_fuzz_paths(self) -> None: self.assertFalse(any(path.startswith("^fuzz/") for path in self.gitleaks_paths)) + def test_root_secret_scan_excludes_only_named_generated_ignored_trees(self) -> None: + generated_trees = ( + ( + r"^docs/site/\.repo-docs-cache/", + "docs/site/.repo-docs-cache/generated.txt", + ), + ( + r"^editors/vscode/\.vscode-test/", + "editors/vscode/.vscode-test/generated.txt", + ), + ) + for allowlist_path, generated_probe in generated_trees: + with self.subTest(generated_probe=generated_probe): + self.assertIn(allowlist_path, self.gitleaks_paths) + ignored = subprocess.run( + ["git", "check-ignore", "--quiet", generated_probe], + cwd=ROOT, + check=False, + ) + self.assertEqual(0, ignored.returncode) + def test_missing_platform_fuzz_bound_is_reported(self) -> None: text = self.workflow.replace("-max_total_time=60", "-runs=0") self.assertIn( From 1b6c0126dcab01e145127b2f0b9336853ea3c4d8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 22:08:29 +0700 Subject: [PATCH 62/65] feat(relay): expose last-good refresh health Signed-off-by: Jeremi Joslin --- .../fixtures/posture/default-allowlist.json | 1 + ...gistry-relay.restricted-posture.valid.json | 89 ++++ .../registry.ops.posture.v1.schema.json | 38 ++ crates/registry-platform-ops/src/lib.rs | 3 + .../tests/posture_contract.rs | 27 ++ crates/registry-relay/docs/ops.md | 26 +- crates/registry-relay/docs/release-notes.md | 4 + crates/registry-relay/src/api/admin.rs | 20 + crates/registry-relay/src/ingest/mod.rs | 452 ++++++++++++++++-- crates/registry-relay/src/ingest/refresh.rs | 15 +- crates/registry-relay/src/observability.rs | 25 + .../tests/attribute_release_api.rs | 1 + crates/registry-relay/tests/e2e_health.rs | 3 +- crates/registry-relay/tests/entity_routes.rs | 4 + .../tests/observability_metrics.rs | 64 ++- .../tests/spdci_api_standards.rs | 2 + docs/site/scripts/ops-posture-spec.test.mjs | 11 + docs/site/src/content/docs/changelog.mdx | 2 + .../docs/security/hardening-checklist.mdx | 5 + .../src/content/docs/spec/rs-op-posture.mdx | 34 +- release/contracts/selected-metrics.json | 22 + 21 files changed, 786 insertions(+), 62 deletions(-) create mode 100644 crates/registry-platform-ops/fixtures/posture/registry-relay.restricted-posture.valid.json diff --git a/crates/registry-platform-ops/fixtures/posture/default-allowlist.json b/crates/registry-platform-ops/fixtures/posture/default-allowlist.json index 882c4b0f5..12fa226e3 100644 --- a/crates/registry-platform-ops/fixtures/posture/default-allowlist.json +++ b/crates/registry-platform-ops/fixtures/posture/default-allowlist.json @@ -111,6 +111,7 @@ "/build/features", "/configuration/trusted_roots", "/standards_artifacts/*/url", + "/relay/refresh_health", "/deployment/findings/*/waiver/reason", "/deployment/waivers/*/reason", "/notary/signing_keys", diff --git a/crates/registry-platform-ops/fixtures/posture/registry-relay.restricted-posture.valid.json b/crates/registry-platform-ops/fixtures/posture/registry-relay.restricted-posture.valid.json new file mode 100644 index 000000000..083872800 --- /dev/null +++ b/crates/registry-platform-ops/fixtures/posture/registry-relay.restricted-posture.valid.json @@ -0,0 +1,89 @@ +{ + "schema": "registry.ops.posture.v1", + "observed_at": "2026-07-19T10:00:00Z", + "component": "registry-relay", + "tier": "restricted", + "instance": { + "id": "civil-relay-prod", + "environment": "production", + "owner": "Ministry of Interior", + "jurisdiction": "example-country", + "public_base_url": "https://civil-relay.example.gov" + }, + "build": { + "package": "registry-relay", + "version": "0.1.0" + }, + "runtime": { + "auth_mode": "oidc", + "admin_enabled": true, + "readiness": "ready" + }, + "configuration": { + "source": "local_file", + "dynamic_reload_supported": false, + "last_config_hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "last_bundle_id": null, + "last_bundle_sequence": null, + "last_bundle_signer_kids": [], + "last_apply_result": null, + "last_apply_at": null, + "restart_required": false + }, + "deployment": { + "profile": "production", + "findings": [] + }, + "audit": { + "write_policy": "availability_first", + "redaction_mode": "redacted", + "hash_chain": "process_local", + "keyed_integrity": "hmac", + "sink_class": "file", + "retention_owner": "operator", + "checkpoints": "supported", + "anchoring": "none" + }, + "standards_artifacts": {}, + "posture": { + "warnings": [], + "findings": [], + "audit": { + "configured": true, + "sink_type": "file", + "shipping_target_configured": true, + "shipping_target": "declared_external", + "shipping_health": "ok", + "shipping_observed_at": "2026-07-19T09:59:00Z", + "checkpoint_status": "available", + "latest_tail_hash": null, + "latest_sequence": null, + "verified_at": null, + "verification_status": "not_supported" + } + }, + "relay": { + "dataset_count": 1, + "entity_count": 1, + "aggregate_count": 0, + "evidence_offering_count": 0, + "metadata_manifest": { + "configured": false + }, + "standards_adapters": { + "ogcapi_records": "disabled", + "ogcapi_features": "disabled", + "ogcapi_edr": "disabled", + "spdci": "disabled" + }, + "refresh_health": [ + { + "dataset_id": "civil_registry", + "resource_id": "persons", + "last_successful_refresh_at": "2026-07-19T09:45:00Z", + "consecutive_refresh_failures": 3, + "serving_last_good": true + } + ] + } +} diff --git a/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json b/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json index 6252132ec..96cc914a0 100644 --- a/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json +++ b/crates/registry-platform-ops/schemas/registry.ops.posture.v1.schema.json @@ -910,6 +910,44 @@ }, "standards_adapters": { "$ref": "#/$defs/adapter_state_map" + }, + "refresh_health": { + "description": "Restricted per-resource refresh health. Producers omit this field from the default tier.", + "type": "array", + "items": { + "$ref": "#/$defs/relay_refresh_health" + } + } + } + }, + "relay_refresh_health": { + "type": "object", + "additionalProperties": false, + "required": [ + "dataset_id", + "resource_id", + "last_successful_refresh_at", + "consecutive_refresh_failures", + "serving_last_good" + ], + "properties": { + "dataset_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + }, + "resource_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + }, + "last_successful_refresh_at": { + "$ref": "#/$defs/rfc3339" + }, + "consecutive_refresh_failures": { + "type": "integer", + "minimum": 0 + }, + "serving_last_good": { + "type": "boolean" } } }, diff --git a/crates/registry-platform-ops/src/lib.rs b/crates/registry-platform-ops/src/lib.rs index d713d5e91..26c90c749 100644 --- a/crates/registry-platform-ops/src/lib.rs +++ b/crates/registry-platform-ops/src/lib.rs @@ -52,6 +52,9 @@ pub const DEFAULT_REDACTED_POSTURE_FIXTURE_V1: &str = pub const RESTRICTED_POSTURE_FIXTURE_V1: &str = include_str!("../fixtures/posture/restricted-posture.valid.json"); +pub const RELAY_RESTRICTED_POSTURE_FIXTURE_V1: &str = + include_str!("../fixtures/posture/registry-relay.restricted-posture.valid.json"); + /// JSON Schema for the audit off-host ack cursor: the local state file written /// by whatever ships audit events off-host and read by services to check /// shipping freshness. diff --git a/crates/registry-platform-ops/tests/posture_contract.rs b/crates/registry-platform-ops/tests/posture_contract.rs index a167d4968..1e382602a 100644 --- a/crates/registry-platform-ops/tests/posture_contract.rs +++ b/crates/registry-platform-ops/tests/posture_contract.rs @@ -70,11 +70,38 @@ fn posture_examples_and_redaction_fixtures_validate() { registry_platform_ops::NOTARY_POSTURE_EXAMPLE_V1, registry_platform_ops::DEFAULT_REDACTED_POSTURE_FIXTURE_V1, registry_platform_ops::RESTRICTED_POSTURE_FIXTURE_V1, + registry_platform_ops::RELAY_RESTRICTED_POSTURE_FIXTURE_V1, ] { assert_valid(&validator, &parse(fixture)); } } +#[test] +fn relay_refresh_health_is_restricted_and_schema_valid() { + let validator = posture_validator(); + let restricted = parse(registry_platform_ops::RELAY_RESTRICTED_POSTURE_FIXTURE_V1); + assert_valid(&validator, &restricted); + assert_eq!( + restricted["relay"]["refresh_health"][0]["consecutive_refresh_failures"], + 3 + ); + assert_eq!( + restricted["relay"]["refresh_health"][0]["serving_last_good"], + true + ); + + let default = registry_platform_ops::filter_posture_for_tier( + restricted, + registry_platform_ops::PostureTier::Default, + ) + .expect("restricted Relay posture filters to default"); + assert_valid(&validator, &default); + assert!( + default["relay"].get("refresh_health").is_none(), + "per-resource refresh health must stay out of the default tier" + ); +} + #[test] fn posture_audit_shipping_state_fields_round_trip() { let validator = posture_validator(); diff --git a/crates/registry-relay/docs/ops.md b/crates/registry-relay/docs/ops.md index ea1f74d60..852786159 100644 --- a/crates/registry-relay/docs/ops.md +++ b/crates/registry-relay/docs/ops.md @@ -547,7 +547,15 @@ Refresh modes: - `interval`: reload unconditionally on the configured interval. - `manual`: reload only through an admin request. -The original source file is never modified. On single-resource ingest failure, the service keeps serving the previously loaded table and marks readiness degraded when no prior generation is ready. +The original source file is never modified. When a refresh fails after a successful ingest, Relay +keeps serving the last-good table and `/ready` remains `200`. Relay records the failure against that +resource without advancing the last successful data-load timestamp. A resource with no successful +generation remains not ready. + +This refresh-health signal reports whether Relay can still poll and load a source. It does not +establish semantic or domain freshness. A successful refresh can load source data whose business +dates are old, while an unchanged `mtime` poll can prove that the source is reachable without +proving that the source owner published data on time. Manual table reload: @@ -578,6 +586,9 @@ curl -H "Authorization: Bearer $OPS_READ_API_KEY" \ ``` Use `?tier=restricted` only for trusted operations users who need the restricted projection. The default projection is redacted for broader operational sharing. +The restricted Relay posture includes per-resource `relay.refresh_health` observations. Each entry +reports the last successful data-load time, consecutive refresh failures, and whether Relay is +serving last-good data. The default tier omits these resource identifiers and observations. The independent `registry_relay:admin` scope still protects reload operations: @@ -661,6 +672,18 @@ The response is Prometheus-style `text/plain` suitable for scraping from the pri Metrics are intentionally bounded. Request metrics use low-cardinality labels such as method, route or endpoint class, and status, plus request-duration buckets. Readiness metrics are gauges derived from the ingest readiness snapshot. Metrics must not include raw query values, raw bearer tokens, request ids, API-key ids, key fingerprints, `Data-Purpose` values, or dataset row content. +Relay exports these private per-resource refresh-health gauges on the same protected metrics route: + +```text +registry_relay_ingest_consecutive_refresh_failures{dataset_id,resource_id} +registry_relay_ingest_last_successful_refresh_timestamp_seconds{dataset_id,resource_id} +``` + +Alert when `registry_relay_ingest_consecutive_refresh_failures` reaches `3`. Use a lower threshold +when the deployment's freshness service-level objective cannot tolerate three failed attempts. A +successful data load resets the count and advances the timestamp. A successful unchanged `mtime` +poll resets the count without advancing the timestamp. + Recommended scrape posture: - Scrape only the admin listener from a private monitoring network. @@ -668,6 +691,7 @@ Recommended scrape posture: - Treat `/metrics` as operational telemetry, not an audit record or per-request trace. - Use audit logs for security review and request-level accountability. - Alert on readiness gauges and elevated 5xx/error counters before routing traffic away. +- Alert on three consecutive refresh failures by default, even while `/ready` remains `200`. ## Troubleshooting diff --git a/crates/registry-relay/docs/release-notes.md b/crates/registry-relay/docs/release-notes.md index 0f7a7d25d..01d4e77d4 100644 --- a/crates/registry-relay/docs/release-notes.md +++ b/crates/registry-relay/docs/release-notes.md @@ -2,6 +2,10 @@ ## Unreleased +- Relay now surfaces per-resource last-good refresh health through protected + Prometheus gauges and restricted admin posture. Failed refreshes preserve a + valid last-good table and `/ready` remains healthy, while consecutive failure + counts and the last successful data-load timestamp support operator alerts. - The 1.0 support roster now treats OpenAPI 3.x and RFC 9457, RFC 9727 and the portable metadata family, CSV and XLSX source input, and JSON aggregate output as stable. Live OGC adapters, SP DCI routes, standards-CEL mapping, CSV and diff --git a/crates/registry-relay/src/api/admin.rs b/crates/registry-relay/src/api/admin.rs index 9ecdec382..b89b627d2 100644 --- a/crates/registry-relay/src/api/admin.rs +++ b/crates/registry-relay/src/api/admin.rs @@ -447,6 +447,7 @@ fn build_posture_with_observation( "ogcapi_edr": feature_status(cfg!(feature = "ogcapi-edr")), "spdci": feature_status(cfg!(feature = "spdci-api-standards") && config.standards.spdci.is_some()), }, + "refresh_health": relay_refresh_health(readiness), }, "posture": { "warnings": warnings, @@ -457,6 +458,25 @@ fn build_posture_with_observation( filter_posture_for_tier(posture, tier) } +fn relay_refresh_health(readiness: Option<&ReadinessSnapshot>) -> Value { + let resources = readiness + .into_iter() + .flat_map(|snapshot| &snapshot.ready) + .map(|((dataset_id, resource_id), resource)| { + json!({ + "dataset_id": dataset_id.as_str(), + "resource_id": resource_id.as_str(), + "last_successful_refresh_at": resource.registered_at + .format(&Rfc3339) + .expect("ready timestamp formats as RFC3339"), + "consecutive_refresh_failures": resource.consecutive_refresh_failures, + "serving_last_good": resource.consecutive_refresh_failures > 0, + }) + }) + .collect::>(); + Value::Array(resources) +} + fn posture_warnings(config: &Config, readiness: Option<&ReadinessSnapshot>) -> Vec { let mut warnings = Vec::new(); if !config.audit.chain { diff --git a/crates/registry-relay/src/ingest/mod.rs b/crates/registry-relay/src/ingest/mod.rs index 3f65387cf..c3e13225a 100644 --- a/crates/registry-relay/src/ingest/mod.rs +++ b/crates/registry-relay/src/ingest/mod.rs @@ -8,6 +8,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::io; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, OnceLock}; use arc_swap::ArcSwap; @@ -80,6 +81,10 @@ pub struct IngestPlan { cache_layout: Arc, df_ctx: Arc, readiness: Arc>, + /// Consecutive refresh or refresh-metadata failures while a last-good + /// table remains ready. This is separate from readiness so availability + /// does not hide refresh health. + consecutive_refresh_failures: AtomicU64, materializations: OnceLock>, /// Serialises concurrent refresh attempts so they don't pile up. refresh_lock: Mutex<()>, @@ -174,6 +179,7 @@ impl IngestPlan { cache_layout: Arc::new(CacheLayout::new(cache_root)), df_ctx, readiness: Arc::new(ArcSwap::from_pointee(ResourceReadiness::NotReady)), + consecutive_refresh_failures: AtomicU64::new(0), materializations: OnceLock::new(), refresh_lock: Mutex::new(()), } @@ -195,32 +201,34 @@ impl IngestPlan { async fn refresh_unlocked(&self) -> Result<(), IngestError> { let prior = self.readiness.load_full(); - if let Some(coordinator) = self.materializations.get() { - return self - .refresh_snapshot_exact(coordinator, prior.as_ref()) - .await; - } - let result = match self.prepare_pipeline().await { - Ok(prepared) => { - let result = { - let _publication_guard = publication_write_guard().await; - self.commit_prepared(&prepared).await - }; - if result.is_ok() { - self.finalize_prepared(&prepared).await; + let result = if let Some(coordinator) = self.materializations.get() { + self.refresh_snapshot_exact(coordinator, prior.as_ref()) + .await + } else { + match self.prepare_pipeline().await { + Ok(prepared) => { + let result = { + let _publication_guard = publication_write_guard().await; + self.commit_prepared(&prepared).await + }; + if result.is_ok() { + self.finalize_prepared(&prepared).await; + } + result } - result + Err(error) => Err(error), } - Err(error) => Err(error), }; if let Err(ref e) = result { let code = ingest_error_code(e); // Preserve prior Ready state on refresh failure (W1-15). - if !matches!(prior.as_ref(), ResourceReadiness::Ready { .. }) { + if matches!(prior.as_ref(), ResourceReadiness::Ready { .. }) { + self.record_refresh_failure(); + } else { self.store_failed(code, prior.as_ref()); } - // If we were already Ready, leave it unchanged so queries - // keep serving the last good data. + // If we were already Ready, keep the last-good table and its + // registration timestamp while surfacing the refresh failure. } result } @@ -238,12 +246,7 @@ impl IngestPlan { .await .map_err(|_| IngestError::MaterializationFailed)? { - return self - .reconcile_snapshot_exact(coordinator, active) - .await - .inspect_err(|error| { - self.store_failed(ingest_error_code(error), prior); - }); + return self.reconcile_snapshot_exact(coordinator, active).await; } } @@ -268,9 +271,6 @@ impl IngestPlan { resource_id = %self.resource_id, ); } - if !matches!(prior, ResourceReadiness::Ready { .. }) { - self.store_failed(ingest_error_code(&error), prior); - } return Err(error); } }; @@ -281,9 +281,6 @@ impl IngestPlan { Ok(pending) => pending, Err(_) => { self.discard_prepared(&prepared).await; - if !matches!(prior, ResourceReadiness::Ready { .. }) { - self.store_failed("ingest.materialization_failed", prior); - } return Err(IngestError::MaterializationFailed); } }; @@ -299,9 +296,6 @@ impl IngestPlan { } Err(error) => { self.discard_prepared(&prepared).await; - if !matches!(prior, ResourceReadiness::Ready { .. }) { - self.store_failed(ingest_error_code(&error), prior); - } Err(error) } } @@ -373,6 +367,8 @@ impl IngestPlan { } fn store_failed(&self, code: &'static str, prior: &ResourceReadiness) { + self.consecutive_refresh_failures + .store(0, Ordering::Relaxed); let since = match prior { ResourceReadiness::Failed { since, .. } => *since, _ => OffsetDateTime::now_utc(), @@ -381,6 +377,36 @@ impl IngestPlan { .store(Arc::new(ResourceReadiness::Failed { code, since })); } + fn record_refresh_failure(&self) { + if matches!(self.readiness(), ResourceReadiness::Ready { .. }) { + let _ = self.consecutive_refresh_failures.fetch_update( + Ordering::Relaxed, + Ordering::Relaxed, + |count| Some(count.saturating_add(1)), + ); + } + } + + pub(crate) fn record_unchanged_metadata_success(&self) { + if matches!(self.readiness(), ResourceReadiness::Ready { .. }) { + self.consecutive_refresh_failures + .store(0, Ordering::Relaxed); + } + } + + pub(crate) fn loaded_source_revision(&self) -> Option { + match self.readiness() { + ResourceReadiness::Ready { + source_revision, .. + } => source_revision, + ResourceReadiness::NotReady | ResourceReadiness::Failed { .. } => None, + } + } + + fn refresh_failure_count(&self) -> u64 { + self.consecutive_refresh_failures.load(Ordering::Relaxed) + } + /// Expose dataset_id for the refresh loop. pub(crate) fn dataset_id(&self) -> &DatasetId { &self.dataset_id @@ -393,12 +419,17 @@ impl IngestPlan { /// Sample connector metadata for mtime-policy polling. pub(crate) async fn connector_metadata(&self) -> Result { - if self.materializations.get().is_some() { - return Err(ConnectorError::source_unreadable( + let result = if self.materializations.get().is_some() { + Err(ConnectorError::source_unreadable( "snapshot-exact metadata polling requires an audited materialization attempt", - )); + )) + } else { + self.connector.metadata().await + }; + if result.is_err() { + self.record_refresh_failure(); } - self.connector.metadata().await + result } // ── Inner pipeline ──────────────────────────────────────────────────────── @@ -626,10 +657,13 @@ impl IngestPlan { IngestError::RegistrationFailed })?; + self.consecutive_refresh_failures + .store(0, Ordering::Relaxed); self.readiness.store(Arc::new(ResourceReadiness::Ready { ingest_ulid: prepared.readiness_ingest_ulid, schema: Arc::clone(&prepared.schema), registered_at: OffsetDateTime::now_utc(), + source_revision: prepared.source_revision.clone(), })); Ok(()) @@ -748,6 +782,10 @@ pub enum ResourceReadiness { ingest_ulid: Ulid, schema: SchemaRef, registered_at: OffsetDateTime, + /// Non-reversible digest of the connector token captured with this + /// loaded generation. Raw ETags and source timestamps are not retained + /// in this public, debug-printable state. + source_revision: Option, }, /// Last attempt failed. Carries the stable `ingest.*` code and the /// timestamp of the first failure (not the most recent). @@ -786,6 +824,7 @@ struct PreparedReloadResource { resource_id: ResourceId, plan: Arc, prior_readiness: ResourceReadiness, + prior_refresh_failures: u64, prior_table: Option, prepared: PreparedIngest, } @@ -1079,13 +1118,16 @@ impl IngestRegistry { resource_id: resource_id.clone(), plan: Arc::clone(plan), prior_readiness, + prior_refresh_failures: plan.refresh_failure_count(), prior_table: None, prepared: prepared_ingest, }); } Err(error) => { prepare_failed = true; - if !matches!(prior_readiness, ResourceReadiness::Ready { .. }) { + if matches!(prior_readiness, ResourceReadiness::Ready { .. }) { + plan.record_refresh_failure(); + } else { plan.store_failed(ingest_error_code(&error), &prior_readiness); } resources.insert( @@ -1127,6 +1169,7 @@ impl IngestRegistry { resource.prior_table = prior_table; } Err(error) => { + resource.plan.record_refresh_failure(); resources.insert( (resource.dataset_id.clone(), resource.resource_id.clone()), ResourceReloadResult { @@ -1156,7 +1199,9 @@ impl IngestRegistry { for (idx, resource) in prepared.iter().enumerate() { if let Err(error) = resource.plan.commit_prepared(&resource.prepared).await { self.rollback_committed_reload(&prepared[..idx]).await; - if !matches!(resource.prior_readiness, ResourceReadiness::Ready { .. }) { + if matches!(resource.prior_readiness, ResourceReadiness::Ready { .. }) { + resource.plan.record_refresh_failure(); + } else { resource .plan .store_failed(ingest_error_code(&error), &resource.prior_readiness); @@ -1223,6 +1268,7 @@ impl IngestRegistry { ReadyResource { ingest_ulid, registered_at, + consecutive_refresh_failures: plan.refresh_failure_count(), }, ); } @@ -1258,6 +1304,10 @@ impl IngestRegistry { resource .plan .restore_readiness(resource.prior_readiness.clone()); + resource + .plan + .consecutive_refresh_failures + .store(resource.prior_refresh_failures, Ordering::Relaxed); } Err(error) => { tracing::error!( @@ -1307,6 +1357,9 @@ pub struct ReadyResource { /// registered with the session context. The `/ready` and aggregate /// handlers treat this as the resource's `as_of`. pub registered_at: OffsetDateTime, + /// Number of consecutive refresh or metadata-poll failures after this + /// last-good table was registered. + pub consecutive_refresh_failures: u64, } /// Aggregate readiness across every resource. The `/ready` handler @@ -1354,14 +1407,16 @@ fn atomic_reload_failed_report( } fn restricted_source_revision(metadata: &crate::source::SourceMetadata) -> Option { - if let Some(etag) = metadata.etag.as_deref() { - return Some(format!( - "sha256:{}", - encode_sha256(Sha256::digest(etag.as_bytes()).into()) - )); - } - metadata.mtime.map(|mtime| { - let value = mtime.to_string(); + let change_token = metadata + .etag + .as_deref() + .map(str::to_owned) + .or_else(|| metadata.mtime.map(|mtime| mtime.to_string())); + restricted_change_token_revision(change_token.as_deref()) +} + +pub(super) fn restricted_change_token_revision(change_token: Option<&str>) -> Option { + change_token.map(|value| { format!( "sha256:{}", encode_sha256(Sha256::digest(value.as_bytes()).into()) @@ -1534,7 +1589,7 @@ fn refresh_policy_from_config(cfg: &RefreshConfig) -> RefreshPolicy { #[cfg(test)] mod tests { use std::pin::Pin; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use tempfile::TempDir; use tokio::sync::Notify; @@ -1542,8 +1597,11 @@ mod tests { use super::*; use crate::config::{FieldConfig, FieldType}; + use crate::format::csv::CsvFormat; use crate::format::{DecodedStream, FormatError, FormatFuture}; - use crate::source::{OpenedSource, SourceDescriptor, SourceFuture, SourceMetadata}; + use crate::source::{ + OpenedSource, SourceDescriptor, SourceError, SourceFuture, SourceMetadata, + }; fn id(value: &str) -> T { serde_json::from_str(&format!(r#""{value}""#)).expect("id deserializes") @@ -1576,6 +1634,68 @@ mod tests { } } + struct ToggleSource { + target: String, + open_count: AtomicUsize, + fail_open: AtomicBool, + fail_metadata: AtomicBool, + } + + impl ToggleSource { + fn new(target: &str) -> Arc { + Arc::new(Self { + target: target.to_string(), + open_count: AtomicUsize::new(0), + fail_open: AtomicBool::new(false), + fail_metadata: AtomicBool::new(false), + }) + } + } + + impl Source for ToggleSource { + fn descriptor(&self) -> SourceDescriptor { + SourceDescriptor { + scheme: "test", + target: self.target.clone(), + } + } + + fn open<'a>(&'a self) -> SourceFuture<'a, OpenedSource> { + Box::pin(async move { + self.open_count.fetch_add(1, Ordering::SeqCst); + if self.fail_open.load(Ordering::SeqCst) { + return Err(SourceError::Unreadable( + "synthetic open failure".to_string(), + )); + } + let bytes = b"id\n1\n".to_vec(); + Ok(OpenedSource { + reader: Box::pin(std::io::Cursor::new(bytes.clone())), + metadata: SourceMetadata { + size_bytes: Some(bytes.len() as u64), + etag: Some("revision-1".to_string()), + ..SourceMetadata::default() + }, + }) + }) + } + + fn metadata<'a>(&'a self) -> SourceFuture<'a, SourceMetadata> { + Box::pin(async move { + if self.fail_metadata.load(Ordering::SeqCst) { + return Err(SourceError::Unreadable( + "synthetic metadata failure".to_string(), + )); + } + Ok(SourceMetadata { + size_bytes: Some(5), + etag: Some("revision-1".to_string()), + ..SourceMetadata::default() + }) + }) + } + } + #[derive(Debug)] struct FailingFormat; @@ -1609,6 +1729,22 @@ mod tests { } } + fn csv_schema_config() -> SchemaConfig { + SchemaConfig { + strict: false, + fields: vec![FieldConfig { + name: "c0".to_string(), + r#type: FieldType::String, + nullable: true, + sensitive: false, + concept_uri: None, + codelist: None, + unit: None, + language: None, + }], + } + } + fn test_plan(format: Arc) -> IngestPlan { let tmp = TempDir::new().expect("tempdir"); IngestPlan::new( @@ -1622,6 +1758,35 @@ mod tests { ) } + fn successful_plan( + dataset: &str, + resource: &str, + source: Arc, + cache_root: Arc, + df_ctx: Arc, + ) -> Arc { + Arc::new(IngestPlan::new( + id(dataset), + id(resource), + source, + Arc::new(CsvFormat::new()), + csv_schema_config(), + cache_root, + df_ctx, + )) + } + + fn readiness_details(plan: &IngestPlan) -> (Ulid, OffsetDateTime) { + match plan.readiness() { + ResourceReadiness::Ready { + ingest_ulid, + registered_at, + .. + } => (ingest_ulid, registered_at), + other => panic!("expected ready resource, got {other:?}"), + } + } + #[tokio::test] async fn repeated_failures_preserve_failed_since_until_success() { let plan = test_plan(Arc::new(FailingFormat)); @@ -1643,6 +1808,195 @@ mod tests { assert_eq!(first_since, second_since); } + #[tokio::test] + async fn failed_manual_refresh_preserves_last_good_readiness_and_recovers() { + let tmp = TempDir::new().expect("tempdir"); + let source = ToggleSource::new("resource"); + let plan = successful_plan( + "dataset", + "resource", + Arc::clone(&source), + Arc::from(tmp.path()), + Arc::new(SessionContext::new()), + ); + plan.initial_ingest() + .await + .expect("initial ingest succeeds"); + let (initial_ulid, initial_registered_at) = readiness_details(&plan); + + source.fail_open.store(true, Ordering::SeqCst); + plan.refresh().await.expect_err("refresh fails"); + let (failed_refresh_ulid, failed_refresh_registered_at) = readiness_details(&plan); + assert_eq!(failed_refresh_ulid, initial_ulid); + assert_eq!(failed_refresh_registered_at, initial_registered_at); + assert_eq!(plan.refresh_failure_count(), 1); + + let registry = IngestRegistry { + plans: BTreeMap::from([((id("dataset"), id("resource")), Arc::clone(&plan))]), + }; + let snapshot = registry.snapshot(); + assert!(snapshot.fully_ready(), "last-good data remains ready"); + assert_eq!( + snapshot + .ready + .get(&(id("dataset"), id("resource"))) + .expect("ready resource") + .consecutive_refresh_failures, + 1 + ); + + source.fail_open.store(false, Ordering::SeqCst); + plan.refresh().await.expect("refresh recovers"); + let (recovered_ulid, _) = readiness_details(&plan); + assert_ne!(recovered_ulid, initial_ulid); + assert_eq!(plan.refresh_failure_count(), 0); + } + + #[tokio::test] + async fn mtime_first_poll_uses_loaded_token_and_recovers_health_without_reingest() { + let tmp = TempDir::new().expect("tempdir"); + let source = ToggleSource::new("resource"); + let plan = successful_plan( + "dataset", + "resource", + Arc::clone(&source), + Arc::from(tmp.path()), + Arc::new(SessionContext::new()), + ); + plan.initial_ingest() + .await + .expect("initial ingest succeeds"); + let (initial_ulid, initial_registered_at) = readiness_details(&plan); + let readiness_debug = format!("{:?}", plan.readiness()); + assert!( + !readiness_debug.contains("revision-1"), + "raw connector tokens must not enter public readiness state" + ); + assert!( + plan.loaded_source_revision() + .is_some_and(|revision| revision.starts_with("sha256:")), + "the loaded generation retains only a non-reversible token digest" + ); + + source.fail_metadata.store(true, Ordering::SeqCst); + plan.connector_metadata() + .await + .expect_err("metadata poll fails"); + assert_eq!(plan.refresh_failure_count(), 1); + + source.fail_metadata.store(false, Ordering::SeqCst); + let notified = Arc::new(Notify::new()); + let shutdown = CancellationToken::new(); + let publish = { + let notified = Arc::clone(¬ified); + Arc::new(move || notified.notify_one()) + }; + let task = tokio::spawn(run_refresh_loop( + Arc::clone(&plan), + RefreshPolicy::Mtime { + interval: Duration::from_millis(1), + }, + shutdown.clone(), + publish, + None, + )); + timeout(Duration::from_secs(1), notified.notified()) + .await + .expect("unchanged first poll publishes readiness"); + shutdown.cancel(); + task.await.expect("mtime refresh loop joins"); + + assert_eq!(plan.refresh_failure_count(), 0); + assert_eq!( + source.open_count.load(Ordering::SeqCst), + 1, + "the unchanged first poll must reuse the startup-loaded generation" + ); + assert_eq!( + readiness_details(&plan), + (initial_ulid, initial_registered_at), + "an unchanged metadata poll must not advance the last data-load timestamp" + ); + } + + #[tokio::test] + async fn refresh_failure_count_saturates() { + let tmp = TempDir::new().expect("tempdir"); + let source = ToggleSource::new("resource"); + let plan = successful_plan( + "dataset", + "resource", + source, + Arc::from(tmp.path()), + Arc::new(SessionContext::new()), + ); + plan.initial_ingest() + .await + .expect("initial ingest succeeds"); + plan.consecutive_refresh_failures + .store(u64::MAX, Ordering::Relaxed); + plan.record_refresh_failure(); + assert_eq!(plan.refresh_failure_count(), u64::MAX); + } + + #[tokio::test] + async fn atomic_reload_marks_only_the_resource_that_actually_failed() { + let tmp = TempDir::new().expect("tempdir"); + let df_ctx = Arc::new(SessionContext::new()); + let healthy_source = ToggleSource::new("healthy"); + let failing_source = ToggleSource::new("failing"); + let healthy = successful_plan( + "dataset", + "a_healthy", + Arc::clone(&healthy_source), + Arc::from(tmp.path()), + Arc::clone(&df_ctx), + ); + let failing = successful_plan( + "dataset", + "b_failing", + Arc::clone(&failing_source), + Arc::from(tmp.path()), + df_ctx, + ); + healthy + .initial_ingest() + .await + .expect("healthy initial ingest"); + failing + .initial_ingest() + .await + .expect("failing initial ingest"); + let registry = IngestRegistry { + plans: BTreeMap::from([ + ((id("dataset"), id("a_healthy")), Arc::clone(&healthy)), + ((id("dataset"), id("b_failing")), Arc::clone(&failing)), + ]), + }; + + failing_source.fail_open.store(true, Ordering::SeqCst); + let report = registry.reload_all().await; + assert_eq!(report.succeeded, 0); + assert_eq!( + report.failed, 2, + "atomic reload reports the whole batch failed" + ); + assert_eq!( + healthy.refresh_failure_count(), + 0, + "prepared peer was skipped" + ); + assert_eq!( + failing.refresh_failure_count(), + 1, + "actual failure is marked" + ); + assert!( + registry.snapshot().fully_ready(), + "both last-good tables remain ready" + ); + } + #[tokio::test] async fn refresh_loop_publishes_readiness_after_runtime_failure() { let plan = Arc::new(test_plan(Arc::new(FailingFormat))); diff --git a/crates/registry-relay/src/ingest/refresh.rs b/crates/registry-relay/src/ingest/refresh.rs index 48ae6bf6e..ffe55a2a6 100644 --- a/crates/registry-relay/src/ingest/refresh.rs +++ b/crates/registry-relay/src/ingest/refresh.rs @@ -12,7 +12,7 @@ use tokio_util::sync::CancellationToken; use crate::audit::{AuditPipeline, OperationalAuditEvent}; -use super::IngestPlan; +use super::{restricted_change_token_revision, IngestPlan}; /// Refresh schedule policy parsed from `ResourceConfig.refresh`. pub enum RefreshPolicy { @@ -102,7 +102,6 @@ async fn run_mtime_loop( audit_sink: Option>, ) { let mut backoff = BackoffState::new(); - let mut last_change_token: Option = None; loop { let wait = backoff.next_wait(interval); @@ -134,11 +133,12 @@ async fn run_mtime_loop( } }; - let current_change_token = meta.change_token.clone(); - let changed = match (&last_change_token, ¤t_change_token) { - (None, _) => true, // First poll: treat as changed. + let current_source_revision = + restricted_change_token_revision(meta.change_token.as_deref()); + let loaded_source_revision = plan.loaded_source_revision(); + let changed = match (&loaded_source_revision, ¤t_source_revision) { (Some(prev), Some(cur)) => prev != cur, - (Some(_), None) => true, // Connector lost its token: re-ingest. + _ => true, // Missing token: conservatively re-ingest. }; if !changed { @@ -149,6 +149,8 @@ async fn run_mtime_loop( ); // Successful poll (no change): reset backoff. backoff.reset(); + plan.record_unchanged_metadata_success(); + publish_readiness(); continue; } @@ -160,7 +162,6 @@ async fn run_mtime_loop( match plan.refresh().await { Ok(()) => { - last_change_token = current_change_token; backoff.reset(); publish_readiness(); } diff --git a/crates/registry-relay/src/observability.rs b/crates/registry-relay/src/observability.rs index a37e11571..7f00e4235 100644 --- a/crates/registry-relay/src/observability.rs +++ b/crates/registry-relay/src/observability.rs @@ -249,6 +249,10 @@ fn render_readiness(readiness: Option<&ReadinessSnapshot>, out: &mut String) { out.push_str("# TYPE registry_relay_readiness_unresolved_entities gauge\n"); out.push_str("# HELP registry_relay_readiness_fully_ready Whether all resources are ready and all entities are resolved, as 0 or 1.\n"); out.push_str("# TYPE registry_relay_readiness_fully_ready gauge\n"); + out.push_str("# HELP registry_relay_ingest_consecutive_refresh_failures Consecutive failed refresh attempts since the last successful data load or unchanged metadata poll.\n"); + out.push_str("# TYPE registry_relay_ingest_consecutive_refresh_failures gauge\n"); + out.push_str("# HELP registry_relay_ingest_last_successful_refresh_timestamp_seconds Unix timestamp of the last successful data load.\n"); + out.push_str("# TYPE registry_relay_ingest_last_successful_refresh_timestamp_seconds gauge\n"); let (ready, not_ready, failed, unresolved, fully_ready) = readiness.map_or((0, 0, 0, 0, 0), |snapshot| { @@ -270,6 +274,27 @@ fn render_readiness(readiness: Option<&ReadinessSnapshot>, out: &mut String) { ready, not_ready, failed, unresolved, fully_ready ) .expect("write to String cannot fail"); + + if let Some(snapshot) = readiness { + for ((dataset_id, resource_id), resource) in &snapshot.ready { + writeln!( + out, + "registry_relay_ingest_consecutive_refresh_failures{{dataset_id=\"{}\",resource_id=\"{}\"}} {}", + escape_label(dataset_id.as_str()), + escape_label(resource_id.as_str()), + resource.consecutive_refresh_failures, + ) + .expect("write to String cannot fail"); + writeln!( + out, + "registry_relay_ingest_last_successful_refresh_timestamp_seconds{{dataset_id=\"{}\",resource_id=\"{}\"}} {}", + escape_label(dataset_id.as_str()), + escape_label(resource_id.as_str()), + resource.registered_at.unix_timestamp(), + ) + .expect("write to String cannot fail"); + } + } } fn method_label(method: &str) -> &'static str { diff --git a/crates/registry-relay/tests/attribute_release_api.rs b/crates/registry-relay/tests/attribute_release_api.rs index 85a388860..4b54804b2 100644 --- a/crates/registry-relay/tests/attribute_release_api.rs +++ b/crates/registry-relay/tests/attribute_release_api.rs @@ -287,6 +287,7 @@ async fn try_server_full( ReadyResource { ingest_ulid, registered_at: time::OffsetDateTime::now_utc(), + consecutive_refresh_failures: 0, }, ); let (_tx, readiness) = watch::channel(snapshot); diff --git a/crates/registry-relay/tests/e2e_health.rs b/crates/registry-relay/tests/e2e_health.rs index f1cd3fe3d..cbfad472d 100644 --- a/crates/registry-relay/tests/e2e_health.rs +++ b/crates/registry-relay/tests/e2e_health.rs @@ -322,7 +322,7 @@ async fn ready_returns_200_without_resource_readiness_state() { } #[tokio::test] -async fn ready_returns_200_when_all_resources_registered() { +async fn ready_returns_200_when_last_good_resources_have_refresh_failures() { let dataset: DatasetId = id("social_registry"); let resource: ResourceId = id("beneficiaries"); let mut snapshot = ReadinessSnapshot::default(); @@ -331,6 +331,7 @@ async fn ready_returns_200_when_all_resources_registered() { ReadyResource { ingest_ulid: Ulid::from_string("01ARZ3NDEKTSV4RRFFQ69G5FAV").unwrap(), registered_at: time::OffsetDateTime::now_utc(), + consecutive_refresh_failures: 3, }, ); diff --git a/crates/registry-relay/tests/entity_routes.rs b/crates/registry-relay/tests/entity_routes.rs index 76469537d..cd4c6b7c9 100644 --- a/crates/registry-relay/tests/entity_routes.rs +++ b/crates/registry-relay/tests/entity_routes.rs @@ -809,6 +809,7 @@ catalog: ReadyResource { ingest_ulid: readiness_ingest_version, registered_at: time::OffsetDateTime::now_utc(), + consecutive_refresh_failures: 0, }, ); snapshot.ready.insert( @@ -816,6 +817,7 @@ catalog: ReadyResource { ingest_ulid: readiness_ingest_version, registered_at: time::OffsetDateTime::now_utc(), + consecutive_refresh_failures: 0, }, ); let (_tx, readiness) = watch::channel(snapshot); @@ -2699,6 +2701,7 @@ audit: ReadyResource { ingest_ulid: ingest_version, registered_at: time::OffsetDateTime::now_utc(), + consecutive_refresh_failures: 0, }, ); snapshot.ready.insert( @@ -2706,6 +2709,7 @@ audit: ReadyResource { ingest_ulid: ingest_version, registered_at: time::OffsetDateTime::now_utc(), + consecutive_refresh_failures: 0, }, ); let (_tx, readiness) = watch::channel(snapshot); diff --git a/crates/registry-relay/tests/observability_metrics.rs b/crates/registry-relay/tests/observability_metrics.rs index 14a9a9168..6ffdfe1a7 100644 --- a/crates/registry-relay/tests/observability_metrics.rs +++ b/crates/registry-relay/tests/observability_metrics.rs @@ -21,6 +21,7 @@ use ulid::Ulid; const ADMIN_TOKEN: &str = "metrics-admin-token-0123456789"; const METRICS_TOKEN: &str = "metrics-read-token-0123456789"; +const OPS_TOKEN: &str = "ops-read-token-0123456789"; const SENSITIVE_QUERY_VALUE: &str = "secret-query-value-7788"; const SENSITIVE_REQUEST_ID: &str = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; const SENSITIVE_KEY_ID: &str = "metrics_sensitive_key_id"; @@ -156,7 +157,13 @@ fn build_auth() -> Arc { make_fingerprint(METRICS_TOKEN), ) .expect("metrics fingerprint parses"); - Arc::new(ApiKeyAuth::new(vec![admin_entry, metrics_entry])) + let ops_entry = ApiKeyEntry::new( + "ops_reader".to_string(), + ScopeSet::from_iter(["registry_relay:ops_read"]), + make_fingerprint(OPS_TOKEN), + ) + .expect("ops fingerprint parses"); + Arc::new(ApiKeyAuth::new(vec![admin_entry, metrics_entry, ops_entry])) } fn ready_snapshot() -> ReadinessSnapshot { @@ -166,6 +173,7 @@ fn ready_snapshot() -> ReadinessSnapshot { ReadyResource { ingest_ulid: Ulid::from_string("01BX5ZZKBKACTAV9WEVGEMMVS0").unwrap(), registered_at: time::OffsetDateTime::now_utc(), + consecutive_refresh_failures: 2, }, ); snapshot @@ -311,6 +319,7 @@ fn assert_denial_body_does_not_expose_admin_state(body: &str) { "01BX5ZZKBKACTAV9WEVGEMMVS0", ADMIN_TOKEN, METRICS_TOKEN, + OPS_TOKEN, SENSITIVE_KEY_ID, ] { assert!( @@ -536,6 +545,58 @@ async fn metrics_response_is_plain_prometheus_text_with_request_and_readiness_me assert_prometheus_text(&body); assert_contains_request_metrics(&body); assert_contains_readiness_gauge(&body); + assert!(body.contains( + "registry_relay_ingest_consecutive_refresh_failures{dataset_id=\"social_registry\",resource_id=\"beneficiaries_csv\"} 2" + )); + assert!(body.contains( + "registry_relay_ingest_last_successful_refresh_timestamp_seconds{dataset_id=\"social_registry\",resource_id=\"beneficiaries_csv\"} " + )); + assert_eq!( + body.lines() + .filter(|line| line.starts_with("registry_relay_ingest_consecutive_refresh_failures{")) + .count(), + 1, + "refresh-failure series cardinality is bounded by configured ready resources" + ); +} + +#[tokio::test] +async fn per_resource_refresh_health_is_restricted_to_authorized_posture() { + let fixture = build_fixture(); + + let default = fixture + .admin + .get("/admin/v1/posture") + .add_header("x-api-key", OPS_TOKEN) + .await; + default.assert_status(StatusCode::OK); + let default: serde_json::Value = default.json(); + assert!(default["relay"].get("refresh_health").is_none()); + + let restricted = fixture + .admin + .get("/admin/v1/posture?tier=restricted") + .add_header("x-api-key", OPS_TOKEN) + .await; + restricted.assert_status(StatusCode::OK); + let restricted: serde_json::Value = restricted.json(); + assert_eq!(restricted["tier"], "restricted"); + assert_eq!( + restricted["relay"]["refresh_health"][0]["dataset_id"], + "social_registry" + ); + assert_eq!( + restricted["relay"]["refresh_health"][0]["resource_id"], + "beneficiaries_csv" + ); + assert_eq!( + restricted["relay"]["refresh_health"][0]["consecutive_refresh_failures"], + 2 + ); + assert_eq!( + restricted["relay"]["refresh_health"][0]["serving_last_good"], + true + ); } #[tokio::test] @@ -569,6 +630,7 @@ async fn metrics_do_not_expose_sensitive_or_high_cardinality_values() { SENSITIVE_QUERY_VALUE, SENSITIVE_REQUEST_ID, ADMIN_TOKEN, + OPS_TOKEN, SENSITIVE_KEY_ID, SENSITIVE_PURPOSE, ] { diff --git a/crates/registry-relay/tests/spdci_api_standards.rs b/crates/registry-relay/tests/spdci_api_standards.rs index 209f59ac8..696c8bb54 100644 --- a/crates/registry-relay/tests/spdci_api_standards.rs +++ b/crates/registry-relay/tests/spdci_api_standards.rs @@ -298,6 +298,7 @@ async fn try_server_with_config( ReadyResource { ingest_ulid, registered_at: time::OffsetDateTime::now_utc(), + consecutive_refresh_failures: 0, }, ); let (_tx, readiness) = watch::channel(snapshot); @@ -1835,6 +1836,7 @@ mod full_stack { ReadyResource { ingest_ulid, registered_at: time::OffsetDateTime::now_utc(), + consecutive_refresh_failures: 0, }, ); let (_readiness_tx, readiness) = watch::channel(snapshot); diff --git a/docs/site/scripts/ops-posture-spec.test.mjs b/docs/site/scripts/ops-posture-spec.test.mjs index 981d6220e..0e3fe3278 100644 --- a/docs/site/scripts/ops-posture-spec.test.mjs +++ b/docs/site/scripts/ops-posture-spec.test.mjs @@ -21,6 +21,10 @@ const notaryExamplePath = resolve( repositoryRoot, 'crates/registry-platform-ops/examples/registry-notary.posture.valid.json', ); +const relayRestrictedFixturePath = resolve( + repositoryRoot, + 'crates/registry-platform-ops/fixtures/posture/registry-relay.restricted-posture.valid.json', +); function frontmatter(text) { const end = text.indexOf('\n---\n', 4); @@ -42,6 +46,7 @@ test('RS-OP-POSTURE names the shipped Relay and Notary examples', () => { const page = readFileSync(specPath, 'utf8'); const relayExample = JSON.parse(readFileSync(relayExamplePath, 'utf8')); const notaryExample = JSON.parse(readFileSync(notaryExamplePath, 'utf8')); + const relayRestricted = JSON.parse(readFileSync(relayRestrictedFixturePath, 'utf8')); assert.equal(relayExample.schema, 'registry.ops.posture.v1'); assert.equal(relayExample.component, 'registry-relay'); @@ -49,9 +54,15 @@ test('RS-OP-POSTURE names the shipped Relay and Notary examples', () => { assert.equal(notaryExample.schema, 'registry.ops.posture.v1'); assert.equal(notaryExample.component, 'registry-notary'); assert.equal(notaryExample.tier, 'default'); + assert.equal(relayRestricted.component, 'registry-relay'); + assert.equal(relayRestricted.tier, 'restricted'); + assert.equal(relayRestricted.relay.refresh_health[0].serving_last_good, true); assert.match(page, /registry-relay\.posture\.valid\.json/); assert.match(page, /registry-notary\.posture\.valid\.json/); assert.match(page, /restricted-posture\.valid\.json/); + assert.match(page, /registry-relay\.restricted-posture\.valid\.json/); + assert.match(page, /three consecutive failures/); + assert.match(page, /semantic or domain freshness/); }); test('RS-OP-POSTURE records the closed v1 schema and new-identifier rule', () => { diff --git a/docs/site/src/content/docs/changelog.mdx b/docs/site/src/content/docs/changelog.mdx index 44c76aca9..52167d56d 100644 --- a/docs/site/src/content/docs/changelog.mdx +++ b/docs/site/src/content/docs/changelog.mdx @@ -18,6 +18,8 @@ relevant product pages on this site rather than duplicating release notes. ## 2026-07-19 +- Documented Relay's protected per-resource refresh-health metrics, restricted posture + observation, last-good availability behavior, and default alert at three consecutive failures. - Documented the source installer for the optional VS Code and Zed semantic-navigation integrations. Editor installation is user- or profile-scoped and remains separate from project-local schema setup through `registryctl init` or `registryctl authoring editor`. diff --git a/docs/site/src/content/docs/security/hardening-checklist.mdx b/docs/site/src/content/docs/security/hardening-checklist.mdx index d55dbe55f..3de1522f5 100644 --- a/docs/site/src/content/docs/security/hardening-checklist.mdx +++ b/docs/site/src/content/docs/security/hardening-checklist.mdx @@ -110,6 +110,11 @@ or the [Registry Notary operator configuration reference](../../products/registr - Keep the admin listener private. A publicly exposed admin surface trips `relay.admin.public_exposure` or `notary.admin.shared_exposure`, both `startup_fail` at `evidence_grade`. +- Scrape Relay metrics from the private admin network with `registry_relay:metrics_read`. Alert when + `registry_relay_ingest_consecutive_refresh_failures` reaches `3`, or sooner when the deployment's + freshness service-level objective requires it. Relay deliberately keeps `/ready` at `200` while + a valid last-good table remains available. Refresh health shows source load failures; it does not + prove that business-domain data is current. - Set explicit `cors.allowed_origins`. The default CORS policy is deny by omission; add only the origins your integration actually needs. - Enable `server.trust_proxy` with an explicit `trusted_proxies` list only when a reverse proxy diff --git a/docs/site/src/content/docs/spec/rs-op-posture.mdx b/docs/site/src/content/docs/spec/rs-op-posture.mdx index baac979db..14746515c 100644 --- a/docs/site/src/content/docs/spec/rs-op-posture.mdx +++ b/docs/site/src/content/docs/spec/rs-op-posture.mdx @@ -41,6 +41,7 @@ evolution rules that JSON Schema alone does not express. | Version | Date | Status | Change | | --- | --- | --- | --- | | 0.1.0 | 2026-07-19 | draft | Initial posture-document contract for `registry.ops.posture.v1`. | +| 0.1.1 | 2026-07-19 | draft | Added restricted Relay per-resource refresh health before the 1.0 schema freeze. | ## 1. Scope and surface @@ -110,7 +111,8 @@ The shared sections state the following categories of observation: - `standards_artifacts`: observed published-artifact references keyed by artifact label. - `posture`: warnings, actionable findings, and the observed audit-shipping state. -The `relay` section reports configured surface counts, metadata-manifest state, and adapter state. +The `relay` section reports configured surface counts, metadata-manifest state, adapter state, and +restricted per-resource refresh health. The `notary` section reports claim and credential-profile counts plus state, credential-status, federation, OID4VCI, and optional subject-access observations. @@ -121,6 +123,8 @@ and [Registry Notary default example](https://github.com/registrystack/registry- show the default projection for each component. The [restricted-tier fixture](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/fixtures/posture/restricted-posture.valid.json) shows fields that are valid but intentionally absent from the default projection. +The checked-in `registry-relay.restricted-posture.valid.json` fixture shows the Relay refresh-health +shape and its last-good serving state. Examples are observations, not deployment templates. An operator MUST supply actual deployment configuration through the product's configuration @@ -147,6 +151,8 @@ build revisions and features, trusted roots, artifact URLs, waiver reasons, sign and Notary federation identities or peers. The checked-in sensitive fixture proves that the projection excludes credentials, private keys, source URLs, subject identifiers, raw rows, claim values, and the restricted topology fields. +The default tier also excludes `relay.refresh_health`, including its dataset and resource +identifiers. REQ-OP-POSTURE-005: A `restricted` document MAY contain every field modeled by the v1 schema, including fields excluded from the default allowlist. @@ -199,12 +205,32 @@ It MUST evaluate readiness, findings, audit status, and artifact observations ac policy and MUST NOT treat any of them as an authorization grant, an external conformance claim, or proof of remote receipt or retention. +### Relay refresh health + +REQ-OP-POSTURE-010: A restricted Registry Relay producer MUST include one +`relay.refresh_health` entry for every resource with a valid last-good table. +Each entry MUST carry the configured `dataset_id` and `resource_id`, the RFC 3339 +`last_successful_refresh_at` data-load time, a saturating `consecutive_refresh_failures` count, and +`serving_last_good`. +`serving_last_good` MUST be `true` exactly when the resource remains ready and the consecutive +failure count is greater than zero. +A failed refresh or metadata poll MUST increment the count without advancing the last successful +data-load time or making a valid last-good resource unready. +A successful data load MUST reset the count and advance the time. +A successful unchanged metadata poll MUST reset the count without advancing the time. + +Refresh health describes Relay's ability to observe and load the configured source. +It MUST NOT be interpreted as semantic or domain freshness of the source records. +An operator SHOULD alert at three consecutive failures and MAY use a lower threshold when a +freshness service-level objective requires it. + ## Conformance A producer conforms to this specification when it emits a schema-valid, component-consistent, tier-labeled observation; uses the emit-only allowlist for default output; keeps restricted output -within the same secret and source-data boundary; and assigns a new schema identifier for every -post-1.0 shape change (REQ-OP-POSTURE-001 through REQ-OP-POSTURE-006 and REQ-OP-POSTURE-008). +within the same secret and source-data boundary; assigns a new schema identifier for every +post-1.0 shape change; and reports Relay refresh health when applicable (REQ-OP-POSTURE-001 through +REQ-OP-POSTURE-006, REQ-OP-POSTURE-008, and REQ-OP-POSTURE-010). A consumer conforms when it validates and dispatches by schema, enforces component pairing, handles omissions as unreported, and does not elevate posture observations into proof or authority @@ -221,6 +247,8 @@ filter are shipped in Registry Platform and exercised by contract tests. - The [posture contract tests](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-platform-ops/tests/posture_contract.rs) validate both examples, the default allowlist projection, the sensitive fixture, and exclusion of restricted fields from default output (Sections 3, 4, and 6). +- The checked-in restricted Relay posture fixture and Registry Relay admin tests validate + per-resource last-good state, tier filtering, and the live producer shape (Sections 4 and 6). - The [Registry Relay admin handler](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-relay/src/api/admin.rs) and [Registry Notary admin handler](https://github.com/registrystack/registry-stack/blob/c84b1b9288b925e7c9cc89c47c33cc1f50753d8c/crates/registry-notary-server/src/api/admin.rs) select the requested tier and return the shared posture document (Sections 1, 4, and 6). diff --git a/release/contracts/selected-metrics.json b/release/contracts/selected-metrics.json index 82fb615e0..43851d683 100644 --- a/release/contracts/selected-metrics.json +++ b/release/contracts/selected-metrics.json @@ -70,6 +70,28 @@ "labels": {}, "source": "crates/registry-relay/src/observability.rs" }, + { + "product": "registry-relay", + "name": "registry_relay_ingest_consecutive_refresh_failures", + "type": "gauge", + "meaning": "Consecutive failed refresh attempts for a ready resource since its last successful data load or unchanged metadata poll.", + "labels": { + "dataset_id": "Configured dataset identifier; cardinality is bounded by configured resources.", + "resource_id": "Configured resource identifier; cardinality is bounded by configured resources." + }, + "source": "crates/registry-relay/src/observability.rs" + }, + { + "product": "registry-relay", + "name": "registry_relay_ingest_last_successful_refresh_timestamp_seconds", + "type": "gauge", + "meaning": "Unix timestamp of the last successful data load for a ready resource.", + "labels": { + "dataset_id": "Configured dataset identifier; cardinality is bounded by configured resources.", + "resource_id": "Configured resource identifier; cardinality is bounded by configured resources." + }, + "source": "crates/registry-relay/src/observability.rs" + }, { "product": "registry-notary", "name": "registry_notary_http_requests_total", From 865921f0d8e874e3cefe548d000e8915c4abecbc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 19 Jul 2026 22:49:54 +0700 Subject: [PATCH 63/65] fix(registryctl): stage project initialization Signed-off-by: Jeremi Joslin --- crates/registryctl/CHANGELOG.md | 6 + crates/registryctl/Cargo.toml | 2 +- crates/registryctl/README.md | 4 +- crates/registryctl/src/main.rs | 11 + .../src/project_authoring/commands.rs | 277 +++++++++++++++--- crates/registryctl/tests/init_output.rs | 59 ++++ 6 files changed, 323 insertions(+), 36 deletions(-) diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md index 8dd00ece8..0b9542c56 100644 --- a/crates/registryctl/CHANGELOG.md +++ b/crates/registryctl/CHANGELOG.md @@ -12,6 +12,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). serializer, consistent with the supported configuration boundary used by Relay and Notary. +### Fixed + +- `registryctl init` now stages starter editor setup before publication and + rejects non-UTF-8 JSON destinations before invoking an initializer, avoiding + partial projects on either failure path. + ## [0.11.0] - 2026-07-18 ### Added diff --git a/crates/registryctl/Cargo.toml b/crates/registryctl/Cargo.toml index 9b609033b..6e712feb9 100644 --- a/crates/registryctl/Cargo.toml +++ b/crates/registryctl/Cargo.toml @@ -37,6 +37,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_norway.workspace = true sha2.workspace = true +tempfile = "3" time = { workspace = true } tokio = { workspace = true, features = ["io-util", "process"] } ureq = "2" @@ -45,4 +46,3 @@ zeroize.workspace = true [dev-dependencies] jsonschema = { workspace = true } -tempfile = "3" diff --git a/crates/registryctl/README.md b/crates/registryctl/README.md index 95a1908cc..7b2222cff 100644 --- a/crates/registryctl/README.md +++ b/crates/registryctl/README.md @@ -59,7 +59,9 @@ registryctl build --project-dir registry-project --environment local Initialization copies the five schemas embedded in `registryctl`, configures project-relative VS Code and Zed schema mappings, and reports the generated editor manifest. The explicit `authoring editor` command verifies the setup and safely refreshes an unchanged generated bundle -after an upgrade. +after an upgrade. Starter initialization validates the starter and editor setup in private staging +before publishing project files, so editor failure leaves the destination untouched. JSON init +requires a UTF-8 destination, validated before an initializer runs or writes project files. The authoring contract accepts one to eight exact selector inputs and up to sixteen typed inputs in total. Canonical selectors have a fixed 4096-byte diff --git a/crates/registryctl/src/main.rs b/crates/registryctl/src/main.rs index 01a6ba259..495930fe2 100644 --- a/crates/registryctl/src/main.rs +++ b/crates/registryctl/src/main.rs @@ -37,6 +37,17 @@ fn main() -> Result<()> { format, command, } => { + let destination = match (&from, command.as_deref()) { + (Some(_), None) => Some(project_dir.as_path()), + (None, Some(InitCommand::Relay { dir, .. })) + | (None, Some(InitCommand::SpreadsheetApi { dir, .. })) => Some(dir.as_path()), + _ => None, + }; + if format == OutputFormat::Json + && destination.is_some_and(|destination| destination.to_str().is_none()) + { + anyhow::bail!("init --format json requires a UTF-8 destination path; no project files were written"); + } let report = match (from, command) { (Some(starter), None) => registryctl::init_registry_project(&ProjectInitOptions { starter, diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index e4b948ee4..149557e5b 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -1,59 +1,268 @@ // SPDX-License-Identifier: Apache-2.0 +struct StagedProjectInit { + project: String, + starter_id: String, + starter_release: String, + starter_content_digest: String, +} + pub fn init_registry_project(options: &ProjectInitOptions) -> Result { - if options.directory.exists() { - let metadata = fs::symlink_metadata(&options.directory) - .context("failed to inspect project destination")?; - if metadata.file_type().is_symlink() - || !metadata.is_dir() - || fs::read_dir(&options.directory) - .context("failed to inspect project destination")? - .next() - .is_some() - { - bail!("project destination must be absent or an empty real directory"); + let destination_existed = preflight_project_init_destination(&options.directory)?; + let starter = options.starter.embedded()?; + let parent = options + .directory + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + create_dir_owner_only(parent).context("failed to create project destination parent")?; + let staging = tempfile::Builder::new() + .prefix(".registry-stack-init.transaction-") + .tempdir_in(parent) + .context("failed to create private project initialization staging")?; + + let staged = match stage_registry_project_init(starter, options.starter, staging.path()) { + Ok(staged) => staged, + Err(error) => { + return match staging.close() { + Ok(()) => Err(error.context("project initialization staging was discarded")), + Err(cleanup_error) => Err(error.context(format!( + "failed to discard private project initialization staging: {cleanup_error}" + ))), + }; } + }; + + match (destination_existed, preflight_project_init_destination(&options.directory)?) { + (false, false) => { + let staging = staging.keep(); + if let Err(error) = rename_project_init_noreplace(&staging, &options.directory) { + let _ = fs::remove_dir_all(&staging); + return Err(error); + } + } + (true, true) => { + publish_staged_project_into_existing(staging.path(), &options.directory)?; + staging + .close() + .context("failed to discard private project initialization staging")?; + } + _ => bail!( + "project destination changed while initialization was staged; no project files were published" + ), } - let starter = options.starter.embedded()?; - if !options.directory.exists() { - create_dir_owner_only(&options.directory)?; + + Ok(crate::InitReport { + schema_version: crate::INIT_REPORT_SCHEMA_VERSION, + status: "initialized", + project: staged.project, + project_kind: crate::InitProjectKind::RegistryProject, + output: options.directory.clone(), + source: crate::InitSource::Starter { + id: staged.starter_id, + release: staged.starter_release, + content_digest: staged.starter_content_digest, + content_state: "matches", + }, + artifacts: crate::InitArtifacts { + project_file: options.directory.join(PROJECT_FILE), + bruno_collection: None, + editor_manifest: Some(options.directory.join(EDITOR_MANIFEST_PATH)), + }, + }) +} + +fn preflight_project_init_destination(destination: &Path) -> Result { + let metadata = match fs::symlink_metadata(destination) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error).context("failed to inspect project destination"), + }; + if metadata.file_type().is_symlink() + || !metadata.is_dir() + || fs::read_dir(destination) + .context("failed to inspect project destination")? + .next() + .is_some() + { + bail!("project destination must be absent or an empty real directory"); } - copy_embedded_dir(starter, &options.directory)?; - let project = load_registry_project(&options.directory, None)?; + Ok(true) +} + +fn stage_registry_project_init( + starter: &include_dir::Dir<'_>, + selected_starter: ProjectStarter, + staging: &Path, +) -> Result { + copy_embedded_dir(starter, staging)?; + let project = load_registry_project(staging, None)?; let provenance = project .project .starter .as_ref() .ok_or_else(|| anyhow!("embedded project starter is missing provenance"))?; - if provenance.id != options.starter.id() { + if provenance.id != selected_starter.id() { bail!("embedded project starter provenance does not match the selected starter"); } if provenance.content_digest != project.project_content_digest { bail!("embedded project starter content digest is invalid"); } setup_registry_project_editor(&ProjectEditorSetupOptions { - project_directory: options.directory.clone(), + project_directory: staging.to_path_buf(), })?; - Ok(crate::InitReport { - schema_version: crate::INIT_REPORT_SCHEMA_VERSION, - status: "initialized", + Ok(StagedProjectInit { project: project.project.registry.id.clone(), - project_kind: crate::InitProjectKind::RegistryProject, - output: options.directory.clone(), - source: crate::InitSource::Starter { - id: provenance.id.clone(), - release: provenance.release.clone(), - content_digest: provenance.content_digest.clone(), - content_state: "matches", - }, - artifacts: crate::InitArtifacts { - project_file: options.directory.join(PROJECT_FILE), - bruno_collection: None, - editor_manifest: Some(options.directory.join(EDITOR_MANIFEST_PATH)), - }, + starter_id: provenance.id.clone(), + starter_release: provenance.release.clone(), + starter_content_digest: provenance.content_digest.clone(), }) } +fn publish_staged_project_into_existing(source: &Path, destination: &Path) -> Result<()> { + let mut entries = fs::read_dir(source) + .with_context(|| format!("failed to read staged project {}", source.display()))? + .collect::>>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let source_path = entry.path(); + let target = destination.join(entry.file_name()); + let metadata = fs::symlink_metadata(&source_path).with_context(|| { + format!("failed to inspect staged project path {}", source_path.display()) + })?; + if metadata.file_type().is_symlink() { + bail!("staged project contains a forbidden symlink"); + } + if metadata.is_dir() { + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt as _; + builder.mode(0o700); + } + builder.create(&target).with_context(|| { + format!("failed to create project directory {}", target.display()) + })?; + publish_staged_project_into_existing(&source_path, &target)?; + } else if metadata.is_file() { + write_private_file(&target, &fs::read(&source_path)?).with_context(|| { + format!("failed to publish staged project file {}", target.display()) + })?; + } else { + bail!("staged project contains an unsupported file type"); + } + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn rename_project_init_noreplace(source: &Path, destination: &Path) -> Result<()> { + rustix::fs::renameat_with( + rustix::fs::CWD, + source, + rustix::fs::CWD, + destination, + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(std::io::Error::from) + .context("failed to publish staged project without replacing an existing path") +} + +#[cfg(windows)] +fn rename_project_init_noreplace(source: &Path, destination: &Path) -> Result<()> { + fs::rename(source, destination) + .context("failed to publish staged project without replacing an existing path") +} + +#[cfg(not(any(target_os = "linux", target_vendor = "apple", windows)))] +fn rename_project_init_noreplace(_source: &Path, _destination: &Path) -> Result<()> { + bail!("atomic no-clobber project publication is unsupported on this platform") +} + +#[cfg(test)] +mod project_init_staging_tests { + use super::*; + + fn options(directory: PathBuf) -> ProjectInitOptions { + ProjectInitOptions { + starter: ProjectStarter::Http, + directory, + } + } + + fn inject_late_editor_failure() { + EDITOR_TEST_PUBLISH_FAILURE_AFTER.with(|remaining| remaining.set(Some(3))); + } + + fn assert_no_staging_directories(parent: &Path) { + assert!( + fs::read_dir(parent) + .expect("project parent reads") + .all(|entry| !entry + .expect("project parent entry reads") + .file_name() + .to_string_lossy() + .starts_with(".registry-stack-init.transaction-")), + "project initialization staging must be cleaned" + ); + } + + #[test] + fn late_editor_failure_leaves_absent_destination_untouched_and_retry_succeeds() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let destination = temporary.path().join("missing-parent/registry-project"); + inject_late_editor_failure(); + + init_registry_project(&options(destination.clone())) + .expect_err("late editor publication failure must fail init"); + assert!(!destination.exists()); + assert_no_staging_directories(destination.parent().expect("destination parent")); + + let report = init_registry_project(&options(destination.clone())) + .expect("retry after staged editor failure succeeds"); + assert_eq!(report.status, "initialized"); + assert!(destination.join(PROJECT_FILE).is_file()); + assert!(destination.join(EDITOR_MANIFEST_PATH).is_file()); + } + + #[test] + fn late_editor_failure_leaves_preexisting_empty_destination_untouched() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let destination = temporary.path().join("registry-project"); + fs::create_dir(&destination).expect("empty destination creates"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&destination, fs::Permissions::from_mode(0o750)) + .expect("destination mode sets"); + } + inject_late_editor_failure(); + + init_registry_project(&options(destination.clone())) + .expect_err("late editor publication failure must fail init"); + assert!(destination.is_dir()); + assert!( + fs::read_dir(&destination) + .expect("destination reads") + .next() + .is_none() + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + fs::metadata(&destination) + .expect("destination metadata") + .permissions() + .mode() + & 0o777, + 0o750 + ); + } + assert_no_staging_directories(temporary.path()); + } +} + fn starter_explanation(loaded: &LoadedRegistryProject) -> Value { match &loaded.project.starter { Some(starter) => json!({ diff --git a/crates/registryctl/tests/init_output.rs b/crates/registryctl/tests/init_output.rs index b7bb3dc77..242d0c568 100644 --- a/crates/registryctl/tests/init_output.rs +++ b/crates/registryctl/tests/init_output.rs @@ -265,6 +265,65 @@ fn starter_init_json_is_versioned_and_contains_only_init_facts() { } } +#[cfg(unix)] +#[test] +fn json_init_rejects_non_utf8_destinations_before_all_dispatches() { + use std::os::unix::ffi::OsStringExt as _; + + let temporary = TempDir::new().expect("temporary directory"); + for (name, before, after, needs_image_lock) in [ + ( + "starter", + &["init", "--from", "http", "--project-dir"][..], + &["--format", "json"][..], + false, + ), + ( + "relay", + &["init", "relay"][..], + &["--format", "json"][..], + true, + ), + ( + "spreadsheet-api", + &["init", "spreadsheet-api"][..], + &["--format", "json"][..], + true, + ), + ] { + let mut leaf = format!("{name}-").into_bytes(); + leaf.push(0xff); + let destination = temporary.path().join(std::ffi::OsString::from_vec(leaf)); + let missing_image_lock = temporary.path().join(format!("{name}-missing-lock.json")); + let mut command = Command::new(env!("CARGO_BIN_EXE_registryctl")); + command + .args(before) + .arg(&destination) + .args(after) + .env("REGISTRYCTL_NO_UPDATE_CHECK", "1"); + if needs_image_lock { + // Missing on purpose: the UTF-8 preflight must run before image-lock loading. + command.env("REGISTRYCTL_IMAGE_LOCK", &missing_image_lock); + } + let output = command.output().expect("registryctl runs"); + + assert!( + !output.status.success(), + "{name} init unexpectedly succeeded" + ); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("init --format json requires a UTF-8 destination path"), + "{name} stderr was: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !destination.exists(), + "{name} JSON destination validation must happen before initialization" + ); + } +} + #[test] fn relay_init_defaults_to_the_same_human_result_structure() { let temporary = TempDir::new().expect("temporary directory"); From 32640101b5940e11a9506edae0ca9e21a522c4dc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 20 Jul 2026 05:58:42 +0700 Subject: [PATCH 64/65] chore(release): prepare Registry Stack v0.12.0 beta-14 Prepare beta-14 from the frozen release/1.0 integration scope. Includes the confirmed release-blocker fixes for the local Notary listener and the pinned OpenAPI compatibility gate. Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 15 ++ CONTRIBUTING.md | 2 +- Cargo.lock | 52 +++---- Cargo.toml | 48 +++---- SECURITY.md | 10 +- crates/registry-relay/CHANGELOG.md | 7 + crates/registry-relay/docs/release-notes.md | 4 + .../openapi/registry-relay.openapi.json | 2 +- crates/registryctl/CHANGELOG.md | 16 +-- crates/registryctl/README.md | 10 +- .../bounded-http/registry-stack.yaml | 4 +- crates/registryctl/install.sh | 4 +- crates/registryctl/src/lib.rs | 15 ++ .../notary_addon/compose-fragment.yaml.tmpl | 3 + .../dhis2-tracker/registry-stack.yaml | 4 +- .../registry-stack.yaml | 4 +- .../opencrvs/registry-stack.yaml | 4 +- .../snapshot-exact/registry-stack.yaml | 4 +- crates/registryctl/tests/project_authoring.rs | 2 +- .../scripts/check-registryctl-tutorials.sh | 4 +- .../scripts/relay-release-contract.test.mjs | 2 +- docs/site/src/content/docs/changelog.mdx | 16 +++ .../docs/operate/upgrade-and-rollback.mdx | 8 +- .../content/docs/reference/api-stability.mdx | 2 +- .../docs/reference/apis/registry-notary.mdx | 2 +- .../docs/reference/apis/registry-relay.mdx | 2 +- .../src/content/docs/reference/errors.mdx | 6 +- .../content/docs/reference/registryctl.mdx | 8 +- .../docs/security/report-a-vulnerability.mdx | 2 +- .../content/docs/security/support-window.mdx | 2 +- .../tutorials/author-registry-project.mdx | 2 +- .../deploy-standalone-with-own-data.mdx | 6 +- ...blish-spreadsheet-secured-registry-api.mdx | 12 +- .../tutorials/verify-claim-registry-api.mdx | 6 +- .../docs/tutorials/verify-opencrvs-claims.mdx | 2 +- docs/site/src/data/contracts.yaml | 16 +-- docs/site/src/data/docsets.yaml | 38 ++++- docs/site/src/data/generated/contracts.json | 16 +-- docs/site/src/data/generated/docsets.json | 48 ++++++- docs/site/src/data/generated/projects.json | 18 +-- docs/site/src/data/generated/standards.json | 98 ++++++------- docs/site/src/data/projects.yaml | 18 +-- docs/site/src/data/repo-docs.yaml | 80 +++++------ docs/site/src/data/standards.yaml | 98 ++++++------- editors/tests/install_test.sh | 6 +- products/manifest/CHANGELOG.md | 4 + products/manifest/docs/release-notes.md | 4 + products/manifest/fuzz/Cargo.lock | 6 +- products/notary/CHANGELOG.md | 4 + products/notary/docs/release-notes.md | 8 ++ products/notary/fuzz/Cargo.lock | 130 ++++++++++++++---- .../notary/openapi/oasdiff-1.0-err-ignore.txt | 9 -- .../openapi/registry-notary.openapi.json | 4 +- .../notary/scripts/check-openapi-contract.sh | 56 +------- products/platform/CHANGELOG.md | 10 ++ products/platform/fuzz/Cargo.lock | 18 +-- release/VERIFY.md | 2 +- release/manifests/registry-stack-beta-14.yaml | 31 +++++ release/notes/v0.12.0.md | 113 +++++++++++++++ release/scripts/check-gates-inventory.py | 6 + release/scripts/test_registry_release.py | 2 +- 61 files changed, 728 insertions(+), 407 deletions(-) delete mode 100644 products/notary/openapi/oasdiff-1.0-err-ignore.txt create mode 100644 release/manifests/registry-stack-beta-14.yaml create mode 100644 release/notes/v0.12.0.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2199f6c39..ae895808f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ env: CARGO_LLVM_COV_VERSION: "0.8.7" GITLEAKS_VERSION: "8.30.1" GITLEAKS_LINUX_X64_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + OASDIFF_VERSION: "1.23.0" + OASDIFF_LINUX_X64_SHA256: "972b10535c3db4366b9dc3ebc11ca021279af3095267c3cffdca854a3a3c4f89" jobs: changes: @@ -341,6 +343,19 @@ jobs: with: tool: cargo-deny@0.19.8,just@1.51.0 + - name: Install pinned oasdiff + shell: bash + run: | + set -euo pipefail + mkdir -p "${RUNNER_TEMP}/bin" + curl --fail --silent --show-error --location \ + --output "${RUNNER_TEMP}/oasdiff.tar.gz" \ + "https://github.com/oasdiff/oasdiff/releases/download/v${OASDIFF_VERSION}/oasdiff_${OASDIFF_VERSION}_linux_amd64.tar.gz" + echo "${OASDIFF_LINUX_X64_SHA256} ${RUNNER_TEMP}/oasdiff.tar.gz" | sha256sum -c - + tar -xzf "${RUNNER_TEMP}/oasdiff.tar.gz" -C "${RUNNER_TEMP}/bin" oasdiff + chmod +x "${RUNNER_TEMP}/bin/oasdiff" + echo "${RUNNER_TEMP}/bin" >> "${GITHUB_PATH}" + - name: Cargo metadata run: cargo metadata --locked --format-version 1 >/tmp/registry-stack-cargo-metadata.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76656a089..4fdfdcef5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -145,7 +145,7 @@ These checks require Python 3.11 or later. ```bash python3 -m unittest release/scripts/test_registry_release.py python3 -m unittest release/scripts/test_openid_conformance_runner.py -release/scripts/registry-release validate release/manifests/registry-stack-beta-13.yaml +release/scripts/registry-release validate release/manifests/registry-stack-beta-14.yaml release/scripts/registry-release audit release/manifests/import-map-2026-06-24.yaml REGISTRY_RELEASE_SOURCE_MODE=monorepo release/scripts/check-release-source-model.sh python3 -m unittest release/scripts/test_check_release_source_model.py diff --git a/Cargo.lock b/Cargo.lock index c2457e0d4..e45c84ba4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5192,7 +5192,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "registry-config-report" -version = "0.11.0" +version = "0.12.0" dependencies = [ "jsonschema 0.46.6", "serde", @@ -5201,7 +5201,7 @@ dependencies = [ [[package]] name = "registry-language-server" -version = "0.11.0" +version = "0.12.0" dependencies = [ "anyhow", "serde_json", @@ -5214,7 +5214,7 @@ dependencies = [ [[package]] name = "registry-manifest-cli" -version = "0.11.0" +version = "0.12.0" dependencies = [ "registry-manifest-core", "serde", @@ -5225,7 +5225,7 @@ dependencies = [ [[package]] name = "registry-manifest-core" -version = "0.11.0" +version = "0.12.0" dependencies = [ "oxiri", "oxjsonld", @@ -5240,7 +5240,7 @@ dependencies = [ [[package]] name = "registry-notary" -version = "0.11.0" +version = "0.12.0" dependencies = [ "axum", "axum-test", @@ -5277,7 +5277,7 @@ dependencies = [ [[package]] name = "registry-notary-client" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "axum", @@ -5305,7 +5305,7 @@ dependencies = [ [[package]] name = "registry-notary-core" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64", "humantime-serde", @@ -5333,7 +5333,7 @@ dependencies = [ [[package]] name = "registry-notary-server" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "aws-lc-rs", @@ -5391,7 +5391,7 @@ dependencies = [ [[package]] name = "registry-notary-worker-harness" -version = "0.11.0" +version = "0.12.0" dependencies = [ "libc", "serde", @@ -5403,7 +5403,7 @@ dependencies = [ [[package]] name = "registry-platform-audit" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "hmac 0.13.0", @@ -5424,7 +5424,7 @@ dependencies = [ [[package]] name = "registry-platform-authcommon" -version = "0.11.0" +version = "0.12.0" dependencies = [ "proptest", "serde", @@ -5438,7 +5438,7 @@ dependencies = [ [[package]] name = "registry-platform-cache" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "sha2 0.11.0", @@ -5449,7 +5449,7 @@ dependencies = [ [[package]] name = "registry-platform-canonical-json" -version = "0.11.0" +version = "0.12.0" dependencies = [ "ryu-js", "serde", @@ -5459,7 +5459,7 @@ dependencies = [ [[package]] name = "registry-platform-config" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64", "registry-platform-crypto", @@ -5474,7 +5474,7 @@ dependencies = [ [[package]] name = "registry-platform-crypto" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "aws-lc-rs", @@ -5497,7 +5497,7 @@ dependencies = [ [[package]] name = "registry-platform-httpsec" -version = "0.11.0" +version = "0.12.0" dependencies = [ "axum", "http", @@ -5512,7 +5512,7 @@ dependencies = [ [[package]] name = "registry-platform-httputil" -version = "0.11.0" +version = "0.12.0" dependencies = [ "axum", "base64", @@ -5539,7 +5539,7 @@ dependencies = [ [[package]] name = "registry-platform-oid4vci" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64", "registry-platform-crypto", @@ -5554,7 +5554,7 @@ dependencies = [ [[package]] name = "registry-platform-oidc" -version = "0.11.0" +version = "0.12.0" dependencies = [ "axum", "base64", @@ -5570,7 +5570,7 @@ dependencies = [ [[package]] name = "registry-platform-ops" -version = "0.11.0" +version = "0.12.0" dependencies = [ "fs2", "jsonschema 0.46.6", @@ -5587,14 +5587,14 @@ dependencies = [ [[package]] name = "registry-platform-pdp" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde", ] [[package]] name = "registry-platform-replay" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "getrandom 0.4.3", @@ -5606,7 +5606,7 @@ dependencies = [ [[package]] name = "registry-platform-sdjwt" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "base64", @@ -5623,7 +5623,7 @@ dependencies = [ [[package]] name = "registry-platform-testing" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "axum", @@ -5649,7 +5649,7 @@ dependencies = [ [[package]] name = "registry-relay" -version = "0.11.0" +version = "0.12.0" dependencies = [ "arc-swap", "assert-json-diff", @@ -5730,7 +5730,7 @@ dependencies = [ [[package]] name = "registryctl" -version = "0.11.0" +version = "0.12.0" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 1bfa5590f..f70d02530 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "0.11.0" +version = "0.12.0" edition = "2021" rust-version = "1.95" license = "Apache-2.0" @@ -51,29 +51,29 @@ repository = "https://github.com/registrystack/registry-stack" unsafe_code = "forbid" [workspace.dependencies] -registry-config-report = { path = "crates/registry-config-report", version = "0.11.0" } -registry-language-server = { path = "crates/registry-language-server", version = "0.11.0" } -registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.11.0" } -registry-notary-client = { path = "crates/registry-notary-client", version = "0.11.0" } -registry-notary-core = { path = "crates/registry-notary-core", version = "0.11.0" } -registry-relay = { path = "crates/registry-relay", version = "0.11.0" } -registry-notary-server = { path = "crates/registry-notary-server", version = "0.11.0", default-features = false } -registry-notary-worker-harness = { path = "crates/registry-notary-worker-harness", version = "0.11.0" } -registry-platform-audit = { path = "crates/registry-platform-audit", version = "0.11.0" } -registry-platform-authcommon = { path = "crates/registry-platform-authcommon", version = "0.11.0" } -registry-platform-cache = { path = "crates/registry-platform-cache", version = "0.11.0" } -registry-platform-canonical-json = { path = "crates/registry-platform-canonical-json", version = "0.11.0" } -registry-platform-config = { path = "crates/registry-platform-config", version = "0.11.0" } -registry-platform-crypto = { path = "crates/registry-platform-crypto", version = "0.11.0" } -registry-platform-httpsec = { path = "crates/registry-platform-httpsec", version = "0.11.0" } -registry-platform-httputil = { path = "crates/registry-platform-httputil", version = "0.11.0" } -registry-platform-oid4vci = { path = "crates/registry-platform-oid4vci", version = "0.11.0" } -registry-platform-oidc = { path = "crates/registry-platform-oidc", version = "0.11.0" } -registry-platform-ops = { path = "crates/registry-platform-ops", version = "0.11.0" } -registry-platform-pdp = { path = "crates/registry-platform-pdp", version = "0.11.0" } -registry-platform-replay = { path = "crates/registry-platform-replay", version = "0.11.0" } -registry-platform-sdjwt = { path = "crates/registry-platform-sdjwt", version = "0.11.0" } -registry-platform-testing = { path = "crates/registry-platform-testing", version = "0.11.0" } +registry-config-report = { path = "crates/registry-config-report", version = "0.12.0" } +registry-language-server = { path = "crates/registry-language-server", version = "0.12.0" } +registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.12.0" } +registry-notary-client = { path = "crates/registry-notary-client", version = "0.12.0" } +registry-notary-core = { path = "crates/registry-notary-core", version = "0.12.0" } +registry-relay = { path = "crates/registry-relay", version = "0.12.0" } +registry-notary-server = { path = "crates/registry-notary-server", version = "0.12.0", default-features = false } +registry-notary-worker-harness = { path = "crates/registry-notary-worker-harness", version = "0.12.0" } +registry-platform-audit = { path = "crates/registry-platform-audit", version = "0.12.0" } +registry-platform-authcommon = { path = "crates/registry-platform-authcommon", version = "0.12.0" } +registry-platform-cache = { path = "crates/registry-platform-cache", version = "0.12.0" } +registry-platform-canonical-json = { path = "crates/registry-platform-canonical-json", version = "0.12.0" } +registry-platform-config = { path = "crates/registry-platform-config", version = "0.12.0" } +registry-platform-crypto = { path = "crates/registry-platform-crypto", version = "0.12.0" } +registry-platform-httpsec = { path = "crates/registry-platform-httpsec", version = "0.12.0" } +registry-platform-httputil = { path = "crates/registry-platform-httputil", version = "0.12.0" } +registry-platform-oid4vci = { path = "crates/registry-platform-oid4vci", version = "0.12.0" } +registry-platform-oidc = { path = "crates/registry-platform-oidc", version = "0.12.0" } +registry-platform-ops = { path = "crates/registry-platform-ops", version = "0.12.0" } +registry-platform-pdp = { path = "crates/registry-platform-pdp", version = "0.12.0" } +registry-platform-replay = { path = "crates/registry-platform-replay", version = "0.12.0" } +registry-platform-sdjwt = { path = "crates/registry-platform-sdjwt", version = "0.12.0" } +registry-platform-testing = { path = "crates/registry-platform-testing", version = "0.12.0" } crosswalk-core = { git = "https://github.com/PublicSchema/crosswalk", rev = "1d44ec735fdc8a7c719264b339574371e8330337", version = "0.2.0" } crosswalk-functions = { git = "https://github.com/PublicSchema/crosswalk", rev = "1d44ec735fdc8a7c719264b339574371e8330337", version = "0.2.0" } diff --git a/SECURITY.md b/SECURITY.md index 0bf45e2ef..166f51b34 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,14 +44,14 @@ include cosign signatures without SLSA provenance. For each signed release asset, download three files from the GitHub Release: -- The asset, for example `registryctl-v0.11.0-linux-amd64` -- The matching signature, for example `registryctl-v0.11.0-linux-amd64.sig` -- The matching certificate, for example `registryctl-v0.11.0-linux-amd64.pem` +- The asset, for example `registryctl-v0.12.0-linux-amd64` +- The matching signature, for example `registryctl-v0.12.0-linux-amd64.sig` +- The matching certificate, for example `registryctl-v0.12.0-linux-amd64.pem` Then verify the asset: ```bash -asset=registryctl-v0.11.0-linux-amd64 +asset=registryctl-v0.12.0-linux-amd64 cosign verify-blob \ --certificate "${asset}.pem" \ @@ -69,7 +69,7 @@ For releases with SLSA provenance, download the provenance asset and verify the artifact against the release tag: ```bash -tag=v0.11.0 +tag=v0.12.0 asset=registryctl-${tag}-linux-amd64 provenance=registry-stack-${tag}-release-provenance.intoto.jsonl diff --git a/crates/registry-relay/CHANGELOG.md b/crates/registry-relay/CHANGELOG.md index 1c4180071..8213f01d3 100644 --- a/crates/registry-relay/CHANGELOG.md +++ b/crates/registry-relay/CHANGELOG.md @@ -2,11 +2,18 @@ ## Unreleased +## 0.12.0 - 2026-07-19 + - Align the 1.0 standards roster with the canonical no-optional-feature release build. OpenAPI and problem contracts, portable metadata, CSV and XLSX input, and JSON aggregate output are stable. Optional standards adapters and the shipped non-gated Parquet, CSV aggregate, and SDMX-JSON surfaces are experimental and feature-frozen, outside the 1.0 compatibility promise. +- Surface protected per-resource last-good refresh health through Prometheus + metrics and restricted admin posture without failing readiness while a valid + last-good table remains available. +- Move maintained Relay runtime images and the release image to Debian 13 and + enforce the matching image-base and vulnerability policy in release gates. ## 0.11.0 - 2026-07-18 diff --git a/crates/registry-relay/docs/release-notes.md b/crates/registry-relay/docs/release-notes.md index 01d4e77d4..2a65b8714 100644 --- a/crates/registry-relay/docs/release-notes.md +++ b/crates/registry-relay/docs/release-notes.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.12.0 + - Relay now surfaces per-resource last-good refresh health through protected Prometheus gauges and restricted admin posture. Failed refreshes preserve a valid last-good table and `/ready` remains healthy, while consecutive failure @@ -14,6 +16,8 @@ excluded from the canonical release binary while their source and all-feature tests remain available. Non-feature-gated experimental formats remain shipped but outside the 1.0 compatibility promise. +- Maintained Relay runtime images now use Debian 13. Release checks enforce the + expected base and vulnerability policy before publication. ## 0.11.0 diff --git a/crates/registry-relay/openapi/registry-relay.openapi.json b/crates/registry-relay/openapi/registry-relay.openapi.json index 79bffe16b..b33ac6d8c 100644 --- a/crates/registry-relay/openapi/registry-relay.openapi.json +++ b/crates/registry-relay/openapi/registry-relay.openapi.json @@ -2903,7 +2903,7 @@ }, "summary": "Read-only data gateway exposing entity records, catalog metadata, and SHACL/DCAT-AP shapes for governed datasets.", "title": "Registry Relay API", - "version": "0.11.0" + "version": "0.12.0" }, "openapi": "3.1.0", "paths": { diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md index 0b9542c56..7d55c0402 100644 --- a/crates/registryctl/CHANGELOG.md +++ b/crates/registryctl/CHANGELOG.md @@ -6,8 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +## [0.12.0] - 2026-07-19 + ### Changed +- Interactive report commands now print concise human-readable results by + default. This covers project initialization, Notary add-on setup, offline + tests, checks, editor setup, builds, doctor validation, Config Bundle + operations, and trust-anchor operations. Add `--format json` for versioned + machine-readable reports. Artifact and protocol streams keep their existing + formats. - Registryctl now uses the workspace-pinned `serde_norway` YAML parser and serializer, consistent with the supported configuration boundary used by Relay and Notary. @@ -52,14 +60,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). catalog now drives documentation and tests for additional maintained and conformance workspaces without turning them into public starters. -### Changed - -- Interactive report commands now print concise human-readable results by default. This covers - project initialization, Notary add-on setup, offline tests, checks, editor setup, builds, - doctor validation, Config Bundle operations, and trust-anchor operations. Add `--format json` - for versioned machine-readable reports. Artifact and protocol streams keep their existing - formats. - ### Removed - BREAKING: project authoring no longer accepts credential profiles or OID4VCI diff --git a/crates/registryctl/README.md b/crates/registryctl/README.md index 7b2222cff..ca9915d3b 100644 --- a/crates/registryctl/README.md +++ b/crates/registryctl/README.md @@ -5,7 +5,7 @@ Install a pinned release without cloning this repo: ```sh -curl -fsSL https://raw.githubusercontent.com/registrystack/registry-stack/refs/tags/v0.11.0/crates/registryctl/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/registrystack/registry-stack/refs/tags/v0.12.0/crates/registryctl/install.sh | bash ``` The quick installer verifies downloaded release assets against `SHA256SUMS` @@ -99,7 +99,7 @@ workers need a platform-specific process limit. The Notary default remains 128 MiB. The maximum 1 GiB value supports emulated local runtimes and is a per-worker data/address-space ceiling, not reserved memory. -The installer defaults to `v0.11.0`. To install a different pinned release, set +The installer defaults to `v0.12.0`. To install a different pinned release, set `REGISTRYCTL_VERSION`: ```sh @@ -110,10 +110,10 @@ Fetch the installer from the same pinned tag selected by `REGISTRYCTL_VERSION`. An older installer does not know the asset contract of a newer release. -Prebuilt binaries are published for the `v0.11.0` stack release on Linux x86_64, +Prebuilt binaries are published for the `v0.12.0` stack release on Linux x86_64, Linux arm64, and macOS arm64. On other platforms, install from source with -`cargo install --git https://github.com/registrystack/registry-stack --tag v0.11.0 registryctl --locked`. -Intel macOS has no prebuilt binary for `v0.11.0`, so the installer stops after +`cargo install --git https://github.com/registrystack/registry-stack --tag v0.12.0 registryctl --locked`. +Intel macOS has no prebuilt binary for `v0.12.0`, so the installer stops after printing that Cargo command. It does not run the source build automatically. ## Release image lock (`v0.9.0` and later) diff --git a/crates/registryctl/assets/project-starters/bounded-http/registry-stack.yaml b/crates/registryctl/assets/project-starters/bounded-http/registry-stack.yaml index dae5cb20d..9b265de86 100644 --- a/crates/registryctl/assets/project-starters/bounded-http/registry-stack.yaml +++ b/crates/registryctl/assets/project-starters/bounded-http/registry-stack.yaml @@ -1,8 +1,8 @@ version: 1 starter: id: http - release: 0.11.0 - content_digest: sha256:c349257d84d442318969452fd79c82d92a9fa43798758291dc7dba30dbf02ec7 + release: 0.12.0 + content_digest: sha256:144f56e2f56c5c4d5e197347b11c074b75fa63ce39ec76da275ab4d1ae87e7f7 registry: id: fictional-citizen-registry diff --git a/crates/registryctl/install.sh b/crates/registryctl/install.sh index 63dc625a4..c4023c9d6 100755 --- a/crates/registryctl/install.sh +++ b/crates/registryctl/install.sh @@ -2,7 +2,7 @@ set -euo pipefail repo="registrystack/registry-stack" -default_version="v0.11.0" +default_version="v0.12.0" version="${REGISTRYCTL_VERSION:-$default_version}" install_dir="${REGISTRYCTL_INSTALL_DIR:-$HOME/.local/bin}" verify_url="https://github.com/${repo}/blob/main/release/VERIFY.md" @@ -19,7 +19,7 @@ unsigned. Follow the canonical release verification guide: $verify_url Environment: - REGISTRYCTL_VERSION Pinned release tag to install. Defaults to v0.11.0. + REGISTRYCTL_VERSION Pinned release tag to install. Defaults to v0.12.0. REGISTRYCTL_INSTALL_DIR Install directory. Defaults to ~/.local/bin. EOF } diff --git a/crates/registryctl/src/lib.rs b/crates/registryctl/src/lib.rs index ec26fd3c0..e580fb653 100644 --- a/crates/registryctl/src/lib.rs +++ b/crates/registryctl/src/lib.rs @@ -4700,6 +4700,14 @@ mod tests { services["registry-notary"]["network_mode"], "service:registry-notary-jwks" ); + assert_eq!( + services["registry-notary"]["environment"]["REGISTRY_NOTARY_BIND"], + "0.0.0.0:8081" + ); + assert_eq!( + services["registry-notary"]["environment"]["REGISTRY_NOTARY_HEALTHCHECK_URL"], + "http://127.0.0.1:8081/healthz" + ); assert!(consultation_mounts.iter().any(|mount| { mount == "./notary/project/.registry-stack/build/local/private/relay/config:/etc/registry-relay:ro" @@ -4783,6 +4791,7 @@ mod tests { assert!(notary_config_text.contains("person-registration-accepted")); assert!(notary_config_text.contains("pending")); let notary_config: Value = serde_norway::from_str(¬ary_config_text).unwrap(); + assert_eq!(notary_config["server"]["bind"], "0.0.0.0:8081"); assert_eq!(notary_config["state"]["storage"], "in_memory"); assert!(notary_config["evidence"]["credential_profiles"] .as_mapping() @@ -4801,6 +4810,12 @@ mod tests { assert_eq!(signing_keys.len(), 1); assert!(signing_keys.contains_key("relay-workload")); + let consultation_relay_config: Value = serde_norway::from_str( + &fs::read_to_string(project.join(CONSULTATION_RELAY_CONFIG_PATH)).unwrap(), + ) + .unwrap(); + assert_eq!(consultation_relay_config["server"]["bind"], "0.0.0.0:8082"); + let error = add_notary_to_project(&project, &image_lock).unwrap_err(); assert!(format!("{error:#}").contains("already has a Notary")); } diff --git a/crates/registryctl/src/templates/notary_addon/compose-fragment.yaml.tmpl b/crates/registryctl/src/templates/notary_addon/compose-fragment.yaml.tmpl index 97208139e..bec0401d7 100644 --- a/crates/registryctl/src/templates/notary_addon/compose-fragment.yaml.tmpl +++ b/crates/registryctl/src/templates/notary_addon/compose-fragment.yaml.tmpl @@ -74,6 +74,9 @@ services: user: "${REGISTRY_STACK_RUNTIME_UID:-65532}:${REGISTRY_STACK_RUNTIME_GID:-65532}" env_file: - ./secrets/local.env + environment: + REGISTRY_NOTARY_BIND: 0.0.0.0:8081 + REGISTRY_NOTARY_HEALTHCHECK_URL: http://127.0.0.1:8081/healthz command: [--config, /etc/registry-notary/notary.yaml] network_mode: service:registry-notary-jwks volumes: diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/registry-stack.yaml index ea71708a2..a58304d06 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/registry-stack.yaml @@ -1,8 +1,8 @@ version: 1 starter: id: dhis2-tracker - release: 0.11.0 - content_digest: sha256:49a811e0d70e4a0b3d75cb293bd6fb000b83e31b495d0f4dcdc1ed772f92d99d + release: 0.12.0 + content_digest: sha256:4cba0beae68665bfc0bd4362d136b49dbf9ac592d0f5fa7951ed6958e95a8d41 registry: { id: fictional-health-registry } integrations: health-record: { file: integrations/health-record/integration.yaml } diff --git a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/registry-stack.yaml index 071c06e8a..cdffcffb6 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/registry-stack.yaml @@ -1,8 +1,8 @@ version: 1 starter: id: fhir-r4 - release: 0.11.0 - content_digest: sha256:2708d485e48785d045c25e895179e651019d48baf23d98e4383c87188ba753f6 + release: 0.12.0 + content_digest: sha256:c024a3b77b0468af927dba986a9787a714ff67d5d590a887a9d7371ab553ad6d registry: { id: fictional-fhir-registry } integrations: coverage: { file: integrations/coverage/integration.yaml } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/registry-stack.yaml index b15d899f4..8cf84bcb1 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/registry-stack.yaml @@ -1,8 +1,8 @@ version: 1 starter: id: opencrvs-dci - release: 0.11.0 - content_digest: sha256:4d1be45915d5130540771bfd067344ef8b377988d441b736da4b6f4fcd062e36 + release: 0.12.0 + content_digest: sha256:750441a1a72b01aeb98485e2fc9afb25ecc0545a403b79cdb43cc43db963f069 registry: { id: fictional-civil-registry } integrations: birth-record: { file: integrations/birth-record/integration.yaml } diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/registry-stack.yaml index ecb21f272..d8feaa3d0 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/registry-stack.yaml @@ -1,8 +1,8 @@ version: 1 starter: id: snapshot - release: 0.11.0 - content_digest: sha256:de0b3bfc64823e5f8bbf10554427ec838602a700a03409a080ad70bde03d3fe5 + release: 0.12.0 + content_digest: sha256:8a41ad6dbd6603da449ba9cf44d30406214ea4256bbc36c4892a43953640d38a registry: { id: fictional-population-registry } integrations: person-snapshot: { file: integrations/person-snapshot/integration.yaml } diff --git a/crates/registryctl/tests/project_authoring.rs b/crates/registryctl/tests/project_authoring.rs index ae2468c94..b6b25428c 100644 --- a/crates/registryctl/tests/project_authoring.rs +++ b/crates/registryctl/tests/project_authoring.rs @@ -4308,7 +4308,7 @@ fn check_and_build_produce_deterministic_product_inputs() { assert_eq!(first_closure, directory_closure(&output)); assert_eq!( closure_digest(&first_closure), - "2a85af0238a9a3606e34bc66cad4d5e21b93832809ee2fd2c0101f1bcfffc76b", + "a2b871f191e463b9948fb79607a2f7a1c271aa2120d92b181d8ab99e7bc2a8c2", "project inputs must match the cross-machine golden digest" ); } diff --git a/docs/site/scripts/check-registryctl-tutorials.sh b/docs/site/scripts/check-registryctl-tutorials.sh index 9e2a15088..8c0c27547 100644 --- a/docs/site/scripts/check-registryctl-tutorials.sh +++ b/docs/site/scripts/check-registryctl-tutorials.sh @@ -301,7 +301,7 @@ run_relay_tutorial() { '["Install registryctl","Create the sample project","Start the local stack","Run the smoke check","Load local demo keys","Make one denied request","Make one allowed request","Read one protected record","Read one protected record","Read restricted identity fields","Read restricted identity fields","Inspect the generated contract","Inspect the generated contract","Run an aggregate","Change the disclosure rule","Change the disclosure rule","Change the disclosure rule","Change the disclosure rule","Change the disclosure rule","Stop the stack"]' node "$HELPER" extract-shell "$RELAY_TUTORIAL" "$blocks" - expected_install=$'curl -fsSL https://raw.githubusercontent.com/registrystack/registry-stack/refs/tags/v0.11.0/crates/registryctl/install.sh | REGISTRYCTL_VERSION=v0.11.0 bash\nregistryctl --version' + expected_install=$'curl -fsSL https://raw.githubusercontent.com/registrystack/registry-stack/refs/tags/v0.12.0/crates/registryctl/install.sh | REGISTRYCTL_VERSION=v0.12.0 bash\nregistryctl --version' if [[ "$(cat "$blocks/01.sh")" != "$expected_install" ]]; then printf 'release-only install block changed; update the explicit source-under-test boundary\n' >&2 exit 1 @@ -316,7 +316,7 @@ run_relay_tutorial() { cd "$tutorial_root" printf '\nrelease installer skipped: this gate uses the checked-out registryctl; GH#198 verifies release assets\n' run_block 'Relay 1: source registryctl version' "$version_block" success - assert_contains "$LAST_OUTPUT" 'registryctl 0.11.0' + assert_contains "$LAST_OUTPUT" 'registryctl 0.12.0' run_block 'Relay 2: Create the sample project' "$blocks/02.sh" success CURRENT_SECRET_FILE="$PWD/secrets/local.env" run_block 'Relay 3: Start the local stack' "$blocks/03.sh" success diff --git a/docs/site/scripts/relay-release-contract.test.mjs b/docs/site/scripts/relay-release-contract.test.mjs index 01421e197..6c1468c2f 100644 --- a/docs/site/scripts/relay-release-contract.test.mjs +++ b/docs/site/scripts/relay-release-contract.test.mjs @@ -177,7 +177,7 @@ test('canonical Relay release, local image, and OpenAPI use the same feature set 'stable link-free OGC Records metadata must remain in the pinned OpenAPI', ); - const justfile = await readRepo('crates/registry-relay/Justfile'); + const justfile = await readRepo('crates/registry-relay/justfile'); assert.match(justfile, /^\s*cargo test --all-features$/m, 'all-feature tests must remain enabled'); }); diff --git a/docs/site/src/content/docs/changelog.mdx b/docs/site/src/content/docs/changelog.mdx index 52167d56d..fa314b2d7 100644 --- a/docs/site/src/content/docs/changelog.mdx +++ b/docs/site/src/content/docs/changelog.mdx @@ -18,6 +18,22 @@ relevant product pages on this site rather than duplicating release notes. ## 2026-07-19 +Documentation updates for the v0.12.0 beta-14 release: + +- Advanced current source citations, install commands, verification examples, + contracts, projects, and standards links to Registry Stack `v0.12.0`. +- Added the `v0.12.0` archived docset while retaining the v0.11.0 and earlier + archives. +- Documented Registry Notary's issuer-initiated pre-authorized-code profile, + bounded batch evaluation, generated runtime configuration schema, and + fail-closed credential-status verification. +- Documented Registry Relay's intended 1.0 support roster and protected + per-resource last-good refresh health. +- Updated Registryctl tutorials for human-readable report defaults and the + matching v0.12.0 binary, image lock, and image generation. +- Recorded the Debian 13 runtime transition and kept candidate-only lifecycle, + external integration, OpenID, Solmara, hosted, and pilot evidence held. + - Documented Relay's protected per-resource refresh-health metrics, restricted posture observation, last-good availability behavior, and default alert at three consecutive failures. - Documented the source installer for the optional VS Code and Zed semantic-navigation diff --git a/docs/site/src/content/docs/operate/upgrade-and-rollback.mdx b/docs/site/src/content/docs/operate/upgrade-and-rollback.mdx index 6ac6daec4..4a9e53455 100644 --- a/docs/site/src/content/docs/operate/upgrade-and-rollback.mdx +++ b/docs/site/src/content/docs/operate/upgrade-and-rollback.mdx @@ -32,7 +32,7 @@ release evidence. ## Prerequisites -- Your deployment pins a version tag (`v0.11.0`) or an image digest, never `latest`. +- Your deployment pins a version tag (`v0.12.0`) or an image digest, never `latest`. - You know where your deployment keeps its persistent state. At minimum: the audit log directory of each product, the anti-rollback state file if `config_trust` is enabled (`antirollback_state_path`), each PostgreSQL database backing Relay consultation or Registry @@ -42,7 +42,7 @@ release evidence. before changing the running containers. Store each release's config and anti-rollback state as a separate restore set; do not overwrite an older set with a newer file. - You can run the release-verification steps in - [SECURITY.md](https://github.com/registrystack/registry-stack/blob/v0.11.0/SECURITY.md) + [SECURITY.md](https://github.com/registrystack/registry-stack/blob/v0.12.0/SECURITY.md) (cosign, and slsa-verifier for releases with provenance). ## Upgrade @@ -124,14 +124,14 @@ release evidence. - Metrics are flowing to your monitoring. This procedure has been partially exercised against a real topology: the - [v0.8.3 -> v0.8.4 exercise record](https://github.com/registrystack/registry-stack/blob/v0.11.0/release/exercises/upgrade-v0.8.3-to-v0.8.4.md) + [v0.8.3 -> v0.8.4 exercise record](https://github.com/registrystack/registry-stack/blob/v0.12.0/release/exercises/upgrade-v0.8.3-to-v0.8.4.md) covers the upgrade and roll-back steps above; release-artifact verification, credential issuance, metrics, the historical Redis replay/nonce path used by that release, and anti-rollback monotonicity were not exercised by that run. Current Registry Notary releases keep replay and nonce correctness state in PostgreSQL. The current - [v0.10.0 -> v0.11.0 -> v0.10.0 release-gate procedure](https://github.com/registrystack/registry-stack/blob/v0.11.0/release/exercises/upgrade-v0.10.0-to-v0.11.0.md) + [v0.10.0 -> v0.11.0 -> v0.10.0 release-gate procedure](https://github.com/registrystack/registry-stack/blob/v0.12.0/release/exercises/upgrade-v0.10.0-to-v0.11.0.md) adds old-evaluation rejection and re-evaluation before issuance, candidate-neutral multiple-credential rejection, and retained audit-chain recovery. It is a prepared procedure and does not claim a successful run diff --git a/docs/site/src/content/docs/reference/api-stability.mdx b/docs/site/src/content/docs/reference/api-stability.mdx index ff02191ac..941c2c89a 100644 --- a/docs/site/src/content/docs/reference/api-stability.mdx +++ b/docs/site/src/content/docs/reference/api-stability.mdx @@ -52,7 +52,7 @@ The enforcement column names the repository CI checks so the mechanism is audita | Registry Manifest schema and rendered artifacts | `registry-manifest/v1` and the rendered artifact schema versions, governed by [RS-DM-MANIFEST](../../spec/rs-dm-manifest/) | `validate_manifest` accepts only `registry-manifest/v1`; REQ-DM-MANIFEST-013 requires strict unknown-key rejection at parse time | | Source-adaptation integration surfaces | Registry Relay's compiled `http`, `script`, and `snapshot` consultation plans, including the Rhai `consult(ctx)` host API and versioned protocol helpers | Registry Stack project-authoring schemas and fixtures; Relay source-plan compiler and consultation contract tests | | Command-line interfaces | Documented commands and flags of `registryctl`, `registry-relay`, `registry-notary`, and `registry-manifest`, and their machine-readable output modes (`--format json`, `openapi`, `schema`) | CLI reference pages; `registryctl` version test | -| Release artifacts and verification interface | Released binary and image names (`ghcr.io/registrystack/registry-relay`, `ghcr.io/registrystack/registry-notary`), signature and provenance layout per `release/VERIFY.md` | Release workflow; signed assets verified as documented in [SECURITY.md](https://github.com/registrystack/registry-stack/blob/v0.11.0/SECURITY.md) | +| Release artifacts and verification interface | Released binary and image names (`ghcr.io/registrystack/registry-relay`, `ghcr.io/registrystack/registry-notary`), signature and provenance layout per `release/VERIFY.md` | Release workflow; signed assets verified as documented in [SECURITY.md](https://github.com/registrystack/registry-stack/blob/v0.12.0/SECURITY.md) | | Selected Prometheus metrics | Family name, type, label keys, and label meaning in `release/contracts/selected-metrics.json` | `release/scripts/check-stable-surface-compatibility.py` permits additive families and rejects incompatible changes; product tests verify bounded labels and rendering | Metric help text, output ordering, and corrections to observed values are not covered. diff --git a/docs/site/src/content/docs/reference/apis/registry-notary.mdx b/docs/site/src/content/docs/reference/apis/registry-notary.mdx index d77cd1977..f0812a624 100644 --- a/docs/site/src/content/docs/reference/apis/registry-notary.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-notary.mdx @@ -128,7 +128,7 @@ or execute direct registry source connectors. `registry.admin.capability.not_supported`; key and configuration changes require a service restart. - Admin posture: `GET /admin/v1/posture` returns the shared versioned operations observation. - [RS-OP-POSTURE](../../spec/rs-op-posture/) defines its envelope, tiers, and consumer rules. + [RS-OP-POSTURE](../../../spec/rs-op-posture/) defines its envelope, tiers, and consumer rules. ## Behavior the schema cannot express diff --git a/docs/site/src/content/docs/reference/apis/registry-relay.mdx b/docs/site/src/content/docs/reference/apis/registry-relay.mdx index a1f7d00ef..f43cc6609 100644 --- a/docs/site/src/content/docs/reference/apis/registry-relay.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-relay.mdx @@ -74,7 +74,7 @@ For setup steps, scope strings, and credential configuration, see - **Admin routes.** Reload and metrics routes are mounted on a separate, optional admin listener, require `registry_relay:admin` for reload/config mutation, `registry_relay:metrics_read` for metrics, and `registry_relay:ops_read` for operational posture reads. They do not appear in the - public OpenAPI document. [RS-OP-POSTURE](../../spec/rs-op-posture/) defines the versioned + public OpenAPI document. [RS-OP-POSTURE](../../../spec/rs-op-posture/) defines the versioned posture document returned by `GET /admin/v1/posture`. - **Evidence-offering discovery.** Relay publishes evidence-offering metadata at `GET /metadata/evidence-offerings` and `GET /metadata/evidence-offerings/{offering_id}`. diff --git a/docs/site/src/content/docs/reference/errors.mdx b/docs/site/src/content/docs/reference/errors.mdx index af0ab45b5..60d5afada 100644 --- a/docs/site/src/content/docs/reference/errors.mdx +++ b/docs/site/src/content/docs/reference/errors.mdx @@ -345,10 +345,10 @@ Registry Relay returns namespaced codes from its error taxonomy. Codes in the co ## Source The codes above are transcribed from the error taxonomies in the owning crates. -For the v0.11.0 source definitions, read the -[Registry Notary `EvidenceError` taxonomy](https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-core/src/error.rs) +For the v0.12.0 source definitions, read the +[Registry Notary `EvidenceError` taxonomy](https://github.com/registrystack/registry-stack/blob/v0.12.0/crates/registry-notary-core/src/error.rs) and the -[Registry Relay error taxonomy](https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/src/error.rs). +[Registry Relay error taxonomy](https://github.com/registrystack/registry-stack/blob/v0.12.0/crates/registry-relay/src/error.rs). The Notary server-layer codes (federation, credential status, configuration, admin, readiness, posture, and request handling) are defined in the `registry-notary-server` crate, principally `src/federation/errors.rs`, `src/federation/mod.rs`, and `src/api.rs`. The release compatibility gate compares this registry to its base reference and verifies that every diff --git a/docs/site/src/content/docs/reference/registryctl.mdx b/docs/site/src/content/docs/reference/registryctl.mdx index 65404c135..03803d206 100644 --- a/docs/site/src/content/docs/reference/registryctl.mdx +++ b/docs/site/src/content/docs/reference/registryctl.mdx @@ -281,7 +281,7 @@ default and accept `--format json` for automation. ## Project setup The legacy `init relay` command creates a local spreadsheet project. -In registryctl `v0.11.0`, this generation command requires the strict +In registryctl `v0.12.0`, this generation command requires the strict `registryctl-vX.Y.Z-image-lock.json` from the same release beside the running binary. Set `REGISTRYCTL_IMAGE_LOCK` only when an operator has verified the same file at a separate explicit path. Registryctl does not search the current @@ -308,7 +308,7 @@ Notary-only project contains source-free or self-attested evaluation-only claims ### Local Notary add-on -Registryctl `v0.11.0` adds `registryctl add notary`. From a generated `benefits` spreadsheet +Registryctl `v0.12.0` includes `registryctl add notary`. From a generated `benefits` spreadsheet project, it creates an editable authored project under `notary/project/`, adds a private consultation Relay and local Notary services, and generates local-only evaluator and workload credentials. It refuses an existing Notary directory or a second invocation instead of @@ -379,6 +379,6 @@ Related environment variables are listed in the [environment variable reference] ## Source -The released command tree comes from the registryctl source at `v0.11.0`. +The released command tree comes from the registryctl source at `v0.12.0`. For its canonical definitions, read the -[registryctl command tree in `src/main.rs`](https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registryctl/src/main.rs). +[registryctl command tree in `src/main.rs`](https://github.com/registrystack/registry-stack/blob/v0.12.0/crates/registryctl/src/main.rs). diff --git a/docs/site/src/content/docs/security/report-a-vulnerability.mdx b/docs/site/src/content/docs/security/report-a-vulnerability.mdx index 0806e6412..57459601b 100644 --- a/docs/site/src/content/docs/security/report-a-vulnerability.mdx +++ b/docs/site/src/content/docs/security/report-a-vulnerability.mdx @@ -62,7 +62,7 @@ For each signed release asset, download the asset, its `.sig` signature, and its certificate from the GitHub Release, then verify: ```bash -asset=registryctl-v0.11.0-linux-amd64 # replace with the asset you downloaded +asset=registryctl-v0.12.0-linux-amd64 # replace with the asset you downloaded cosign verify-blob \ --certificate "${asset}.pem" \ diff --git a/docs/site/src/content/docs/security/support-window.mdx b/docs/site/src/content/docs/security/support-window.mdx index aeecd3cf8..359534e9e 100644 --- a/docs/site/src/content/docs/security/support-window.mdx +++ b/docs/site/src/content/docs/security/support-window.mdx @@ -53,7 +53,7 @@ maintenance moves to the successor's latest minor. - A security fix ships as a release with signed artifacts and provenance, verifiable as documented in - [SECURITY.md](https://github.com/registrystack/registry-stack/blob/v0.11.0/SECURITY.md). + [SECURITY.md](https://github.com/registrystack/registry-stack/blob/v0.12.0/SECURITY.md). - Release notes identify security-relevant changes. When a fix required tightening covered surface, the notes say so explicitly, per the security exception in the [deprecation policy](../../reference/deprecation-policy/). diff --git a/docs/site/src/content/docs/tutorials/author-registry-project.mdx b/docs/site/src/content/docs/tutorials/author-registry-project.mdx index 7e2681904..8ac145c98 100644 --- a/docs/site/src/content/docs/tutorials/author-registry-project.mdx +++ b/docs/site/src/content/docs/tutorials/author-registry-project.mdx @@ -58,7 +58,7 @@ The human-readable result confirms the project, starter provenance, and next com ```text Initialized Registry Stack project "fictional-citizen-registry". Directory: registry-project - Starter: http (Registry Stack 0.11.0) + Starter: http (Registry Stack 0.12.0) Starter content: matches bundled digest Editor support: VS Code and Zed (registry-project/.registry-stack-editor/manifest.json) diff --git a/docs/site/src/content/docs/tutorials/deploy-standalone-with-own-data.mdx b/docs/site/src/content/docs/tutorials/deploy-standalone-with-own-data.mdx index 09e384251..421fc4730 100644 --- a/docs/site/src/content/docs/tutorials/deploy-standalone-with-own-data.mdx +++ b/docs/site/src/content/docs/tutorials/deploy-standalone-with-own-data.mdx @@ -44,7 +44,7 @@ credentials, or production records in commands or source control. Pull a release tag or verified digest rather than `latest`: ```sh -docker pull ghcr.io/registrystack/registry-relay:v0.11.0 +docker pull ghcr.io/registrystack/registry-relay:v0.12.0 ``` Review the release verification evidence before promoting an image into an institutional @@ -89,7 +89,7 @@ Generate an API key and store only its fingerprint in the environment named by t ```sh docker run --rm \ - ghcr.io/registrystack/registry-relay:v0.11.0 \ + ghcr.io/registrystack/registry-relay:v0.12.0 \ generate-api-key --id program-system ``` @@ -111,7 +111,7 @@ docker run --rm \ -v "$(pwd)/data:/var/lib/registry-relay/data:ro" \ -v "$(pwd)/cache:/var/lib/registry-relay/cache" \ --env-file \ - ghcr.io/registrystack/registry-relay:v0.11.0 \ + ghcr.io/registrystack/registry-relay:v0.12.0 \ --config /etc/registry-relay/config.yaml ``` diff --git a/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx b/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx index 22b1bf41b..d6757f10b 100644 --- a/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx +++ b/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx @@ -25,14 +25,14 @@ identity reads, and inspect the contract Registry Stack generated. outcome="A protected local API over a benefits workbook, with operational and restricted identity reads separated, one canonical birth date retained, and an edited disclosure rule enforced." time="About 15 minutes after Docker is installed" level="Local single-node" - prerequisites={['registryctl 0.11.0', 'A Docker Compose provider', 'curl']} + prerequisites={['registryctl 0.12.0', 'A Docker Compose provider', 'curl']} /> This tutorial uses synthetic data and local demo credentials. Do not use the generated local keys in production. :::note[Current first-run route] -The v0.11.0 source beta and this local tutorial can be used while hosted credential publication is +The v0.12.0 source beta and this local tutorial can be used while hosted credential publication is held by [GH#330](https://github.com/registrystack/registry-stack/issues/330) and the incomplete [GH#198](https://github.com/registrystack/registry-stack/issues/198) fresh-reader run. ::: @@ -42,7 +42,7 @@ held by [GH#330](https://github.com/registrystack/registry-stack/issues/330) and Install `registryctl` without cloning the repository: The quick installer verifies downloaded release assets against the release's -`SHA256SUMS`. The pinned `v0.11.0` installer installs the binary and matching +`SHA256SUMS`. The pinned `v0.12.0` installer installs the binary and matching release image lock beside each other. It does not authenticate the installer script or release. Before running it, review the available signature and provenance evidence in the @@ -50,7 +50,7 @@ canonical [`release/VERIFY.md`](https://github.com/registrystack/registry-stack/ guide. Evidence availability varies by release, and `v0.8.0` is unsigned. ```sh -curl -fsSL https://raw.githubusercontent.com/registrystack/registry-stack/refs/tags/v0.11.0/crates/registryctl/install.sh | REGISTRYCTL_VERSION=v0.11.0 bash +curl -fsSL https://raw.githubusercontent.com/registrystack/registry-stack/refs/tags/v0.12.0/crates/registryctl/install.sh | REGISTRYCTL_VERSION=v0.12.0 bash registryctl --version ``` @@ -61,7 +61,7 @@ Prebuilt `registryctl` assets exist for Linux x86_64, Linux arm64, and macOS arm On Intel macOS, the installer stops after printing the pinned `cargo install` command. It does not run the source build automatically. After installing a Rust toolchain, run the command printed by the installer. For -`v0.11.0`, also checksum-verify the matching release image lock and +`v0.12.0`, also checksum-verify the matching release image lock and place it beside the Cargo-installed binary, as described in the troubleshooting table. ## Create the sample project @@ -483,7 +483,7 @@ Relay refused to start when the floor dropped under 2 on personal data. | `registryctl` is not found | The install directory is not on `PATH`. | Add the directory printed by the installer, usually `~/.local/bin`, to `PATH`. | | The installer reports an unsupported platform | No binary is published for that OS or CPU. | Run the pinned `cargo install` command printed by the installer. For `v0.9.0` or later, also checksum-verify the matching `registryctl-vX.Y.Z-image-lock.json` release asset and place it beside the installed binary. | | `registryctl start` cannot find Docker | Docker or another Compose provider is not installed or running. | Start Docker Desktop, OrbStack, Colima, Podman, or your supported provider, then run `registryctl start` again. | -| Docker reports no `linux/arm64` manifest for a Registry Stack image | The v0.11.0 Relay and Notary images are published for `linux/amd64` only. | Start with `DOCKER_DEFAULT_PLATFORM=linux/amd64 registryctl start`. | +| Docker reports no `linux/arm64` manifest for a Registry Stack image | The v0.12.0 Relay and Notary images are published for `linux/amd64` only. | Start with `DOCKER_DEFAULT_PLATFORM=linux/amd64 registryctl start`. | | `registryctl start` fails and the container log shows `failed to parse config YAML ... unknown field` | The locally cached container image does not match the digest-pinned image in the generated `compose.yaml`. | Run `docker compose pull` in the project directory (on Apple silicon, prefix it with `DOCKER_DEFAULT_PLATFORM=linux/amd64`), then `registryctl start` again. | | `registryctl start` times out and the container log shows `missing field \`commitment\`` | registryctl v0.8.4 pins a service image older than its own generated config format ([GH#278](https://github.com/registrystack/registry-stack/issues/278)). | Pin the project's `compose.yaml` to the published v0.8.4 release digests: `ghcr.io/registrystack/registry-relay@sha256:93e194500a3500ba3f6331d5a0a9a3069127c48a709beccb083a5fcbdbc3ec61` and, when present, `ghcr.io/registrystack/registry-notary@sha256:0cf05184885d7ed17dd9889e20f3797eb2a9ad07517f4ac7c05c03b774a00b8f`. Then run `docker compose pull`; reapply the pins after a generation command rewrites `compose.yaml`. | | `registryctl init` reports that its image lock is missing or invalid | The binary was moved or built without the strict image lock from the same release, or the file failed its release, source, platform, or digest checks. | Rerun the installer from the same pinned target tag so it checksum-verifies and installs both files. For an operator-managed or source-test location, set `REGISTRYCTL_IMAGE_LOCK` to the checksum-verified lock from that exact release. Do not substitute a lock from another version or a mutable image tag. | diff --git a/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx b/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx index 5bde9e4f9..027daf7d6 100644 --- a/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx +++ b/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx @@ -31,7 +31,7 @@ claim. outcome="A live registry-backed boolean claim, matched without a national identifier, with a meaningful policy edit exercised end to end." time="About 10 minutes after completing the first tutorial" level="Local single-node" - prerequisites={['The my-first-api project from the first tutorial', 'Matching Registry Stack v0.11.0 registryctl, release images, and image lock', 'A Docker Compose provider', 'curl']} + prerequisites={['The my-first-api project from the first tutorial', 'Matching Registry Stack v0.12.0 registryctl, release images, and image lock', 'A Docker Compose provider', 'curl']} /> This tutorial uses synthetic data and local demo credentials. Do not use the generated keys or @@ -39,7 +39,7 @@ database settings in production. :::note[Release status] This tutorial requires `registryctl`, immutable Relay and Notary images, and the image lock from -the same Registry Stack `v0.11.0` release. Do not combine a source-built CLI with images or an +the same Registry Stack `v0.12.0` release. Do not combine a source-built CLI with images or an image lock from another release. This is an evaluation-only local tutorial. It does not issue a credential or @@ -392,7 +392,7 @@ answer. | Symptom | Cause | Resolution | | --- | --- | --- | -| `registryctl add notary` is unknown | The CLI predates Registry Stack v0.11.0 or does not match the installed release image lock. | Install `registryctl`, the immutable Relay and Notary images, and the image lock from the same Registry Stack v0.11.0 release. Do not substitute an ad hoc source build or a lock from another release. | +| `registryctl add notary` is unknown | The CLI predates Registry Stack v0.11.0 or does not match the installed release image lock. | Install `registryctl`, the immutable Relay and Notary images, and the image lock from the same Registry Stack v0.12.0 release. Do not substitute an ad hoc source build or a lock from another release. | | `registryctl add notary` says the project already has a Notary add-on | The command has already completed for this project. | Continue with `registryctl start`; do not run the add command twice. | | Notary returns `401` | The evaluator key was not loaded or is from another generated project. | Source `secrets/local.env` again in the current shell. | | Notary returns `403` | The key lacks the evidence scope or the purpose does not match the authored service. | Use `TUTORIAL_EVALUATOR_RAW` and the tutorial purpose shown above. | diff --git a/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx b/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx index f87240cfd..d72595643 100644 --- a/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx +++ b/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx @@ -57,7 +57,7 @@ The result confirms the initialized project and next command: ```text Initialized Registry Stack project "fictional-civil-registry". Directory: opencrvs-project - Starter: opencrvs-dci (Registry Stack 0.11.0) + Starter: opencrvs-dci (Registry Stack 0.12.0) Starter content: matches bundled digest Editor support: VS Code and Zed (opencrvs-project/.registry-stack-editor/manifest.json) diff --git a/docs/site/src/data/contracts.yaml b/docs/site/src/data/contracts.yaml index bf5b1d771..7dffd0a73 100644 --- a/docs/site/src/data/contracts.yaml +++ b/docs/site/src/data/contracts.yaml @@ -5,7 +5,7 @@ surface: Shared Rust crate APIs for auth helpers, OIDC verification, audit envelopes and sinks, HTTP security middleware, outbound HTTP policy, Ed25519 JWK and DID helpers, SD-JWT VC issuance, and testing fixtures. source_of_truth: label: Registry Platform crates - url: https://github.com/registrystack/registry-stack/tree/v0.11.0/crates + url: https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/crates consumer_note: Relay, Notary, and future registry services should consume these primitives instead of reimplementing security or operational behavior locally. - id: registry-platform.ops-posture-v1 name: Registry Ops Posture v1 @@ -27,7 +27,7 @@ surface: "Protected Registry Data API, metadata API, evidence offering discovery, aggregates, health and readiness, plus optional standards adapters (OGC API Features, OGC API Records, OGC API EDR, SP DCI)." source_of_truth: label: Registry Relay abstract OpenAPI contract - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/openapi/registry-relay.openapi.json + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/openapi/registry-relay.openapi.json consumer_note: Hand-authored from `docs/api.md` and the runtime route table. Uses templated paths and capability tags. For instance-specific shape of `{dataset_id}` and `{entity}`, fetch `GET /openapi.json` from a configured gateway. Runtime deployments gate that route by default unless `openapi_requires_auth` is disabled for demos or controlled tooling. - id: registry-notary.openapi name: Registry Notary OpenAPI @@ -36,7 +36,7 @@ surface: Claim discovery, claim evaluation, batch evaluation, federated delegated evaluation, rendering, JWKS, service metadata, and credential issuance. source_of_truth: label: Registry Notary OpenAPI generator - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/openapi.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-server/src/openapi.rs consumer_note: Generate with `cargo run -p registry-notary -- openapi`. Both direct and OID4VCI credential routes require a fresh non-delegated stored evaluation with exact claim pins and normalized unique Relay execution records for every selected root's registry-backed dependency closure. - id: registry-notary.oid4vci name: Registry Notary OID4VCI surface @@ -45,7 +45,7 @@ surface: OID4VCI issuer metadata, Type Metadata, offer start and authenticated callback, pre-authorized token redemption, and registry-backed credential issuance. The wallet-facing grant is issuer-initiated pre-authorized code. Primitives are sourced from the `registry-platform-oid4vci` crate. source_of_truth: label: Registry Notary OID4VCI routes - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/api.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-server/src/api.rs consumer_note: >- Registry Notary advertises `dc+sd-jwt`, EdDSA `did:jwk` holder proof, and the configured EdDSA or ES256 issuer algorithm. Credential configurations @@ -64,7 +64,7 @@ surface: Static-peer delegated evaluation over `POST /federation/v1/evaluations`, compact JWS request and response JWTs, peer policy checks, pairwise subject handles, replay protection, and federation audit fields. source_of_truth: label: Registry Notary federation module - url: https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-notary-server/src/federation + url: https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-server/src/federation consumer_note: This is delegated evaluation only. Open federation, trust-chain discovery, shared replay storage, audit checkpoint exchange, and federated credential issuance are outside the MVP. - id: registry-manifest.metadata-yaml name: Metadata Manifest @@ -73,7 +73,7 @@ surface: Portable `metadata.yaml` documents, compiled metadata model, public services, forms, policies, requirements, evidence type lists, evidence offering metadata, public federation metadata, and evaluation profile metadata. source_of_truth: label: Registry Manifest core - url: https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-manifest-core + url: https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-manifest-core consumer_note: Runtime source paths, scopes, table names, backend URLs, peer allowlists, replay storage, and federation secrets belong in runtime service config, not manifests. - id: registry-manifest.cpsv-ap-service-catalogue name: CPSV-AP Service Catalogue Render Contract @@ -82,7 +82,7 @@ surface: CPSV-AP JSON-LD service catalogue, CCCEV requirements, grouped evidence type lists, local form-definition links, DCAT data services, and form JSON Schemas. source_of_truth: label: Registry Manifest CPSV-AP fixture - url: https://github.com/registrystack/registry-stack/tree/v0.11.0/products/manifest/fixtures/cpsv-ap + url: https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/fixtures/cpsv-ap consumer_note: Each CCCEV evidence type list is one grouped option; multiple lists on a requirement are alternatives. - id: registry-manifest.static-publication name: Static Metadata Publication Bundle @@ -91,5 +91,5 @@ surface: Static index, catalog JSON, evidence offerings, policies, DCAT, CPSV-AP, BRegDCAT-AP, SHACL, OGC Records item collection, entity JSON Schemas, form JSON Schemas, and embedded SKOS-shaped codelist nodes. source_of_truth: label: Registry Manifest CLI - url: https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-manifest-cli + url: https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-manifest-cli consumer_note: The bundle can be hosted as static files without running Registry Relay. diff --git a/docs/site/src/data/docsets.yaml b/docs/site/src/data/docsets.yaml index d495e1324..afa447af4 100644 --- a/docs/site/src/data/docsets.yaml +++ b/docs/site/src/data/docsets.yaml @@ -9,17 +9,47 @@ docsets: description: Current RegistryStack monorepo documentation build. products: registry-stack: - version: v0.11.0 + version: v0.12.0 ref: HEAD registry-relay: - version: v0.11.0 + version: v0.12.0 ref: HEAD registry-notary: - version: v0.11.0 + version: v0.12.0 ref: HEAD registry-manifest: - version: v0.11.0 + version: v0.12.0 ref: HEAD + - id: v0.12.0 + label: v0.12.0 + path: /v/0.12.0/ + status: archived + source: registry-stack-v0.12.0 + repo_docs_source: monorepo + published_at: 2026-07-19 + description: RegistryStack v0.12.0 beta-14 documentation set. + products: + registry-stack: + version: v0.12.0 + ref: 865921f0d8e874e3cefe548d000e8915c4abecbc + registry-platform: + version: v0.12.0 + ref: 865921f0d8e874e3cefe548d000e8915c4abecbc + registry-manifest: + version: v0.12.0 + ref: 865921f0d8e874e3cefe548d000e8915c4abecbc + registry-notary: + version: v0.12.0 + ref: 865921f0d8e874e3cefe548d000e8915c4abecbc + registry-relay: + version: v0.12.0 + ref: 865921f0d8e874e3cefe548d000e8915c4abecbc + registry-registryctl: + version: v0.12.0 + ref: 865921f0d8e874e3cefe548d000e8915c4abecbc + crosswalk: + version: crosswalk-core-v0.2.0 + ref: 1d44ec735fdc8a7c719264b339574371e8330337 - id: v0.11.0 label: v0.11.0 path: /v/0.11.0/ diff --git a/docs/site/src/data/generated/contracts.json b/docs/site/src/data/generated/contracts.json index 8a1256cb6..fcb866522 100644 --- a/docs/site/src/data/generated/contracts.json +++ b/docs/site/src/data/generated/contracts.json @@ -7,7 +7,7 @@ "surface": "Shared Rust crate APIs for auth helpers, OIDC verification, audit envelopes and sinks, HTTP security middleware, outbound HTTP policy, Ed25519 JWK and DID helpers, SD-JWT VC issuance, and testing fixtures.", "source_of_truth": { "label": "Registry Platform crates", - "url": "https://github.com/registrystack/registry-stack/tree/v0.11.0/crates" + "url": "https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/crates" }, "consumer_note": "Relay, Notary, and future registry services should consume these primitives instead of reimplementing security or operational behavior locally." }, @@ -31,7 +31,7 @@ "surface": "Protected Registry Data API, metadata API, evidence offering discovery, aggregates, health and readiness, plus optional standards adapters (OGC API Features, OGC API Records, OGC API EDR, SP DCI).", "source_of_truth": { "label": "Registry Relay abstract OpenAPI contract", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/openapi/registry-relay.openapi.json" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/openapi/registry-relay.openapi.json" }, "consumer_note": "Hand-authored from `docs/api.md` and the runtime route table. Uses templated paths and capability tags. For instance-specific shape of `{dataset_id}` and `{entity}`, fetch `GET /openapi.json` from a configured gateway. Runtime deployments gate that route by default unless `openapi_requires_auth` is disabled for demos or controlled tooling." }, @@ -43,7 +43,7 @@ "surface": "Claim discovery, claim evaluation, batch evaluation, federated delegated evaluation, rendering, JWKS, service metadata, and credential issuance.", "source_of_truth": { "label": "Registry Notary OpenAPI generator", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/openapi.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-server/src/openapi.rs" }, "consumer_note": "Generate with `cargo run -p registry-notary -- openapi`. Both direct and OID4VCI credential routes require a fresh non-delegated stored evaluation with exact claim pins and normalized unique Relay execution records for every selected root's registry-backed dependency closure." }, @@ -55,7 +55,7 @@ "surface": "OID4VCI issuer metadata, Type Metadata, offer start and authenticated callback, pre-authorized token redemption, and registry-backed credential issuance. The wallet-facing grant is issuer-initiated pre-authorized code. Primitives are sourced from the `registry-platform-oid4vci` crate.", "source_of_truth": { "label": "Registry Notary OID4VCI routes", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/api.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-server/src/api.rs" }, "consumer_note": "Registry Notary advertises `dc+sd-jwt`, EdDSA `did:jwk` holder proof, and the configured EdDSA or ES256 issuer algorithm. Credential configurations accept non-delegated registry-backed claims only, and issuance verifies the stored transaction plus exact dependency-closure Relay execution provenance before signing. Transaction code is required by default. An explicit no-PIN profile has a maximum 300-second bearer-offer window. There is no wallet-facing authorization-code grant, public nonce route, response next nonce, source-free issuance, ES256 holder proof, PAR, DPoP, wallet attestation, EUDI, or HAIP claim. External wallet and verifier evidence is candidate-only until recorded against a frozen artifact." }, @@ -67,7 +67,7 @@ "surface": "Static-peer delegated evaluation over `POST /federation/v1/evaluations`, compact JWS request and response JWTs, peer policy checks, pairwise subject handles, replay protection, and federation audit fields.", "source_of_truth": { "label": "Registry Notary federation module", - "url": "https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-notary-server/src/federation" + "url": "https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-server/src/federation" }, "consumer_note": "This is delegated evaluation only. Open federation, trust-chain discovery, shared replay storage, audit checkpoint exchange, and federated credential issuance are outside the MVP." }, @@ -79,7 +79,7 @@ "surface": "Portable `metadata.yaml` documents, compiled metadata model, public services, forms, policies, requirements, evidence type lists, evidence offering metadata, public federation metadata, and evaluation profile metadata.", "source_of_truth": { "label": "Registry Manifest core", - "url": "https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-manifest-core" + "url": "https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-manifest-core" }, "consumer_note": "Runtime source paths, scopes, table names, backend URLs, peer allowlists, replay storage, and federation secrets belong in runtime service config, not manifests." }, @@ -91,7 +91,7 @@ "surface": "CPSV-AP JSON-LD service catalogue, CCCEV requirements, grouped evidence type lists, local form-definition links, DCAT data services, and form JSON Schemas.", "source_of_truth": { "label": "Registry Manifest CPSV-AP fixture", - "url": "https://github.com/registrystack/registry-stack/tree/v0.11.0/products/manifest/fixtures/cpsv-ap" + "url": "https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/fixtures/cpsv-ap" }, "consumer_note": "Each CCCEV evidence type list is one grouped option; multiple lists on a requirement are alternatives." }, @@ -103,7 +103,7 @@ "surface": "Static index, catalog JSON, evidence offerings, policies, DCAT, CPSV-AP, BRegDCAT-AP, SHACL, OGC Records item collection, entity JSON Schemas, form JSON Schemas, and embedded SKOS-shaped codelist nodes.", "source_of_truth": { "label": "Registry Manifest CLI", - "url": "https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-manifest-cli" + "url": "https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-manifest-cli" }, "consumer_note": "The bundle can be hosted as static files without running Registry Relay." } diff --git a/docs/site/src/data/generated/docsets.json b/docs/site/src/data/generated/docsets.json index 79ae65a8b..0e5f882f3 100644 --- a/docs/site/src/data/generated/docsets.json +++ b/docs/site/src/data/generated/docsets.json @@ -11,23 +11,63 @@ "description": "Current RegistryStack monorepo documentation build.", "products": { "registry-stack": { - "version": "v0.11.0", + "version": "v0.12.0", "ref": "HEAD" }, "registry-relay": { - "version": "v0.11.0", + "version": "v0.12.0", "ref": "HEAD" }, "registry-notary": { - "version": "v0.11.0", + "version": "v0.12.0", "ref": "HEAD" }, "registry-manifest": { - "version": "v0.11.0", + "version": "v0.12.0", "ref": "HEAD" } } }, + { + "id": "v0.12.0", + "label": "v0.12.0", + "path": "/v/0.12.0/", + "status": "archived", + "source": "registry-stack-v0.12.0", + "repo_docs_source": "monorepo", + "published_at": "2026-07-19", + "description": "RegistryStack v0.12.0 beta-14 documentation set.", + "products": { + "registry-stack": { + "version": "v0.12.0", + "ref": "865921f0d8e874e3cefe548d000e8915c4abecbc" + }, + "registry-platform": { + "version": "v0.12.0", + "ref": "865921f0d8e874e3cefe548d000e8915c4abecbc" + }, + "registry-manifest": { + "version": "v0.12.0", + "ref": "865921f0d8e874e3cefe548d000e8915c4abecbc" + }, + "registry-notary": { + "version": "v0.12.0", + "ref": "865921f0d8e874e3cefe548d000e8915c4abecbc" + }, + "registry-relay": { + "version": "v0.12.0", + "ref": "865921f0d8e874e3cefe548d000e8915c4abecbc" + }, + "registry-registryctl": { + "version": "v0.12.0", + "ref": "865921f0d8e874e3cefe548d000e8915c4abecbc" + }, + "crosswalk": { + "version": "crosswalk-core-v0.2.0", + "ref": "1d44ec735fdc8a7c719264b339574371e8330337" + } + } + }, { "id": "v0.11.0", "label": "v0.11.0", diff --git a/docs/site/src/data/generated/projects.json b/docs/site/src/data/generated/projects.json index 53a78aa2f..41ebf27f0 100644 --- a/docs/site/src/data/generated/projects.json +++ b/docs/site/src/data/generated/projects.json @@ -19,11 +19,11 @@ "source_docs": [ { "label": "README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/platform/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/v0.12.0/products/platform/README.md" }, { "label": "Registry Platform crates", - "url": "https://github.com/registrystack/registry-stack/tree/v0.11.0/crates" + "url": "https://github.com/registrystack/registry-stack/tree/v0.12.0/crates" } ], "rename_status": "New shared workspace; not part of the 2026-05-23 rename wave." @@ -48,11 +48,11 @@ "source_docs": [ { "label": "README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/v0.12.0/products/manifest/README.md" }, { "label": "Local examples", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/examples/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/v0.12.0/products/manifest/examples/README.md" } ], "rename_status": "Already using the target repo name locally." @@ -81,15 +81,15 @@ "source_docs": [ { "label": "README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/v0.12.0/crates/registry-relay/README.md" }, { "label": "Local API docs", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/docs/api.md" + "url": "https://github.com/registrystack/registry-stack/blob/v0.12.0/crates/registry-relay/docs/api.md" }, { "label": "Local metadata docs", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/docs/metadata.md" + "url": "https://github.com/registrystack/registry-stack/blob/v0.12.0/crates/registry-relay/docs/metadata.md" } ], "rename_status": "Local checkout renamed to `registry-relay`; old worktrees under `registry_relay-*` remain for historical evidence." @@ -123,11 +123,11 @@ "source_docs": [ { "label": "README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/notary/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/v0.12.0/products/notary/README.md" }, { "label": "OpenAPI source", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/openapi.rs" + "url": "https://github.com/registrystack/registry-stack/blob/v0.12.0/crates/registry-notary-server/src/openapi.rs" } ], "rename_status": "Already using the target repo name locally." diff --git a/docs/site/src/data/generated/standards.json b/docs/site/src/data/generated/standards.json index 66b2be053..de51252fc 100644 --- a/docs/site/src/data/generated/standards.json +++ b/docs/site/src/data/generated/standards.json @@ -20,15 +20,15 @@ "evidence_docs": [ { "label": "Registry Relay README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md" }, { "label": "Registry Manifest README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/README.md" }, { "label": "Registry Relay DCAT catalog tests", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/catalog_entity.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/catalog_entity.rs" } ], "last_checked": "2026-06-13", @@ -54,15 +54,15 @@ "evidence_docs": [ { "label": "Registry Relay README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md" }, { "label": "Registry Manifest README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/README.md" }, { "label": "Registry Relay BRegDCAT-AP route tests", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/catalog_entity.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/catalog_entity.rs" } ], "last_checked": "2026-06-13", @@ -88,7 +88,7 @@ "evidence_docs": [ { "label": "Registry Manifest CPSV-AP fixture", - "url": "https://github.com/registrystack/registry-stack/tree/v0.11.0/products/manifest/fixtures/cpsv-ap" + "url": "https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/fixtures/cpsv-ap" } ], "last_checked": "2026-05-25", @@ -114,15 +114,15 @@ "evidence_docs": [ { "label": "Registry Relay README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md" }, { "label": "Registry Manifest README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/README.md" }, { "label": "Registry Relay OGC records API tests", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/ogc_records_api.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/ogc_records_api.rs" } ], "last_checked": "2026-07-19", @@ -147,11 +147,11 @@ "evidence_docs": [ { "label": "Registry Relay README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md" }, { "label": "Registry Relay OGC Features API tests", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/ogc_api.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/ogc_api.rs" } ], "last_checked": "2026-07-19", @@ -176,7 +176,7 @@ "evidence_docs": [ { "label": "Registry Relay OGC EDR API tests", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/ogc_edr_api.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/ogc_edr_api.rs" } ], "last_checked": "2026-07-19", @@ -202,19 +202,19 @@ "evidence_docs": [ { "label": "Registry Relay README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md" }, { "label": "Registry Notary README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/notary/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/notary/README.md" }, { "label": "Registry Relay generated OpenAPI document (pinned)", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/openapi/registry-relay.openapi.json" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/openapi/registry-relay.openapi.json" }, { "label": "Registry Notary generated OpenAPI document (pinned)", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/notary/openapi/registry-notary.openapi.json" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/notary/openapi/registry-notary.openapi.json" } ], "last_checked": "2026-07-19", @@ -240,15 +240,15 @@ "evidence_docs": [ { "label": "Registry Relay README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md" }, { "label": "Registry Manifest README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/README.md" }, { "label": "Registry Relay SHACL document structure tests", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/catalog_entity.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/catalog_entity.rs" } ], "last_checked": "2026-06-13", @@ -274,11 +274,11 @@ "evidence_docs": [ { "label": "Registry Manifest codelist renderer", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-manifest-core/src/lib.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-manifest-core/src/lib.rs" }, { "label": "Registry Manifest codelist tests", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-manifest-core/tests/metadata_core.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-manifest-core/tests/metadata_core.rs" } ], "last_checked": "2026-06-20", @@ -304,15 +304,15 @@ "evidence_docs": [ { "label": "Registry Relay README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md" }, { "label": "Registry Manifest README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/README.md" }, { "label": "Registry Manifest form schema fixture (Draft 2020-12)", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/fixtures/cpsv-ap/health-linked-child-support.form.schema.json" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/fixtures/cpsv-ap/health-linked-child-support.form.schema.json" } ], "last_checked": "2026-06-13", @@ -341,15 +341,15 @@ "evidence_docs": [ { "label": "Registry Relay JSON-LD renderers", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/src/metadata/shacl.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/src/metadata/shacl.rs" }, { "label": "Registry Notary CCCEV renderer", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/runtime.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-server/src/runtime.rs" }, { "label": "Registry Manifest CPSV-AP JSON-LD fixture", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/fixtures/cpsv-ap/health-linked-child-support.cpsv-ap.jsonld" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/fixtures/cpsv-ap/health-linked-child-support.cpsv-ap.jsonld" } ], "last_checked": "2026-06-13", @@ -376,15 +376,15 @@ "evidence_docs": [ { "label": "Registry Notary SD-JWT VC conformance profile", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/notary/docs/sd-jwt-vc-conformance-profile.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/notary/docs/sd-jwt-vc-conformance-profile.md" }, { "label": "Registry Notary format constants", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-core/src/model.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-core/src/model.rs" }, { "label": "Registry Platform SD-JWT crate", - "url": "https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-platform-sdjwt" + "url": "https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-platform-sdjwt" } ], "last_checked": "2026-06-13", @@ -409,7 +409,7 @@ "evidence_docs": [ { "label": "Registry Notary README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/notary/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/notary/README.md" } ], "last_checked": "2026-05-23", @@ -438,15 +438,15 @@ "evidence_docs": [ { "label": "Registry Manifest grouped evidence fixture", - "url": "https://github.com/registrystack/registry-stack/tree/v0.11.0/products/manifest/fixtures/cpsv-ap" + "url": "https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/fixtures/cpsv-ap" }, { "label": "Registry Notary CCCEV renderer", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/runtime.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-server/src/runtime.rs" }, { "label": "Registry Notary media type constants", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-core/src/model.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-core/src/model.rs" } ], "last_checked": "2026-05-25", @@ -474,23 +474,23 @@ "evidence_docs": [ { "label": "Registry Relay README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md" }, { "label": "Registry Manifest README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/README.md" }, { "label": "Registry Relay catalog tests (ODRL offer assertions)", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/catalog_entity.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/catalog_entity.rs" }, { "label": "Registry Platform PDP ODRL enforcement profile constants", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-platform-pdp/src/lib.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-platform-pdp/src/lib.rs" }, { "label": "Registry Relay governed evidence PDP path", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/src/api/governed.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/src/api/governed.rs" } ], "last_checked": "2026-07-09", @@ -515,11 +515,11 @@ "evidence_docs": [ { "label": "Registry Relay client integration guide", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/docs/client-integration.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/docs/client-integration.md" }, { "label": "Registry Relay aggregates tests (schema-validated SDMX-JSON output)", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/aggregates_entity.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/aggregates_entity.rs" } ], "last_checked": "2026-07-19", @@ -544,7 +544,7 @@ "evidence_docs": [ { "label": "Registry Notary claim model", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-core/src/model.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-core/src/model.rs" } ], "last_checked": "2026-05-23", @@ -598,7 +598,7 @@ "evidence_docs": [ { "label": "Registry Relay README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md" } ], "last_checked": "2026-05-23", @@ -650,7 +650,7 @@ "evidence_docs": [ { "label": "Registry Relay README", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md" }, { "label": "Registry Stack OpenCRVS project-authoring fixture", @@ -684,11 +684,11 @@ "evidence_docs": [ { "label": "Registry Notary OID4VCI routes", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/api.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-server/src/api.rs" }, { "label": "Registry Platform OID4VCI crate", - "url": "https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-platform-oid4vci" + "url": "https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-platform-oid4vci" }, { "label": "Registry Notary wallet facade specification", @@ -719,11 +719,11 @@ "evidence_docs": [ { "label": "Registry Notary did:jwk handling", - "url": "https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/api.rs" + "url": "https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-server/src/api.rs" }, { "label": "Registry Platform crypto crate", - "url": "https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-platform-crypto" + "url": "https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-platform-crypto" } ], "last_checked": "2026-06-13", diff --git a/docs/site/src/data/projects.yaml b/docs/site/src/data/projects.yaml index b2ab56968..7e052bda5 100644 --- a/docs/site/src/data/projects.yaml +++ b/docs/site/src/data/projects.yaml @@ -14,9 +14,9 @@ - Product-level authorization policy, tenant isolation, audit retention, secret provisioning, or deployment configuration. source_docs: - label: README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/platform/README.md + url: https://github.com/registrystack/registry-stack/blob/v0.12.0/products/platform/README.md - label: Registry Platform crates - url: https://github.com/registrystack/registry-stack/tree/v0.11.0/crates + url: https://github.com/registrystack/registry-stack/tree/v0.12.0/crates rename_status: New shared workspace; not part of the 2026-05-23 rename wave. - id: registry-manifest name: Registry Manifest @@ -34,9 +34,9 @@ - Production source configuration. source_docs: - label: README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md + url: https://github.com/registrystack/registry-stack/blob/v0.12.0/products/manifest/README.md - label: Local examples - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/examples/README.md + url: https://github.com/registrystack/registry-stack/blob/v0.12.0/products/manifest/examples/README.md rename_status: Already using the target repo name locally. - id: registry-relay name: Registry Relay @@ -58,11 +58,11 @@ - Storage table ids or arbitrary SQL in public APIs. source_docs: - label: README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md + url: https://github.com/registrystack/registry-stack/blob/v0.12.0/crates/registry-relay/README.md - label: Local API docs - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/docs/api.md + url: https://github.com/registrystack/registry-stack/blob/v0.12.0/crates/registry-relay/docs/api.md - label: Local metadata docs - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/docs/metadata.md + url: https://github.com/registrystack/registry-stack/blob/v0.12.0/crates/registry-relay/docs/metadata.md rename_status: Local checkout renamed to `registry-relay`; old worktrees under `registry_relay-*` remain for historical evidence. - id: registry-notary name: Registry Notary @@ -89,9 +89,9 @@ - Credential issuance from source-free or self-attested evidence. source_docs: - label: README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/notary/README.md + url: https://github.com/registrystack/registry-stack/blob/v0.12.0/products/notary/README.md - label: OpenAPI source - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/openapi.rs + url: https://github.com/registrystack/registry-stack/blob/v0.12.0/crates/registry-notary-server/src/openapi.rs rename_status: Already using the target repo name locally. - id: solmara-lab name: Solmara Lab diff --git a/docs/site/src/data/repo-docs.yaml b/docs/site/src/data/repo-docs.yaml index da07016c2..314d9dd96 100644 --- a/docs/site/src/data/repo-docs.yaml +++ b/docs/site/src/data/repo-docs.yaml @@ -8,7 +8,7 @@ repos: registry-relay: remote: https://github.com/registrystack/registry-stack ref: HEAD - version: v0.11.0 + version: v0.12.0 local: ../.. openapi: crates/registry-relay/openapi/registry-relay.openapi.json archive_remote: https://github.com/jeremi/registry-relay @@ -23,7 +23,7 @@ repos: last_reviewed: unreviewed standards_referenced: [openapi, ogc-api-records, ogc-api-features, ogc-api-edr, odrl, sp-dci] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0] standards_referenced: [openapi, ogc-api-records, ogc-api-features, ogc-api-edr, odrl, sp-dci] last_reviewed: unreviewed - docsets: [v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] @@ -39,7 +39,7 @@ repos: last_reviewed: unreviewed standards_referenced: [openapi, sdmx, w3c-did] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0] standards_referenced: [openapi, sdmx, w3c-did] last_reviewed: unreviewed - docsets: [v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] @@ -54,7 +54,7 @@ repos: last_reviewed: unreviewed standards_referenced: [openapi, ogc-api-features, ogc-api-edr, odrl, sp-dci, w3c-did] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0] standards_referenced: [openapi, ogc-api-features, ogc-api-edr, odrl, sp-dci, w3c-did] last_reviewed: unreviewed - docsets: [v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] @@ -68,7 +68,7 @@ repos: last_reviewed: unreviewed standards_referenced: [dcat, bregdcat-ap, ogc-api-records, ogc-api-features, ogc-api-edr, openapi, shacl, json-schema, sdmx, sp-dci, w3c-did] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0] standards_referenced: [dcat, bregdcat-ap, ogc-api-records, ogc-api-features, ogc-api-edr, openapi, shacl, json-schema, sdmx, sp-dci, w3c-did] last_reviewed: unreviewed - docsets: [v0.8.4, v0.8.3, v0.8.2, v0.8.1] @@ -84,7 +84,7 @@ repos: standards_referenced: [dcat, bregdcat-ap, cpsv-ap, ogc-api-records, ogc-api-features, shacl, json-schema, json-ld, odrl, sp-dci, w3c-did] exclude_docsets: [beta-5, beta-4, beta-3, beta-2026-06-12] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [dcat, bregdcat-ap, cpsv-ap, ogc-api-records, ogc-api-features, shacl, json-schema, json-ld, odrl, sp-dci, w3c-did] last_reviewed: unreviewed - src: crates/registry-relay/docs/ops.md @@ -95,7 +95,7 @@ repos: last_reviewed: unreviewed standards_referenced: [dcat, bregdcat-ap, ogc-api-edr, sp-dci, w3c-did] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0] standards_referenced: [dcat, bregdcat-ap, ogc-api-edr, sp-dci, w3c-did] last_reviewed: unreviewed - docsets: [v0.8.4, v0.8.3, v0.8.2, v0.8.1] @@ -110,7 +110,7 @@ repos: last_reviewed: unreviewed standards_referenced: [json-ld, w3c-did] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0] standards_referenced: [json-ld, w3c-did] last_reviewed: unreviewed - docsets: [v0.8.4, v0.8.3, v0.8.2, v0.8.1] @@ -126,7 +126,7 @@ repos: standards_referenced: [] exclude_docsets: [beta-5, beta-4, beta-3, beta-2026-06-12] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [] last_reviewed: unreviewed - src: crates/registry-relay/docs/standards-adapter-operator-guide.md @@ -137,7 +137,7 @@ repos: last_reviewed: unreviewed standards_referenced: [ogc-api-records, ogc-api-features, ogc-api-edr, sp-dci] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0] standards_referenced: [ogc-api-records, ogc-api-features, ogc-api-edr, sp-dci] last_reviewed: unreviewed - docsets: [v0.8.4, v0.8.3, v0.8.2, v0.8.1] @@ -153,7 +153,7 @@ repos: standards_referenced: [] exclude_docsets: [beta-5, beta-4, beta-3, beta-2026-06-12] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [] last_reviewed: unreviewed - src: crates/registry-relay/STANDARDS_ASSUMPTIONS.md @@ -165,7 +165,7 @@ repos: standards_referenced: [dcat, bregdcat-ap, cpsv-ap, ogc-api-records, shacl, json-schema, odrl, sp-dci, w3c-did] exclude_docsets: [beta-5, beta-4, beta-3, beta-2026-06-12] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [dcat, bregdcat-ap, cpsv-ap, ogc-api-records, shacl, json-schema, odrl, sp-dci, w3c-did] last_reviewed: unreviewed - src: crates/registry-relay/docs/relay-scenario-catalog.md @@ -177,7 +177,7 @@ repos: standards_referenced: [dcat, ogc-api-records, ogc-api-features, openapi, shacl, sp-dci] exclude_docsets: [beta-5, beta-4, beta-3, beta-2026-06-12] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [dcat, ogc-api-records, ogc-api-features, openapi, shacl, sp-dci] last_reviewed: unreviewed - src: crates/registry-relay/docs/release-notes.md @@ -188,7 +188,7 @@ repos: last_reviewed: unreviewed standards_referenced: [dcat, ogc-api-edr, openapi, shacl, json-ld, sdmx, sp-dci, w3c-did] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0] standards_referenced: [dcat, ogc-api-edr, openapi, shacl, json-ld, sdmx, sp-dci, w3c-did] last_reviewed: unreviewed - docsets: [v0.8.4, v0.8.3, v0.8.2, v0.8.1] @@ -198,7 +198,7 @@ repos: registry-notary: remote: https://github.com/registrystack/registry-stack ref: HEAD - version: v0.11.0 + version: v0.12.0 local: ../.. openapi: products/notary/openapi/registry-notary.openapi.json archive_remote: https://github.com/jeremi/registry-notary @@ -216,7 +216,7 @@ repos: - docsets: [beta-2026-06-12] standards_referenced: [sd-jwt-vc, oid4vci] last_reviewed: unreviewed - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3] standards_referenced: [sd-jwt-vc, oid4vci, fhir-r4] last_reviewed: unreviewed description: Integrator and operator documentation for Registry Notary. @@ -232,7 +232,7 @@ repos: - docsets: [beta-4, beta-3, beta-2026-06-12] standards_referenced: [sd-jwt-vc, oid4vci, w3c-did] last_reviewed: unreviewed - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5] standards_referenced: [sd-jwt-vc, oid4vci, w3c-did, fhir-r4] last_reviewed: unreviewed - src: products/notary/docs/client-sdk-guide.md @@ -247,7 +247,7 @@ repos: - docsets: [beta-2026-06-12] standards_referenced: [sd-jwt-vc, oid4vci] last_reviewed: unreviewed - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3] standards_referenced: [sd-jwt-vc, oid4vci, w3c-did] last_reviewed: unreviewed - src: products/notary/docs/identity-and-record-matching.md @@ -259,7 +259,7 @@ repos: last_reviewed: unreviewed standards_referenced: [sd-jwt-vc, verifiable-credentials] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] standards_referenced: [sd-jwt-vc, verifiable-credentials] last_reviewed: unreviewed - src: products/notary/docs/source-claim-modeling-guide.md @@ -274,7 +274,7 @@ repos: - docsets: [beta-4, beta-3, beta-2026-06-12] standards_referenced: [sd-jwt-vc] last_reviewed: unreviewed - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5] standards_referenced: [sd-jwt-vc, fhir-r4] last_reviewed: unreviewed - src: products/notary/docs/operator-config-reference.md @@ -286,7 +286,7 @@ repos: last_reviewed: unreviewed standards_referenced: [openapi, sd-jwt-vc, sp-dci, oid4vci, w3c-did, fhir-r4] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0] standards_referenced: [openapi, sd-jwt-vc, sp-dci, oid4vci, w3c-did, fhir-r4] last_reviewed: unreviewed - docsets: [v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3] @@ -303,7 +303,7 @@ repos: last_reviewed: unreviewed standards_referenced: [] docset_overrides: - - docsets: [v0.11.0, v0.10.0] + - docsets: [v0.12.0, v0.11.0, v0.10.0] standards_referenced: [] last_reviewed: unreviewed exclude_docsets: [v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] @@ -316,7 +316,7 @@ repos: last_reviewed: unreviewed standards_referenced: [sd-jwt-vc, w3c-did] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] standards_referenced: [sd-jwt-vc, w3c-did] last_reviewed: unreviewed - src: products/notary/docs/credential-issuance-migration.md @@ -327,7 +327,7 @@ repos: last_reviewed: "2026-07-17" standards_referenced: [sd-jwt-vc, oid4vci, w3c-did] docset_overrides: - - docsets: [v0.11.0] + - docsets: [v0.12.0, v0.11.0] standards_referenced: [sd-jwt-vc, oid4vci, w3c-did] last_reviewed: "2026-07-17" exclude_docsets: [v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] @@ -340,7 +340,7 @@ repos: last_reviewed: unreviewed standards_referenced: [sd-jwt-vc, w3c-did] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] standards_referenced: [sd-jwt-vc, w3c-did] last_reviewed: unreviewed - src: products/notary/docs/sd-jwt-vc-conformance-profile.md @@ -352,7 +352,7 @@ repos: last_reviewed: unreviewed standards_referenced: [json-ld, sd-jwt-vc, verifiable-credentials, oid4vci, w3c-did] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] standards_referenced: [json-ld, sd-jwt-vc, verifiable-credentials, oid4vci, w3c-did] last_reviewed: unreviewed - src: products/notary/docs/notary-capability-matrix.md @@ -364,7 +364,7 @@ repos: standards_referenced: [sd-jwt-vc, w3c-did] exclude_docsets: [beta-5, beta-4, beta-3, beta-2026-06-12] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [sd-jwt-vc, w3c-did] last_reviewed: unreviewed - src: products/notary/docs/notary-scenario-patterns.md @@ -376,7 +376,7 @@ repos: standards_referenced: [sd-jwt-vc, w3c-did] exclude_docsets: [beta-5, beta-4, beta-3, beta-2026-06-12] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [sd-jwt-vc, w3c-did] last_reviewed: unreviewed - src: products/notary/docs/federated-evaluation-operator-guide.md @@ -388,7 +388,7 @@ repos: standards_referenced: [w3c-did] exclude_docsets: [beta-5, beta-4, beta-3, beta-2026-06-12] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [w3c-did] last_reviewed: unreviewed - src: products/notary/docs/self-attestation-operator-guide.md @@ -400,7 +400,7 @@ repos: standards_referenced: [sd-jwt-vc, oid4vci, w3c-did] exclude_docsets: [beta-5, beta-4, beta-3, beta-2026-06-12] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [sd-jwt-vc, oid4vci, w3c-did] last_reviewed: unreviewed - src: products/notary/docs/deployment-hardening-runbook.md @@ -412,7 +412,7 @@ repos: standards_referenced: [sd-jwt-vc, oid4vci] exclude_docsets: [beta-5, beta-4, beta-3, beta-2026-06-12] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [sd-jwt-vc, oid4vci] last_reviewed: unreviewed - src: products/notary/docs/api-reference.md @@ -424,7 +424,7 @@ repos: standards_referenced: [openapi, sd-jwt-vc, cccev, oid4vci, fhir-r4] exclude_docsets: [beta-5, beta-4, beta-3, beta-2026-06-12] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [openapi, sd-jwt-vc, cccev, oid4vci, fhir-r4] last_reviewed: unreviewed - src: products/notary/docs/oid4vci-wallet-interop.md @@ -436,7 +436,7 @@ repos: standards_referenced: [sd-jwt-vc, oid4vci, w3c-did] exclude_docsets: [beta-5, beta-4, beta-3, beta-2026-06-12] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [sd-jwt-vc, oid4vci, w3c-did] last_reviewed: unreviewed - src: products/notary/docs/release-notes.md @@ -448,13 +448,13 @@ repos: standards_referenced: [openapi, sd-jwt-vc, oid4vci, fhir-r4] exclude_docsets: [beta-5, beta-4, beta-3, beta-2026-06-12] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [openapi, sd-jwt-vc, oid4vci, fhir-r4] last_reviewed: unreviewed registry-manifest: remote: https://github.com/registrystack/registry-stack ref: HEAD - version: v0.11.0 + version: v0.12.0 local: ../.. archive_remote: https://github.com/jeremi/registry-manifest docs: @@ -467,7 +467,7 @@ repos: last_reviewed: unreviewed standards_referenced: [dcat, bregdcat-ap, cpsv-ap, ogc-api-records, shacl, skos, json-schema, json-ld, odrl, sp-dci, w3c-did, prov-o, govstack-digital-registries] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3] standards_referenced: [dcat, bregdcat-ap, cpsv-ap, ogc-api-records, shacl, skos, json-schema, json-ld, odrl, sp-dci, w3c-did, prov-o, govstack-digital-registries] last_reviewed: unreviewed - docsets: [beta-2026-06-12] @@ -486,7 +486,7 @@ repos: - docsets: [beta-2026-06-12] standards_referenced: [dcat, bregdcat-ap, cpsv-ap, ogc-api-records, shacl, json-schema, json-ld, odrl] last_reviewed: unreviewed - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3] standards_referenced: [dcat, bregdcat-ap, cpsv-ap, ogc-api-records, shacl, skos, json-schema, json-ld, odrl] last_reviewed: unreviewed - src: products/manifest/docs/profile-fixtures.md @@ -498,7 +498,7 @@ repos: last_reviewed: unreviewed standards_referenced: [sp-dci] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] standards_referenced: [sp-dci] last_reviewed: unreviewed - src: products/manifest/docs/reference.md @@ -513,7 +513,7 @@ repos: - docsets: [beta-2026-06-12] standards_referenced: [dcat, bregdcat-ap, cpsv-ap, ogc-api-records, shacl, json-schema, json-ld, cccev, odrl, w3c-did] last_reviewed: unreviewed - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3] standards_referenced: [dcat, bregdcat-ap, cpsv-ap, ogc-api-records, shacl, skos, json-schema, json-ld, cccev, odrl, w3c-did] last_reviewed: unreviewed - src: products/manifest/docs/itb-semic-validation.md @@ -525,6 +525,6 @@ repos: standards_referenced: [dcat, bregdcat-ap, shacl, skos, json-schema, json-ld] exclude_docsets: [beta-5, beta-4, beta-3, beta-2026-06-12] docset_overrides: - - docsets: [v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] + - docsets: [v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [dcat, bregdcat-ap, shacl, skos, json-schema, json-ld] last_reviewed: unreviewed diff --git a/docs/site/src/data/standards.yaml b/docs/site/src/data/standards.yaml index ba399722b..fd1627e0e 100644 --- a/docs/site/src/data/standards.yaml +++ b/docs/site/src/data/standards.yaml @@ -15,11 +15,11 @@ - access service metadata evidence_docs: - label: Registry Relay README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md - label: Registry Manifest README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/README.md - label: Registry Relay DCAT catalog tests - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/catalog_entity.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/catalog_entity.rs last_checked: 2026-06-13 notes: Relay and Manifest emit DCAT-shaped metadata; the emitted catalog structure is asserted by Relay's catalog tests. Profile conformance claims still require profile-specific validation evidence (no DCAT validator output is pinned). - id: bregdcat-ap @@ -38,11 +38,11 @@ - embedded SHACL entity shapes evidence_docs: - label: Registry Relay README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md - label: Registry Manifest README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/README.md - label: Registry Relay BRegDCAT-AP route tests - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/catalog_entity.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/catalog_entity.rs last_checked: 2026-06-13 notes: Manifest and Relay emit BRegDCAT-shaped registry and data-service metadata; the `/metadata/dcat/bregdcat-ap` route output is asserted by Relay's catalog tests. CPSV-AP is now the service-discovery layer; BRegDCAT-AP remains the registry and data-service discovery layer. Later BRegDCAT-AP releases require a separate renderer review. - id: cpsv-ap @@ -61,7 +61,7 @@ - competent authority and requirement links evidence_docs: - label: Registry Manifest CPSV-AP fixture - url: https://github.com/registrystack/registry-stack/tree/v0.11.0/products/manifest/fixtures/cpsv-ap + url: https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/fixtures/cpsv-ap last_checked: 2026-05-25 notes: Registry Manifest emits CPSV-AP service catalogues. Registry Relay does not expose a current CPSV-AP runtime route. - id: ogc-api-records @@ -80,11 +80,11 @@ - OGC records item bodies evidence_docs: - label: Registry Relay README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md - label: Registry Manifest README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/README.md - label: Registry Relay OGC records API tests - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/ogc_records_api.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/ogc_records_api.rs last_checked: 2026-07-19 notes: The link-free Records metadata artifacts are stable for 1.0. The live Relay adapter behind ogcapi-records is a separate experimental, feature-frozen surface outside the 1.0 compatibility promise. Manifest renders and publishes static OGC Records item collections. - id: ogc-api-features @@ -102,9 +102,9 @@ - dataset-scoped feature items evidence_docs: - label: Registry Relay README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md - label: Registry Relay OGC Features API tests - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/ogc_api.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/ogc_api.rs last_checked: 2026-07-19 notes: Relay exposes OGC API Features routes behind the ogcapi-features feature. The adapter is experimental, feature-frozen, and outside the 1.0 compatibility promise. The claim is scoped to the profiled route output tested in Registry Relay, not full OGC conformance. - id: ogc-api-edr @@ -122,7 +122,7 @@ - EDR collection discovery evidence_docs: - label: Registry Relay OGC EDR API tests - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/ogc_edr_api.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/ogc_edr_api.rs last_checked: 2026-07-19 notes: Relay exposes OGC API EDR area routes behind the ogcapi-edr feature for configured spatial aggregates. The adapter is experimental, feature-frozen, and outside the 1.0 compatibility promise. The claim is scoped to the tested adapter surface, not full OGC conformance. - id: openapi @@ -141,13 +141,13 @@ - native OpenAPI publication evidence_docs: - label: Registry Relay README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md - label: Registry Notary README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/notary/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/notary/README.md - label: Registry Relay generated OpenAPI document (pinned) - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/openapi/registry-relay.openapi.json + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/openapi/registry-relay.openapi.json - label: Registry Notary generated OpenAPI document (pinned) - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/notary/openapi/registry-notary.openapi.json + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/notary/openapi/registry-notary.openapi.json last_checked: 2026-07-19 notes: Relay and Notary generate OpenAPI; the pinned generated documents are cited directly. These docs publish the same pinned artifacts as native API reference pages. - id: shacl @@ -166,11 +166,11 @@ - entity node shapes evidence_docs: - label: Registry Relay README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md - label: Registry Manifest README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/README.md - label: Registry Relay SHACL document structure tests - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/catalog_entity.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/catalog_entity.rs last_checked: 2026-06-13 notes: Relay emits SHACL/JSON-LD documents whose structure is asserted by its catalog tests, and Manifest renders SHACL. No SHACL validator run against the emitted shapes is pinned. - id: skos @@ -189,9 +189,9 @@ - BRegDCAT/DCAT dataset codelist references evidence_docs: - label: Registry Manifest codelist renderer - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-manifest-core/src/lib.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-manifest-core/src/lib.rs - label: Registry Manifest codelist tests - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-manifest-core/tests/metadata_core.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-manifest-core/tests/metadata_core.rs last_checked: 2026-06-20 notes: Registry Manifest emits flat SKOS-shaped `skos:ConceptScheme` and `skos:Concept` nodes for manifest codelists inside SHACL and BRegDCAT/DCAT-shaped outputs. It does not yet publish a standalone SKOS artifact or claim full SKOS conformance. - id: json-schema @@ -210,11 +210,11 @@ - static discovery bundle evidence_docs: - label: Registry Relay README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md - label: Registry Manifest README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/README.md - label: Registry Manifest form schema fixture (Draft 2020-12) - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/fixtures/cpsv-ap/health-linked-child-support.form.schema.json + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/fixtures/cpsv-ap/health-linked-child-support.form.schema.json last_checked: 2026-06-13 notes: Manifest renders entity JSON Schemas (the pinned fixture declares Draft 2020-12) and Relay exposes entity schema endpoints. - id: json-ld @@ -236,11 +236,11 @@ - CCCEV render output evidence_docs: - label: Registry Relay JSON-LD renderers - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/src/metadata/shacl.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/src/metadata/shacl.rs - label: Registry Notary CCCEV renderer - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/runtime.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-server/src/runtime.rs - label: Registry Manifest CPSV-AP JSON-LD fixture - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/fixtures/cpsv-ap/health-linked-child-support.cpsv-ap.jsonld + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/fixtures/cpsv-ap/health-linked-child-support.cpsv-ap.jsonld last_checked: 2026-06-13 notes: The projects emit JSON-LD artifacts and contexts; a pinned JSON-LD fixture is cited as a concrete emitted artifact. No broad RDF dataset conformance claim is made here. - id: sd-jwt-vc @@ -260,11 +260,11 @@ - shared SD-JWT VC issuance and holder-proof helpers evidence_docs: - label: Registry Notary SD-JWT VC conformance profile - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/notary/docs/sd-jwt-vc-conformance-profile.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/notary/docs/sd-jwt-vc-conformance-profile.md - label: Registry Notary format constants - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-core/src/model.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-core/src/model.rs - label: Registry Platform SD-JWT crate - url: https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-platform-sdjwt + url: https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-platform-sdjwt last_checked: 2026-06-13 notes: >- Registry Platform owns reusable SD-JWT VC issuance and holder-proof helpers. @@ -289,7 +289,7 @@ - SD-JWT VC credential issuance evidence_docs: - label: Registry Notary README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/notary/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/notary/README.md last_checked: 2026-05-23 notes: Registry Notary issues SD-JWT VC. Relay no longer emits or hosts VC artifacts. - id: cccev @@ -311,11 +311,11 @@ - evidence node JSON-LD evidence_docs: - label: Registry Manifest grouped evidence fixture - url: https://github.com/registrystack/registry-stack/tree/v0.11.0/products/manifest/fixtures/cpsv-ap + url: https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/fixtures/cpsv-ap - label: Registry Notary CCCEV renderer - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/runtime.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-server/src/runtime.rs - label: Registry Notary media type constants - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-core/src/model.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-core/src/model.rs last_checked: 2026-05-25 notes: Manifest emits CCCEV requirement and evidence type list metadata. Notary renders CCCEV-shaped claim results. Profile conformance is not claimed. - id: odrl @@ -336,15 +336,15 @@ - governed evidence PDP constraint terms evidence_docs: - label: Registry Relay README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md - label: Registry Manifest README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/products/manifest/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/products/manifest/README.md - label: Registry Relay catalog tests (ODRL offer assertions) - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/catalog_entity.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/catalog_entity.rs - label: Registry Platform PDP ODRL enforcement profile constants - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-platform-pdp/src/lib.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-platform-pdp/src/lib.rs - label: Registry Relay governed evidence PDP path - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/src/api/governed.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/src/api/governed.rs last_checked: 2026-07-09 notes: Relay catalog JSON-LD can include dataset-scoped ODRL Offers, and Manifest renders ODRL policy metadata. That publication surface is descriptive and is not itself an access grant. The shared governed Evidence Gateway profile vocabulary includes `odrl:purpose` and `odrl:spatial`, but Relay's governed runtime path currently enforces `odrl:purpose` only; `odrl:spatial` and other unsupported ODRL terms are denied fail-closed rather than treated as enforced. - id: sdmx @@ -362,9 +362,9 @@ - content negotiation delivery evidence_docs: - label: Registry Relay client integration guide - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/docs/client-integration.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/docs/client-integration.md - label: Registry Relay aggregates tests (schema-validated SDMX-JSON output) - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/tests/aggregates_entity.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/tests/aggregates_entity.rs last_checked: 2026-07-19 notes: >- Relay serves configured aggregates as SDMX-JSON 2.1 data messages via @@ -391,7 +391,7 @@ - provenance-shaped claim fields evidence_docs: - label: Registry Notary claim model - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-core/src/model.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-core/src/model.rs last_checked: 2026-05-23 notes: The code uses provenance concepts, but current evidence does not show a PROV-O vocabulary emission surface. Keep this as design influence until PROV-O terms are emitted or mapped. - id: fhir-r4 @@ -429,7 +429,7 @@ - capability boundary evidence_docs: - label: Registry Relay README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md last_checked: 2026-05-23 notes: Relay explores a protected consultation gateway model rather than the current single uniform CRUD platform. - id: universal-dpi-safeguards @@ -472,7 +472,7 @@ - authored Relay source integration evidence_docs: - label: Registry Relay README - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-relay/README.md + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-relay/README.md - label: Registry Stack OpenCRVS project-authoring fixture url: https://github.com/registrystack/registry-stack/tree/2dfcf2ddf22e2c0fa4bf5fd84eb06b8736057605/crates/registryctl/tests/fixtures/project-authoring/opencrvs last_checked: 2026-07-19 @@ -497,9 +497,9 @@ - registry-backed holder-wallet issuance evidence_docs: - label: Registry Notary OID4VCI routes - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/api.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-server/src/api.rs - label: Registry Platform OID4VCI crate - url: https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-platform-oid4vci + url: https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-platform-oid4vci - label: Registry Notary wallet facade specification url: https://github.com/registrystack/registry-stack/blob/37ce044567f05f8b3088b54f5bd4dbf389fd2ddb/products/notary/specs/openid4vci-wallet-facade-spec.md last_checked: 2026-07-19 @@ -533,8 +533,8 @@ - shared DID validation and Ed25519 JWK helpers evidence_docs: - label: Registry Notary did:jwk handling - url: https://github.com/registrystack/registry-stack/blob/v0.11.0/crates/registry-notary-server/src/api.rs + url: https://github.com/registrystack/registry-stack/blob/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-notary-server/src/api.rs - label: Registry Platform crypto crate - url: https://github.com/registrystack/registry-stack/tree/v0.11.0/crates/registry-platform-crypto + url: https://github.com/registrystack/registry-stack/tree/865921f0d8e874e3cefe548d000e8915c4abecbc/crates/registry-platform-crypto last_checked: 2026-06-13 notes: Notary parses did:jwk values for credential subjects, and Platform owns shared DID and Ed25519 JWK helpers. Relay no longer hosts a did:web document. No DID resolver, DID URL dereferencing, or DID Core conformance class is implemented, so the claim stays at emits. The site does not claim conformance to the wider DID method registry beyond did:jwk. diff --git a/editors/tests/install_test.sh b/editors/tests/install_test.sh index 764d5095c..4b253e835 100755 --- a/editors/tests/install_test.sh +++ b/editors/tests/install_test.sh @@ -57,7 +57,7 @@ readonly FAKE_OPEN_CANONICAL printf '%s\n' \ '[workspace]' \ '[workspace.package]' \ - 'version = "0.11.0"' \ + 'version = "0.12.0"' \ > "${FAKE_REPO_ROOT}/Cargo.toml" printf '{}\n' > "${FAKE_REPO_ROOT}/editors/vscode/package.json" printf '[package]\nname = "registry-stack-zed"\n' \ @@ -79,7 +79,7 @@ command_name="$(basename -- "$0")" case "${command_name}" in registryctl) if [[ "${1:-}" == "--version" ]]; then - printf 'registryctl %s\n' "${FAKE_REGISTRYCTL_VERSION:-0.11.0}" + printf 'registryctl %s\n' "${FAKE_REGISTRYCTL_VERSION:-0.12.0}" fi ;; node) @@ -176,7 +176,7 @@ if FAKE_REGISTRYCTL_VERSION=0.10.0 "${INSTALLER}" vscode \ > "${mismatch_output}" 2>&1; then fail 'version mismatch should fail' fi -assert_contains 'this checkout is 0.11.0 but registryctl is 0.10.0' "${mismatch_output}" +assert_contains 'this checkout is 0.12.0 but registryctl is 0.10.0' "${mismatch_output}" assert_not_contains 'npm <' "${COMMAND_LOG}" reset_log diff --git a/products/manifest/CHANGELOG.md b/products/manifest/CHANGELOG.md index 390b9cc2a..9b1556fe0 100644 --- a/products/manifest/CHANGELOG.md +++ b/products/manifest/CHANGELOG.md @@ -7,6 +7,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +## [0.12.0] - 2026-07-19 + +- No user-visible Registry Manifest changes. + ## [0.11.0] - 2026-07-18 - No user-visible Registry Manifest changes. diff --git a/products/manifest/docs/release-notes.md b/products/manifest/docs/release-notes.md index c8d392d6a..58d088e6b 100644 --- a/products/manifest/docs/release-notes.md +++ b/products/manifest/docs/release-notes.md @@ -2,6 +2,10 @@ ## Unreleased +## 0.12.0 + +- No user-visible Registry Manifest changes. + ## 0.11.0 - Registry Manifest has no user-visible changes in this release. diff --git a/products/manifest/fuzz/Cargo.lock b/products/manifest/fuzz/Cargo.lock index a448bd7b9..99b34c197 100644 --- a/products/manifest/fuzz/Cargo.lock +++ b/products/manifest/fuzz/Cargo.lock @@ -188,7 +188,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "registry-manifest-cli" -version = "0.11.0" +version = "0.12.0" dependencies = [ "registry-manifest-core", "serde", @@ -199,7 +199,7 @@ dependencies = [ [[package]] name = "registry-manifest-core" -version = "0.11.0" +version = "0.12.0" dependencies = [ "oxiri", "registry-platform-canonical-json", @@ -223,7 +223,7 @@ dependencies = [ [[package]] name = "registry-platform-canonical-json" -version = "0.11.0" +version = "0.12.0" dependencies = [ "ryu-js", "serde", diff --git a/products/notary/CHANGELOG.md b/products/notary/CHANGELOG.md index a3e7dcbf2..9b559fef9 100644 --- a/products/notary/CHANGELOG.md +++ b/products/notary/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.0] - 2026-07-19 + ### Added - Added a complete source-tested registry-backed OID4VCI journey for EdDSA and @@ -45,6 +47,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - OID4VCI remains limited to EdDSA or ES256 issuer signing and EdDSA `did:jwk` holder proof. No EUDI, HAIP, PAR, DPoP, wallet-attestation, ES256-holder, or external conformance claim is made without frozen candidate evidence. +- Maintained Notary runtime images now use Debian 13 distroless. Release checks + enforce the expected base and vulnerability policy before publication. ## [0.11.0] - 2026-07-18 diff --git a/products/notary/docs/release-notes.md b/products/notary/docs/release-notes.md index 0826723be..165265f53 100644 --- a/products/notary/docs/release-notes.md +++ b/products/notary/docs/release-notes.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.12.0 + - BREAKING: The 1.0 wallet facade supports only issuer-initiated pre-authorized code backed by a stored registry transaction. The former credential-offer and public nonce routes are removed, and the credential @@ -25,6 +27,12 @@ selectively disclosable. - External wallet, verifier, OIDF, EUDI, or HAIP evidence remains candidate-only until recorded against a frozen release artifact. +- Batch evaluation now has a fixed 100-member platform ceiling with lower + operator limits and pre-side-effect `batch.too_large` rejection. +- Registry Notary publishes a generated Draft 2020-12 runtime configuration + schema derived from the production deserialization graph. +- Maintained Notary runtime images now use Debian 13 distroless. Release checks + enforce the expected base and vulnerability policy before publication. ## 0.11.0 diff --git a/products/notary/fuzz/Cargo.lock b/products/notary/fuzz/Cargo.lock index 1b3a7b02d..729c91a34 100644 --- a/products/notary/fuzz/Cargo.lock +++ b/products/notary/fuzz/Cargo.lock @@ -16,7 +16,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -318,7 +318,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -379,7 +379,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -388,6 +388,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -545,7 +551,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1016,7 +1022,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.118", ] [[package]] @@ -1035,7 +1041,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1481,9 +1487,29 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.0", +] + [[package]] name = "registry-notary-core" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64", "humantime-serde", @@ -1495,6 +1521,7 @@ dependencies = [ "registry-platform-oid4vci", "registry-platform-ops", "registry-platform-sdjwt", + "schemars", "serde", "serde_json", "sha2 0.11.0", @@ -1516,7 +1543,7 @@ dependencies = [ [[package]] name = "registry-platform-authcommon" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde", "sha2 0.11.0", @@ -1527,7 +1554,7 @@ dependencies = [ [[package]] name = "registry-platform-cache" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "sha2 0.11.0", @@ -1537,7 +1564,7 @@ dependencies = [ [[package]] name = "registry-platform-canonical-json" -version = "0.11.0" +version = "0.12.0" dependencies = [ "ryu-js", "serde", @@ -1547,7 +1574,7 @@ dependencies = [ [[package]] name = "registry-platform-config" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64", "registry-platform-crypto", @@ -1561,7 +1588,7 @@ dependencies = [ [[package]] name = "registry-platform-crypto" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "aws-lc-rs", @@ -1582,7 +1609,7 @@ dependencies = [ [[package]] name = "registry-platform-httputil" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64", "bytes", @@ -1605,7 +1632,7 @@ dependencies = [ [[package]] name = "registry-platform-oid4vci" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64", "registry-platform-crypto", @@ -1618,7 +1645,7 @@ dependencies = [ [[package]] name = "registry-platform-ops" -version = "0.11.0" +version = "0.12.0" dependencies = [ "fs2", "registry-platform-config", @@ -1633,7 +1660,7 @@ dependencies = [ [[package]] name = "registry-platform-replay" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "getrandom 0.4.3", @@ -1644,7 +1671,7 @@ dependencies = [ [[package]] name = "registry-platform-sdjwt" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64", "getrandom 0.4.3", @@ -1837,6 +1864,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1913,7 +1965,18 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] @@ -2060,6 +2123,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2077,7 +2151,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2124,7 +2198,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2206,7 +2280,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2361,7 +2435,7 @@ checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2457,7 +2531,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -2684,7 +2758,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -2705,7 +2779,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2725,7 +2799,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -2746,7 +2820,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2779,7 +2853,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] diff --git a/products/notary/openapi/oasdiff-1.0-err-ignore.txt b/products/notary/openapi/oasdiff-1.0-err-ignore.txt deleted file mode 100644 index c93a49de7..000000000 --- a/products/notary/openapi/oasdiff-1.0-err-ignore.txt +++ /dev/null @@ -1,9 +0,0 @@ -# One-time accepted Registry Stack 1.0 break. The wallet facade now exposes -# registry-backed, issuer-initiated pre-authorized issuance only. The contract -# check also requires every line below to remain present in the raw diff, so this -# file must be removed after the comparison baseline includes the new contract. -GET /oid4vci/credential-offer api path removed without deprecation -POST /oid4vci/nonce api path removed without deprecation -GET /.well-known/openid-credential-issuer removed the optional property 'nonce_endpoint' from the response with the '200' status -POST /oid4vci/credential removed the optional property 'c_nonce' from the response with the '200' status -POST /oid4vci/credential removed the optional property 'c_nonce_expires_in' from the response with the '200' status diff --git a/products/notary/openapi/registry-notary.openapi.json b/products/notary/openapi/registry-notary.openapi.json index 457b25dbf..a63928247 100644 --- a/products/notary/openapi/registry-notary.openapi.json +++ b/products/notary/openapi/registry-notary.openapi.json @@ -1787,7 +1787,7 @@ }, "summary": "Standalone evidence evaluation, rendering, and credential issuance service.", "title": "Registry Notary API", - "version": "0.11.0" + "version": "0.12.0" }, "openapi": "3.1.0", "paths": { @@ -3185,7 +3185,7 @@ "example": { "info": { "title": "Registry Notary API", - "version": "0.11.0" + "version": "0.12.0" }, "openapi": "3.1.0", "paths": { diff --git a/products/notary/scripts/check-openapi-contract.sh b/products/notary/scripts/check-openapi-contract.sh index f48d480b6..832b8a6b4 100755 --- a/products/notary/scripts/check-openapi-contract.sh +++ b/products/notary/scripts/check-openapi-contract.sh @@ -9,8 +9,6 @@ BASE_REF="${1:-${OPENAPI_CONTRACT_BASE_REF:-}}" WORK_DIR="target/openapi-contract" GENERATED="$WORK_DIR/generated.openapi.json" BASELINE="$WORK_DIR/base.openapi.json" -RAW_BREAKING_DIFF="$WORK_DIR/breaking.singleline.txt" -BREAKING_IGNORE="openapi/oasdiff-1.0-err-ignore.txt" mkdir -p "$WORK_DIR" @@ -62,56 +60,4 @@ if ! git cat-file -e "$BASE_REF:$SPEC_PATH_FROM_ROOT" 2>/dev/null; then fi git show "$BASE_REF:$SPEC_PATH_FROM_ROOT" > "$BASELINE" - -# Keep the one-time 1.0 exception exact and self-expiring. An added allowlist -# line fails this check, and an accepted line that disappears from the raw diff -# also fails so the exception cannot silently survive a baseline advance. -oasdiff breaking --format singleline "$BASELINE" "$GENERATED" > "$RAW_BREAKING_DIFF" -python3 - "$BREAKING_IGNORE" "$RAW_BREAKING_DIFF" <<'PY' -import sys -from pathlib import Path - -expected = { - "GET /oid4vci/credential-offer api path removed without deprecation", - "POST /oid4vci/nonce api path removed without deprecation", - "GET /.well-known/openid-credential-issuer removed the optional property 'nonce_endpoint' from the response with the '200' status", - "POST /oid4vci/credential removed the optional property 'c_nonce' from the response with the '200' status", - "POST /oid4vci/credential removed the optional property 'c_nonce_expires_in' from the response with the '200' status", -} - -ignore_path = Path(sys.argv[1]) -raw_path = Path(sys.argv[2]) -allowed = { - line.strip() - for line in ignore_path.read_text(encoding="utf-8").splitlines() - if line.strip() and not line.lstrip().startswith("#") -} -if allowed != expected: - missing = sorted(expected - allowed) - extra = sorted(allowed - expected) - raise SystemExit( - f"Notary OpenAPI 1.0 allowlist is not exact; missing={missing}, extra={extra}" - ) - -observed = set() -for raw_line in raw_path.read_text(encoding="utf-8").splitlines(): - marker = "in API " - if marker not in raw_line: - continue - change = raw_line.split(marker, 1)[1] - if " [" in change: - change = change.rsplit(" [", 1)[0] - observed.add(change.strip().removesuffix(".").rstrip()) - -if observed != allowed: - missing = sorted(allowed - observed) - extra = sorted(observed - allowed) - raise SystemExit( - f"Notary OpenAPI 1.0 raw diff is not exactly allowlisted; " - f"missing={missing}, extra={extra}" - ) -PY - -oasdiff breaking --fail-on ERR --err-ignore "$BREAKING_IGNORE" \ - --warn-ignore "$BREAKING_IGNORE" \ - "$BASELINE" "$GENERATED" +oasdiff breaking --fail-on ERR "$BASELINE" "$GENERATED" diff --git a/products/platform/CHANGELOG.md b/products/platform/CHANGELOG.md index 735fae6cf..4b7ca7fa8 100644 --- a/products/platform/CHANGELOG.md +++ b/products/platform/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +## v0.12.0 - 2026-07-19 + +### Added + +- `registry-platform-ops` now carries the restricted per-resource Relay + refresh-health posture contract and a matching reference fixture. +- `CredentialFingerprintProvider`, `KeyProviderKind`, and `KeyStatus` expose + declaration-ordered `ALL` rosters so config-schema generation consumes the + same closed labels as runtime parsing and diagnostics. + ## v0.11.0 - 2026-07-18 - BREAKING: shared configuration `${VAR}` expansion now rejects environment diff --git a/products/platform/fuzz/Cargo.lock b/products/platform/fuzz/Cargo.lock index d47e8475b..5d5ec0ce5 100644 --- a/products/platform/fuzz/Cargo.lock +++ b/products/platform/fuzz/Cargo.lock @@ -1433,7 +1433,7 @@ dependencies = [ [[package]] name = "registry-platform-authcommon" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde", "sha2 0.11.0", @@ -1444,7 +1444,7 @@ dependencies = [ [[package]] name = "registry-platform-cache" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "sha2 0.11.0", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "registry-platform-canonical-json" -version = "0.11.0" +version = "0.12.0" dependencies = [ "ryu-js", "serde", @@ -1464,7 +1464,7 @@ dependencies = [ [[package]] name = "registry-platform-crypto" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "aws-lc-rs", @@ -1504,7 +1504,7 @@ dependencies = [ [[package]] name = "registry-platform-httputil" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64", "bytes", @@ -1527,7 +1527,7 @@ dependencies = [ [[package]] name = "registry-platform-oid4vci" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64", "registry-platform-crypto", @@ -1540,7 +1540,7 @@ dependencies = [ [[package]] name = "registry-platform-oidc" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64", "jsonwebtoken", @@ -1555,7 +1555,7 @@ dependencies = [ [[package]] name = "registry-platform-replay" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "getrandom 0.4.3", @@ -1566,7 +1566,7 @@ dependencies = [ [[package]] name = "registry-platform-sdjwt" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64", "getrandom 0.4.3", diff --git a/release/VERIFY.md b/release/VERIFY.md index b1c037cfe..62ca4a570 100644 --- a/release/VERIFY.md +++ b/release/VERIFY.md @@ -89,7 +89,7 @@ working directory rather than the `verify-v0.8.4` directory above. ### Download The v0.9.0+ Assets ```bash -tag=v0.11.0 +tag=v0.12.0 asset=registryctl-${tag}-linux-amd64 image_lock=registryctl-${tag}-image-lock.json image_lock_sbom=${image_lock}.spdx.json diff --git a/release/manifests/registry-stack-beta-14.yaml b/release/manifests/registry-stack-beta-14.yaml new file mode 100644 index 000000000..d463c7a88 --- /dev/null +++ b/release/manifests/registry-stack-beta-14.yaml @@ -0,0 +1,31 @@ +stack: + release: beta-14 + version: 0.12.0 + source_repo: registrystack/registry-stack + source_ref: 865921f0d8e874e3cefe548d000e8915c4abecbc + source_tag: v0.12.0 + status: release-candidate + +artifacts: + registry-notary: 0.12.0 + registry-notary-cel-worker: 0.12.0 + registry-relay: 0.12.0 + registry-relay-rhai-worker: 0.12.0 + registry-manifest-cli: 0.12.0 + registryctl: 0.12.0 + registryctl-image-lock: 0.12.0 + registry-docs: 0.12.0 + +external: + crosswalk: + repo: PublicSchema/crosswalk + ref: 1d44ec735fdc8a7c719264b339574371e8330337 + status: tested external input + +warnings: + - code: hosted-publication-held + classification: hosted-gate-held + detail: Source beta may ship; hosted/public announcement remains held until Solmara Lab is atomically repinned to the published v0.12.0 digests and its adopter smoke passes. + - code: solmara-adopter-repin-pending + classification: adopter-gate-pending + detail: Solmara Lab consumes released images and will be repinned and validated only after immutable v0.12.0 digests exist. diff --git a/release/notes/v0.12.0.md b/release/notes/v0.12.0.md new file mode 100644 index 000000000..89c080e79 --- /dev/null +++ b/release/notes/v0.12.0.md @@ -0,0 +1,113 @@ +# Registry Stack v0.12.0 + +Registry Stack v0.12.0 is the beta-14 technical release after v0.11.0. It is a +pre-1.0 release for evaluation, integration pilots, and public review. It does +not declare the 1.0 compatibility freeze or claim completed external +integration, OpenID conformance, lifecycle exercise, hosted promotion, or an +independent pilot. + +## Highlights + +- Registry Notary's OID4VCI wallet facade now supports issuer-initiated + pre-authorized code backed by a stored registry transaction. The public + credential-offer and nonce routes are removed. Token responses provide the + transaction-bound proof nonce used for credential issuance. +- Source-tested OID4VCI coverage now includes EdDSA and ES256 issuer profiles, + EdDSA `did:jwk` holder proof, exact generated metadata, replay protection, + registry-backed provenance, and fail-closed status verification. External + wallet, verifier, OIDF, EUDI, and HAIP evidence remains candidate-only. +- Registry Notary adds a bounded batch-evaluation contract with an immutable + 100-member platform ceiling, lower operator limits, and pre-side-effect HTTP + 413 rejection. It also publishes a generated Draft 2020-12 runtime + configuration schema derived from the production deserialization graph. +- Registry Relay records the intended 1.0 support roster. OpenAPI and problem + contracts, portable metadata, CSV and XLSX input, and JSON aggregate output + are stable candidates. Optional standards adapters and the shipped + non-gated Parquet, CSV aggregate, and SDMX-JSON surfaces remain experimental + and outside the 1.0 compatibility promise. +- Registry Relay exposes protected per-resource last-good refresh health in + metrics and restricted posture. Failed refreshes retain a valid last-good + table while reporting consecutive failures and the last successful load. +- Maintained Registry Relay and Registry Notary runtime images move to Debian + 13. The release workflow pins the Debian 13 builder and enforces image-base + and vulnerability-policy checks before publication. +- Registryctl interactive report commands now use concise human-readable + output by default. Automation must request `--format json`. Registryctl also + uses the workspace YAML parser and stages editor setup before publishing a + new project, avoiding partial output when initialization fails. +- Candidate-neutral upgrade, external-integration, and Relay OIDC harnesses + now bind exact release identifiers and immutable candidate assets. These + harnesses are preparation only until run and reviewed against the published + beta-14 artifacts. + +## Migration from v0.11.0 + +### Update OID4VCI clients and issuer configuration + +- Remove calls to `GET /oid4vci/credential-offer` and `POST /oid4vci/nonce`. + Start with `GET /oid4vci/offer/start`, complete the identity-provider + callback, redeem the rendered offer at `POST /oid4vci/token`, and use the + token response's proof nonce. +- Keep transaction codes enabled unless an explicit no-PIN profile is + required. A no-PIN profile must retain a pre-authorized-code lifetime no + longer than 300 seconds. +- Configure EdDSA or ES256 issuer signing with EdDSA `did:jwk` holder proof. + Do not infer support for other issuer or holder profiles from source tests. +- Re-run `registry-notary state install` and `registry-notary state doctor` + offline before admitting traffic. Do not start an older Notary binary + against state written by v0.12.0. + +### Update batch callers and configuration validation + +- Keep each Notary batch at or below the configured per-claim and global + limits and the fixed 100-member platform ceiling. Handle `batch.too_large` + as a pre-side-effect rejection. +- Validate Notary configuration against + `schemas/registry-notary.config.schema.json`, then run the v0.12.0 product + doctor. Schema validity proves structure, not deployability or security + posture. + +### Update Registryctl automation + +- Add `--format json` to scripts that consume Registryctl report output. + Interactive commands now default to human output; artifact and protocol + streams keep their native formats. +- Regenerate project outputs with Registryctl v0.12.0 and its matching + `registryctl-v0.12.0-image-lock.json`. Run `registryctl test`, `check`, and + `build` before signing or deploying the result. + +### Review Relay features and runtime images + +- Build the canonical release feature set unless an explicitly reviewed + experimental adapter is required. Experimental surfaces remain outside the + 1.0 compatibility promise even when their source or format ships. +- Repin deployments to the v0.12.0 Debian 13 image digests. Do not rely on + packages or paths inherited from the previous Debian 12 runtime. + +### Exercise the release boundary + +- Keep complete, version-matched v0.11.0 and v0.12.0 recovery sets. Populate + and validate `release/exercises/upgrade-exercise-v1.template.json` only + against exact published candidate artifacts. The committed template is not + evidence of a successful upgrade or rollback. +- Fix forward after v0.12.0 accepts writes or issues credentials unless a + reviewed recovery record proves a safe conversion back to the earlier + release. + +## Release evidence + +The release workflow retains the eight-artifact public inventory: Registry +Notary, the Notary CEL worker, Registry Relay, the Relay Rhai worker, Registry +Manifest CLI, Registryctl, the Registryctl image lock, and Registry Docs. The +workflow also publishes images, checksums, Software Bill of Materials files, +vulnerability reports, a release capsule, signatures, and provenance. + +Crosswalk remains pinned at +`1d44ec735fdc8a7c719264b339574371e8330337`. Registry Manifest has no +user-visible change in this train. + +The committed evidence harnesses have not yet been run against published +v0.12.0 assets. Solmara Lab repinning, the complete upgrade and rollback +exercise, OpenCRVS and DHIS2 E2 results, OpenID conformance, external wallet or +verifier interoperability, hosted publication, and the independent pilot +remain held. This source note does not claim any of those results. diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 80b1cb734..611c56c3a 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -71,6 +71,12 @@ ("Gitleaks archive checksum", "GITLEAKS_LINUX_X64_SHA256:"), ("Gitleaks root config", "--config .gitleaks.toml"), ("Gitleaks redaction", "--redact"), + ("oasdiff version pin", 'OASDIFF_VERSION: "1.23.0"'), + ("oasdiff archive checksum", "OASDIFF_LINUX_X64_SHA256:"), + ( + "oasdiff pinned install", + '"https://github.com/oasdiff/oasdiff/releases/download/v${OASDIFF_VERSION}/oasdiff_${OASDIFF_VERSION}_linux_amd64.tar.gz"', + ), ("Platform fuzz job", "platform-fuzz:"), ("Platform fuzz version pin", 'CARGO_FUZZ_VERSION: "0.13.2"'), ("Platform fuzz bounded runtime", "-max_total_time=60"), diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index e352bfe84..ac653497d 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -305,7 +305,7 @@ def test_validate_docsets_matches_release_manifests(self) -> None: result = run_tool("validate-docsets") self.assertEqual(0, result.returncode, result.stderr) - self.assertIn("validated 7 versioned docsets", result.stdout) + self.assertIn("validated 8 versioned docsets", result.stdout) def test_validate_docsets_rejects_external_ref_drift(self) -> None: with tempfile.TemporaryDirectory() as tmp: From 817ddec63d759653d88f2d934b55943f6a62c7b1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 20 Jul 2026 06:23:52 +0700 Subject: [PATCH 65/65] fix(release): clear beta-14 CodeQL blockers Harden the beta-14 OIDC smoke runtime file boundary and resolve the exact CodeQL findings that blocked promotion. Signed-off-by: Jeremi Joslin --- release/conformance/relay-oidc/zitadel-helper.py | 10 +++++----- release/scripts/relay-oidc-smoke.py | 7 +++++-- release/scripts/test_integration_e2_runner.py | 7 +++---- release/scripts/test_relay_oidc_smoke.py | 14 ++++++++++++++ 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/release/conformance/relay-oidc/zitadel-helper.py b/release/conformance/relay-oidc/zitadel-helper.py index 61c489c5f..eade897a9 100755 --- a/release/conformance/relay-oidc/zitadel-helper.py +++ b/release/conformance/relay-oidc/zitadel-helper.py @@ -184,23 +184,24 @@ def runtime_owner() -> tuple[int, int]: return uid, gid -def atomic_json(path: Path, payload: dict[str, Any], mode: int) -> None: +def atomic_json(path: Path, payload: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(f".{path.name}.tmp") - descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) try: with os.fdopen(descriptor, "w", encoding="utf-8") as handle: json.dump(payload, handle, sort_keys=True, separators=(",", ":")) handle.write("\n") handle.flush() os.fsync(handle.fileno()) - os.chmod(temporary, mode) + os.chmod(temporary, 0o600) os.chown(temporary, *runtime_owner()) os.replace(temporary, path) finally: try: temporary.unlink() except FileNotFoundError: + # The temporary was atomically replaced or already removed. pass @@ -222,6 +223,7 @@ def atomic_secret(path: Path, value: str) -> None: try: temporary.unlink() except FileNotFoundError: + # The temporary was atomically replaced or already removed. pass @@ -316,12 +318,10 @@ def provision() -> None: "service_account_id": service_id, "service_account_org_id": service_org_id, }, - 0o644, ) atomic_json( SECRET_PATH, {"client_id": client_id, "client_secret": client_secret, "canary": canary}, - 0o600, ) print("zitadel-helper: ephemeral project, role, and service account provisioned") diff --git a/release/scripts/relay-oidc-smoke.py b/release/scripts/relay-oidc-smoke.py index 70f7cc796..27c1f9b69 100755 --- a/release/scripts/relay-oidc-smoke.py +++ b/release/scripts/relay-oidc-smoke.py @@ -205,8 +205,10 @@ def plan_document(relay_image: str, source_ref: str, release_id: str) -> dict[st "live_evidence": False, "notes": [ "This plan validates checked-in inputs only and is not conformance evidence.", - "A live run remains unreviewed until its digest-bound report is " - "reviewed without raw secrets.", + ( + "A live run remains unreviewed until its digest-bound report is " + + "reviewed without raw secrets." + ), ], } @@ -417,6 +419,7 @@ def atomic_text(path: Path, value: str, mode: int = 0o644) -> None: try: temporary.unlink() except FileNotFoundError: + # The temporary was atomically replaced or already removed. pass diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py index 79c51be94..77061386a 100644 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -10,10 +10,9 @@ import stat import subprocess import tempfile -import unittest from contextlib import redirect_stderr from pathlib import Path -from unittest import mock +from unittest import TestCase, main, mock ROOT = Path(__file__).resolve().parents[2] @@ -29,7 +28,7 @@ def load_module(): return module -class IntegrationE2RunnerTest(unittest.TestCase): +class IntegrationE2RunnerTest(TestCase): def setUp(self) -> None: self.module = load_module() self.temporary = tempfile.TemporaryDirectory() @@ -738,4 +737,4 @@ def test_public_result_must_retain_all_claim_limitations(self) -> None: if __name__ == "__main__": - unittest.main() + main() diff --git a/release/scripts/test_relay_oidc_smoke.py b/release/scripts/test_relay_oidc_smoke.py index 7ef629641..34dd34274 100644 --- a/release/scripts/test_relay_oidc_smoke.py +++ b/release/scripts/test_relay_oidc_smoke.py @@ -396,6 +396,20 @@ def test_helper_writes_runtime_secrets_for_the_invoking_host_user(self) -> None: self.assertEqual(self.runner.os.getuid(), path.stat().st_uid) self.assertEqual(self.runner.os.getgid(), path.stat().st_gid) + def test_helper_writes_all_runtime_json_owner_only(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "topology.json" + env = { + "REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_UID": str(self.runner.os.getuid()), + "REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_GID": str(self.runner.os.getgid()), + } + with patch.dict(self.helper.os.environ, env, clear=False): + self.helper.atomic_json(path, {"client_id": "synthetic-client"}) + + self.assertEqual(0o600, path.stat().st_mode & 0o777) + self.assertEqual(self.runner.os.getuid(), path.stat().st_uid) + self.assertEqual(self.runner.os.getgid(), path.stat().st_gid) + def test_helper_rejects_invalid_runtime_ownership(self) -> None: env = { "REGISTRY_RELAY_OIDC_SMOKE_RUNTIME_UID": "not-a-uid",