From 42b6441892c2b7b8b56eebcbaaf13aeda6feea69 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 17 Jul 2026 21:32:24 +0700 Subject: [PATCH] fix(notary): bind credential issuance to Relay provenance Signed-off-by: Jeremi Joslin --- .../registry-notary-core/src/config/errors.rs | 2 + .../src/config/evidence/claims.rs | 17 - .../src/config/oid4vci.rs | 5 + .../registry-notary-core/src/config/root.rs | 115 ++++- .../src/config/subject_access.rs | 57 +- .../src/config/tests/credentials.rs | 4 + .../src/config/tests/issuance.rs | 172 +++++++ .../src/config/tests/relay.rs | 29 +- .../src/config/tests/root.rs | 90 +++- crates/registry-notary-core/src/model.rs | 91 ++++ crates/registry-notary-server/src/api.rs | 4 +- .../src/api/attestation_policy.rs | 87 +--- .../src/api/credentials.rs | 49 +- .../src/api/evaluations.rs | 2 +- .../src/api/oid4vci/credential.rs | 30 +- .../src/api/tests/attestation.rs | 8 +- .../src/api/tests/credentials.rs | 485 ++++++++++++++++++ .../src/api/tests/evaluations.rs | 1 + .../src/api/tests/oid4vci.rs | 432 +++++++++++++--- .../src/api/tests/support.rs | 297 +++++++++-- crates/registry-notary-server/src/openapi.rs | 6 +- crates/registry-notary-server/src/runtime.rs | 3 +- .../src/runtime/catalog.rs | 4 +- .../src/runtime/evaluation.rs | 273 +++++++--- .../src/runtime/render.rs | 314 ++++++++++++ .../src/runtime/store.rs | 32 ++ .../src/runtime/tests/catalog.rs | 1 + .../src/runtime/tests/evaluation.rs | 162 +++++- .../src/runtime/tests/render.rs | 238 +++++++++ .../src/runtime/tests/support.rs | 1 + .../src/runtime/types.rs | 18 + .../src/standalone/assembly.rs | 22 + .../src/standalone/tests/deployment_gates.rs | 139 ++++- .../tests/standalone_http/credentials.rs | 92 ++++ .../tests/standalone_http/federation.rs | 42 +- .../tests/standalone_http/oid4vci.rs | 42 +- .../tests/standalone_http/support.rs | 182 ++++++- .../tests/subject_access_guard_test.rs | 55 +- crates/registry-notary/tests/doctor_cli.rs | 36 +- docs/site/astro.config.mjs | 1 - .../images/registry-architecture-flow.svg | 6 +- .../public/images/registry-claim-model.svg | 2 +- .../content/docs/explanation/architecture.mdx | 3 +- ...ta-minimization-and-purpose-limitation.mdx | 11 + .../explanation/dpi-safeguards-alignment.mdx | 2 +- .../docs/explanation/evidence-issuance.mdx | 27 +- .../docs/explanation/integration-patterns.mdx | 6 +- .../docs/explanation/known-limitations.mdx | 9 +- .../single-node-compose-behind-proxy.mdx | 3 +- .../docs/reference/apis/registry-notary.mdx | 14 +- .../src/content/docs/reference/glossary.mdx | 10 +- .../content/docs/reference/registryctl.mdx | 6 +- docs/site/src/content/docs/spec/rs-arc-g.mdx | 6 +- .../src/content/docs/spec/rs-dm-claim.mdx | 11 +- .../src/content/docs/spec/rs-pr-notary.mdx | 56 +- docs/site/src/content/docs/spec/rs-terms.mdx | 6 +- .../content/docs/start/credential-tour.mdx | 124 +---- .../tutorials/first-run-with-solmara-lab.mdx | 1 - ...blish-spreadsheet-secured-registry-api.mdx | 2 - docs/site/src/data/contracts.yaml | 6 +- docs/site/src/data/generated/contracts.json | 4 +- docs/site/src/data/generated/projects.json | 7 +- docs/site/src/data/generated/sidebar.json | 4 + docs/site/src/data/projects.yaml | 5 +- docs/site/src/data/repo-docs.yaml | 8 + products/notary/CHANGELOG.md | 11 + products/notary/README.md | 7 +- products/notary/docs/README.md | 5 +- products/notary/docs/client-sdk-guide.md | 7 + .../docs/credential-issuance-migration.md | 89 ++++ .../notary/docs/notary-capability-matrix.md | 10 +- .../notary/docs/oid4vci-wallet-interop.md | 43 +- .../notary/docs/operator-config-reference.md | 29 +- products/notary/docs/release-notes.md | 7 + .../docs/self-attestation-operator-guide.md | 48 +- .../openapi/registry-notary.openapi.json | 4 +- products/notary/specs/README.md | 4 +- .../specs/citizen-self-attestation-spec.md | 8 + .../specs/openid4vci-wallet-facade-spec.md | 5 + 79 files changed, 3586 insertions(+), 670 deletions(-) create mode 100644 products/notary/docs/credential-issuance-migration.md diff --git a/crates/registry-notary-core/src/config/errors.rs b/crates/registry-notary-core/src/config/errors.rs index f9626359a..815a52d31 100644 --- a/crates/registry-notary-core/src/config/errors.rs +++ b/crates/registry-notary-core/src/config/errors.rs @@ -126,6 +126,8 @@ pub enum EvidenceConfigError { claim; an empty list would permit any claim at issuance" )] EmptyAllowedClaims { profile: String }, + #[error("invalid credential claim binding: {reason}")] + InvalidCredentialClaimBinding { reason: String }, /// Registry Notary currently issues only SD-JWT VC credentials using the /// current `application/dc+sd-jwt` media type. Reject aliases and profile /// labels so operator config cannot drift from the wire contract. diff --git a/crates/registry-notary-core/src/config/evidence/claims.rs b/crates/registry-notary-core/src/config/evidence/claims.rs index cb06ed773..4cad742b8 100644 --- a/crates/registry-notary-core/src/config/evidence/claims.rs +++ b/crates/registry-notary-core/src/config/evidence/claims.rs @@ -457,23 +457,6 @@ pub(in crate::config) fn validate_self_attested_dependency_modes( Ok(()) } -pub(in crate::config) fn validate_registry_backed_dependency_modes( - claims: &[ClaimDefinition], -) -> Result<(), EvidenceConfigError> { - for claim in claims - .iter() - .filter(|claim| claim.evidence_mode.is_registry_backed()) - { - if !claim.depends_on.is_empty() { - return invalid_claim_evidence_mode( - claim, - "the initial registry_backed journey cannot declare depends_on; one claim maps only its pinned Relay consultation", - ); - } - } - Ok(()) -} - pub(in crate::config) fn validate_relay_activation_shape( claims: &[ClaimDefinition], ) -> Result<(), EvidenceConfigError> { diff --git a/crates/registry-notary-core/src/config/oid4vci.rs b/crates/registry-notary-core/src/config/oid4vci.rs index b95b1f29e..60b68966a 100644 --- a/crates/registry-notary-core/src/config/oid4vci.rs +++ b/crates/registry-notary-core/src/config/oid4vci.rs @@ -809,6 +809,11 @@ pub(super) fn validate_oid4vci_credential_claim_reference<'a>( "credential configuration '{configuration_id}' references unknown claim '{claim_id}'" ), })?; + if !claim.evidence_mode.is_registry_backed() { + return invalid_oid4vci(format!( + "credential configuration '{configuration_id}' maps source-free claim '{claim_id}' to credential profile '{credential_profile_id}'; OID4VCI credential claims must be registry_backed" + )); + } if !claim .credential_profiles .iter() diff --git a/crates/registry-notary-core/src/config/root.rs b/crates/registry-notary-core/src/config/root.rs index ba7edf592..b0d03d5c1 100644 --- a/crates/registry-notary-core/src/config/root.rs +++ b/crates/registry-notary-core/src/config/root.rs @@ -360,10 +360,10 @@ impl StandaloneRegistryNotaryConfig { &self.evidence.claims, &self.subject_access.delegation, )?; - validate_registry_backed_dependency_modes(&self.evidence.claims)?; validate_relay_activation_shape(&self.evidence.claims)?; self.subject_access.validate(&self.auth, &self.evidence)?; self.validate_oid4vci_cross_block()?; + validate_credential_claim_bindings(&self.evidence)?; self.validate_access_token_signing_cross_block()?; self.federation.validate(&self.evidence)?; self.validate_signing_key_alg_usage()?; @@ -692,6 +692,119 @@ impl StandaloneRegistryNotaryConfig { } } +/// Close both sides of every credential claim/profile binding at load time. +/// +/// A credential profile is a signing capability. Keeping this validation at +/// the shared root prevents direct issuance, subject-access issuance, and +/// OID4VCI from interpreting a one-sided or source-free binding differently. +/// Source-free claims remain valid evaluation inputs when neither side grants +/// them credential capability. +fn validate_credential_claim_bindings( + evidence: &EvidenceConfig, +) -> Result<(), EvidenceConfigError> { + for (profile_id, profile) in &evidence.credential_profiles { + for claim_id in &profile.allowed_claims { + let claim = evidence + .claims + .iter() + .find(|claim| claim.id == *claim_id) + .ok_or_else(|| EvidenceConfigError::InvalidCredentialClaimBinding { + reason: format!( + "credential profile '{profile_id}' allowed_claims references unknown claim '{claim_id}'" + ), + })?; + if !claim.evidence_mode.is_registry_backed() { + return Err(EvidenceConfigError::InvalidCredentialClaimBinding { + reason: format!( + "credential profile '{profile_id}' allowed_claims references source-free claim '{claim_id}'; credential claims must be registry_backed" + ), + }); + } + if !claim + .credential_profiles + .iter() + .any(|candidate| candidate == profile_id) + { + return Err(EvidenceConfigError::InvalidCredentialClaimBinding { + reason: format!( + "credential profile '{profile_id}' allows claim '{claim_id}', but the claim does not reference that profile" + ), + }); + } + let mut pending = claim + .depends_on + .iter() + .map(String::as_str) + .collect::>(); + let mut visited = HashSet::new(); + while let Some(dependency_id) = pending.pop() { + if !visited.insert(dependency_id) { + continue; + } + let dependency = evidence + .claims + .iter() + .find(|candidate| candidate.id == dependency_id) + .ok_or_else(|| EvidenceConfigError::InvalidCredentialClaimBinding { + reason: format!( + "credential profile '{profile_id}' claim '{claim_id}' dependency closure references unknown claim '{dependency_id}'" + ), + })?; + if !dependency.evidence_mode.is_registry_backed() { + return Err(EvidenceConfigError::InvalidCredentialClaimBinding { + reason: format!( + "credential profile '{profile_id}' claim '{claim_id}' dependency closure contains source-free claim '{dependency_id}'; credential roots and all dependencies must be registry_backed" + ), + }); + } + if dependency.purpose != claim.purpose { + return Err(EvidenceConfigError::InvalidCredentialClaimBinding { + reason: format!( + "credential profile '{profile_id}' claim '{claim_id}' dependency '{dependency_id}' must declare the same canonical purpose" + ), + }); + } + pending.extend(dependency.depends_on.iter().map(String::as_str)); + } + } + } + + for claim in &evidence.claims { + for profile_id in &claim.credential_profiles { + if !claim.evidence_mode.is_registry_backed() { + return Err(EvidenceConfigError::InvalidCredentialClaimBinding { + reason: format!( + "source-free claim '{}' references credential profile '{profile_id}'; source-free claims may be evaluated but cannot have credential capability", + claim.id + ), + }); + } + let profile = evidence + .credential_profiles + .get(profile_id) + .ok_or_else(|| EvidenceConfigError::InvalidCredentialClaimBinding { + reason: format!( + "claim '{}' references unknown credential profile '{profile_id}'", + claim.id + ), + })?; + if !profile + .allowed_claims + .iter() + .any(|candidate| candidate == &claim.id) + { + return Err(EvidenceConfigError::InvalidCredentialClaimBinding { + reason: format!( + "claim '{}' references credential profile '{profile_id}', but the profile allowed_claims does not include that claim", + claim.id + ), + }); + } + } + } + Ok(()) +} + pub(super) fn validate_static_credential_ids( api_keys: &[EvidenceCredentialConfig], bearer_tokens: &[EvidenceCredentialConfig], diff --git a/crates/registry-notary-core/src/config/subject_access.rs b/crates/registry-notary-core/src/config/subject_access.rs index 09c6446bd..81208eba4 100644 --- a/crates/registry-notary-core/src/config/subject_access.rs +++ b/crates/registry-notary-core/src/config/subject_access.rs @@ -354,6 +354,12 @@ impl SubjectAccessDelegatedRelationshipConfig { "subject_access.delegation.credential_profiles", &self.credential_profiles, )?; + if !self.credential_profiles.is_empty() { + return invalid_subject_access(format!( + "subject_access.delegation.allowed_relationships relationship '{}' credential_profiles must be empty in 1.0; delegated attestation is evaluation-only. Remove credential_profiles to keep delegated evaluation, or issue a registry-backed non-delegated claim through subject_access.credential_profiles", + self.relationship_type + )); + } let allowed_purposes: HashSet<&str> = self.allowed_purposes.iter().map(String::as_str).collect(); let allowed_formats: HashSet<&str> = @@ -363,11 +369,6 @@ impl SubjectAccessDelegatedRelationshipConfig { .iter() .map(String::as_str) .collect(); - let allowed_profiles: HashSet<&str> = self - .credential_profiles - .iter() - .map(String::as_str) - .collect(); for claim_id in &self.allowed_claims { if !claim_ids.contains(claim_id.as_str()) { return Err(EvidenceConfigError::InvalidSubjectAccessConfig { @@ -397,24 +398,18 @@ impl SubjectAccessDelegatedRelationshipConfig { self.proof_claim )); } + if !claim.credential_profiles.is_empty() { + return invalid_subject_access(format!( + "delegated claim '{claim_id}' credential_profiles must be empty in 1.0; delegated attestation is evaluation-only. Remove the claim credential_profiles binding to keep delegated evaluation, or issue a registry-backed non-delegated claim" + )); + } validate_delegated_attestation_claim( - self, claim, &allowed_purposes, &allowed_formats, &allowed_disclosures, - &allowed_profiles, )?; } - for profile_id in &self.credential_profiles { - if !evidence.credential_profiles.contains_key(profile_id) { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "subject_access.delegation credential_profiles references unknown profile '{profile_id}'" - ), - }); - } - } validate_delegated_attestation_allow_lists_are_supported(self, evidence)?; Ok(()) } @@ -940,12 +935,10 @@ pub(super) fn validate_delegated_proof_claim_binding( } pub(super) fn validate_delegated_attestation_claim( - relationship: &SubjectAccessDelegatedRelationshipConfig, claim: &ClaimDefinition, allowed_purposes: &HashSet<&str>, allowed_formats: &HashSet<&str>, allowed_disclosures: &HashSet<&str>, - allowed_profiles: &HashSet<&str>, ) -> Result<(), EvidenceConfigError> { if !claim.operations.evaluate.enabled { return invalid_subject_access(format!( @@ -991,17 +984,6 @@ pub(super) fn validate_delegated_attestation_claim( claim.id )); } - if !relationship.credential_profiles.is_empty() - && !claim - .credential_profiles - .iter() - .any(|profile| allowed_profiles.contains(profile.as_str())) - { - return invalid_subject_access(format!( - "delegated claim '{}' must reference an allowed credential profile", - claim.id - )); - } Ok(()) } @@ -1014,12 +996,6 @@ pub(super) fn validate_delegated_attestation_allow_lists_are_supported( .iter() .filter_map(|claim_id| evidence.claims.iter().find(|claim| claim.id == *claim_id)) .collect(); - let allowed_profiles: Vec<&CredentialProfileConfig> = relationship - .credential_profiles - .iter() - .filter_map(|profile_id| evidence.credential_profiles.get(profile_id)) - .collect(); - for purpose in &relationship.allowed_purposes { if !allowed_claims .iter() @@ -1050,16 +1026,9 @@ pub(super) fn validate_delegated_attestation_allow_lists_are_supported( .iter() .any(|candidate| candidate == disclosure) }); - let supported_by_profile = allowed_profiles.iter().any(|profile| { - profile - .disclosure - .allowed - .iter() - .any(|candidate| candidate == disclosure) - }); - if !supported_by_claim && !supported_by_profile { + if !supported_by_claim { return invalid_subject_access(format!( - "subject_access.delegation allowed_disclosures entry '{disclosure}' is not supported by any allowed claim or profile" + "subject_access.delegation allowed_disclosures entry '{disclosure}' is not supported by any allowed claim" )); } } diff --git a/crates/registry-notary-core/src/config/tests/credentials.rs b/crates/registry-notary-core/src/config/tests/credentials.rs index 35fb7c2c2..df16c4851 100644 --- a/crates/registry-notary-core/src/config/tests/credentials.rs +++ b/crates/registry-notary-core/src/config/tests/credentials.rs @@ -26,6 +26,7 @@ allowed_claims: .evidence .credential_profiles .insert("test-profile".to_string(), profile); + add_registry_credential_claim(&mut config, "some-claim", "test-profile"); assert!( config.validate().is_ok(), "did:jwk only should pass validation" @@ -242,6 +243,7 @@ allowed_claims: .evidence .credential_profiles .insert("test-profile".to_string(), profile); + add_registry_credential_claim(&mut config, "some-claim", "test-profile"); config .validate() @@ -398,6 +400,7 @@ allowed_claims: .evidence .credential_profiles .insert("test-profile".to_string(), profile); + add_registry_credential_claim(&mut config, "some-claim", "test-profile"); config.validate().expect("PKCS#11 key shape validates"); } @@ -431,6 +434,7 @@ allowed_claims: .evidence .credential_profiles .insert("test-profile".to_string(), profile); + add_registry_credential_claim(&mut config, "some-claim", "test-profile"); config.validate().expect("file-watch key shape validates"); } diff --git a/crates/registry-notary-core/src/config/tests/issuance.rs b/crates/registry-notary-core/src/config/tests/issuance.rs index 6d135d5e0..5a06da99c 100644 --- a/crates/registry-notary-core/src/config/tests/issuance.rs +++ b/crates/registry-notary-core/src/config/tests/issuance.rs @@ -67,6 +67,172 @@ pub(super) fn valid_oid4vci_projection_config_passes_validation() { .expect("projection credential config validates"); } +#[test] +pub(super) fn source_free_claim_remains_valid_for_evaluation_only() { + let mut config = minimal_config(); + config.evidence.claims = vec![minimal_claim("evaluation-only")]; + + config + .validate() + .expect("source-free evaluation without credential capability remains valid"); +} + +#[test] +pub(super) fn credential_profile_rejects_source_free_claim_binding() { + let mut config = valid_subject_access_config(); + config.subject_access = SubjectAccessConfig::default(); + config.oid4vci = Oid4vciConfig::default(); + config.evidence.relay = None; + config.evidence.claims[0].evidence_mode = ClaimEvidenceMode::SelfAttested; + config.evidence.claims[0].rule = RuleConfig::Cel { + expression: "true".to_string(), + bindings: Default::default(), + }; + + let error = config + .validate() + .expect_err("source-free credential binding must fail startup"); + assert!(matches!( + error, + EvidenceConfigError::InvalidCredentialClaimBinding { ref reason } + if reason.contains("source-free") && reason.contains("registry_backed") + )); +} + +#[test] +pub(super) fn credential_claim_and_profile_bindings_must_be_mutual() { + let mut config = valid_subject_access_config(); + config.subject_access = SubjectAccessConfig::default(); + config.oid4vci = Oid4vciConfig::default(); + config.evidence.claims[0].credential_profiles.clear(); + + let error = config + .validate() + .expect_err("one-sided profile binding must fail startup"); + assert!(matches!( + error, + EvidenceConfigError::InvalidCredentialClaimBinding { ref reason } + if reason.contains("does not reference") + )); +} + +#[test] +pub(super) fn credential_root_accepts_registry_backed_dependency_closure() { + let mut config = valid_subject_access_config(); + let root = config.evidence.claims[0].clone(); + let mut dependency = root.clone(); + dependency.id = "civil-status-source-record".to_string(); + dependency.title = "Civil status source record".to_string(); + dependency.credential_profiles.clear(); + config.evidence.claims[0] + .depends_on + .push(dependency.id.clone()); + config.evidence.claims.push(dependency); + + config + .validate() + .expect("credential root may retain an exact registry-backed dependency closure"); +} + +#[test] +pub(super) fn credential_root_rejects_source_free_dependency_closure() { + let mut config = valid_subject_access_config(); + let mut dependency = minimal_claim("caller-asserted-dependency"); + dependency.purpose = config.evidence.claims[0].purpose.clone(); + config.evidence.claims[0] + .depends_on + .push(dependency.id.clone()); + config.evidence.claims.push(dependency); + + let error = config + .validate() + .expect_err("source-free credential dependency must fail startup"); + assert!(matches!( + error, + EvidenceConfigError::InvalidCredentialClaimBinding { ref reason } + if reason.contains("dependency closure contains source-free claim 'caller-asserted-dependency'") + && reason.contains("all dependencies must be registry_backed") + )); +} + +#[test] +pub(super) fn delegated_relationship_credential_profiles_are_retired_with_remediation() { + let mut config = valid_delegated_subject_access_config(); + config.subject_access.delegation.allowed_relationships[0] + .credential_profiles + .push("civil_status_sd_jwt".to_string()); + + let reason = expect_subject_access_error(&config); + assert!( + reason.contains("credential_profiles must be empty in 1.0") + && reason.contains("delegated attestation is evaluation-only") + && reason.contains("registry-backed non-delegated claim"), + "unexpected: {reason}" + ); +} + +#[test] +pub(super) fn delegated_claim_credential_profile_binding_is_retired_with_remediation() { + let mut config = valid_delegated_subject_access_config(); + config + .evidence + .claims + .iter_mut() + .find(|claim| claim.id == "dependent-date-of-birth") + .expect("delegated dependent claim exists") + .credential_profiles + .push("civil_status_sd_jwt".to_string()); + + let reason = expect_subject_access_error(&config); + assert!( + reason.contains("delegated claim 'dependent-date-of-birth'") + && reason.contains("credential_profiles must be empty in 1.0") + && reason.contains("delegated attestation is evaluation-only"), + "unexpected: {reason}" + ); +} + +#[test] +pub(super) fn oid4vci_wrapper_rejects_source_free_claim_binding() { + let mut config = valid_oid4vci_config(); + config.evidence.relay = None; + config.evidence.claims[0].evidence_mode = ClaimEvidenceMode::SelfAttested; + config.evidence.claims[0].rule = RuleConfig::Cel { + expression: "true".to_string(), + bindings: Default::default(), + }; + + let reason = expect_oid4vci_error(&config); + assert!( + reason.contains("source-free") && reason.contains("registry_backed"), + "unexpected: {reason}" + ); +} + +#[test] +pub(super) fn oid4vci_projection_rejects_mixed_evidence_modes() { + let mut config = valid_oid4vci_projection_config(); + let claim = config + .evidence + .claims + .iter_mut() + .find(|claim| claim.id == "birth-place") + .expect("projection claim exists"); + claim.evidence_mode = ClaimEvidenceMode::SelfAttested; + claim.rule = RuleConfig::Cel { + expression: "true".to_string(), + bindings: Default::default(), + }; + + let reason = expect_oid4vci_error(&config); + assert!( + reason.contains("birth-place") + && reason.contains("source-free") + && reason.contains("registry_backed"), + "unexpected: {reason}" + ); +} + #[test] pub(super) fn oid4vci_projection_rejects_claim_id_and_claims_together() { let mut config = valid_oid4vci_projection_config(); @@ -801,7 +967,13 @@ pub(super) fn subject_access_rejects_unallowed_claim_purpose() { #[test] pub(super) fn subject_access_rejects_claim_without_purpose() { let mut config = valid_subject_access_config(); + config.evidence.relay = None; config.evidence.claims[0].purpose = None; + config.evidence.claims[0].evidence_mode = ClaimEvidenceMode::SelfAttested; + config.evidence.claims[0].rule = RuleConfig::Cel { + expression: "true".to_string(), + bindings: Default::default(), + }; let reason = expect_subject_access_error(&config); assert!( diff --git a/crates/registry-notary-core/src/config/tests/relay.rs b/crates/registry-notary-core/src/config/tests/relay.rs index ef5eb5643..663c5e20e 100644 --- a/crates/registry-notary-core/src/config/tests/relay.rs +++ b/crates/registry-notary-core/src/config/tests/relay.rs @@ -954,6 +954,11 @@ fn subject_access_allows_exact_subject_bound_registry_claims() { fn subject_access_still_rejects_registry_backed_dependencies_of_self_attested_claims() { let mut config = valid_subject_access_config(); config.evidence.relay = Some(relay_connection()); + config.evidence.claims[0].evidence_mode = ClaimEvidenceMode::SelfAttested; + config.evidence.claims[0].rule = RuleConfig::Cel { + expression: "true".to_string(), + bindings: Default::default(), + }; let mut dependency = minimal_claim("registry-dependency"); make_registry_backed(&mut dependency, "civil_status"); config.evidence.claims[0] @@ -964,14 +969,17 @@ fn subject_access_still_rejects_registry_backed_dependencies_of_self_attested_cl } #[test] -fn registry_backed_v1_rejects_all_claim_dependencies() { +fn registry_backed_claim_accepts_registry_backed_dependency() { let mut config = valid_registry_backed_config(); - let dependency = minimal_claim("self-attested-dependency"); + let mut dependency = minimal_claim("registry-dependency"); + make_registry_backed(&mut dependency, "registry_dependency"); config.evidence.claims[0] .depends_on .push(dependency.id.clone()); config.evidence.claims.push(dependency); - expect_mode_error(&config, "cannot declare depends_on"); + config + .validate() + .expect("registry-backed claim dependency is supported"); } #[test] @@ -1009,13 +1017,18 @@ fn delegated_subject_access_allows_only_its_configured_registry_proof_edge() { .expect("configured delegated Relay proof edge validates"); let mut ordinary = config.clone(); - ordinary + let ordinary_claim = ordinary .evidence .claims .iter_mut() .find(|claim| claim.id == "date-of-birth") - .expect("ordinary self-attested claim") - .depends_on = vec!["guardian-link".to_string()]; + .expect("ordinary claim exists"); + ordinary_claim.evidence_mode = ClaimEvidenceMode::SelfAttested; + ordinary_claim.rule = RuleConfig::Cel { + expression: "true".to_string(), + bindings: Default::default(), + }; + ordinary_claim.depends_on = vec!["guardian-link".to_string()]; expect_subject_access_closure_error(&ordinary); let mut registry_dependent = config; @@ -1026,7 +1039,9 @@ fn delegated_subject_access_allows_only_its_configured_registry_proof_edge() { .find(|claim| claim.id == "dependent-date-of-birth") .expect("delegated claim"); make_registry_backed(delegated, "civil_status"); - expect_subject_access_closure_error(®istry_dependent); + delegated.purpose = Some("dependent_attestation".to_string()); + let reason = expect_subject_access_error(®istry_dependent); + assert!(reason.contains("must be self_attested")); } #[test] diff --git a/crates/registry-notary-core/src/config/tests/root.rs b/crates/registry-notary-core/src/config/tests/root.rs index 6b4e27499..1129b41aa 100644 --- a/crates/registry-notary-core/src/config/tests/root.rs +++ b/crates/registry-notary-core/src/config/tests/root.rs @@ -432,6 +432,54 @@ rule: .expect("minimal claim is valid YAML") } +pub(super) fn add_registry_credential_claim( + config: &mut StandaloneRegistryNotaryConfig, + claim_id: &str, + profile_id: &str, +) { + config.evidence.relay = Some( + serde_norway::from_str( + r#" +base_url: https://relay.internal.example +workload_client_id: registry-notary +token_file: /run/secrets/registry-notary-relay.jwt +"#, + ) + .expect("Relay connection parses"), + ); + config.evidence.allowed_purposes = vec!["credential-test".to_string()]; + let mut claim = minimal_claim(claim_id); + claim.purpose = Some("credential-test".to_string()); + claim.required_scopes = vec!["credential:test".to_string()]; + claim.value.value_type = "boolean".to_string(); + claim.evidence_mode = ClaimEvidenceMode::RegistryBacked { + consultations: BTreeMap::from([( + "credential_test".to_string(), + RelayConsultationConfig { + profile: RelayConsultationProfileRef { + id: "example.credential-test.exact".to_string(), + contract_hash: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + }, + inputs: BTreeMap::from([( + "subject_id".to_string(), + RelayConsultationInput::TargetId, + )]), + outputs: BTreeMap::from([( + "active".to_string(), + RelayOutputContract::Boolean { nullable: false }, + )]), + }, + )]), + }; + claim.rule = RuleConfig::ConsultationMatched { + consultation: "credential_test".to_string(), + }; + claim.credential_profiles = vec![profile_id.to_string()]; + config.evidence.claims = vec![claim]; +} + #[test] pub(super) fn config_trust_is_optional_but_requires_explicit_antirollback_path() { let mut config = minimal_config(); @@ -553,6 +601,10 @@ pub(super) fn valid_subject_access_config() -> StandaloneRegistryNotaryConfig { r#" evidence: enabled: true + relay: + base_url: https://relay.internal.example + workload_client_id: registry-notary + token_file: /run/secrets/registry-notary-relay.jwt signing_keys: issuer-key: provider: local_jwk_env @@ -583,13 +635,28 @@ evidence: version: "1.0" subject_type: person evidence_mode: - type: self_attested + type: registry_backed + consultations: + civil_status: + profile: + id: example.civil-status.exact + contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + inputs: + national_id: request.target.identifiers.national_id + outputs: + value: + type: boolean + nullable: true value: type: boolean + nullable: true purpose: citizen_subject_access + required_scopes: + - subject_access rule: - type: cel - expression: "true" + type: consultation_output + consultation: civil_status + output: value disclosure: default: value allowed: @@ -712,6 +779,7 @@ token_file: /run/secrets/registry-notary-relay.jwt )]), }; proof.value.nullable = true; + proof.credential_profiles.clear(); proof.rule = RuleConfig::ConsultationOutput { consultation: "guardian_link".to_string(), output: "established".to_string(), @@ -722,14 +790,12 @@ token_file: /run/secrets/registry-notary-relay.jwt dependent.title = "Dependent date of birth".to_string(); dependent.purpose = Some("dependent_attestation".to_string()); dependent.depends_on = vec!["guardian-link".to_string()]; - - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .expect("credential profile exists") - .allowed_claims - .push("dependent-date-of-birth".to_string()); + dependent.evidence_mode = ClaimEvidenceMode::SelfAttested; + dependent.rule = RuleConfig::Cel { + expression: "true".to_string(), + bindings: Default::default(), + }; + dependent.credential_profiles.clear(); config.evidence.claims.push(proof); config.evidence.claims.push(dependent); @@ -743,7 +809,7 @@ token_file: /run/secrets/registry-notary-relay.jwt allowed_purposes: vec!["dependent_attestation".to_string()], allowed_formats: vec!["application/vnd.registry-notary.claim-result+json".to_string()], allowed_disclosures: vec!["value".to_string()], - credential_profiles: vec!["civil_status_sd_jwt".to_string()], + credential_profiles: Vec::new(), }], }; config diff --git a/crates/registry-notary-core/src/model.rs b/crates/registry-notary-core/src/model.rs index 1f31553da..90b2ac7fd 100644 --- a/crates/registry-notary-core/src/model.rs +++ b/crates/registry-notary-core/src/model.rs @@ -1470,6 +1470,17 @@ pub struct StoredEvaluation { pub created_at: String, pub expires_at: String, pub request_hash: String, + /// Private issuance-only Relay provenance for the selected dependency closure. + /// + /// This is deliberately separate from public [`ClaimProvenance`]. It is + /// persisted only for credential-capable selections so a later credential + /// request can prove that every fact being signed came from the exact + /// compiler-pinned Relay consultation. Evaluation-only selections retain + /// no private Relay execution identifiers. Evaluations written before this + /// field existed remain readable, but are not credential-issuable and must + /// be evaluated again. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuance_provenance: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub subject_access: Option, } @@ -1496,6 +1507,85 @@ impl StoredEvaluation { } } +/// Bounded private provenance retained for credential issuance. +/// +/// The runtime admits at most the v1 claim-closure bound and the issuance +/// verifier rejects an over-sized or incomplete set before any signer or +/// credential-status side effect. Consultation identifiers remain restricted +/// state and never appear in evaluation, render, or credential responses. +#[derive(Clone, Serialize, Deserialize)] +pub struct StoredIssuanceProvenance { + pub claims: Vec, + /// Unique Relay executions referenced by the claim closure. + /// + /// Keeping executions separate permits one coalesced Relay consultation to + /// support several claim pins without duplicating the restricted execution + /// record. A missing empty legacy field is readable but nonissuable. + #[serde(default)] + pub consultations: Vec, +} + +impl std::fmt::Debug for StoredIssuanceProvenance { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredIssuanceProvenance") + .field("claim_count", &self.claims.len()) + .field("consultation_count", &self.consultations.len()) + .finish() + } +} + +/// The exact compiler pin and successful Relay execution for one claim in a +/// selected root's dependency closure. This restricted persistence shape is +/// not a public API model. +#[derive(Clone, Serialize, Deserialize)] +pub struct StoredIssuanceClaimProvenance { + pub claim_id: String, + pub claim_version: String, + pub relay_profile_id: String, + pub relay_contract_hash: String, + pub canonical_purpose: String, + pub consultation_id: String, + /// Deterministic SHA-256 commitment over this compiler pin, its Relay + /// execution record, and the claim result provenance produced by the + /// evaluation. A missing legacy value remains readable but is not + /// credential-issuable. + #[serde(default)] + pub execution_binding: String, +} + +impl std::fmt::Debug for StoredIssuanceClaimProvenance { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredIssuanceClaimProvenance") + .field("claim_id", &self.claim_id) + .field("claim_version", &self.claim_version) + .field("relay_profile_id", &self.relay_profile_id) + .field("relay_contract_hash", &self.relay_contract_hash) + .field("canonical_purpose", &self.canonical_purpose) + .field("consultation_id", &"[REDACTED]") + .field("execution_binding", &self.execution_binding) + .finish() + } +} + +/// One unique successful Relay execution retained for issuance verification. +#[derive(Clone, Serialize, Deserialize)] +pub struct StoredIssuanceConsultationProvenance { + pub consultation_id: String, + pub acquired_at: String, +} + +impl std::fmt::Debug for StoredIssuanceConsultationProvenance { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoredIssuanceConsultationProvenance") + .field("consultation_id", &"[REDACTED]") + .field("acquired_at", &"[REDACTED]") + .finish() + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct StoredSubjectAccessMetadata { @@ -2510,6 +2600,7 @@ mod tests { stored.selected_claim_refs(), vec![ClaimRef::from("person-is-alive")] ); + assert!(stored.issuance_provenance.is_none()); assert!(stored.subject_access.is_none()); } } diff --git a/crates/registry-notary-server/src/api.rs b/crates/registry-notary-server/src/api.rs index 47060dc2e..bdb3e38fd 100644 --- a/crates/registry-notary-server/src/api.rs +++ b/crates/registry-notary-server/src/api.rs @@ -113,7 +113,9 @@ use crate::{ preauth_state::{LoginState, PreauthorizationStateError}, replay::{require_replay_insert, ReplayReadiness, ReplayStores}, runtime::{ - claim_ids, claim_semantics_metadata, validate_batch_subject_limit, EvaluationAuditSnapshot, + build_claim_levels, claim_ids, claim_semantics_metadata, requested_claim_versions, + require_issuable_evaluation_provenance, require_registry_backed_credential_claims, + validate_batch_subject_limit, EvaluationAuditSnapshot, }, standalone::{ generate_numeric_tx_code, generate_opaque_token, pkce_s256_challenge, pre_auth_audit_event, diff --git a/crates/registry-notary-server/src/api/attestation_policy.rs b/crates/registry-notary-server/src/api/attestation_policy.rs index a6b81cf73..b22b6a0a7 100644 --- a/crates/registry-notary-server/src/api/attestation_policy.rs +++ b/crates/registry-notary-server/src/api/attestation_policy.rs @@ -780,17 +780,19 @@ fn prepare_subject_access_evaluation_for_operation( policy_hash: Some(policy_hash.clone()), evaluation_expires_at: Some(format_time(evaluation_expires_at)), }; + let root_claim_id = (request_claim_ids.len() == 1) + .then(|| BoundedClaimId::new(request_claim_ids[0].clone())) + .transpose() + .map_err(|_| EvidenceError::InvalidRequest)?; + let claim_versions = requested_claim_versions(&request.claims)?; + let levels = build_claim_levels(evidence, &request.claims, &claim_versions)?; let mut allowed_claim_ids = BTreeSet::new(); - for claim_id in request_claim_ids { + for claim_id in levels.into_iter().flatten() { allowed_claim_ids .insert(BoundedClaimId::new(claim_id).map_err(|_| EvidenceError::InvalidRequest)?); } let evaluation_capability = EvaluationCapability::SubjectBound { - claim_id: if allowed_claim_ids.len() == 1 { - allowed_claim_ids.iter().next().cloned() - } else { - None - }, + claim_id: root_claim_id, allowed_claim_ids, subject_binding_hash, }; @@ -1087,46 +1089,6 @@ pub(super) fn require_subject_access_credential_profile_policy( Ok(()) } -pub(super) fn require_delegated_attestation_credential_profile_policy( - config: &SubjectAccessConfig, - metadata: &StoredSubjectAccessMetadata, - profile_id: &str, - profile: &CredentialProfileConfig, -) -> Result<(), EvidenceError> { - let relationship_type = metadata - .relationship_type - .as_ref() - .ok_or_else(|| subject_access_denied(SubjectAccessDenialCode::ProfileDenied))?; - let relationship = config - .delegation - .relationship(relationship_type.as_str()) - .ok_or_else(|| subject_access_denied(SubjectAccessDenialCode::ProfileDenied))?; - let allowed = relationship - .credential_profiles - .iter() - .any(|allowed| allowed == profile_id); - let validity_seconds = u64::try_from(profile.validity_seconds).ok(); - let validity_ceiling = config.token_policy.max_credential_validity_seconds; - let did_jwk_only = !profile.holder_binding.allowed_did_methods.is_empty() - && profile - .holder_binding - .allowed_did_methods - .iter() - .all(|method| method == "did:jwk"); - if !allowed - || profile.format != FORMAT_SD_JWT_VC - || validity_seconds.is_none_or(|seconds| seconds == 0 || seconds > validity_ceiling) - || profile.holder_binding.mode != "did" - || profile.holder_binding.proof_of_possession.as_deref() != Some("required") - || !did_jwk_only - { - return Err(subject_access_denied( - SubjectAccessDenialCode::ProfileDenied, - )); - } - Ok(()) -} - pub(super) async fn consume_subject_mismatch_denial( state: &RegistryNotaryApiState, principal_hash: &Hashed, @@ -1146,7 +1108,7 @@ pub(super) fn require_subject_access_stored_access( requested_claims: &[String], disclosure: &str, format: &str, - credential_profile: Option<&str>, + issue_credential: bool, ) -> Result<(), EvidenceError> { let Some(metadata) = evaluation.subject_access.as_ref() else { if principal.is_subject_access() { @@ -1160,12 +1122,12 @@ pub(super) fn require_subject_access_stored_access( if principal.access_mode() != metadata.access_mode { return Err(EvidenceError::EvaluationBindingMismatch); } - if credential_profile.is_some() && !state.subject_access.allowed_operations.issue_credential { + if issue_credential && !state.subject_access.allowed_operations.issue_credential { return Err(EvidenceError::SubjectAccessDenied { reason: SubjectAccessDenialCode::OperationDenied, }); } - if credential_profile.is_none() && !state.subject_access.allowed_operations.render { + if !issue_credential && !state.subject_access.allowed_operations.render { return Err(EvidenceError::SubjectAccessDenied { reason: SubjectAccessDenialCode::OperationDenied, }); @@ -1274,33 +1236,6 @@ pub(super) fn require_subject_access_stored_access( if metadata.disclosure.as_str() != disclosure || metadata.result_format.as_str() != format { return Err(EvidenceError::EvaluationBindingMismatch); } - if let Some(profile_id) = credential_profile { - match delegated_relationship { - Some(relationship) => { - if !relationship - .credential_profiles - .iter() - .any(|allowed| allowed == profile_id) - { - return Err(EvidenceError::SubjectAccessDenied { - reason: SubjectAccessDenialCode::ProfileDenied, - }); - } - } - None => { - if !state - .subject_access - .credential_profiles - .iter() - .any(|allowed| allowed == profile_id) - { - return Err(EvidenceError::SubjectAccessDenied { - reason: SubjectAccessDenialCode::ProfileDenied, - }); - } - } - } - } let expected_policy_hash = subject_access_policy_hash( evidence, &state.subject_access, diff --git a/crates/registry-notary-server/src/api/credentials.rs b/crates/registry-notary-server/src/api/credentials.rs index 21dcd4f3d..44bdf52e9 100644 --- a/crates/registry-notary-server/src/api/credentials.rs +++ b/crates/registry-notary-server/src/api/credentials.rs @@ -166,6 +166,20 @@ pub(super) async fn issue_credential( Some((profile_id, profile)), ); } + if evaluation + .subject_access + .as_ref() + .is_some_and(|metadata| metadata.access_mode == AccessMode::DelegatedAttestation) + { + return credential_denial_response_for_evaluation( + &state, + subject_access_denied(SubjectAccessDenialCode::ProfileDenied), + &request.evaluation_id, + &evaluation, + &principal, + Some((profile_id, profile)), + ); + } if let Err(error) = require_subject_access_stored_access( &state, evidence, @@ -177,7 +191,7 @@ pub(super) async fn issue_credential( .as_deref() .unwrap_or(&evaluation.disclosure), &evaluation.format, - Some(profile_id), + true, ) { return credential_denial_response_for_evaluation( &state, @@ -199,22 +213,11 @@ pub(super) async fn issue_credential( Some((profile_id, profile)), ); } - let profile_policy = match evaluation.subject_access.as_ref() { - Some(metadata) if metadata.access_mode == AccessMode::DelegatedAttestation => { - require_delegated_attestation_credential_profile_policy( - &state.subject_access, - metadata, - profile_id, - profile, - ) - } - _ => require_subject_access_credential_profile_policy( - &state.subject_access, - profile_id, - profile, - ), - }; - if let Err(error) = profile_policy { + if let Err(error) = require_subject_access_credential_profile_policy( + &state.subject_access, + profile_id, + profile, + ) { return credential_denial_response_for_evaluation( &state, error, @@ -282,6 +285,18 @@ pub(super) async fn issue_credential( ); } }; + if let Err(error) = + require_issuable_evaluation_provenance(evidence, &request.evaluation_id, &evaluation) + { + return credential_denial_response_for_evaluation( + &state, + error, + &request.evaluation_id, + &evaluation, + &principal, + Some((profile_id, profile)), + ); + } let holder_id = request .holder .as_ref() diff --git a/crates/registry-notary-server/src/api/evaluations.rs b/crates/registry-notary-server/src/api/evaluations.rs index d8a07325a..3386072ad 100644 --- a/crates/registry-notary-server/src/api/evaluations.rs +++ b/crates/registry-notary-server/src/api/evaluations.rs @@ -505,7 +505,7 @@ pub(super) async fn render( .as_deref() .unwrap_or(&evaluation.disclosure), &request.format, - None, + false, ) { return evidence_error_response(error); } diff --git a/crates/registry-notary-server/src/api/oid4vci/credential.rs b/crates/registry-notary-server/src/api/oid4vci/credential.rs index fb84a9eac..f8526d1cd 100644 --- a/crates/registry-notary-server/src/api/oid4vci/credential.rs +++ b/crates/registry-notary-server/src/api/oid4vci/credential.rs @@ -44,6 +44,9 @@ 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( @@ -107,16 +110,6 @@ pub(in crate::api) async fn oid4vci_credential( Some(profile) => profile, None => return oid4vci_error_response(Oid4vciWireError::UnsupportedCredentialType), }; - let issuer = match state - .issuer_resolver() - .issuer(&configuration.credential_profile) - { - Ok(issuer) => issuer, - Err(_) => return oid4vci_error_response(Oid4vciWireError::ServerError), - }; - if holder_key_matches_issuer_key(&validated_proof.holder_jwk, &issuer.public_jwk()) { - return oid4vci_error_response(Oid4vciWireError::InvalidProof); - } if let Some(nonce) = expected_nonce { let key = match state.subject_access_rate_keys.oid4vci_nonce( &state.oid4vci.credential_issuer, @@ -272,7 +265,7 @@ pub(in crate::api) async fn oid4vci_credential( &evaluation.claim_ids, &evaluation.disclosure, &evaluation.format, - Some(configuration.credential_profile.as_str()), + true, ) { return oid4vci_error_response(oid4vci_error_from_evidence(&error)); } @@ -286,6 +279,21 @@ pub(in crate::api) async fn oid4vci_credential( ) { 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)); + } + let issuer = match state + .issuer_resolver() + .issuer(&configuration.credential_profile) + { + Ok(issuer) => issuer, + Err(_) => return oid4vci_error_response(Oid4vciWireError::ServerError), + }; + if holder_key_matches_issuer_key(&validated_proof.holder_jwk, &issuer.public_jwk()) { + return oid4vci_error_response(Oid4vciWireError::InvalidProof); + } let iat = earliest_issued_at(&evaluation.results).unwrap_or_else(OffsetDateTime::now_utc); let credential_id = state .credential_status diff --git a/crates/registry-notary-server/src/api/tests/attestation.rs b/crates/registry-notary-server/src/api/tests/attestation.rs index 1a198f832..ab483c4b9 100644 --- a/crates/registry-notary-server/src/api/tests/attestation.rs +++ b/crates/registry-notary-server/src/api/tests/attestation.rs @@ -879,7 +879,7 @@ fn stored_delegated_attestation_rechecks_current_authorization_details() { &evaluation.claim_ids, &evaluation.disclosure, &evaluation.format, - None, + false, ) .expect_err("stored delegated access must re-check current proof coverage"); @@ -937,7 +937,7 @@ fn stored_delegated_attestation_rechecks_current_target_binding() { &evaluation.claim_ids, &evaluation.disclosure, &evaluation.format, - None, + false, ) .expect_err("stored delegated access must re-check current target binding"); @@ -1087,7 +1087,7 @@ fn stored_subject_access_rechecks_issuer_client_and_audience() { &evaluation.claim_ids, &evaluation.disclosure, &evaluation.format, - None, + false, ) .expect_err("changed client id must not access stored evaluation"); @@ -1134,7 +1134,7 @@ fn stored_subject_access_rejects_expired_metadata_even_with_future_store_ttl() { &evaluation.claim_ids, &evaluation.disclosure, &evaluation.format, - None, + false, ) .expect_err("expired subject-access metadata must fail closed"); diff --git a/crates/registry-notary-server/src/api/tests/credentials.rs b/crates/registry-notary-server/src/api/tests/credentials.rs index 9fd29fccc..3d5eda4e8 100644 --- a/crates/registry-notary-server/src/api/tests/credentials.rs +++ b/crates/registry-notary-server/src/api/tests/credentials.rs @@ -58,6 +58,353 @@ impl CacheStore for UnavailableCredentialStatusStore { } } +#[tokio::test] +async fn registry_backed_evaluation_with_exact_provenance_issues_directly() { + let evidence = credential_issue_evidence_with_dependency(); + let store = Arc::new(EvidenceStore::default()); + let sign_count = Arc::new(AtomicUsize::new(0)); + let evaluation_id = "eval-registry-direct"; + let mut result = claim_result_view(evaluation_id, "person-is-alive"); + result.provenance.used.relay_consultation_count = 2; + let mut evaluation = registry_notary_core::StoredEvaluation { + client_id: "caseworker".to_string(), + purpose: "test".to_string(), + claim_ids: vec!["person-is-alive".to_string()], + claim_refs: Vec::new(), + disclosure: "predicate".to_string(), + format: FORMAT_CLAIM_RESULT_JSON.to_string(), + results: vec![result], + created_at: "2026-05-23T00:00:00Z".to_string(), + expires_at: "2999-01-01T00:00:00Z".to_string(), + request_hash: "request-hash".to_string(), + issuance_provenance: Some(issuance_provenance_with_dependency( + "person-is-alive", + "civil-record-active", + "test", + evaluation_id, + )), + subject_access: None, + }; + store + .insert(evaluation.clone()) + .await + .expect("registry-backed evaluation inserts"); + let state = Arc::new( + RegistryNotaryApiState::new_with_federation( + Arc::new(evidence), + Arc::new(SubjectAccessConfig::default()), + Arc::new(Oid4vciConfig::default()), + Arc::new(FederationConfig::default()), + AuditKeyHasher::unkeyed_dev_only(), + None, + ReplayStores::memory(), + CredentialStatusStore::disabled(), + Arc::new(AppMetrics::default()), + Arc::clone(&store), + Arc::new(CountingIssuerResolver { + sign_count: Arc::clone(&sign_count), + }), + None, + ) + .expect("state builds"), + ); + let principal = EvidencePrincipal { + auth_profile_id: registry_notary_core::EvidenceAuthProfileId::StaticApiKey, + principal_id: "caseworker".to_string(), + scopes: vec!["civil_registry:evidence_verification".to_string()], + access_mode: AccessMode::MachineClient, + verified_claims: None, + authorization_details: None, + }; + + let request = CredentialIssueRequest { + evaluation_id: evaluation_id.to_string(), + credential_profile: Some("civil_status_sd_jwt".to_string()), + format: Some(FORMAT_SD_JWT_VC.to_string()), + claims: Some(vec!["person-is-alive".to_string()]), + disclosure: Some("predicate".to_string()), + purpose: Some("test".to_string()), + holder: Some(HolderRequest { + binding: Some("did".to_string()), + id: Some(holder_did_jwk()), + proof: None, + }), + }; + let response = issue_credential( + HeaderMap::new(), + Some(Extension(Arc::clone(&state))), + Some(Extension(principal.clone())), + Ok(Json(request.clone())), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("credential body reads"); + let body: Value = serde_json::from_slice(&body).expect("credential response parses"); + assert_eq!(body["credential_profile"], "civil_status_sd_jwt"); + assert!(body["credential"] + .as_str() + .is_some_and(|credential| credential.contains('~'))); + assert_eq!(sign_count.load(Ordering::SeqCst), 1); + + evaluation + .issuance_provenance + .as_mut() + .expect("private closure exists") + .claims + .retain(|claim| claim.claim_id != "civil-record-active"); + store + .insert(evaluation.clone()) + .await + .expect("missing dependency fixture inserts"); + let missing = issue_credential( + HeaderMap::new(), + Some(Extension(Arc::clone(&state))), + Some(Extension(principal.clone())), + Ok(Json(request.clone())), + ) + .await; + assert_eq!(missing.status(), StatusCode::FORBIDDEN); + assert_eq!(sign_count.load(Ordering::SeqCst), 1); + + evaluation.issuance_provenance = Some(issuance_provenance_with_dependency( + "person-is-alive", + "civil-record-active", + "test", + evaluation_id, + )); + evaluation + .issuance_provenance + .as_mut() + .expect("private closure exists") + .consultations + .push(registry_notary_core::StoredIssuanceConsultationProvenance { + consultation_id: "01J00000000000000000000002".to_string(), + acquired_at: "2026-05-23T00:00:00Z".to_string(), + }); + store + .insert(evaluation) + .await + .expect("extra dependency execution fixture inserts"); + let extra = issue_credential( + HeaderMap::new(), + Some(Extension(state)), + Some(Extension(principal)), + Ok(Json(request)), + ) + .await; + assert_eq!(extra.status(), StatusCode::FORBIDDEN); + assert_eq!(sign_count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn direct_dependency_execution_tampering_is_denied_before_signing() { + let evidence = credential_issue_evidence_with_dependency(); + let store = Arc::new(EvidenceStore::default()); + let sign_count = Arc::new(AtomicUsize::new(0)); + let evaluation_id = "eval-registry-direct-tamper"; + let state = Arc::new( + RegistryNotaryApiState::new_with_federation( + Arc::new(evidence), + Arc::new(SubjectAccessConfig::default()), + Arc::new(Oid4vciConfig::default()), + Arc::new(FederationConfig::default()), + AuditKeyHasher::unkeyed_dev_only(), + None, + ReplayStores::memory(), + CredentialStatusStore::disabled(), + Arc::new(AppMetrics::default()), + Arc::clone(&store), + Arc::new(CountingIssuerResolver { + sign_count: Arc::clone(&sign_count), + }), + None, + ) + .expect("state builds"), + ); + let principal = EvidencePrincipal { + auth_profile_id: registry_notary_core::EvidenceAuthProfileId::StaticApiKey, + principal_id: "caseworker".to_string(), + scopes: vec!["civil_registry:evidence_verification".to_string()], + access_mode: AccessMode::MachineClient, + verified_claims: None, + authorization_details: None, + }; + let request = CredentialIssueRequest { + evaluation_id: evaluation_id.to_string(), + credential_profile: Some("civil_status_sd_jwt".to_string()), + format: Some(FORMAT_SD_JWT_VC.to_string()), + claims: Some(vec!["person-is-alive".to_string()]), + disclosure: Some("predicate".to_string()), + purpose: Some("test".to_string()), + holder: Some(HolderRequest { + binding: Some("did".to_string()), + id: Some(holder_did_jwk()), + proof: None, + }), + }; + let mut result = claim_result_view(evaluation_id, "person-is-alive"); + result.provenance.used.relay_consultation_count = 2; + let baseline = registry_notary_core::StoredEvaluation { + client_id: "caseworker".to_string(), + purpose: "test".to_string(), + claim_ids: vec!["person-is-alive".to_string()], + claim_refs: Vec::new(), + disclosure: "predicate".to_string(), + format: FORMAT_CLAIM_RESULT_JSON.to_string(), + results: vec![result], + created_at: "2026-05-23T00:00:00Z".to_string(), + expires_at: "2999-01-01T00:00:00Z".to_string(), + request_hash: "request-hash".to_string(), + issuance_provenance: Some(issuance_provenance_with_dependency( + "person-is-alive", + "civil-record-active", + "test", + evaluation_id, + )), + subject_access: None, + }; + + let mut acquired_at_tampered = baseline.clone(); + acquired_at_tampered + .issuance_provenance + .as_mut() + .expect("private closure exists") + .consultations[1] + .acquired_at = "2026-05-23T00:00:01Z".to_string(); + store + .insert(acquired_at_tampered) + .await + .expect("acquired-at tamper fixture inserts"); + let acquired_at_denial = issue_credential( + HeaderMap::new(), + Some(Extension(Arc::clone(&state))), + Some(Extension(principal.clone())), + Ok(Json(request.clone())), + ) + .await; + assert_eq!(acquired_at_denial.status(), StatusCode::FORBIDDEN); + assert_eq!(sign_count.load(Ordering::SeqCst), 0); + + let mut ids_swapped = baseline; + let claims = &mut ids_swapped + .issuance_provenance + .as_mut() + .expect("private closure exists") + .claims; + let dependency_id = claims[0].consultation_id.clone(); + claims[0].consultation_id = claims[1].consultation_id.clone(); + claims[1].consultation_id = dependency_id; + store + .insert(ids_swapped) + .await + .expect("execution-id swap fixture inserts"); + let swapped_denial = issue_credential( + HeaderMap::new(), + Some(Extension(state)), + Some(Extension(principal)), + Ok(Json(request)), + ) + .await; + assert_eq!(swapped_denial.status(), StatusCode::FORBIDDEN); + assert_eq!(sign_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn delegated_evaluation_cannot_issue_directly_even_with_registry_provenance() { + let evidence = Arc::new(registry_backed_oid4vci_evidence_with_dependency()); + let subject_access = Arc::new(subject_access_config()); + let store = Arc::new(EvidenceStore::default()); + let sign_count = Arc::new(AtomicUsize::new(0)); + let state = Arc::new( + RegistryNotaryApiState::new_with_federation( + Arc::clone(&evidence), + Arc::clone(&subject_access), + Arc::new(Oid4vciConfig::default()), + Arc::new(FederationConfig::default()), + AuditKeyHasher::unkeyed_dev_only(), + None, + ReplayStores::memory(), + CredentialStatusStore::disabled(), + Arc::new(AppMetrics::default()), + Arc::clone(&store), + Arc::new(CountingIssuerResolver { + sign_count: Arc::clone(&sign_count), + }), + None, + ) + .expect("state builds"), + ); + let mut principal = fresh_oidc_principal( + Some("client_id:citizen-portal"), + &["subject_access", "person_is_alive"], + ); + let classified = classify_subject_access_principal(&subject_access, &principal) + .expect("subject-access principal classifies"); + let mut context = prepare_subject_access_evaluate( + &state, + &evidence, + &classified, + &evaluate_request("NAT-123"), + ) + .expect("subject-access metadata prepares"); + context.metadata.access_mode = AccessMode::DelegatedAttestation; + principal.authorization_details = Some(registry_notary_core::EvidenceAuthorizationDetails { + access_mode: Some(AccessMode::DelegatedAttestation), + ..Default::default() + }); + let evaluation_id = "eval-delegated-direct-retired"; + let mut result = claim_result_view(evaluation_id, "person-is-alive"); + result.provenance.used.relay_consultation_count = 2; + store + .insert(registry_notary_core::StoredEvaluation { + client_id: context.metadata.principal_hash.as_str().to_string(), + purpose: "citizen_subject_access".to_string(), + claim_ids: vec!["person-is-alive".to_string()], + claim_refs: Vec::new(), + disclosure: "predicate".to_string(), + format: FORMAT_CLAIM_RESULT_JSON.to_string(), + results: vec![result], + created_at: "2026-05-23T00:00:00Z".to_string(), + expires_at: "2999-01-01T00:00:00Z".to_string(), + request_hash: "request-hash".to_string(), + issuance_provenance: Some(issuance_provenance_with_dependency( + "person-is-alive", + "civil-record-active", + "citizen_subject_access", + evaluation_id, + )), + subject_access: Some(context.metadata), + }) + .await + .expect("delegated evaluation fixture inserts"); + + let response = issue_credential( + HeaderMap::new(), + Some(Extension(state)), + Some(Extension(principal)), + Ok(Json(CredentialIssueRequest { + evaluation_id: evaluation_id.to_string(), + credential_profile: Some("civil_status_sd_jwt".to_string()), + format: Some(FORMAT_SD_JWT_VC.to_string()), + claims: Some(vec!["person-is-alive".to_string()]), + disclosure: Some("predicate".to_string()), + purpose: Some("citizen_subject_access".to_string()), + holder: Some(HolderRequest { + binding: Some("did".to_string()), + id: Some(holder_did_jwk()), + proof: None, + }), + })), + ) + .await; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(sign_count.load(Ordering::SeqCst), 0); +} + #[tokio::test] async fn issue_credential_fails_closed_when_status_record_write_fails() { let evidence = credential_issue_evidence_config(); @@ -77,6 +424,11 @@ async fn issue_credential_fails_closed_when_status_record_write_fails() { created_at: "2026-05-23T00:00:00Z".to_string(), expires_at: "2999-01-01T00:00:00Z".to_string(), request_hash: "request-hash".to_string(), + issuance_provenance: Some(issuance_provenance( + "person-is-alive", + "test", + "eval-status-write-fails", + )), subject_access: None, }) .await @@ -163,6 +515,11 @@ async fn issue_credential_rejects_purpose_mismatch() { created_at: "2026-05-23T00:00:00Z".to_string(), expires_at: "2999-01-01T00:00:00Z".to_string(), request_hash: "request-hash".to_string(), + issuance_provenance: Some(issuance_provenance( + "person-is-alive", + "benefits", + "eval-purpose-mismatch", + )), subject_access: None, }) .await @@ -224,6 +581,134 @@ async fn issue_credential_rejects_purpose_mismatch() { ); } +#[tokio::test] +async fn issuance_provenance_denial_precedes_signer_status_and_holder_replay() { + let mut evidence = credential_issue_evidence_config(); + evidence + .credential_profiles + .get_mut("civil_status_sd_jwt") + .expect("credential profile exists") + .holder_binding = holder_required_profile().holder_binding; + let store = Arc::new(EvidenceStore::default()); + let sign_count = Arc::new(AtomicUsize::new(0)); + let evaluation_id = "eval-provenance-denied"; + let mut evaluation = registry_notary_core::StoredEvaluation { + client_id: "caseworker".to_string(), + purpose: "test".to_string(), + claim_ids: vec!["person-is-alive".to_string()], + claim_refs: Vec::new(), + disclosure: "predicate".to_string(), + format: FORMAT_CLAIM_RESULT_JSON.to_string(), + results: vec![claim_result_view(evaluation_id, "person-is-alive")], + created_at: "2026-05-23T00:00:00Z".to_string(), + expires_at: "2999-01-01T00:00:00Z".to_string(), + request_hash: "request-hash".to_string(), + issuance_provenance: None, + subject_access: None, + }; + store + .insert(evaluation.clone()) + .await + .expect("legacy evaluation inserts"); + let credential_status = CredentialStatusStore::with_test_store( + &CredentialStatusConfig { + enabled: true, + base_url: "https://issuer.example".to_string(), + retention_seconds: 60, + }, + Arc::new(UnavailableCredentialStatusStore), + ); + let state = Arc::new( + RegistryNotaryApiState::new_with_federation( + Arc::new(evidence), + Arc::new(SubjectAccessConfig::default()), + Arc::new(Oid4vciConfig::default()), + Arc::new(FederationConfig::default()), + AuditKeyHasher::unkeyed_dev_only(), + None, + ReplayStores::memory(), + credential_status, + Arc::new(AppMetrics::default()), + Arc::clone(&store), + Arc::new(CountingIssuerResolver { + sign_count: Arc::clone(&sign_count), + }), + None, + ) + .expect("state builds"), + ); + let principal = EvidencePrincipal { + auth_profile_id: registry_notary_core::EvidenceAuthProfileId::StaticApiKey, + principal_id: "caseworker".to_string(), + scopes: vec!["civil_registry:evidence_verification".to_string()], + access_mode: AccessMode::MachineClient, + verified_claims: None, + authorization_details: None, + }; + let holder_id = holder_did_jwk(); + let now = OffsetDateTime::now_utc().unix_timestamp(); + let proof = sign_holder_proof( + &holder_id, + json!({ + "sub": holder_id, + "aud": "registry-notary", + "iat": now, + "exp": now + 60, + "jti": "provenance-denial-proof", + "evaluation_id": evaluation_id, + "credential_profile": "civil_status_sd_jwt", + "disclosure": holder_proof_disclosure("predicate"), + "claims": ["person-is-alive"], + }), + ); + let request = CredentialIssueRequest { + evaluation_id: evaluation_id.to_string(), + credential_profile: Some("civil_status_sd_jwt".to_string()), + format: Some(FORMAT_SD_JWT_VC.to_string()), + claims: Some(vec!["person-is-alive".to_string()]), + disclosure: Some("predicate".to_string()), + purpose: Some("test".to_string()), + holder: Some(HolderRequest { + binding: Some("did".to_string()), + id: Some(holder_id), + proof: Some(proof), + }), + }; + + let denied = issue_credential( + HeaderMap::new(), + Some(Extension(Arc::clone(&state))), + Some(Extension(principal.clone())), + Ok(Json(request.clone())), + ) + .await; + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + assert_eq!(sign_count.load(Ordering::SeqCst), 0); + + evaluation.issuance_provenance = Some(issuance_provenance( + "person-is-alive", + "test", + evaluation_id, + )); + store + .insert(evaluation) + .await + .expect("re-evaluated record replaces legacy test record"); + let after_reevaluation = issue_credential( + HeaderMap::new(), + Some(Extension(state)), + Some(Extension(principal)), + Ok(Json(request)), + ) + .await; + assert_eq!( + after_reevaluation.status(), + StatusCode::INTERNAL_SERVER_ERROR, + "the same holder proof reaches status persistence after re-evaluation, so the denial did not consume replay state" + ); + assert_eq!(sign_count.load(Ordering::SeqCst), 1); +} + #[test] fn strict_credential_issue_rejects_oid4vci_proof_shape() { let holder_id = holder_did_jwk(); diff --git a/crates/registry-notary-server/src/api/tests/evaluations.rs b/crates/registry-notary-server/src/api/tests/evaluations.rs index 4a3397f2b..fdd61f75b 100644 --- a/crates/registry-notary-server/src/api/tests/evaluations.rs +++ b/crates/registry-notary-server/src/api/tests/evaluations.rs @@ -315,6 +315,7 @@ fn evaluation_access_uses_stored_claim_version_scope() { created_at: "2026-05-23T00:00:00Z".to_string(), expires_at: "2999-01-01T00:00:00Z".to_string(), request_hash: "request-hash".to_string(), + issuance_provenance: None, subject_access: None, }; let principal = EvidencePrincipal { diff --git a/crates/registry-notary-server/src/api/tests/oid4vci.rs b/crates/registry-notary-server/src/api/tests/oid4vci.rs index b73858ba2..dec76dc7a 100644 --- a/crates/registry-notary-server/src/api/tests/oid4vci.rs +++ b/crates/registry-notary-server/src/api/tests/oid4vci.rs @@ -255,6 +255,72 @@ async fn oid4vci_credential_rejects_delegated_transaction_token() { assert_eq!(body["error"], "access_denied"); } +#[tokio::test] +async fn oid4vci_source_free_bypass_denies_before_nonce_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()); + let subject_access = Arc::new(subject_access_config()); + let mut oid4vci = oid4vci_config(); + 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(), + }), + ) + .await; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + 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 + )); +} + #[tokio::test] async fn oid4vci_credential_scope_prevents_cross_configuration_issuance_before_nonce_consume() { let store = Arc::new(EvidenceStore::default()); @@ -627,39 +693,56 @@ async fn oid4vci_token_error_fails_closed_when_denial_audit_fails() { #[cfg(feature = "registry-notary-cel")] #[tokio::test] -async fn oid4vci_credential_issues_sd_jwt_and_rejects_nonce_replay() { +async fn oid4vci_projected_registry_credential_issues_and_rejects_nonce_replay() { let store = Arc::new(EvidenceStore::default()); - let subject_access = subject_access_config(); - let mut evidence = evidence_config(); + let mut subject_access = subject_access_config(); + subject_access + .allowed_claims + .push("person-is-registered".to_string()); + let mut evidence = registry_backed_oid4vci_evidence_with_dependency(); + let mut registered = evidence.claims[0].clone(); + registered.id = "person-is-registered".to_string(); + registered.title = "Person is registered".to_string(); + evidence.claims.push(registered); evidence - .claims - .first_mut() - .expect("claim exists") .credential_profiles - .push("civil_status_sd_jwt".to_string()); - evidence.credential_profiles.insert( - "civil_status_sd_jwt".to_string(), - serde_json::from_value(json!({ - "format": FORMAT_SD_JWT_VC, - "issuer": "did:web:issuer.example", - "signing_key": "issuer-key", - "vct": "https://issuer.example/credentials/civil-status", - "validity_seconds": 600, - "holder_binding": { - "mode": "did", - "proof_of_possession": "required", - "allowed_did_methods": ["did:jwk"] - }, - "allowed_claims": ["person-is-alive"], - "disclosure": { "allowed": ["predicate"] } - })) - .expect("profile parses"), - ); + .get_mut("civil_status_sd_jwt") + .expect("credential profile exists") + .allowed_claims + .push("person-is-registered".to_string()); let mut oid4vci = oid4vci_config(); oid4vci.accepted_token_audiences = vec!["registry-notary-citizen".to_string()]; + let configuration = oid4vci + .credential_configurations + .get_mut("person_is_alive_sd_jwt") + .expect("credential configuration exists"); + configuration.claim_id = None; + configuration.claims = vec![ + registry_notary_core::Oid4vciCredentialClaimConfig { + id: "person-is-alive".to_string(), + output_path: vec!["person_alive".to_string()], + display_name: "Person is alive".to_string(), + sd: "always".to_string(), + }, + registry_notary_core::Oid4vciCredentialClaimConfig { + id: "person-is-registered".to_string(), + output_path: vec!["person_registered".to_string()], + display_name: "Person is registered".to_string(), + sd: "always".to_string(), + }, + ]; let evidence = Arc::new(evidence); let subject_access = Arc::new(subject_access); let oid4vci = Arc::new(oid4vci); + require_registry_backed_credential_claims( + &evidence, + &oid4vci + .credential_configurations + .get("person_is_alive_sd_jwt") + .unwrap() + .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), @@ -668,6 +751,10 @@ async fn oid4vci_credential_issues_sd_jwt_and_rejects_nonce_replay() { Arc::clone(&store), Arc::new(StaticIssuerResolver), )); + let relay = Arc::new(RegistryCredentialRelay::default()); + state + .install_activated_relay(relay.clone()) + .expect("registry credential Relay activates once"); let missing_nonce = oid4vci_credential( Some(Extension(Arc::clone(&state))), Some(Extension(fresh_oidc_principal( @@ -768,21 +855,38 @@ async fn oid4vci_credential_issues_sd_jwt_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(oid4vci_authorized_principal( - &evidence, - &subject_access, - &oid4vci, - "person_is_alive_sd_jwt", - &["subject_access", "person_is_alive"], - ))), + Some(Extension(authorized_principal.clone())), Some(Extension(validated_proof.clone())), Json(request.clone()), ) .await; + let denial = response + .extensions() + .get::() + .and_then(|audit| audit.denial_code) + .map(|code| code.as_str().to_string()); + assert_eq!( + relay.calls.load(Ordering::SeqCst), + 2, + "registry Relay must execute before issuance response: {}, denial={denial:?}", + response.status(), + ); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await @@ -795,16 +899,67 @@ async fn oid4vci_credential_issues_sd_jwt_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) + .await + .expect("stored evaluation read succeeds") + .expect("projected registry evaluation is stored"); + let issuance = stored + .issuance_provenance + .expect("projected evaluation stores private issuance provenance"); + assert_eq!(issuance.claims.len(), 3); + assert_eq!(issuance.consultations.len(), 2); + let claim_ids = issuance + .claims + .iter() + .map(|entry| entry.claim_id.as_str()) + .collect::>(); + assert_eq!( + claim_ids, + BTreeSet::from([ + "civil-record-active", + "person-is-alive", + "person-is-registered", + ]) + ); + assert!(issuance.claims.iter().all(|entry| { + let expected_pin = if entry.claim_id == "civil-record-active" { + ( + "example.civil-record.exact", + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + } else { + ( + "example.person-status.exact", + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + }; + entry.claim_version == "1" + && entry.relay_profile_id == expected_pin.0 + && entry.relay_contract_hash == expected_pin.1 + && entry.canonical_purpose == "citizen_subject_access" + && ulid::Ulid::from_string(&entry.consultation_id).is_ok() + && entry.execution_binding.starts_with("sha256:") + })); + assert!(issuance.consultations.iter().all(|execution| { + ulid::Ulid::from_string(&execution.consultation_id).is_ok() + && OffsetDateTime::parse(&execution.acquired_at, &Rfc3339).is_ok() + })); + assert!(stored + .results + .iter() + .all(|result| { result.provenance.used.relay_consultation_count == 2 })); let replay = 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(authorized_principal)), Some(Extension(validated_proof)), Json(request), ) @@ -817,35 +972,167 @@ async fn oid4vci_credential_issues_sd_jwt_and_rejects_nonce_replay() { assert_eq!(replay_body["error"], "invalid_proof"); } +#[cfg(feature = "registry-notary-cel")] #[tokio::test] -async fn oid4vci_rejects_holder_key_equal_to_issuer_key_before_side_effects() { +async fn oid4vci_rejects_tampered_dependency_catalog_before_signing() { let store = Arc::new(EvidenceStore::default()); - let subject_access = subject_access_config(); - let mut evidence = evidence_config(); - evidence + let subject_access = Arc::new(subject_access_config()); + let mut evidence = registry_backed_oid4vci_evidence_with_dependency(); + let duplicate_dependency = evidence .claims - .first_mut() - .expect("claim exists") - .credential_profiles - .push("civil_status_sd_jwt".to_string()); - evidence.credential_profiles.insert( - "civil_status_sd_jwt".to_string(), - serde_json::from_value(json!({ - "format": FORMAT_SD_JWT_VC, - "issuer": "did:web:issuer.example", - "signing_key": "issuer-key", - "vct": "https://issuer.example/credentials/civil-status", - "validity_seconds": 600, - "holder_binding": { - "mode": "did", - "proof_of_possession": "required", - "allowed_did_methods": ["did:jwk"] + .iter() + .find(|claim| claim.id == "civil-record-active") + .cloned() + .expect("dependency exists"); + evidence.claims.push(duplicate_dependency); + 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 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, }, - "allowed_claims": ["person-is-alive"], - "disclosure": { "allowed": ["predicate"] } - })) - .expect("profile parses"), - ); + proofs: registry_platform_oid4vci::CredentialRequestProofs::default(), + }), + ) + .await; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(relay.calls.load(Ordering::SeqCst), 0); + assert_eq!(sign_count.load(Ordering::SeqCst), 0); +} + +#[cfg(feature = "registry-notary-cel")] +#[tokio::test] +async fn oid4vci_dependency_execution_tampering_is_denied_before_signing() { + for tamper_acquired_at in [true, false] { + let store = Arc::new(EvidenceStore::default()); + let subject_access = Arc::new(subject_access_config()); + 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 relay = Arc::new(RegistryCredentialRelay::default()); + state + .install_activated_relay(relay.clone()) + .expect("registry credential Relay activates once"); + store.tamper_next_read(move |evaluation| { + let issuance = evaluation + .issuance_provenance + .as_mut() + .expect("OID evaluation retained a credential-capable closure"); + if tamper_acquired_at { + let dependency_execution_id = issuance + .claims + .iter() + .find(|claim| claim.claim_id == "civil-record-active") + .expect("dependency pin exists") + .consultation_id + .clone(); + issuance + .consultations + .iter_mut() + .find(|execution| execution.consultation_id == dependency_execution_id) + .expect("dependency execution exists") + .acquired_at = "2026-05-23T00:00:01Z".to_string(); + } else { + let dependency_index = issuance + .claims + .iter() + .position(|claim| claim.claim_id == "civil-record-active") + .expect("dependency pin exists"); + let root_index = issuance + .claims + .iter() + .position(|claim| claim.claim_id == "person-is-alive") + .expect("root pin exists"); + let dependency_id = issuance.claims[dependency_index].consultation_id.clone(); + issuance.claims[dependency_index].consultation_id = + issuance.claims[root_index].consultation_id.clone(); + issuance.claims[root_index].consultation_id = dependency_id; + } + }); + 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(), + }), + ) + .await; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(relay.calls.load(Ordering::SeqCst), 2); + assert_eq!(sign_count.load(Ordering::SeqCst), 0); + } +} + +#[tokio::test] +async fn oid4vci_rejects_holder_key_equal_to_issuer_key_after_registry_evaluation() { + let store = Arc::new(EvidenceStore::default()); + let subject_access = subject_access_config(); + let evidence = registry_backed_oid4vci_evidence_config(); let mut oid4vci = oid4vci_config(); oid4vci.accepted_token_audiences = vec!["registry-notary-citizen".to_string()]; let evidence = Arc::new(evidence); @@ -859,6 +1146,10 @@ async fn oid4vci_rejects_holder_key_equal_to_issuer_key_before_side_effects() { Arc::clone(&store), Arc::new(HolderIssuerResolver), )); + 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 @@ -911,6 +1202,17 @@ async fn oid4vci_rejects_holder_key_equal_to_issuer_key_before_side_effects() { ) .await; + let denial = response + .extensions() + .get::() + .and_then(|audit| audit.denial_code) + .map(|code| code.as_str().to_string()); + assert_eq!( + relay.calls.load(Ordering::SeqCst), + 1, + "registry Relay must execute before holder and issuer keys are compared: {}, denial={denial:?}", + response.status(), + ); assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert!(matches!( state @@ -919,7 +1221,7 @@ async fn oid4vci_rejects_holder_key_equal_to_issuer_key_before_side_effects() { .consume_nonce(&nonce_scope, &nonce_key) .await .expect("nonce store is available"), - ReplayInsertOutcome::Inserted + 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 9f5cc37df..81ebd4ee1 100644 --- a/crates/registry-notary-server/src/api/tests/support.rs +++ b/crates/registry-notary-server/src/api/tests/support.rs @@ -3,14 +3,15 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; use registry_notary_core::{ - BoundedVerifiedClaims, CredentialStatusConfig, EvidenceAuthorizationDetails, - SubjectRequest, VerifiedClaimName, VerifiedClaimValue, + BoundedVerifiedClaims, ClaimEvidenceMode, CredentialStatusConfig, + EvidenceAuthorizationDetails, RuleConfig, SubjectRequest, VerifiedClaimName, + VerifiedClaimValue, }; use registry_platform_crypto::{did_jwk_from_public_jwk, sign, LocalJwkSigner, PrivateJwk}; use registry_platform_replay::ReplayInsertOutcome; use registry_platform_testing::sign_openid4vci_proof_jwt; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - use std::sync::{Arc, Barrier}; + use std::sync::{Arc, Barrier, Mutex}; use std::thread; use std::time::Instant; @@ -153,7 +154,7 @@ allowed_purposes: vec!["dependent_attestation".to_string()], allowed_formats: vec![FORMAT_CLAIM_RESULT_JSON.to_string()], allowed_disclosures: vec!["predicate".to_string()], - credential_profiles: vec!["dependent_status_sd_jwt".to_string()], + credential_profiles: Vec::new(), }], }; config @@ -220,26 +221,9 @@ "allowed": ["predicate"], "downgrade": "deny" }, - "formats": [FORMAT_CLAIM_RESULT_JSON], - "credential_profiles": ["dependent_status_sd_jwt"] - } - ], - "credential_profiles": { - "dependent_status_sd_jwt": { - "format": FORMAT_SD_JWT_VC, - "issuer": "did:web:issuer.example", - "signing_key": "issuer-key", - "vct": "https://issuer.example/credentials/dependent-status", - "validity_seconds": 600, - "holder_binding": { - "mode": "did", - "proof_of_possession": "required", - "allowed_did_methods": ["did:jwk"] - }, - "allowed_claims": ["dependent-person-is-alive"], - "disclosure": { "allowed": ["predicate"] } + "formats": [FORMAT_CLAIM_RESULT_JSON] } - } + ] })) .expect("delegated evidence config parses") } @@ -399,6 +383,104 @@ evidence } + fn registry_backed_oid4vci_evidence_config() -> EvidenceConfig { + let mut evidence = oid4vci_evidence_config(); + let claim = evidence.claims.first_mut().expect("claim exists"); + claim.required_scopes = vec!["subject_access".to_string()]; + claim.evidence_mode = ClaimEvidenceMode::RegistryBacked { + consultations: BTreeMap::from([( + "person_status".to_string(), + registry_notary_core::RelayConsultationConfig { + profile: registry_notary_core::RelayConsultationProfileRef { + id: "example.person-status.exact".to_string(), + contract_hash: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + }, + inputs: BTreeMap::from([( + "subject_id".to_string(), + registry_notary_core::RelayConsultationInput::TargetIdentifier( + "request.target.identifiers.national_id".to_string(), + ), + )]), + outputs: BTreeMap::from([( + "active".to_string(), + registry_notary_core::RelayOutputContract::Boolean { nullable: false }, + )]), + }, + )]), + }; + claim.rule = RuleConfig::ConsultationMatched { + consultation: "person_status".to_string(), + }; + evidence + } + + fn registry_backed_oid4vci_evidence_with_dependency() -> EvidenceConfig { + let mut evidence = registry_backed_oid4vci_evidence_config(); + let mut dependency = evidence.claims[0].clone(); + dependency.id = "civil-record-active".to_string(); + dependency.title = "Civil record active".to_string(); + dependency.credential_profiles.clear(); + let ClaimEvidenceMode::RegistryBacked { consultations } = &mut dependency.evidence_mode + else { + panic!("dependency is registry backed"); + }; + let consultation = consultations + .get_mut("person_status") + .expect("dependency consultation exists"); + consultation.profile.id = "example.civil-record.exact".to_string(); + consultation.profile.contract_hash = + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + .to_string(); + evidence.claims[0].depends_on = vec![dependency.id.clone()]; + evidence.claims.push(dependency); + evidence + } + + #[derive(Debug, Default)] + struct RegistryCredentialRelay { + calls: AtomicUsize, + evaluation_ids: Mutex>, + } + + #[async_trait::async_trait] + impl crate::runtime::ActivatedRelayConsultations for RegistryCredentialRelay { + async fn check_ready(&self) -> Result<(), crate::relay_client::RelayClientError> { + Ok(()) + } + + fn validate( + &self, + _key: &crate::runtime::consultation::ConsultationGroupKeyV1, + ) -> Result<(), crate::relay_client::RelayClientError> { + Ok(()) + } + + async fn execute( + &self, + key: &crate::runtime::consultation::ConsultationGroupKeyV1, + ) -> Result< + crate::runtime::consultation::RuntimeRelayConsultationResult, + crate::relay_client::RelayClientError, + > { + self.calls.fetch_add(1, Ordering::SeqCst); + self.evaluation_ids + .lock() + .expect("evaluation id lock is not poisoned") + .push(key.evaluation_id().to_string()); + let outputs = crate::runtime::consultation::RuntimeRelayOutputMap::from_json( + BTreeMap::from([("active".to_string(), json!(true))]), + )?; + crate::runtime::consultation::RuntimeRelayConsultationResult::new( + ulid::Ulid::new(), + crate::runtime::consultation::RuntimeRelayOutcome::Match, + Some(crate::runtime::consultation::RuntimeRelayMatchData::OutputMap(outputs)), + OffsetDateTime::now_utc(), + ) + } + } + fn runtime_config_with_custom_access_token_typ() -> StandaloneRegistryNotaryConfig { let mut config = classifier_config(); config.auth.access_token_signing.enabled = true; @@ -823,6 +905,7 @@ evaluation_profiles: created_at: "1970-01-01T00:00:00Z".to_string(), expires_at: "1970-01-01T00:00:00Z".to_string(), request_hash: "h".to_string(), + issuance_provenance: None, subject_access: None, } } @@ -848,24 +931,158 @@ evaluation_profiles: issued_at: "2026-05-23T00:00:00Z".to_string(), expires_at: None, provenance: registry_notary_core::ClaimProvenance::new( - "test".to_string(), - "eval-test".to_string(), - "claim".to_string(), + "registry-notary".to_string(), + evaluation_id.to_string(), + claim_id.to_string(), "1".to_string(), registry_notary_core::ProvenanceUsed { - relay_consultation_count: 0, + relay_consultation_count: 1, }, ), } } + fn issuance_provenance( + claim_id: &str, + purpose: &str, + evaluation_id: &str, + ) -> registry_notary_core::StoredIssuanceProvenance { + let mut stored = registry_notary_core::StoredIssuanceProvenance { + claims: vec![registry_notary_core::StoredIssuanceClaimProvenance { + claim_id: claim_id.to_string(), + claim_version: "1".to_string(), + relay_profile_id: "example.person-status.exact".to_string(), + relay_contract_hash: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + canonical_purpose: purpose.to_string(), + consultation_id: "01J00000000000000000000000".to_string(), + execution_binding: String::new(), + }], + consultations: vec![ + registry_notary_core::StoredIssuanceConsultationProvenance { + consultation_id: "01J00000000000000000000000".to_string(), + acquired_at: "2026-05-23T00:00:00Z".to_string(), + }, + ], + }; + bind_fixture_issuance_claim(&mut stored, 0, evaluation_id, 1); + stored + } + + fn issuance_provenance_with_dependency( + root_claim_id: &str, + dependency_claim_id: &str, + purpose: &str, + evaluation_id: &str, + ) -> registry_notary_core::StoredIssuanceProvenance { + let mut stored = registry_notary_core::StoredIssuanceProvenance { + claims: vec![ + registry_notary_core::StoredIssuanceClaimProvenance { + claim_id: dependency_claim_id.to_string(), + claim_version: "1".to_string(), + relay_profile_id: "example.civil-record.exact".to_string(), + relay_contract_hash: + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + .to_string(), + canonical_purpose: purpose.to_string(), + consultation_id: "01J00000000000000000000001".to_string(), + execution_binding: String::new(), + }, + registry_notary_core::StoredIssuanceClaimProvenance { + claim_id: root_claim_id.to_string(), + claim_version: "1".to_string(), + relay_profile_id: "example.person-status.exact".to_string(), + relay_contract_hash: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + canonical_purpose: purpose.to_string(), + consultation_id: "01J00000000000000000000000".to_string(), + execution_binding: String::new(), + }, + ], + consultations: vec![ + registry_notary_core::StoredIssuanceConsultationProvenance { + consultation_id: "01J00000000000000000000000".to_string(), + acquired_at: "2026-05-23T00:00:00Z".to_string(), + }, + registry_notary_core::StoredIssuanceConsultationProvenance { + consultation_id: "01J00000000000000000000001".to_string(), + acquired_at: "2026-05-23T00:00:00Z".to_string(), + }, + ], + }; + bind_fixture_issuance_claim(&mut stored, 0, evaluation_id, 1); + bind_fixture_issuance_claim(&mut stored, 1, evaluation_id, 2); + stored + } + + fn bind_fixture_issuance_claim( + stored: &mut registry_notary_core::StoredIssuanceProvenance, + claim_index: usize, + evaluation_id: &str, + relay_consultation_count: usize, + ) { + let claim = &stored.claims[claim_index]; + let consultation = stored + .consultations + .iter() + .find(|execution| execution.consultation_id == claim.consultation_id) + .expect("claim execution exists"); + let provenance = registry_notary_core::ClaimProvenance::new( + "registry-notary".to_string(), + evaluation_id.to_string(), + claim.claim_id.clone(), + claim.claim_version.clone(), + registry_notary_core::ProvenanceUsed { + relay_consultation_count, + }, + ); + let binding = crate::runtime::issuance_execution_binding( + claim, + consultation, + evaluation_id, + &consultation.acquired_at, + &provenance, + ) + .expect("fixture execution binding hashes"); + stored.claims[claim_index].execution_binding = binding; + } + fn credential_issue_evidence_config() -> EvidenceConfig { let mut evidence = evidence_config(); evidence.service_id = "registry-notary".to_string(); - evidence + let claim = evidence .claims .first_mut() - .expect("person-is-alive claim exists") + .expect("person-is-alive claim exists"); + claim.purpose = Some("test".to_string()); + claim.required_scopes = vec!["civil_registry:evidence_verification".to_string()]; + claim.evidence_mode = ClaimEvidenceMode::RegistryBacked { + consultations: BTreeMap::from([( + "person_status".to_string(), + registry_notary_core::RelayConsultationConfig { + profile: registry_notary_core::RelayConsultationProfileRef { + id: "example.person-status.exact".to_string(), + contract_hash: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + }, + inputs: BTreeMap::from([( + "subject_id".to_string(), + registry_notary_core::RelayConsultationInput::TargetId, + )]), + outputs: BTreeMap::from([( + "active".to_string(), + registry_notary_core::RelayOutputContract::Boolean { nullable: false }, + )]), + }, + )]), + }; + claim.rule = RuleConfig::ConsultationMatched { + consultation: "person_status".to_string(), + }; + claim .credential_profiles .push("civil_status_sd_jwt".to_string()); evidence.credential_profiles.insert( @@ -884,6 +1101,28 @@ evaluation_profiles: evidence } + fn credential_issue_evidence_with_dependency() -> EvidenceConfig { + let mut evidence = credential_issue_evidence_config(); + let mut dependency = evidence.claims[0].clone(); + dependency.id = "civil-record-active".to_string(); + dependency.title = "Civil record active".to_string(); + dependency.credential_profiles.clear(); + let ClaimEvidenceMode::RegistryBacked { consultations } = &mut dependency.evidence_mode + else { + panic!("dependency is registry backed"); + }; + let consultation = consultations + .get_mut("person_status") + .expect("dependency consultation exists"); + consultation.profile.id = "example.civil-record.exact".to_string(); + consultation.profile.contract_hash = + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + .to_string(); + evidence.claims[0].depends_on = vec![dependency.id.clone()]; + evidence.claims.push(dependency); + evidence + } + fn decode_jwt_header(jwt: &str) -> Value { decode_jwt_segment(jwt, 0) } diff --git a/crates/registry-notary-server/src/openapi.rs b/crates/registry-notary-server/src/openapi.rs index 1376ca731..490e75baa 100644 --- a/crates/registry-notary-server/src/openapi.rs +++ b/crates/registry-notary-server/src/openapi.rs @@ -346,7 +346,7 @@ fn build_openapi_document() -> Value { "post": { "summary": "Issue a credential through OpenID4VCI", "operationId": "issueOid4vciCredential", - "description": "Issues a dc+sd-jwt credential for an authenticated subject-access principal. Error responses use the OpenID4VCI error envelope, not RFC 9457 Problem Details.", + "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.", "security": [ { "bearerAuth": [] } ], @@ -720,6 +720,7 @@ fn build_openapi_document() -> Value { "/v1/credentials": { "post": { "summary": "Issue a credential from a stored evaluation", + "description": "Issues only when a fresh non-delegated registry-backed evaluation has exact compiler pins and normalized unique Relay executions for every selected root's dependency closure, matching the active configuration and public evaluation result. Source-free, delegated, and legacy evaluations remain renderable but are not issuable.", "operationId": "issueCredential", "requestBody": { "required": true, @@ -3380,6 +3381,7 @@ fn discovery_example() -> Value { "json_ld_vc_issuance", "data_integrity_proofs", "credential_status", + "delegated_credential_issuance", "mso_mdoc", "openid4vci_full_issuer" ] @@ -4397,7 +4399,7 @@ mod tests { ); assert_eq!( doc["paths"]["/oid4vci/credential"]["post"]["description"], - json!("Issues a dc+sd-jwt credential for an authenticated subject-access principal. Error responses use the OpenID4VCI error envelope, not RFC 9457 Problem Details.") + 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.") ); assert_eq!( doc["components"]["schemas"]["TokenRequest"]["type"], diff --git a/crates/registry-notary-server/src/runtime.rs b/crates/registry-notary-server/src/runtime.rs index 8f137ff15..43064c5b6 100644 --- a/crates/registry-notary-server/src/runtime.rs +++ b/crates/registry-notary-server/src/runtime.rs @@ -18,6 +18,7 @@ use registry_notary_core::{ DisclosureDowngrade, DisclosureProfile, EvaluateRequest, EvaluationCapability, EvidenceConfig, EvidenceEntity, EvidenceEntityRef, EvidenceError, EvidenceFormat, EvidencePrincipal, EvidenceRequestContext, ProvenanceUsed, RegistryNotaryCelConfig, RenderRequest, RuleConfig, + StoredIssuanceClaimProvenance, StoredIssuanceConsultationProvenance, StoredIssuanceProvenance, StoredSubjectAccessMetadata, SubjectAccessConfig, SubjectAccessDenialCode, SubjectRequest, TargetRefView, FORMAT_CCCEV_JSONLD, FORMAT_CLAIM_RESULT_JSON, FORMAT_SD_JWT_VC, MAX_CLAIM_DEPENDENCY_EDGES_V1, MAX_CLAIM_DEPENDENCY_NODES_V1, SD_JWT_VC_HOLDER_BINDING_METHOD, @@ -39,7 +40,7 @@ use zeroize::Zeroizing; #[cfg(feature = "registry-notary-cel")] use crate::cel_worker::{cel_expression_uses_regex, CelWorker, CelWorkerError}; -use crate::digest::hex_encode; +use crate::digest::{hex_encode, sha256_canonical_json}; use crate::json_path::get_json_path; use crate::problem::evidence_title; use crate::request_context::with_request_correlation_id; diff --git a/crates/registry-notary-server/src/runtime/catalog.rs b/crates/registry-notary-server/src/runtime/catalog.rs index 750adb665..e275beedb 100644 --- a/crates/registry-notary-server/src/runtime/catalog.rs +++ b/crates/registry-notary-server/src/runtime/catalog.rs @@ -6,7 +6,7 @@ pub(crate) fn claim_ids(claims: &[ClaimRef]) -> Vec { claims.iter().map(|claim| claim.id.clone()).collect() } -pub(super) fn requested_claim_versions( +pub(crate) fn requested_claim_versions( claims: &[ClaimRef], ) -> Result { let mut versions = BTreeMap::new(); @@ -65,7 +65,7 @@ pub(super) fn find_claim_for_selection<'a>( /// /// Cycle and unknown-dep validation already happened at config load; we still /// guard with bounded iterations so a malformed config cannot infinite-loop. -pub(super) fn build_claim_levels( +pub(crate) fn build_claim_levels( evidence: &EvidenceConfig, requested: &[ClaimRef], claim_versions: &ClaimVersionSelections, diff --git a/crates/registry-notary-server/src/runtime/evaluation.rs b/crates/registry-notary-server/src/runtime/evaluation.rs index 448176046..7a63b03b3 100644 --- a/crates/registry-notary-server/src/runtime/evaluation.rs +++ b/crates/registry-notary-server/src/runtime/evaluation.rs @@ -27,6 +27,11 @@ struct PreparedRegistryBatchItem { evaluation_capability: EvaluationCapability, } +struct EvaluatedRegistryClaims { + views: Vec, + issuance_provenance: Option, +} + pub(crate) fn registry_backed_batch_requested( evidence: &EvidenceConfig, request: &BatchEvaluateRequest, @@ -170,6 +175,7 @@ impl RegistryNotaryRuntime { "json_ld_vc_issuance", "data_integrity_proofs", "credential_status", + "delegated_credential_issuance", "mso_mdoc", "openid4vci_full_issuer" ] @@ -539,6 +545,8 @@ impl RegistryNotaryRuntime { policy, ) .await?; + let issuance_provenance = + stored_issuance_provenance(&evidence, &request.claims, &claim_versions, &internal)?; let views = request .claims .iter() @@ -560,7 +568,7 @@ impl RegistryNotaryRuntime { .as_ref() .and_then(|metadata| metadata.evaluation_expires_at.as_deref()) .and_then(|value| OffsetDateTime::parse(value, &Rfc3339).ok()) - .unwrap_or(now + time::Duration::minutes(15)); + .unwrap_or_else(|| default_stored_evaluation_expires_at(now)); let client_id = stored_evaluation_client_id(principal, subject_access.as_ref()); store .insert(registry_notary_core::StoredEvaluation { @@ -574,6 +582,7 @@ impl RegistryNotaryRuntime { created_at: format_time(now), expires_at: format_time(expires_at), request_hash, + issuance_provenance, subject_access, }) .await?; @@ -799,10 +808,10 @@ impl RegistryNotaryRuntime { // Retain per-subject evaluations on the calling task. An // idempotent batch publishes these and its completed // response together after all subjects finish. - if let Some(first) = results.first() { - let now_parsed = OffsetDateTime::parse(&first.issued_at, &Rfc3339) - .unwrap_or(OffsetDateTime::now_utc()); - let expires_at = now_parsed + time::Duration::minutes(15); + if !results.is_empty() { + // Retention is a Notary storage lifecycle. It does not + // inherit any evidence observation timestamp. + let stored_at = OffsetDateTime::now_utc(); retained_evaluations.push(registry_notary_core::StoredEvaluation { client_id: principal.principal_id.clone(), purpose: subject_purposes[input_index].clone(), @@ -814,9 +823,12 @@ impl RegistryNotaryRuntime { .map(|view| view.format.clone()) .unwrap_or_default(), results: results.clone(), - created_at: first.issued_at.clone(), - expires_at: format_time(expires_at), + created_at: format_time(stored_at), + expires_at: format_time(default_stored_evaluation_expires_at( + stored_at, + )), request_hash: request_hash.clone(), + issuance_provenance: None, subject_access: None, }); } @@ -1084,7 +1096,8 @@ impl RegistryNotaryRuntime { }) .transpose()?; match result { - Ok(results) => { + Ok(evaluated) => { + let results = evaluated.views; succeeded += 1; let evaluation_id = results.first().map(|result| result.evaluation_id.clone()); let claim_results = results @@ -1092,8 +1105,9 @@ impl RegistryNotaryRuntime { .map(|result| batch_claim_result(&evidence, result)) .collect::, EvidenceError>>()?; if let Some(first) = results.first() { - let now = OffsetDateTime::parse(&first.issued_at, &Rfc3339) - .unwrap_or_else(|_| OffsetDateTime::now_utc()); + // Relay acquisition time belongs to claim provenance; + // evaluation retention starts when Notary stores it. + let stored_at = OffsetDateTime::now_utc(); retained_evaluations.push(registry_notary_core::StoredEvaluation { client_id: principal.principal_id.clone(), purpose: subject_purposes[input_index].clone(), @@ -1102,9 +1116,12 @@ impl RegistryNotaryRuntime { disclosure: stored_disclosure(&results), format: first.format.clone(), results: results.clone(), - created_at: first.issued_at.clone(), - expires_at: format_time(now + time::Duration::minutes(15)), + created_at: format_time(stored_at), + expires_at: format_time(default_stored_evaluation_expires_at( + stored_at, + )), request_hash: request_hash.clone(), + issuance_provenance: evaluated.issuance_provenance, subject_access: None, }); } @@ -1152,7 +1169,7 @@ impl RegistryNotaryRuntime { evidence: Arc, item: PreparedRegistryBatchItem, #[cfg(feature = "registry-notary-cel")] cel_concurrency: Option>, - ) -> Result, EvidenceError> { + ) -> Result { let now = OffsetDateTime::now_utc(); let internal = self .evaluate_claims_dag( @@ -1171,7 +1188,14 @@ impl RegistryNotaryRuntime { EvaluationPolicy::default(), ) .await?; - item.request + let issuance_provenance = stored_issuance_provenance( + &evidence, + &item.request.claims, + &item.claim_versions, + &internal, + )?; + let views = item + .request .claims .iter() .map(|claim_ref| { @@ -1187,7 +1211,11 @@ impl RegistryNotaryRuntime { &item.format, ) }) - .collect() + .collect::, EvidenceError>>()?; + Ok(EvaluatedRegistryClaims { + views, + issuance_provenance, + }) } /// Like `evaluate` but without writing the per-subject evaluation to the @@ -1588,6 +1616,93 @@ fn relay_expected_result( .map_err(|_| EvidenceError::InvalidRequest) } +fn stored_issuance_provenance( + evidence: &EvidenceConfig, + selected_claims: &[ClaimRef], + claim_versions: &ClaimVersionSelections, + internal: &BTreeMap, +) -> Result, EvidenceError> { + // Retain restricted Relay identifiers only when the selected roots share + // an actual credential profile. Root configuration validation closes both + // sides of these bindings and validates the registry-backed dependency + // closure. OID4VCI configurations use the same profile binding. + let credential_capable = evidence + .credential_profiles + .iter() + .any(|(profile_id, profile)| { + selected_claims.iter().all(|claim_ref| { + find_claim_for_selection(evidence, claim_ref, claim_versions).is_ok_and(|claim| { + claim + .credential_profiles + .iter() + .any(|candidate| candidate == profile_id) + && profile + .allowed_claims + .iter() + .any(|candidate| candidate == &claim.id) + }) + }) + }); + if !credential_capable { + return Ok(None); + } + + let levels = build_claim_levels(evidence, selected_claims, claim_versions)?; + let closure = levels.iter().flatten().collect::>(); + if closure.len() > MAX_CLAIM_DEPENDENCY_NODES_V1 { + return Err(EvidenceError::RuleEvaluationFailed); + } + for claim_id in &closure { + if !find_claim_for_selection(evidence, claim_id.as_str(), claim_versions)? + .evidence_mode + .is_registry_backed() + { + return Ok(None); + } + } + + let mut claims = Vec::with_capacity(closure.len()); + let mut consultations: BTreeMap = BTreeMap::new(); + for claim_id in closure { + let provenance = internal + .get(claim_id.as_str()) + .and_then(|result| result.own_issuance_provenance.clone()) + .ok_or(EvidenceError::RuleEvaluationFailed)?; + match consultations.get(&provenance.consultation.consultation_id) { + Some(existing) if existing.acquired_at != provenance.consultation.acquired_at => { + return Err(EvidenceError::RuleEvaluationFailed); + } + Some(_) => {} + None => { + consultations.insert( + provenance.consultation.consultation_id.clone(), + provenance.consultation.clone(), + ); + } + } + let result = internal + .get(claim_id.as_str()) + .ok_or(EvidenceError::RuleEvaluationFailed)?; + let mut claim = provenance.claim; + claim.execution_binding = issuance_execution_binding( + &claim, + &provenance.consultation, + &result.evaluation_id, + &format_time(result.issued_at), + &result.provenance, + )?; + claims.push(claim); + } + Ok(Some(StoredIssuanceProvenance { + claims, + consultations: consultations.into_values().collect(), + })) +} + +fn default_stored_evaluation_expires_at(stored_at: OffsetDateTime) -> OffsetDateTime { + stored_at + time::Duration::minutes(15) +} + /// Derive the evaluation policy identity for provenance from stored /// subject-access metadata. Self-attestation results are produced under the /// canonical `subject-access` evaluation policy; the version and hash come @@ -1657,60 +1772,83 @@ pub(super) async fn evaluate_claim_task( } } let delegated_proof_claim = ctx.evaluation_capability.is_delegated_proof_claim(claim_id); - let (consultation_outputs, observed_at, mut relay_consultation_ids) = match &claim.evidence_mode - { - ClaimEvidenceMode::SelfAttested => (BTreeMap::new(), None, BTreeSet::new()), - ClaimEvidenceMode::RegistryBacked { .. } => { - require_relay_consultation_capability(&ctx.evaluation_capability, &claim.id)?; - let plan = ctx - .relay_plan - .as_ref() - .ok_or(EvidenceError::EvidenceNotAvailable)?; - let result = plan.consult(&claim.id).await.map_err(|_| { - if delegated_proof_claim { - delegated_proof_denied() - } else { - EvidenceError::EvidenceNotAvailable - } - })?; - let relay_outcome = result.outcome(); - let consultation_outputs_result = match relay_outcome { - RuntimeRelayOutcome::Match => materialize_relay_match(&claim, &result), - RuntimeRelayOutcome::NoMatch - if matches!(&claim.rule, RuleConfig::ConsultationOutput { .. }) - && registry_claim_has_typed_outputs(&claim) => - { - materialize_relay_absence(&claim) - } - RuntimeRelayOutcome::NoMatch - if matches!(&claim.rule, RuleConfig::ConsultationOutput { .. }) => - { - Err(EvidenceError::EvidenceNotAvailable) - } - RuntimeRelayOutcome::NoMatch if matches!(&claim.rule, RuleConfig::Cel { .. }) => { - materialize_relay_absence(&claim) - } - RuntimeRelayOutcome::NoMatch => Ok(BTreeMap::new()), - RuntimeRelayOutcome::Ambiguous => Err(EvidenceError::EvidenceNotAvailable), - }; - let consultation_outputs = consultation_outputs_result.map_err(|error| { - if delegated_proof_claim { - if relay_outcome == RuntimeRelayOutcome::NoMatch { - delegated_relationship_unproven() - } else { + let (consultation_outputs, observed_at, mut relay_consultation_ids, own_issuance_provenance) = + match &claim.evidence_mode { + ClaimEvidenceMode::SelfAttested => (BTreeMap::new(), None, BTreeSet::new(), None), + ClaimEvidenceMode::RegistryBacked { consultations } => { + require_relay_consultation_capability(&ctx.evaluation_capability, &claim.id)?; + let (_, consultation) = consultations + .first_key_value() + .filter(|_| consultations.len() == 1) + .ok_or(EvidenceError::RuleEvaluationFailed)?; + let plan = ctx + .relay_plan + .as_ref() + .ok_or(EvidenceError::EvidenceNotAvailable)?; + let result = plan.consult(&claim.id).await.map_err(|_| { + if delegated_proof_claim { delegated_proof_denied() + } else { + EvidenceError::EvidenceNotAvailable } - } else { - error - } - })?; - ( - consultation_outputs, - Some(result.acquired_at()), - BTreeSet::from([result.consultation_id().to_string()]), - ) - } - }; + })?; + let relay_outcome = result.outcome(); + let consultation_outputs_result = match relay_outcome { + RuntimeRelayOutcome::Match => materialize_relay_match(&claim, &result), + RuntimeRelayOutcome::NoMatch + if matches!(&claim.rule, RuleConfig::ConsultationOutput { .. }) + && registry_claim_has_typed_outputs(&claim) => + { + materialize_relay_absence(&claim) + } + RuntimeRelayOutcome::NoMatch + if matches!(&claim.rule, RuleConfig::ConsultationOutput { .. }) => + { + Err(EvidenceError::EvidenceNotAvailable) + } + RuntimeRelayOutcome::NoMatch + if matches!(&claim.rule, RuleConfig::Cel { .. }) => + { + materialize_relay_absence(&claim) + } + RuntimeRelayOutcome::NoMatch => Ok(BTreeMap::new()), + RuntimeRelayOutcome::Ambiguous => Err(EvidenceError::EvidenceNotAvailable), + }; + let consultation_outputs = consultation_outputs_result.map_err(|error| { + if delegated_proof_claim { + if relay_outcome == RuntimeRelayOutcome::NoMatch { + delegated_relationship_unproven() + } else { + delegated_proof_denied() + } + } else { + error + } + })?; + let acquired_at = result.acquired_at(); + let consultation_id = result.consultation_id().to_string(); + ( + consultation_outputs, + Some(acquired_at), + BTreeSet::from([consultation_id.clone()]), + Some(ClaimIssuanceProvenanceInternal { + claim: StoredIssuanceClaimProvenance { + claim_id: claim.id.clone(), + claim_version: claim.version.clone(), + relay_profile_id: consultation.profile.id.clone(), + relay_contract_hash: consultation.profile.contract_hash.clone(), + canonical_purpose: ctx.purpose.clone(), + consultation_id: consultation_id.clone(), + execution_binding: String::new(), + }, + consultation: StoredIssuanceConsultationProvenance { + consultation_id, + acquired_at: format_time(acquired_at), + }, + }), + ) + } + }; // Relay acquisition time pins the result to the consultation evidence. let issued_at = observed_at.unwrap_or(ctx.now); let value_result = match &claim.rule { @@ -1814,6 +1952,7 @@ pub(super) async fn evaluate_claim_task( expires_at: None, provenance, relay_consultation_ids, + own_issuance_provenance, }) } diff --git a/crates/registry-notary-server/src/runtime/render.rs b/crates/registry-notary-server/src/runtime/render.rs index b10ae6683..85583de10 100644 --- a/crates/registry-notary-server/src/runtime/render.rs +++ b/crates/registry-notary-server/src/runtime/render.rs @@ -149,6 +149,320 @@ pub fn credential_profile_for<'a>( Err(EvidenceError::CredentialIssuerNotConfigured) } +/// Fail closed unless every credential fact currently resolves to one +/// registry-backed claim. OID4VCI uses this before issuer or nonce access so a +/// config assembled without the shared loader cannot recover a signing path. +pub(crate) fn require_registry_backed_credential_claims( + evidence: &EvidenceConfig, + claim_ids: &[String], +) -> Result<(), EvidenceError> { + if claim_ids.is_empty() || claim_ids.len() > MAX_CLAIM_DEPENDENCY_NODES_V1 { + return Err(EvidenceError::EvaluationBindingMismatch); + } + let mut seen = BTreeSet::new(); + for claim_id in claim_ids { + if !seen.insert(claim_id.as_str()) { + return Err(EvidenceError::EvaluationBindingMismatch); + } + } + let selected = claim_ids + .iter() + .map(|claim_id| ClaimRef::from(claim_id.as_str())) + .collect::>(); + let versions = requested_claim_versions(&selected)?; + let levels = build_claim_levels(evidence, &selected, &versions) + .map_err(|_| EvidenceError::EvaluationBindingMismatch)?; + for claim_id in levels.iter().flatten() { + if evidence + .claims + .iter() + .filter(|candidate| candidate.id.as_str() == claim_id.as_str()) + .count() + != 1 + { + return Err(EvidenceError::EvaluationBindingMismatch); + } + let claim = find_claim_for_selection(evidence, claim_id.as_str(), &versions) + .map_err(|_| EvidenceError::EvaluationBindingMismatch)?; + let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode else { + return Err(EvidenceError::EvaluationBindingMismatch); + }; + if consultations.len() != 1 || claim.purpose.as_deref().is_none() { + return Err(EvidenceError::EvaluationBindingMismatch); + } + } + Ok(()) +} + +/// Commit one compiler pin to the exact Relay execution and claim provenance +/// observed during evaluation. This is an unkeyed integrity cross-binding for +/// detecting partial stored-record mutation. It is not an authenticity anchor +/// against an operator able to rewrite every committed field and the digest. +pub(crate) fn issuance_execution_binding( + claim: &StoredIssuanceClaimProvenance, + consultation: &StoredIssuanceConsultationProvenance, + evaluation_id: &str, + issued_at: &str, + provenance: &ClaimProvenance, +) -> Result { + sha256_canonical_json(&json!({ + "schema": "registry.notary.issuance-execution-binding/v1", + "claim": { + "id": claim.claim_id, + "version": claim.claim_version, + "relay_profile_id": claim.relay_profile_id, + "relay_contract_hash": claim.relay_contract_hash, + "canonical_purpose": claim.canonical_purpose, + }, + "execution": { + "consultation_id": consultation.consultation_id, + "acquired_at": consultation.acquired_at, + }, + "result": { + "evaluation_id": evaluation_id, + "issued_at": issued_at, + "provenance": provenance, + }, + })) +} + +fn expected_issuance_claim_provenance( + evidence: &EvidenceConfig, + evaluation: ®istry_notary_core::StoredEvaluation, + evaluation_id: &str, + claim_id: &str, + claim_version: &str, + relay_consultation_count: usize, +) -> ClaimProvenance { + let mut provenance = ClaimProvenance::new( + evidence.service_id.clone(), + evaluation_id.to_string(), + claim_id.to_string(), + claim_version.to_string(), + ProvenanceUsed { + relay_consultation_count, + }, + ); + let policy = evaluation_policy_from_subject_access(evaluation.subject_access.as_ref()); + provenance.generated_by.policy_id = policy.policy_id; + provenance.generated_by.policy_version = policy.policy_version; + provenance.generated_by.policy_hash = policy.policy_hash; + provenance +} + +/// Verify the restricted Relay execution record before credential signing. +/// +/// Public result provenance intentionally exposes only a consultation count. +/// This verifier joins it to the private dependency-closure compiler pins and rejects +/// legacy, incomplete, duplicated, extra, or tampered state before a signer, +/// holder-proof replay store, credential id, or status store is touched. +pub(crate) fn require_issuable_evaluation_provenance( + evidence: &EvidenceConfig, + evaluation_id: &str, + evaluation: ®istry_notary_core::StoredEvaluation, +) -> Result<(), EvidenceError> { + let selected = evaluation.selected_claim_refs(); + if selected.is_empty() + || selected.len() > MAX_CLAIM_DEPENDENCY_NODES_V1 + || evaluation.claim_ids.len() != selected.len() + || evaluation.results.len() != selected.len() + { + return Err(EvidenceError::EvaluationBindingMismatch); + } + let stored = evaluation + .issuance_provenance + .as_ref() + .ok_or(EvidenceError::EvaluationBindingMismatch)?; + if stored.claims.is_empty() + || stored.claims.len() > MAX_CLAIM_DEPENDENCY_NODES_V1 + || stored.consultations.is_empty() + || stored.consultations.len() > MAX_CLAIM_DEPENDENCY_NODES_V1 + { + return Err(EvidenceError::EvaluationBindingMismatch); + } + + let versions = requested_claim_versions(&selected)?; + let levels = build_claim_levels(evidence, &selected, &versions) + .map_err(|_| EvidenceError::EvaluationBindingMismatch)?; + let expected_claim_ids = levels.iter().flatten().collect::>(); + if expected_claim_ids.len() != stored.claims.len() { + return Err(EvidenceError::EvaluationBindingMismatch); + } + + let mut selected_ids = BTreeSet::new(); + let mut result_ids = BTreeSet::new(); + let mut private_claims = BTreeMap::new(); + let mut private_consultations = BTreeMap::new(); + for result in &evaluation.results { + if !result_ids.insert(result.claim_id.as_str()) { + return Err(EvidenceError::EvaluationBindingMismatch); + } + } + for provenance in &stored.claims { + if private_claims + .insert(provenance.claim_id.as_str(), provenance) + .is_some() + { + return Err(EvidenceError::EvaluationBindingMismatch); + } + } + for consultation in &stored.consultations { + let consultation_id = Ulid::from_string(&consultation.consultation_id) + .map_err(|_| EvidenceError::EvaluationBindingMismatch)?; + let acquired_at = OffsetDateTime::parse(&consultation.acquired_at, &Rfc3339) + .map_err(|_| EvidenceError::EvaluationBindingMismatch)?; + if consultation_id.to_string() != consultation.consultation_id + || format_time(acquired_at) != consultation.acquired_at + || private_consultations + .insert( + consultation.consultation_id.as_str(), + (consultation, acquired_at), + ) + .is_some() + { + return Err(EvidenceError::EvaluationBindingMismatch); + } + } + + let mut referenced_consultations = BTreeSet::new(); + for claim_id in &expected_claim_ids { + if evidence + .claims + .iter() + .filter(|candidate| candidate.id.as_str() == claim_id.as_str()) + .count() + != 1 + { + return Err(EvidenceError::EvaluationBindingMismatch); + } + let claim = find_claim_for_selection(evidence, claim_id.as_str(), &versions) + .map_err(|_| EvidenceError::EvaluationBindingMismatch)?; + let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode else { + return Err(EvidenceError::EvaluationBindingMismatch); + }; + let (_, consultation) = consultations + .first_key_value() + .filter(|_| consultations.len() == 1) + .ok_or(EvidenceError::EvaluationBindingMismatch)?; + if claim.purpose.as_deref() != Some(evaluation.purpose.as_str()) { + return Err(EvidenceError::EvaluationBindingMismatch); + } + let private = private_claims + .get(claim_id.as_str()) + .ok_or(EvidenceError::EvaluationBindingMismatch)?; + let (private_consultation, _) = private_consultations + .get(private.consultation_id.as_str()) + .ok_or(EvidenceError::EvaluationBindingMismatch)?; + if private.claim_version != claim.version + || private.relay_profile_id != consultation.profile.id + || private.relay_contract_hash != consultation.profile.contract_hash + || private.canonical_purpose != evaluation.purpose + { + return Err(EvidenceError::EvaluationBindingMismatch); + } + let claim_levels = + build_claim_levels(evidence, &[ClaimRef::from(claim_id.as_str())], &versions) + .map_err(|_| EvidenceError::EvaluationBindingMismatch)?; + let claim_consultations = claim_levels + .iter() + .flatten() + .map(|dependency_id| { + private_claims + .get(dependency_id.as_str()) + .map(|dependency| dependency.consultation_id.as_str()) + .ok_or(EvidenceError::EvaluationBindingMismatch) + }) + .collect::, _>>()?; + let expected_provenance = expected_issuance_claim_provenance( + evidence, + evaluation, + evaluation_id, + claim_id, + &claim.version, + claim_consultations.len(), + ); + let expected_binding = issuance_execution_binding( + private, + private_consultation, + evaluation_id, + &private_consultation.acquired_at, + &expected_provenance, + )?; + if private.execution_binding != expected_binding { + return Err(EvidenceError::EvaluationBindingMismatch); + } + referenced_consultations.insert(private.consultation_id.as_str()); + } + if referenced_consultations.len() != private_consultations.len() { + return Err(EvidenceError::EvaluationBindingMismatch); + } + + for (position, claim_ref) in selected.iter().enumerate() { + if evaluation.claim_ids[position] != claim_ref.id + || !selected_ids.insert(claim_ref.id.as_str()) + { + return Err(EvidenceError::EvaluationBindingMismatch); + } + let selected_claim = find_claim_for_selection(evidence, claim_ref, &versions) + .map_err(|_| EvidenceError::EvaluationBindingMismatch)?; + let root_private = private_claims + .get(claim_ref.id.as_str()) + .ok_or(EvidenceError::EvaluationBindingMismatch)?; + let (_, root_acquired_at) = private_consultations + .get(root_private.consultation_id.as_str()) + .ok_or(EvidenceError::EvaluationBindingMismatch)?; + let (root_consultation, _) = private_consultations + .get(root_private.consultation_id.as_str()) + .ok_or(EvidenceError::EvaluationBindingMismatch)?; + let root_levels = build_claim_levels(evidence, std::slice::from_ref(claim_ref), &versions) + .map_err(|_| EvidenceError::EvaluationBindingMismatch)?; + let root_consultations = root_levels + .iter() + .flatten() + .map(|claim_id| { + private_claims + .get(claim_id.as_str()) + .map(|private| private.consultation_id.as_str()) + .ok_or(EvidenceError::EvaluationBindingMismatch) + }) + .collect::, _>>()?; + + let result = evaluation + .results + .iter() + .find(|result| result.claim_id == claim_ref.id) + .ok_or(EvidenceError::EvaluationBindingMismatch)?; + let generated = &result.provenance.generated_by; + let result_issued_at = OffsetDateTime::parse(&result.issued_at, &Rfc3339) + .map_err(|_| EvidenceError::EvaluationBindingMismatch)?; + let result_binding = issuance_execution_binding( + root_private, + root_consultation, + evaluation_id, + &result.issued_at, + &result.provenance, + )?; + if result.evaluation_id != evaluation_id + || result.claim_version != selected_claim.version + || result_issued_at != *root_acquired_at + || result.provenance.schema_version + != registry_notary_core::CLAIM_PROVENANCE_SCHEMA_VERSION + || generated.entry_type + != registry_notary_core::PROVENANCE_GENERATED_BY_CLAIM_EVALUATION + || generated.service_id != evidence.service_id + || generated.evaluation_id != evaluation_id + || generated.claim_id != claim_ref.id + || generated.claim_version != selected_claim.version + || result.provenance.used.relay_consultation_count != root_consultations.len() + || result_binding != root_private.execution_binding + { + return Err(EvidenceError::EvaluationBindingMismatch); + } + } + + Ok(()) +} + pub fn format_time(value: OffsetDateTime) -> String { value .format(&Rfc3339) diff --git a/crates/registry-notary-server/src/runtime/store.rs b/crates/registry-notary-server/src/runtime/store.rs index eb74a37ac..4d3b38a6e 100644 --- a/crates/registry-notary-server/src/runtime/store.rs +++ b/crates/registry-notary-server/src/runtime/store.rs @@ -18,6 +18,10 @@ const BATCH_WAIT_MAX_MILLIS: u64 = 1_000; // accepted by the clean 1.0 storage contract. const STORED_RECORD_VERSION: i16 = 2; +#[cfg(all(test, feature = "registry-notary-cel"))] +type EvaluationReadTamper = + Box; + enum IdempotencyRecord { InFlight { request_hash: String, @@ -59,6 +63,8 @@ pub struct EvidenceStore { state_plane: Option>, evaluations: Mutex>, idempotency: Mutex>, + #[cfg(all(test, feature = "registry-notary-cel"))] + next_read_tamper: Mutex>, } impl Default for EvidenceStore { @@ -67,6 +73,8 @@ impl Default for EvidenceStore { state_plane: None, evaluations: Mutex::new(HashMap::new()), idempotency: Mutex::new(HashMap::new()), + #[cfg(all(test, feature = "registry-notary-cel"))] + next_read_tamper: Mutex::new(None), } } } @@ -346,6 +354,19 @@ impl EvidenceStore { get_postgres_evaluation(state_plane, evaluation_id, client_id).await } + /// Install a one-shot in-memory read mutation for issuance tamper tests. + /// Production builds have no corresponding hook. + #[cfg(all(test, feature = "registry-notary-cel"))] + pub(crate) fn tamper_next_read( + &self, + tamper: impl FnOnce(&mut registry_notary_core::StoredEvaluation) + Send + 'static, + ) { + *self + .next_read_tamper + .lock() + .expect("evidence read-tamper mutex is not poisoned") = Some(Box::new(tamper)); + } + pub(super) async fn reserve_idempotent_batch( &self, key: String, @@ -397,6 +418,17 @@ impl EvidenceStore { .get(evaluation_id) .filter(|evaluation| evaluation.client_id == client_id) .cloned()?; + #[cfg(all(test, feature = "registry-notary-cel"))] + let mut evaluation = evaluation; + #[cfg(all(test, feature = "registry-notary-cel"))] + if let Some(tamper) = self + .next_read_tamper + .lock() + .expect("evidence read-tamper mutex is not poisoned") + .take() + { + tamper(&mut evaluation); + } let expires_at = OffsetDateTime::parse(&evaluation.expires_at, &Rfc3339).ok()?; (expires_at > OffsetDateTime::now_utc()).then_some(evaluation) } diff --git a/crates/registry-notary-server/src/runtime/tests/catalog.rs b/crates/registry-notary-server/src/runtime/tests/catalog.rs index 0f6aa107e..4e4f52c29 100644 --- a/crates/registry-notary-server/src/runtime/tests/catalog.rs +++ b/crates/registry-notary-server/src/runtime/tests/catalog.rs @@ -237,6 +237,7 @@ "json_ld_vc_issuance", "data_integrity_proofs", "credential_status", + "delegated_credential_issuance", "mso_mdoc", "openid4vci_full_issuer" ]) diff --git a/crates/registry-notary-server/src/runtime/tests/evaluation.rs b/crates/registry-notary-server/src/runtime/tests/evaluation.rs index 72dfeb385..9932b1c29 100644 --- a/crates/registry-notary-server/src/runtime/tests/evaluation.rs +++ b/crates/registry-notary-server/src/runtime/tests/evaluation.rs @@ -56,6 +56,38 @@ impl ActivatedRelayConsultations for FixedRelayConsultation { } } +#[derive(Debug, Default)] +struct UniqueRelayConsultation { + calls: AtomicU64, +} + +#[async_trait::async_trait] +impl ActivatedRelayConsultations for UniqueRelayConsultation { + 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 { + let call = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + RuntimeRelayConsultationResult::new( + Ulid::from_parts(call, 1), + RuntimeRelayOutcome::Match, + Some(status_match_data()?), + OffsetDateTime::UNIX_EPOCH, + ) + } +} + #[derive(Debug)] struct DelegatedRelayConsultation { calls: AtomicU64, @@ -1291,8 +1323,13 @@ async fn registry_backed_claims_share_one_relay_consultation_without_fallback() .await .expect("stored evaluation read succeeds") .expect("restricted evaluation record is stored"); + assert!( + stored.issuance_provenance.is_none(), + "registry-backed evaluation-only claims must not retain private Relay identifiers" + ); let stored_wire = serde_json::to_string(&stored).expect("stored evaluation serializes"); assert!(!stored_wire.contains("relay_consultation_ids")); + assert!(!stored_wire.contains(&Ulid::from_parts(2, 1).to_string())); let public_wire = serde_json::to_string(&results).expect("public results serialize"); assert!(!public_wire.contains("relay_consultation_ids")); @@ -1309,6 +1346,93 @@ async fn registry_backed_claims_share_one_relay_consultation_without_fallback() assert_eq!(activated.calls.load(Ordering::SeqCst), 2); } +#[tokio::test] +async fn registry_credential_root_persists_exact_dependency_execution_closure() { + let mut dependency = registry_claim( + "civil-record-active", + RuleConfig::ConsultationMatched { + consultation: "enrollment".to_string(), + }, + "boolean", + ); + let ClaimEvidenceMode::RegistryBacked { consultations } = &mut dependency.evidence_mode else { + panic!("dependency is registry backed"); + }; + consultations + .get_mut("enrollment") + .expect("dependency consultation exists") + .profile + .id = "dhis2.tracker.civil-record.exact".to_string(); + let mut root = registry_claim( + "credential-eligible", + RuleConfig::ConsultationMatched { + consultation: "enrollment".to_string(), + }, + "boolean", + ); + root.depends_on = vec![dependency.id.clone()]; + root.credential_profiles = vec!["credential-profile".to_string()]; + let mut evidence = (*test_evidence(vec![dependency, root])).clone(); + evidence.allowed_purposes = vec!["test".to_string()]; + evidence.credential_profiles.insert( + "credential-profile".to_string(), + serde_json::from_value(json!({ + "format": FORMAT_SD_JWT_VC, + "issuer": "did:web:issuer.example", + "signing_key": "issuer-key", + "vct": "https://issuer.example/credentials/test", + "allowed_claims": ["credential-eligible"] + })) + .expect("credential profile parses"), + ); + let evidence = Arc::new(evidence); + let activated = Arc::new(UniqueRelayConsultation::default()); + 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 results = runtime + .evaluate( + Arc::clone(&evidence), + &store, + &principal, + test_request("credential-eligible"), + None, + ) + .await + .expect("root and dependency execute"); + + assert_eq!(activated.calls.load(Ordering::SeqCst), 2); + assert_eq!(results.len(), 1); + assert_eq!(results[0].provenance.used.relay_consultation_count, 2); + let stored = store + .get(&results[0].evaluation_id, &principal.principal_id) + .await + .expect("stored evaluation reads") + .expect("stored evaluation exists"); + let issuance = stored + .issuance_provenance + .as_ref() + .expect("registry closure provenance is retained"); + assert_eq!( + issuance + .claims + .iter() + .map(|claim| claim.claim_id.as_str()) + .collect::>(), + BTreeSet::from(["civil-record-active", "credential-eligible"]) + ); + assert_eq!(issuance.consultations.len(), 2); + assert!(issuance + .claims + .iter() + .all(|claim| claim.execution_binding.starts_with("sha256:"))); + require_issuable_evaluation_provenance(&evidence, &results[0].evaluation_id, &stored) + .expect("the exact dependency closure is issuable"); +} + fn registry_batch_request(claims: Vec) -> BatchEvaluateRequest { BatchEvaluateRequest { items: ["person-1", "person-1"] @@ -1733,6 +1857,34 @@ async fn registry_batch_coalesces_within_items_never_across_duplicates_and_repla .iter() .all(|item| matches!(item.status, BatchItemStatus::Succeeded))); assert_ne!(first.items[0].evaluation_id, first.items[1].evaluation_id); + for item in &first.items { + let evaluation_id = item + .evaluation_id + .as_deref() + .expect("successful registry batch item has an evaluation id"); + let stored = store + .get(evaluation_id, &principal.principal_id) + .await + .expect("batch evaluation store read succeeds") + .expect("batch evaluation is stored"); + let stored_at = OffsetDateTime::parse(&stored.created_at, &Rfc3339) + .expect("stored_at is canonical RFC3339"); + let expires_at = OffsetDateTime::parse(&stored.expires_at, &Rfc3339) + .expect("expires_at is canonical RFC3339"); + assert_eq!( + expires_at - stored_at, + time::Duration::minutes(15), + "registry batch retention starts when Notary stores the evaluation" + ); + assert_ne!( + stored.created_at, "1970-01-01T00:00:00Z", + "Relay acquisition time must not become Notary storage time" + ); + assert!( + stored.issuance_provenance.is_none(), + "evaluation-only registry batch claims must not retain private Relay identifiers" + ); + } let first_children = activated .child_identities .lock() @@ -2303,11 +2455,17 @@ async fn batch_item_target_ref_serializes_as_opaque_handle() { .evaluation_id .as_deref() .expect("successful item has an evaluation id"); - assert!(store + let stored = store .get(evaluation_id, &principal.principal_id) .await .expect("completed batch evaluation read succeeds") - .is_some()); + .expect("completed source-free batch evaluation is retained"); + assert!(stored.issuance_provenance.is_none()); + let stored_at = OffsetDateTime::parse(&stored.created_at, &Rfc3339) + .expect("stored_at is canonical RFC3339"); + let expires_at = OffsetDateTime::parse(&stored.expires_at, &Rfc3339) + .expect("expires_at is canonical RFC3339"); + assert_eq!(expires_at - stored_at, time::Duration::minutes(15)); } #[tokio::test] diff --git a/crates/registry-notary-server/src/runtime/tests/render.rs b/crates/registry-notary-server/src/runtime/tests/render.rs index 7b0aa9548..d01da91e4 100644 --- a/crates/registry-notary-server/src/runtime/tests/render.rs +++ b/crates/registry-notary-server/src/runtime/tests/render.rs @@ -204,6 +204,7 @@ credential_profiles: created_at: "1970-01-01T00:00:00Z".to_string(), expires_at: "1970-01-01T00:00:00Z".to_string(), request_hash: "h".to_string(), + issuance_provenance: None, subject_access: None, }; @@ -252,6 +253,7 @@ profile_b: created_at: "1970-01-01T00:00:00Z".to_string(), expires_at: "1970-01-01T00:00:00Z".to_string(), request_hash: "h".to_string(), + issuance_provenance: None, subject_access: None, }; @@ -266,3 +268,239 @@ profile_b: credential_profile_for(&evidence, &evaluation, None).expect("default profile resolves"); assert_eq!(profile_id, "profile_b"); } + + fn issuable_registry_evaluation() -> (Arc, registry_notary_core::StoredEvaluation) { + let mut claim = test_claim("registry-fact", Vec::new(), false); + claim.version = "1".to_string(); + claim.purpose = Some("credential-purpose".to_string()); + claim.evidence_mode = ClaimEvidenceMode::RegistryBacked { + consultations: BTreeMap::from([( + "registry".to_string(), + registry_notary_core::RelayConsultationConfig { + profile: registry_notary_core::RelayConsultationProfileRef { + id: "example.registry-fact.exact".to_string(), + contract_hash: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + }, + inputs: BTreeMap::from([( + "subject_id".to_string(), + RelayConsultationInput::TargetId, + )]), + outputs: BTreeMap::from([( + "active".to_string(), + registry_notary_core::RelayOutputContract::Boolean { nullable: false }, + )]), + }, + )]), + }; + claim.rule = RuleConfig::ConsultationMatched { + consultation: "registry".to_string(), + }; + let evidence = test_evidence(vec![claim]); + let evaluation_id = "01J00000000000000000000001"; + let issued_at = "2026-07-17T00:00:00Z"; + let result = ClaimResultView { + evaluation_id: evaluation_id.to_string(), + claim_id: "registry-fact".to_string(), + claim_version: "1".to_string(), + subject_type: "person".to_string(), + requester_ref: None, + target_ref: TargetRefView { + entity_type: "Person".to_string(), + handle: "rnref:v1:target".to_string(), + identifier_schemes: Vec::new(), + profile: None, + }, + value: Some(json!(true)), + satisfied: Some(true), + disclosure: "predicate".to_string(), + redacted_fields: Vec::new(), + format: FORMAT_CLAIM_RESULT_JSON.to_string(), + issued_at: issued_at.to_string(), + expires_at: None, + provenance: ClaimProvenance::new( + "runtime.test".to_string(), + evaluation_id.to_string(), + "registry-fact".to_string(), + "1".to_string(), + ProvenanceUsed { + relay_consultation_count: 1, + }, + ), + }; + let mut evaluation = registry_notary_core::StoredEvaluation { + client_id: "client".to_string(), + purpose: "credential-purpose".to_string(), + claim_ids: vec!["registry-fact".to_string()], + claim_refs: vec![ClaimRef::with_version("registry-fact", "1")], + disclosure: "predicate".to_string(), + format: FORMAT_CLAIM_RESULT_JSON.to_string(), + results: vec![result], + created_at: issued_at.to_string(), + expires_at: "2026-07-17T00:10:00Z".to_string(), + request_hash: "request-hash".to_string(), + issuance_provenance: Some(StoredIssuanceProvenance { + claims: vec![StoredIssuanceClaimProvenance { + claim_id: "registry-fact".to_string(), + claim_version: "1".to_string(), + relay_profile_id: "example.registry-fact.exact".to_string(), + relay_contract_hash: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + canonical_purpose: "credential-purpose".to_string(), + consultation_id: "01J00000000000000000000000".to_string(), + execution_binding: String::new(), + }], + consultations: vec![StoredIssuanceConsultationProvenance { + consultation_id: "01J00000000000000000000000".to_string(), + acquired_at: issued_at.to_string(), + }], + }), + subject_access: None, + }; + let issuance = evaluation + .issuance_provenance + .as_mut() + .expect("private issuance provenance exists"); + issuance.claims[0].execution_binding = issuance_execution_binding( + &issuance.claims[0], + &issuance.consultations[0], + evaluation_id, + issued_at, + &evaluation.results[0].provenance, + ) + .expect("execution binding hashes"); + (evidence, evaluation) + } + + fn assert_not_issuable( + evidence: &EvidenceConfig, + evaluation: ®istry_notary_core::StoredEvaluation, + ) { + assert!(matches!( + require_issuable_evaluation_provenance( + evidence, + "01J00000000000000000000001", + evaluation, + ), + Err(EvidenceError::EvaluationBindingMismatch) + )); + } + + #[test] + fn issuance_provenance_verifier_accepts_only_the_exact_registry_execution_record() { + let (evidence, evaluation) = issuable_registry_evaluation(); + require_issuable_evaluation_provenance( + &evidence, + "01J00000000000000000000001", + &evaluation, + ) + .expect("the exact registry execution record is issuable"); + + let mut tampered = evaluation.clone(); + tampered.issuance_provenance = None; + assert_not_issuable(&evidence, &tampered); + + let mut legacy_wire = serde_json::to_value(&evaluation).expect("evaluation serializes"); + legacy_wire["issuance_provenance"]["claims"][0] + .as_object_mut() + .expect("legacy claim provenance is an object") + .remove("execution_binding"); + let legacy: registry_notary_core::StoredEvaluation = + serde_json::from_value(legacy_wire).expect("legacy evaluation remains readable"); + assert_eq!( + legacy + .issuance_provenance + .as_ref() + .expect("legacy private provenance remains readable") + .claims[0] + .execution_binding, + "" + ); + assert_not_issuable(&evidence, &legacy); + + let mut tampered = evaluation.clone(); + tampered.issuance_provenance.as_mut().unwrap().claims[0].claim_id = + "other-fact".to_string(); + assert_not_issuable(&evidence, &tampered); + + let mut tampered = evaluation.clone(); + tampered.issuance_provenance.as_mut().unwrap().claims[0].claim_version = + "2".to_string(); + assert_not_issuable(&evidence, &tampered); + + let mut tampered = evaluation.clone(); + tampered.issuance_provenance.as_mut().unwrap().claims[0].relay_profile_id = + "example.other.exact".to_string(); + assert_not_issuable(&evidence, &tampered); + + let mut tampered = evaluation.clone(); + tampered.issuance_provenance.as_mut().unwrap().claims[0].relay_contract_hash = + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + .to_string(); + assert_not_issuable(&evidence, &tampered); + + let mut tampered = evaluation.clone(); + tampered.issuance_provenance.as_mut().unwrap().claims[0].canonical_purpose = + "other-purpose".to_string(); + assert_not_issuable(&evidence, &tampered); + + let mut tampered = evaluation.clone(); + tampered.issuance_provenance.as_mut().unwrap().claims[0].consultation_id = + "not-a-ulid".to_string(); + assert_not_issuable(&evidence, &tampered); + + let mut tampered = evaluation.clone(); + tampered + .issuance_provenance + .as_mut() + .unwrap() + .consultations[0] + .acquired_at = "2026-07-17T00:00:01Z".to_string(); + assert_not_issuable(&evidence, &tampered); + + let mut tampered = evaluation.clone(); + tampered.results[0].evaluation_id = "01J00000000000000000000002".to_string(); + assert_not_issuable(&evidence, &tampered); + + let mut tampered = evaluation.clone(); + tampered.results[0].provenance.used.relay_consultation_count = 0; + assert_not_issuable(&evidence, &tampered); + + let mut tampered = evaluation.clone(); + let duplicate = tampered + .issuance_provenance + .as_ref() + .unwrap() + .claims[0] + .clone(); + tampered + .issuance_provenance + .as_mut() + .unwrap() + .claims + .push(duplicate); + assert_not_issuable(&evidence, &tampered); + } + + #[test] + fn relationship_proof_count_cannot_make_a_source_free_root_issuable() { + let (evidence, evaluation) = issuable_registry_evaluation(); + let mut source_free = (*evidence).clone(); + source_free.claims[0].evidence_mode = ClaimEvidenceMode::SelfAttested; + source_free.claims[0].rule = RuleConfig::Cel { + expression: "true".to_string(), + bindings: Default::default(), + }; + + assert_eq!( + evaluation.results[0] + .provenance + .used + .relay_consultation_count, + 1, + "the public count models a delegated relationship proof" + ); + assert_not_issuable(&source_free, &evaluation); + } diff --git a/crates/registry-notary-server/src/runtime/tests/support.rs b/crates/registry-notary-server/src/runtime/tests/support.rs index ce60c3775..7ea817ac8 100644 --- a/crates/registry-notary-server/src/runtime/tests/support.rs +++ b/crates/registry-notary-server/src/runtime/tests/support.rs @@ -68,6 +68,7 @@ fn test_claim_result( }, ), relay_consultation_ids: BTreeSet::new(), + own_issuance_provenance: None, } } diff --git a/crates/registry-notary-server/src/runtime/types.rs b/crates/registry-notary-server/src/runtime/types.rs index 288f7475c..6b367c9a0 100644 --- a/crates/registry-notary-server/src/runtime/types.rs +++ b/crates/registry-notary-server/src/runtime/types.rs @@ -17,6 +17,7 @@ pub(super) struct ClaimResultInternal { pub(super) expires_at: Option, pub(super) provenance: ClaimProvenance, pub(super) relay_consultation_ids: BTreeSet, + pub(super) own_issuance_provenance: Option, } impl std::fmt::Debug for ClaimResultInternal { @@ -35,6 +36,23 @@ impl std::fmt::Debug for ClaimResultInternal { .field("expires_at", &self.expires_at) .field("provenance", &self.provenance) .field("relay_consultation_ids", &"[REDACTED]") + .field("own_issuance_provenance", &self.own_issuance_provenance) + .finish() + } +} + +#[derive(Clone)] +pub(super) struct ClaimIssuanceProvenanceInternal { + pub(super) claim: StoredIssuanceClaimProvenance, + pub(super) consultation: StoredIssuanceConsultationProvenance, +} + +impl std::fmt::Debug for ClaimIssuanceProvenanceInternal { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ClaimIssuanceProvenanceInternal") + .field("claim", &self.claim) + .field("consultation", &self.consultation) .finish() } } diff --git a/crates/registry-notary-server/src/standalone/assembly.rs b/crates/registry-notary-server/src/standalone/assembly.rs index 343bccbf6..4f0a86ba7 100644 --- a/crates/registry-notary-server/src/standalone/assembly.rs +++ b/crates/registry-notary-server/src/standalone/assembly.rs @@ -172,6 +172,28 @@ pub(super) async fn standalone_router_for_gate_test( } } +#[cfg(test)] +pub(super) async fn standalone_router_with_ready_relay_for_gate_test( + config: StandaloneRegistryNotaryConfig, + activated: Arc, +) -> Result { + let admin_listener_mode = config.server.admin_listener.mode; + let mut runtime = compile_notary_runtime_for_gate_test(config)?; + runtime + .api_state + .install_activated_relay(activated) + .map_err(|_| StandaloneServerError::RelayAlreadyActivated)?; + runtime.verify_retained_audit_chain().await; + match admin_listener_mode { + RegistryNotaryAdminListenerMode::SharedWithPublic => { + notary_shared_router_from_runtime(runtime) + } + RegistryNotaryAdminListenerMode::Dedicated | RegistryNotaryAdminListenerMode::Disabled => { + Ok(notary_routers_from_runtime(runtime)?.public) + } + } +} + #[cfg(test)] pub(super) fn compile_notary_runtime_with_provenance_for_gate_test( config: StandaloneRegistryNotaryConfig, diff --git a/crates/registry-notary-server/src/standalone/tests/deployment_gates.rs b/crates/registry-notary-server/src/standalone/tests/deployment_gates.rs index 56a5129ee..37542f123 100644 --- a/crates/registry-notary-server/src/standalone/tests/deployment_gates.rs +++ b/crates/registry-notary-server/src/standalone/tests/deployment_gates.rs @@ -11,6 +11,7 @@ use super::super::{ compile_notary_runtime_with_provenance_for_gate_test as compile_notary_runtime_with_provenance, notary_routers_from_runtime, notary_shared_router_from_runtime, standalone_router_for_gate_test as standalone_router, StandaloneServerError, + standalone_router_with_ready_relay_for_gate_test, }; use axum::http::StatusCode; use axum_test::TestServer; @@ -22,7 +23,34 @@ use registry_platform_authcommon::{CredentialFingerprintProvider, CredentialFing use registry_platform_ops::ConfigSource; use registry_platform_testing::fixtures::ED25519_PRIVATE_JWK; use serde_json::{json, Value}; -use std::path::Path; +use std::{path::Path, sync::Arc}; + +#[derive(Debug)] +struct GateReadyRelay; + +#[async_trait::async_trait] +impl crate::runtime::ActivatedRelayConsultations for GateReadyRelay { + async fn check_ready(&self) -> Result<(), crate::relay_client::RelayClientError> { + Ok(()) + } + + fn validate( + &self, + _key: &crate::runtime::consultation::ConsultationGroupKeyV1, + ) -> Result<(), crate::relay_client::RelayClientError> { + Ok(()) + } + + async fn execute( + &self, + _key: &crate::runtime::consultation::ConsultationGroupKeyV1, + ) -> Result< + crate::runtime::consultation::RuntimeRelayConsultationResult, + crate::relay_client::RelayClientError, + > { + Err(crate::relay_client::RelayClientError::TransportUnavailable) + } +} const AUDIT_SECRET: &str = "0123456789abcdef0123456789abcdef"; static SHIPPING_CURSOR_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); @@ -208,12 +236,78 @@ impl ConfigBuilder { } } - fn claim_credential_profiles(&self) -> &'static str { + fn relay_section(&self) -> String { + if !self.credential_signer { + return String::new(); + } + let token_path = Path::new(&self.audit_path) + .with_file_name("gates-relay.jwt") + .display() + .to_string(); + format!( + concat!( + " relay:\n", + " base_url: http://127.0.0.1:1\n", + " workload_client_id: registry-notary\n", + " token_file: \"{}\"\n", + " allow_insecure_localhost: true\n", + ), + token_path, + ) + } + + fn claim_section(&self) -> &'static str { if self.credential_signer { - " credential_profiles:\n - gates_sd_jwt\n" - } else { - "" + return concat!( + " - id: farmed-land-size\n", + " title: Farmed land size\n", + " version: 2026-05\n", + " subject_type: person\n", + " evidence_mode:\n", + " type: registry_backed\n", + " consultations:\n", + " farmed_land:\n", + " profile:\n", + " id: example.farmed-land-size.exact\n", + " contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", + " inputs:\n", + " subject_id: target.id\n", + " outputs:\n", + " active: { type: boolean, nullable: false }\n", + " purpose: test\n", + " required_scopes: [farmer_registry:evidence_verification]\n", + " value:\n", + " type: boolean\n", + " rule:\n", + " type: consultation_matched\n", + " consultation: farmed_land\n", + " disclosure:\n", + " default: value\n", + " allowed: [value, redacted]\n", + " formats:\n", + " - application/vnd.registry-notary.claim-result+json\n", + " credential_profiles:\n", + " - gates_sd_jwt\n", + ); } + concat!( + " - id: farmed-land-size\n", + " title: Farmed land size\n", + " version: 2026-05\n", + " subject_type: person\n", + " evidence_mode:\n", + " type: self_attested\n", + " value:\n", + " type: boolean\n", + " rule:\n", + " type: cel\n", + " expression: \"true\"\n", + " disclosure:\n", + " default: value\n", + " allowed: [value, redacted]\n", + " formats:\n", + " - application/vnd.registry-notary.claim-result+json\n", + ) } fn federation_section(&self) -> &'static str { @@ -274,31 +368,16 @@ impl ConfigBuilder { enabled: true service_id: evidence.test api_base_url: https://evidence.example.test -{signing} +{relay}{signing} claims: - - id: farmed-land-size - title: Farmed land size - version: 2026-05 - subject_type: person - evidence_mode: - type: self_attested - value: - type: boolean - rule: - type: cel - expression: "true" - disclosure: - default: value - allowed: [value, redacted] - formats: - - application/vnd.registry-notary.claim-result+json -{claim_credential_profiles} +{claim} {deployment}{federation}"#, server = self.server_section(), state = state, audit = self.audit_section(), + relay = self.relay_section(), signing = self.signing_section(), - claim_credential_profiles = self.claim_credential_profiles(), + claim = self.claim_section(), deployment = self.deployment_block, federation = self.federation_section(), ); @@ -688,7 +767,9 @@ async fn production_unapproved_local_signer_reports_readiness_failure() { .deployment("deployment:\n profile: production\n") .build(); - let app = standalone_router(config).await.expect("production local signer config still starts"); + let app = standalone_router_with_ready_relay_for_gate_test(config, Arc::new(GateReadyRelay)) + .await + .expect("production local signer config still starts"); let server = TestServer::builder().http_transport().build(app); let ready = server.get("/ready").await; ready.assert_status(StatusCode::SERVICE_UNAVAILABLE); @@ -727,7 +808,9 @@ async fn production_file_watch_signer_requires_custody_approval() { .deployment("deployment:\n profile: production\n") .build(); - let app = standalone_router(config).await.expect("production file-watch signer config starts"); + let app = standalone_router_with_ready_relay_for_gate_test(config, Arc::new(GateReadyRelay)) + .await + .expect("production file-watch signer config starts"); let server = TestServer::builder().http_transport().build(app); let ready = server.get("/ready").await; ready.assert_status(StatusCode::SERVICE_UNAVAILABLE); @@ -781,7 +864,9 @@ async fn production_signer_custody_approval_is_visible_in_readiness() { ) .build(); - let app = standalone_router(config).await.expect("approved local signer config starts"); + let app = standalone_router_with_ready_relay_for_gate_test(config, Arc::new(GateReadyRelay)) + .await + .expect("approved local signer config starts"); let server = TestServer::builder().http_transport().build(app); let ready = server.get("/ready").await; ready.assert_status_ok(); diff --git a/crates/registry-notary-server/tests/standalone_http/credentials.rs b/crates/registry-notary-server/tests/standalone_http/credentials.rs index 6eab011ad..c501ebcd1 100644 --- a/crates/registry-notary-server/tests/standalone_http/credentials.rs +++ b/crates/registry-notary-server/tests/standalone_http/credentials.rs @@ -6,6 +6,98 @@ use super::{ admin::*, audit::*, auth::*, federation::*, http_contracts::*, oid4vci::*, preauth::*, }; +#[tokio::test] +#[cfg(feature = "registry-notary-cel")] +pub(super) async fn direct_registry_dependency_journey_evaluates_and_issues_over_http() { + 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_oidc_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 mut dependency = config.evidence.claims[0].clone(); + dependency.id = "civil-record-active".to_string(); + dependency.title = "Civil record active".to_string(); + dependency.depends_on.clear(); + dependency.credential_profiles.clear(); + config.evidence.claims[0].depends_on = vec![dependency.id.clone()]; + config.evidence.claims.push(dependency); + + let app = standalone_router(config) + .await + .expect("validated config activates the HTTP Relay adapter"); + let server = TestServer::builder().http_transport().build(app); + 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, + })); + let authorization = format!("Bearer {token}"); + let evaluate = server + .post("/v1/evaluations") + .add_header("authorization", authorization.clone()) + .json(&json!({ + "target": person_identifier_target("national_id", "person-1"), + "claims": ["person-is-alive"], + "disclosure": "value", + "format": "application/vnd.registry-notary.claim-result+json" + })) + .await; + evaluate.assert_status_ok(); + let evaluated: Value = evaluate.json(); + assert_eq!(evaluated["results"][0]["value"], json!(true)); + assert_eq!( + evaluated["results"][0]["provenance"]["used"]["relay_consultation_count"], + json!(1), + "the root and dependency share one exact Relay execution" + ); + let evaluation_id = evaluated["results"][0]["evaluation_id"] + .as_str() + .expect("evaluation id returned") + .to_string(); + let holder_id = holder_did_jwk(); + let proof = + sign_direct_holder_proof(&holder_id, &evaluation_id, "registry-dependency-http-jti-1"); + let issue = server + .post("/v1/credentials") + .add_header("authorization", authorization) + .json(&json!({ + "evaluation_id": evaluation_id, + "credential_profile": "civil_status_sd_jwt", + "format": "application/dc+sd-jwt", + "claims": ["person-is-alive"], + "disclosure": "value", + "holder": { + "binding": "did", + "id": holder_id, + "proof": proof + } + })) + .await; + issue.assert_status_ok(); + let issued: Value = issue.json(); + assert_eq!(issued["credential_profile"], json!("civil_status_sd_jwt")); + assert!(issued["credential"] + .as_str() + .is_some_and(|credential| credential.contains('~'))); + + idp.stop().await; +} + #[tokio::test] pub(super) async fn direct_credential_pre_evaluation_denials_are_audited_and_redacted() { set_audit_secret(); diff --git a/crates/registry-notary-server/tests/standalone_http/federation.rs b/crates/registry-notary-server/tests/standalone_http/federation.rs index 5705d8758..90a1c655c 100644 --- a/crates/registry-notary-server/tests/standalone_http/federation.rs +++ b/crates/registry-notary-server/tests/standalone_http/federation.rs @@ -243,7 +243,7 @@ pub(super) fn audit_records(path: &std::path::Path) -> Vec { } pub(super) fn subject_access_oidc_config( - _base_url: &str, + base_url: &str, audit_path: &str, issuer: &str, jwks_uri: &str, @@ -258,6 +258,7 @@ state: server: bind: 127.0.0.1:0 + request_timeout: 30s auth: oidc: issuer: "{issuer}" @@ -286,6 +287,11 @@ evidence: enabled: true service_id: evidence.test api_base_url: https://evidence.example.test + relay: + base_url: "{base_url}" + workload_client_id: registry-notary + token_file: "{audit_path}.relay-token" + allow_insecure_localhost: true signing_keys: issuer-key: provider: local_jwk_env @@ -316,13 +322,26 @@ evidence: version: 2026-05 subject_type: person evidence_mode: - type: self_attested + type: registry_backed + consultations: + person_status: + profile: + id: {TEST_RELAY_PROFILE_ID} + contract_hash: {contract_hash} + inputs: + subject_id: request.target.identifiers.national_id + outputs: + active: {{ type: boolean, nullable: true }} + birth_date: {{ type: date, nullable: true }} + given_name: {{ type: string, nullable: true, max_bytes: 64 }} purpose: citizen_subject_access + required_scopes: + - subject_access value: type: boolean rule: - type: cel - expression: "true" + type: consultation_matched + consultation: person_status disclosure: default: value allowed: [value, redacted] @@ -372,7 +391,8 @@ subject_access: subject_mismatch_per_principal_per_hour: 5 per_holder_per_hour: 10 credential_issuance_per_principal_per_hour: 5 -"# +"#, + contract_hash = test_relay_contract_hash(), ); serde_norway::from_str(&raw).expect("subject-access config deserializes") } @@ -441,14 +461,10 @@ pub(super) fn add_subject_access_projection_claim( claim.id = claim_id.to_string(); claim.title = title.to_string(); claim.value.value_type = value_type.to_string(); - claim.rule = RuleConfig::Cel { - expression: match output_name { - "given_name" => "'Miguel'", - "birth_date" => "'2016-01-15'", - _ => panic!("unsupported self-attested projection output: {output_name}"), - } - .to_string(), - bindings: Default::default(), + claim.value.nullable = true; + claim.rule = RuleConfig::ConsultationOutput { + consultation: "person_status".to_string(), + output: output_name.to_string(), }; claim.formats = vec!["application/vnd.registry-notary.claim-result+json".to_string()]; claim.credential_profiles = vec!["civil_status_sd_jwt".to_string()]; diff --git a/crates/registry-notary-server/tests/standalone_http/oid4vci.rs b/crates/registry-notary-server/tests/standalone_http/oid4vci.rs index 74cc0c5cb..328804d53 100644 --- a/crates/registry-notary-server/tests/standalone_http/oid4vci.rs +++ b/crates/registry-notary-server/tests/standalone_http/oid4vci.rs @@ -949,14 +949,36 @@ pub(super) async fn admin_scope_is_instance_global_across_credential_profiles() std::env::set_var("TEST_SELF_ATTESTATION_ISSUER_JWK", TEST_ISSUER_JWK); std::env::set_var("TEST_SELF_ATTESTATION_ISSUER_JWK_2", TEST_HOLDER_JWK); + let idp = MockIdp::start().await; let tmp = TempDir::new().expect("tempdir"); let audit_path = tmp.path().join("audit.jsonl"); - let mut config = notary_only_config( + let mut config = subject_access_oidc_config( "http://127.0.0.1:1", audit_path.to_str().expect("audit path is UTF-8"), + &idp.issuer(), + &idp.jwks_uri(), ); enable_shared_admin_listener(&mut config); enable_credential_status(&mut config); + config.auth.api_keys.push(EvidenceCredentialConfig { + id: "caseworker".to_string(), + fingerprint: env_fingerprint_ref("TEST_EVIDENCE_API_KEY_HASH"), + scopes: vec!["farmer_registry:evidence_verification".to_string()], + authorization_details: None, + }); + let subject_access_holder_binding = config + .evidence + .credential_profiles + .get("civil_status_sd_jwt") + .expect("subject-access credential profile exists") + .holder_binding + .clone(); + config.evidence.credential_profiles.clear(); + config.evidence.signing_keys.remove("issuer-key"); + config.subject_access.credential_profiles = vec![ + "issuer_one_sd_jwt".to_string(), + "issuer_two_sd_jwt".to_string(), + ]; config.auth.api_keys.push(EvidenceCredentialConfig { id: "admin".to_string(), fingerprint: env_fingerprint_ref("TEST_EVIDENCE_ADMIN_KEY_HASH"), @@ -988,8 +1010,8 @@ pub(super) async fn admin_scope_is_instance_global_across_credential_profiles() signing_key: "issuer-one-key".to_string(), vct: "http://127.0.0.1:4325/credentials/issuer-one".to_string(), validity_seconds: 600, - holder_binding: Default::default(), - allowed_claims: vec!["farmed-land-size".to_string()], + holder_binding: subject_access_holder_binding.clone(), + allowed_claims: vec!["person-is-alive".to_string()], disclosure: Default::default(), }, ); @@ -1001,11 +1023,21 @@ pub(super) async fn admin_scope_is_instance_global_across_credential_profiles() signing_key: "issuer-two-key".to_string(), vct: "http://127.0.0.1:4325/credentials/issuer-two".to_string(), validity_seconds: 600, - holder_binding: Default::default(), - allowed_claims: vec!["farmed-land-size".to_string()], + holder_binding: subject_access_holder_binding, + allowed_claims: vec!["person-is-alive".to_string()], disclosure: Default::default(), }, ); + config + .evidence + .claims + .iter_mut() + .find(|claim| claim.id == "person-is-alive") + .expect("registry-backed credential claim exists") + .credential_profiles = vec![ + "issuer_one_sd_jwt".to_string(), + "issuer_two_sd_jwt".to_string(), + ]; let server = TestServer::builder().http_transport().build( standalone_router(config) diff --git a/crates/registry-notary-server/tests/standalone_http/support.rs b/crates/registry-notary-server/tests/standalone_http/support.rs index d107fe770..739ccb24f 100644 --- a/crates/registry-notary-server/tests/standalone_http/support.rs +++ b/crates/registry-notary-server/tests/standalone_http/support.rs @@ -2,9 +2,10 @@ //! Standalone Registry Notary tests that do not link Registry Relay. pub(super) use axum::body::Bytes; +pub(super) use axum::extract::{Request, State}; pub(super) use axum::http::{header, Method, StatusCode}; pub(super) use axum::routing::get; -pub(super) use axum::Router; +pub(super) use axum::{Json, Router}; pub(super) use axum_test::TestServer; pub(super) use base64::engine::general_purpose::URL_SAFE_NO_PAD; pub(super) use base64::Engine; @@ -20,7 +21,8 @@ pub(super) use registry_notary_core::{ #[cfg(feature = "registry-notary-cel")] pub(super) use registry_notary_core::{Oid4vciCredentialClaimConfig, RuleConfig}; pub(super) use registry_notary_server::{ - compile_notary_runtime, notary_routers_from_runtime, openapi_document, standalone_router, + compile_notary_runtime, notary_routers_from_runtime, notary_shared_router_from_runtime, + openapi_document, StandaloneServerError, }; pub(super) use registry_platform_audit::{ verify_jsonl_lines_with_hasher, AuditChainHasher, AuditEnvelope, @@ -28,16 +30,15 @@ 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::{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, }; pub(super) use serde::Deserialize; pub(super) use serde_json::{json, Value}; -#[cfg(feature = "registry-notary-cel")] pub(super) use sha2::{Digest, Sha256}; pub(super) use std::collections::BTreeMap; pub(super) use std::collections::BTreeSet; @@ -53,6 +54,179 @@ 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_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"; + +#[derive(Clone)] +struct TestRelayState { + contract: Value, + contract_hash: String, +} + +pub(super) fn test_relay_contract() -> Value { + let mut contract: Value = serde_json::from_str(include_str!( + "../../../registry-relay/profiles/dhis2-2.41.9-enrollment-status/public-contract.json" + )) + .expect("test Relay contract parses"); + contract["id"] = json!(TEST_RELAY_PROFILE_ID); + contract["spec"]["inputs"] = json!({ + "subject_id": { + "role": "selector", + "type": "string", + "maxLength": 256, + "x-registry-max-bytes": 256, + "x-registry-canonicalization": "identity" + } + }); + contract["spec"]["authorization"]["required_scope"] = json!("subject_access"); + contract["spec"]["authorization"]["purposes"] = json!(["citizen_subject_access"]); + contract["spec"]["acquisition"]["fields"] = json!({ + "active": { "type": "boolean", "nullable": true }, + "birth_date": { "type": "date", "nullable": true }, + "given_name": { "type": "string", "nullable": true, "max_bytes": 64 } + }); + contract["spec"]["output"] = json!({ + "active": { "type": "boolean", "nullable": true }, + "birth_date": { "type": "date", "nullable": true }, + "given_name": { "type": "string", "nullable": true, "max_bytes": 64 } + }); + contract +} + +pub(super) fn test_relay_contract_hash() -> String { + let canonical = canonicalize_json(&test_relay_contract()).expect("contract canonicalizes"); + let mut hasher = Sha256::new(); + hasher.update(TEST_RELAY_CONTRACT_DOMAIN); + hasher.update(canonical); + let mut encoded = String::from("sha256:"); + for byte in hasher.finalize() { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}").expect("hash encoding cannot fail"); + } + encoded +} + +async fn test_relay_metadata(State(state): State) -> Json { + Json(json!({ + "contract_hash": state.contract_hash, + "contract": state.contract, + })) +} + +async fn test_relay_execute(State(state): State, request: Request) -> Json { + let evaluation_id = request + .headers() + .get("registry-notary-evaluation-id") + .and_then(|value| value.to_str().ok()) + .expect("Notary sends a canonical evaluation id") + .to_string(); + let acquired_at = registry_notary_server::format_time(OffsetDateTime::now_utc()); + Json(json!({ + "schema": "registry.relay.consultation-result.v1", + "consultation_id": Ulid::new().to_string(), + "notary_evaluation_id": evaluation_id, + "profile": { + "id": TEST_RELAY_PROFILE_ID, + "contract_hash": state.contract_hash, + }, + "outcome": "match", + "outputs": { + "active": true, + "birth_date": "2016-01-15", + "given_name": "Miguel", + }, + "provenance": { + "acquired_at": acquired_at, + "source_observed_at": null, + "source_revision": null, + "acquisition_class": "source_projected_exact", + "integration": { + "id": "dhis2.tracker.enrollment-status", + "revision": 1 + }, + "consent": { + "outcome": "not_required", + "verifier_id": null, + "verifier_revision": null, + "checked_at": null, + "expires_at": null, + "revocation_status": "not_applicable" + } + } + })) +} + +async fn start_test_relay() -> String { + let state = TestRelayState { + contract: test_relay_contract(), + contract_hash: test_relay_contract_hash(), + }; + let app = Router::new() + .route( + "/v1/consultations/example.person-status.exact", + get(test_relay_metadata), + ) + .route( + "/v1/consultations/example.person-status.exact/execute", + axum::routing::post(test_relay_execute), + ) + .with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("test Relay binds"); + let address = listener.local_addr().expect("test Relay has an address"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test Relay serves"); + }); + format!("http://{address}/") +} + +fn test_relay_token() -> String { + let header = json!({ + "alg": "RS256", + "kid": "relay-test-key", + "typ": "at+jwt", + "x5t": "relay-test-thumbprint", + }); + let claims = json!({ + "iss": "https://issuer.example.test", + "aud": ["registry-relay"], + "sub": "registry-notary", + "azp": "registry-notary", + "client_id": "registry-notary", + "scope": "subject_access", + "iat": 1_700_000_000_i64, + "nbf": 1_700_000_000_i64, + "exp": 4_102_444_800_i64, + "jti": "standalone-http-relay-test-token", + }); + format!( + "{}.{}.{}", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header serializes")), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims serialize")), + URL_SAFE_NO_PAD.encode(b"relay-test-signature") + ) +} + +pub(super) async fn standalone_router( + mut config: StandaloneRegistryNotaryConfig, +) -> Result { + let admin_listener_mode = config.server.admin_listener.mode; + if let Some(relay) = config.evidence.relay.as_mut() { + std::fs::write(&relay.token_file, test_relay_token()) + .expect("test Relay workload token writes"); + relay.base_url = start_test_relay().await; + } + let runtime = compile_notary_runtime(config)?.activate().await?; + match admin_listener_mode { + RegistryNotaryAdminListenerMode::SharedWithPublic => { + notary_shared_router_from_runtime(runtime) + } + RegistryNotaryAdminListenerMode::Dedicated | RegistryNotaryAdminListenerMode::Disabled => { + Ok(notary_routers_from_runtime(runtime)?.public) + } + } +} #[derive(Debug, Deserialize)] pub(super) struct ExposureManifest { pub(super) endpoints: Vec, 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 6e162c46c..ff2ef8138 100644 --- a/crates/registry-notary-server/tests/subject_access_guard_test.rs +++ b/crates/registry-notary-server/tests/subject_access_guard_test.rs @@ -458,20 +458,6 @@ fn sign_holder_proof(holder_id: &str, evaluation_id: &str) -> String { format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) } -#[cfg(feature = "registry-notary-cel")] -fn jwt_payload(jwt: &str) -> Value { - let payload = jwt - .split('.') - .nth(1) - .expect("compact JWT has a payload segment"); - serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(payload) - .expect("payload decodes as base64url"), - ) - .expect("payload decodes as JSON") -} - #[tokio::test] async fn subject_access_discovery_details_require_subject_access_principal() { let store = Arc::new(EvidenceStore::default()); @@ -724,7 +710,7 @@ async fn subject_access_render_rejects_expired_metadata_via_http() { #[tokio::test] #[cfg(feature = "registry-notary-cel")] -async fn subject_access_credential_issuance_requires_holder_proof_and_hides_civil_id() { +async fn subject_access_credential_issuance_requires_holder_proof_then_registry_provenance() { let store = Arc::new(EvidenceStore::default()); let server = build_issuance_server( credential_issuance_config(), @@ -782,44 +768,13 @@ async fn subject_access_credential_issuance_requires_holder_proof_and_hides_civi } })) .await; - issued.assert_status_ok(); + issued.assert_status(StatusCode::FORBIDDEN); let issued_body: Value = issued.json(); - let payload = jwt_payload( - issued_body["issuer_signed_jwt"] - .as_str() - .expect("issuer-signed JWT is returned"), - ); - - assert_eq!(payload["sub"], json!(holder_id)); + assert_eq!(issued_body["code"], json!("evaluation.binding_mismatch")); assert!( - !payload.to_string().contains(SUBJECT_ID), - "issuer-signed payload must not expose the raw civil id" + !issued_body.to_string().contains(SUBJECT_ID), + "source-free issuance denial must not expose the raw civil id" ); - let iat = payload["iat"].as_i64().expect("iat is an integer"); - let exp = payload["exp"].as_i64().expect("exp is an integer"); - assert!( - exp - iat <= 600, - "subject-access credential validity must not exceed 600 seconds" - ); - - let replay = server - .post("/v1/credentials") - .json(&json!({ - "evaluation_id": evaluation_id, - "credential_profile": "civil_status_sd_jwt", - "format": FORMAT_SD_JWT_VC, - "claims": ["person-is-alive"], - "disclosure": "redacted", - "holder": { - "binding": "did", - "id": holder_id, - "proof": proof - } - })) - .await; - replay.assert_status(StatusCode::CONFLICT); - let replay_body: Value = replay.json(); - assert_eq!(replay_body["code"], json!("credential.holder_proof_replay")); } #[tokio::test] diff --git a/crates/registry-notary/tests/doctor_cli.rs b/crates/registry-notary/tests/doctor_cli.rs index a82b78920..ebe77f0aa 100644 --- a/crates/registry-notary/tests/doctor_cli.rs +++ b/crates/registry-notary/tests/doctor_cli.rs @@ -103,6 +103,20 @@ fn write_config_with_options(tmp: &TempDir, options: TestConfigOptions<'_>) -> P "# .to_string() }; + let relay = if options.unbound_credential_profile { + let token_file = tmp.path().join("relay.jwt"); + std::fs::write(&token_file, b"opaque-test-token").expect("Relay token file writes"); + format!( + r#" relay: + base_url: https://relay.internal.example + workload_client_id: registry-notary + token_file: {} +"#, + token_file.display() + ) + } else { + String::new() + }; let credential_profiles = if options.unbound_credential_profile { r#" credential_profiles: unbound_sd_jwt: @@ -127,10 +141,24 @@ fn write_config_with_options(tmp: &TempDir, options: TestConfigOptions<'_>) -> P version: "1.0" subject_type: person evidence_mode: - type: self_attested + type: registry_backed + consultations: + person_status: + profile: + id: doctor.person-status.exact + contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + inputs: + subject_id: target.id + outputs: + active: { type: boolean, nullable: true } + purpose: credential-issuance-test + required_scopes: + - registry:evidence + value: + type: boolean rule: - type: cel - expression: "true" + type: consultation_matched + consultation: person_status formats: - application/vnd.registry-notary.claim-result+json credential_profiles: @@ -159,7 +187,7 @@ server: {deployment}{state}{audit}evidence: enabled: true service_id: doctor-json-test - signing_keys: +{relay} signing_keys: issuer: provider: local_jwk_env private_jwk_env: TEST_DOCTOR_JSON_ISSUER_JWK diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index aa7909581..a22b5831b 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -272,7 +272,6 @@ export default defineConfig({ { label: 'When to use', slug: 'start/when-to-use' }, { label: 'Run Solmara Lab', slug: 'tutorials/first-run-with-solmara-lab' }, { label: 'Hosted Relay demo (held)', slug: 'start/quickstart' }, - { label: 'Hosted credential tour (held)', slug: 'start/credential-tour' }, ], }, { diff --git a/docs/site/public/images/registry-architecture-flow.svg b/docs/site/public/images/registry-architecture-flow.svg index fb8be6cf1..9bc1f51f8 100644 --- a/docs/site/public/images/registry-architecture-flow.svg +++ b/docs/site/public/images/registry-architecture-flow.svg @@ -2,7 +2,7 @@ font-family="'Public Sans', system-ui, -apple-system, BlinkMacSystemFont, sans-serif" text-rendering="geometricPrecision"> Registry architecture flow - Registry Platform provides shared primitives to Relay and Notary. Registry Manifest produces portable metadata contracts. Registry Relay owns registry source access, product-neutral adaptation, protected APIs, and typed consultations. Registry Notary evaluates Relay-backed or self-attested claims and issues credentials. The external Solmara Lab adopter demo runs published runtime images together. + Registry Platform provides shared primitives to Relay and Notary. Registry Manifest produces portable metadata contracts. Registry Relay owns registry source access, product-neutral adaptation, protected APIs, and typed consultations. Registry Notary evaluates Relay-backed or self-attested claims and issues credentials only from exact Relay-backed evaluation provenance. The external Solmara Lab adopter demo runs published runtime images together. SHARED PRIMITIVES @@ -60,8 +60,8 @@ Evaluates Relay-backed or self-attested claims. - Issues SD-JWT VCs and - CCCEV evidence. + Issues VCs only from exact + Relay-backed provenance. diff --git a/docs/site/public/images/registry-claim-model.svg b/docs/site/public/images/registry-claim-model.svg index 345a3b091..0a3414aeb 100644 --- a/docs/site/public/images/registry-claim-model.svg +++ b/docs/site/public/images/registry-claim-model.svg @@ -2,7 +2,7 @@ font-family="'Public Sans', system-ui, -apple-system, BlinkMacSystemFont, sans-serif" text-rendering="geometricPrecision"> Claim model: five configured stages - A Registry Notary claim evaluation moves through five configured stages in order. (1) Typed request inputs from the caller. (2) A compiler-pinned Registry Relay consultation, or permitted self-attestation. (3) Rule evaluation from closed inputs. (4) Disclosure mode: value, predicate, or redacted. (5) Evaluation render format: claim-result JSON or CCCEV JSON-LD. Eligible stored evaluations can later be materialized as SD-JWT VC credentials. + A Registry Notary claim evaluation moves through five configured stages in order. (1) Typed request inputs from the caller. (2) A compiler-pinned Registry Relay consultation, or permitted self-attestation. (3) Rule evaluation from closed inputs. (4) Disclosure mode: value, predicate, or redacted. (5) Evaluation render format: claim-result JSON or CCCEV JSON-LD. Only registry-backed stored evaluations with exact Relay execution provenance can later be materialized as SD-JWT VC credentials. CLAIM MODEL · FIVE STAGES IN ORDER diff --git a/docs/site/src/content/docs/explanation/architecture.mdx b/docs/site/src/content/docs/explanation/architecture.mdx index 75ce97116..0c900886f 100644 --- a/docs/site/src/content/docs/explanation/architecture.mdx +++ b/docs/site/src/content/docs/explanation/architecture.mdx @@ -58,7 +58,8 @@ updated policy documents without touching deployment config. Architecture flow: Registry Platform provides shared primitives. Registry Manifest compiles
             metadata artifacts. Registry Relay binds those artifacts to protected runtime sources.
-            Registry Notary evaluates Relay-backed or self-attested claims and issues credentials.
+            Registry Notary evaluates Relay-backed or self-attested claims and issues credentials
+            only from exact Relay-backed evaluation provenance.
             The separate Solmara Lab adopter project wires published runtime images into demo
             topologies. 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 df2f52d60..4601657ec 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 @@ -86,6 +86,17 @@ anything about the person. When the system issues a credential rather than returning a direct answer, minimization continues at presentation time. +Issuance also requires one exact compiler pin for every registry-backed claim +in each selected root's dependency closure, plus one normalized execution +record per unique Relay consultation ULID. Notary retains those private Relay +ids and acquisition times only when all selected roots share a mutually +validated credential profile; registry-backed evaluation-only claims retain no +private issuance provenance. A deterministic unkeyed SHA-256 binding detects +partial mutation between a claim pin, execution record, and claim provenance, +but is not an authenticity anchor against an operator able to rewrite all +committed fields and the digest. A source-free, +delegated, or legacy evaluation cannot cross the +credential trust boundary. Issued credentials are SD-JWT VC: a verifiable-credential format in which the signed body carries only a SHA-256 digest of each selectively disclosable field, so a field the holder does not present stays hidden, and the holder controls what is revealed. diff --git a/docs/site/src/content/docs/explanation/dpi-safeguards-alignment.mdx b/docs/site/src/content/docs/explanation/dpi-safeguards-alignment.mdx index c75140ee9..b46c2920f 100644 --- a/docs/site/src/content/docs/explanation/dpi-safeguards-alignment.mdx +++ b/docs/site/src/content/docs/explanation/dpi-safeguards-alignment.mdx @@ -84,7 +84,7 @@ framework. | --- | --- | --- | | [Registry Manifest](../../products/registry-manifest/) | Validates `metadata.yaml`, rejects known runtime-only keys, and renders standards-shaped metadata: Data Catalog Vocabulary (DCAT), BRegDCAT-AP, Core Public Service Vocabulary Application Profile (CPSV-AP), Shapes Constraint Language (SHACL), JSON Schema, Open Digital Rights Language (ODRL), Core Criterion and Core Evidence Vocabulary (CCCEV), Open Geospatial Consortium (OGC) API Records, evidence offerings, and SKOS-shaped codelists. | Manifest does not serve HTTP, read production data, authorize callers, or evaluate claims. See the [Registry Manifest data model](../../spec/rs-dm-manifest/). | | [Registry Relay](../../products/registry-relay/) | Exposes protected, read-only, domain-oriented registry routes for records, relationships, schemas, aggregates, scoped metadata, and governed PDP reads, plus emitted audit events. | Relay does not mutate source data, expose arbitrary SQL, evaluate claims, issue credentials, host issuer DID documents, or put admin routes on the public API. See the [Registry Relay protocol](../../spec/rs-pr-relay/). | -| [Registry Notary](../../products/registry-notary/) | Evaluates configured claims from compiler-pinned Relay consultations or permitted self-attestation, applies disclosure policy, renders claim results, issues SD-JWT VC credentials from stored evaluations, and supports static-peer delegated evaluation. | Notary does not access registry sources directly, decide eligibility, run a wallet, certify source truth, or provide open federation. See the [Registry Notary protocol](../../spec/rs-pr-notary/). | +| [Registry Notary](../../products/registry-notary/) | Evaluates configured claims from compiler-pinned Relay consultations or permitted self-attestation, applies disclosure policy, renders claim results, issues SD-JWT VC credentials only from stored exact non-delegated Relay-backed dependency-closure provenance, and supports static-peer delegated evaluation. | Notary does not issue from source-free or delegated claims, access registry sources directly, decide eligibility, run a wallet, certify source truth, or provide open federation. See the [Registry Notary protocol](../../spec/rs-pr-notary/). | | Registry Platform | Supplies shared primitives for authentication, OpenID Connect (OIDC), audit envelopes, HTTP security, outbound HTTP policy, cryptography, PDP behavior, and SD-JWT VC support. | Platform provides primitives. Relay and Notary configure and enforce their route behavior. See [RS-SEC-G](../../spec/rs-sec-g/). | ## Principle alignment diff --git a/docs/site/src/content/docs/explanation/evidence-issuance.mdx b/docs/site/src/content/docs/explanation/evidence-issuance.mdx index b9d3f0927..c93a60ce0 100644 --- a/docs/site/src/content/docs/explanation/evidence-issuance.mdx +++ b/docs/site/src/content/docs/explanation/evidence-issuance.mdx @@ -35,8 +35,11 @@ shipping the records that justify the answer. Registry Notary is the component t those credentials. In the three-party credential model, Registry Notary is the issuer: it evaluates a configured -claim from compiler-pinned Relay outputs or permitted self-attestation, applies the disclosure -policy, and signs an SD-JWT VC. +registry-backed claim from compiler-pinned Relay outputs, verifies the exact stored Relay execution +provenance and its per-claim execution binding, applies the disclosure policy, +and signs an SD-JWT VC. The unkeyed binding detects partial stored-record +mutation; store access controls remain the authenticity boundary. Permitted self-attestation +without Relay remains evaluation-only. A wallet is the holder: it stores the credential, presents fields selectively, and proves key possession. A verifier is the relying party: it checks the issuer signature, reads the presented fields, and applies its own program policy. What Notary produces is evidence, a signed receipt @@ -47,8 +50,8 @@ to the program, not to Notary.
Registry Notary as issuer in the three-party credential model. Registry Notary
-            evaluates Relay-backed or self-attested claims, applies disclosure policy, and signs an
-            SD-JWT VC. A wallet, the holder, stores the credential, presents selectively,
+            evaluates a Relay-backed claim, verifies its exact execution provenance, applies
+            disclosure policy, and signs an SD-JWT VC. A wallet, the holder, stores the credential, presents selectively,
             and proves key possession. A verifier, the relying party, checks the issuer
             signature, reads presented fields, and applies program policy. Notary produces
             evidence, a signed receipt that a claim was evaluated; the program produces the
@@ -67,12 +70,15 @@ A single claim evaluation moves through five stages.
             (3) Rule evaluation from closed inputs. (4) Disclosure mode:
             value, predicate, or redacted. (5) Evaluation render format: claim-result
             JSON or CCCEV JSON-LD.
-            Eligible stored evaluations can later be materialized as SD-JWT VC credentials. + Eligible registry-backed stored evaluations with exact Relay execution provenance can + later be materialized as SD-JWT VC credentials." />
Registry Stack project authoring makes each stage concrete. An evidence service declares typed request inputs, compiler-pinned consultations, claims over the closed consultation or -self-attestation inputs, disclosure modes, and optional credential profiles. Product and source +self-attestation inputs, disclosure modes, and optional registry-backed credential profiles. +Private Relay execution ids are retained only for selected roots that share one +of those validated profiles. Product and source version labels record interoperability evidence. They do not select a Relay capability or script runtime. @@ -109,6 +115,11 @@ evaluation, or receipt. 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. 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 @@ -120,10 +131,6 @@ evaluation, or receipt. compact signed JWT response. Federation profiles are source-free in this version and cannot select a `registry_backed` claim. This path returns a scoped evaluation result, not a credential. -The [credential tour](../../start/credential-tour/) citizen self-attestation scenario uses path 2: -eSignet provides the access token, Notary evaluates the permitted authenticated context without a -registry source, and a wallet completes the OID4VCI flow. - ## Selective disclosure properties Selective disclosure is the property that distinguishes an SD-JWT VC from a plain JWT. diff --git a/docs/site/src/content/docs/explanation/integration-patterns.mdx b/docs/site/src/content/docs/explanation/integration-patterns.mdx index 6e3b95db7..0a8d46d3b 100644 --- a/docs/site/src/content/docs/explanation/integration-patterns.mdx +++ b/docs/site/src/content/docs/explanation/integration-patterns.mdx @@ -45,7 +45,7 @@ capability, and social protection, agriculture, education, and civil registratio their own authoritative stores. A Registry Stack project can deploy Relay only, Notary only, or both products. Relay-only projects expose governed source capabilities. Notary-only projects evaluate source-free -or self-attested claims. In a combined project, Relay keeps source credentials and returns only +or self-attested claims, but those claims are not issuable. In a combined project, Relay keeps source credentials and returns only reviewed typed consultation outputs for Notary to turn into configured claims. Consuming systems such as a social-protection management information system do not become raw-record readers. @@ -59,8 +59,8 @@ such as a social-protection management information system do not become raw-reco alt="Country evidence mesh: civil registration, clinical health over FHIR API, public health through DHIS2 via a reviewed Rhai script, social protection, agriculture, and education systems keep their source data. Registry Stack projects can deploy Relay - only, source-free Notary only, or both. Relay returns governed typed outputs, combined - projects turn consultation outputs into minimized claims, and the social protection MIS + only, evaluation-only source-free Notary, or both. Relay returns governed typed outputs, + combined projects turn consultation outputs into minimized claims and credentials, and the social protection MIS owns the eligibility decision and reads the registry it owns directly." /> diff --git a/docs/site/src/content/docs/explanation/known-limitations.mdx b/docs/site/src/content/docs/explanation/known-limitations.mdx index 40478292e..8f16af713 100644 --- a/docs/site/src/content/docs/explanation/known-limitations.mdx +++ b/docs/site/src/content/docs/explanation/known-limitations.mdx @@ -108,10 +108,11 @@ Read the full context in the [Registry Notary protocol](../../spec/rs-pr-notary/ 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-attestation issuance is limited to the direct path: The OID4VCI transaction-token path - for delegated attestation is rejected at the credential endpoint. - Direct credential issuance via `/v1/credentials` is supported when the stored evaluation's - access mode is delegated-attestation and the relationship allow-lists the credential profile. +- Delegated self-attestation is evaluation-only: Both `/v1/credentials` and the OID4VCI + credential endpoint reject 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. - No revocation flow, no issuer-metadata discovery endpoint, no erasure workflow: The RS-* specs define no revocation flow. The credential-status surface is optional and off by default; when an operator enables it, the 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 6d7ce375a..9f8bc6621 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 @@ -44,7 +44,8 @@ flowchart LR Relay owns every registry source origin, credential, adaptation recipe, and minimized consultation result. Notary consumes a compiler-pinned Relay consultation for registry-backed claims. A Notary-only deployment evaluates source-free or self-attested claims and has no source -connection. +connection. It cannot issue credentials. Credential issuance requires a combined deployment and a +fresh exact Relay-backed evaluation for every selected claim. ## Before you start 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 fe28fb5f2..19bc892f1 100644 --- a/docs/site/src/content/docs/reference/apis/registry-notary.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-notary.mdx @@ -34,7 +34,8 @@ For exact routes and schemas, read the generated API reference. For setup and op Registry Notary implements the claim-evaluation and credential-issuance parts of the Evidence Gateway pattern. It is a standalone service for claim evaluation, disclosure policy, credential issuance, and audit. A Notary-only deployment supports source-free and self-attested claims. -Registry-backed claims use a combined deployment in which Notary calls Registry Relay. +Those claims are evaluation-only. Registry-backed claims use a combined deployment in which +Notary calls Registry Relay, and only those claims can enter credential issuance. Callers discover its capabilities and claim catalog at `GET /.well-known/evidence-service`. This route requires authentication (the same `x-api-key` or `Authorization: Bearer` token as other evaluation routes); it is not a public discovery endpoint. @@ -75,6 +76,17 @@ Issuance and holder-proof helpers come from Registry Platform; Notary owns the c claim selection, and route workflow. Notary does **not** emit W3C Verifiable Credentials Data Model envelopes: the format is SD-JWT, not a W3C VC JSON object. +Both issuance routes require a newly stored, non-delegated evaluation with +exact compiler pins and normalized unique Relay execution records for every +selected root's registry-backed dependency closure. Each pin has a +deterministic SHA-256 binding to its execution and exact claim provenance; +issuance recomputes it before signer access. This detects partial stored-record +mutation but is not a keyed authenticity anchor. Private execution ids are +stored only for evaluations whose roots share a validated credential profile. +Legacy, source-free, and +delegated evaluations remain renderable but are nonissuable and must be +re-evaluated. + ## CCCEV rendering The render route can return CCCEV-shaped JSON-LD when the caller requests diff --git a/docs/site/src/content/docs/reference/glossary.mdx b/docs/site/src/content/docs/reference/glossary.mdx index 169df5237..c825d8277 100644 --- a/docs/site/src/content/docs/reference/glossary.mdx +++ b/docs/site/src/content/docs/reference/glossary.mdx @@ -64,7 +64,7 @@ Product names are always in English, including on future translated pages.
The Relay feature that maps source field names and values to canonical domain terms using configured CEL expressions. Replaces the former `cel-mapping` name. Enabled by the `crosswalk-runtime` Cargo feature and the `standards-cel-mapping` feature alias. Config key: `crosswalk`.
credential profile
-
Registry Notary configuration that names which claims may be issued as a credential. A Registry Stack project authors claim membership once on the profile, and the compiler derives the product-level claim-to-profile reverse index.
+
Registry Notary configuration that names which non-delegated registry-backed claims may be issued as a credential. Profile and claim bindings are mutually validated. Only evaluations whose selected roots share a profile retain private issuance provenance, and issuance requires exact claim pins, per-claim execution bindings, and unique Relay execution records for every selected root's dependency closure.
deployment bundle
The target generated, signed artifact for one Registry Stack project and environment. Its root manifest binds compatible Relay and Notary submanifests when both are present. The current authoring command builds separate unsigned product inputs, not a deployment bundle; signed project-root bundles are deferred.
@@ -79,7 +79,7 @@ Product names are always in English, including on future translated pages.
Decentralized Identifier. W3C DID Core 1.0. Registry Relay no longer publishes a `did:web` document. Registry Notary parses `did:jwk` values for credential subjects.
delegated self-attestation
-
Registry Notary self-attestation access mode `delegated_attestation`. The authenticated principal is the requester, the request target and scoped authorization details identify the same configured dependent subject, and the dependent claim can continue only after a compiler-pinned Relay relationship proof evaluates to boolean `true`. This is separate from static-peer delegated evaluation under Notary federation.
+
Registry Notary self-attestation access mode `delegated_attestation`. The authenticated principal is the requester, the request target and scoped authorization details identify the same configured dependent subject, and the dependent claim can continue only after a compiler-pinned Relay relationship proof evaluates to boolean `true`. It is evaluation and rendering only in 1.0; direct and OID4VCI credential issuance reject delegated evaluations. This is separate from static-peer delegated evaluation under Notary federation.
disclosure profile
Registry Notary control over what the caller receives: `Value` (full value disclosed), `Predicate` (only true/false satisfaction), or `Redacted` (value fully hidden).
@@ -106,7 +106,7 @@ Product names are always in English, including on future translated pages.
Manifest-level binding that connects an external ecosystem profile or governed evidence pack to a Registry Stack runtime surface. A governed evidence ecosystem binding names the evidence pack metadata and policy identity a runtime service can use for PDP enforcement.
eSignet
-
Open-source identity and authentication service (part of MOSIP). The hosted Solmara Lab demo uses eSignet as the authorization server a citizen signs in to before Registry Notary issues a self-attestation credential.
+
Open-source identity and authentication service (part of MOSIP). Registry Notary can use eSignet as an authorization server for subject-bound evaluation and registry-backed credential flows.
evidence offering
Metadata entry that describes a verification capability and the access path a client uses to reach it. In the current stack it points a client from Registry Relay metadata to Registry Notary or another evidence service; Relay describes the offering but does not evaluate the claim.
@@ -220,7 +220,7 @@ Product names are always in English, including on future translated pages.
Config-driven Rust service that owns registry source access, product-neutral HTTP and script adaptation, immutable snapshot lookups, and protected read-only consultation APIs. Repo slug: `registry-relay`.
Registry Notary
-
Standalone Rust service for Relay-backed or self-attested claim evaluation, disclosure policy, credential issuance, and audit. Repo slug: `registry-notary`.
+
Standalone Rust service for Relay-backed or self-attested claim evaluation, disclosure policy, credential issuance from exact Relay-backed evaluation provenance, and audit. Source-free claims are evaluation-only. Repo slug: `registry-notary`.
Registry Stack project
The authored root for one registry trust domain. A project can describe a Relay-only, Notary-only, or combined deployment and compiles separate product inputs for the selected topology.
@@ -259,7 +259,7 @@ Product names are always in English, including on future translated pages.
Selective Disclosure JWT Verifiable Credential (IETF draft). Registry Notary issues SD-JWT VC credentials signed with EdDSA. 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 or credential issuance. 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.
+
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.
signed response credentials
Removed Registry Relay feature that attached a W3C VCDM 2.0 VC-JWT signed credential to entity record and aggregate responses. Registry Relay no longer accepts `provenance` or entity `publicschema` credential mapping config, 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.
diff --git a/docs/site/src/content/docs/reference/registryctl.mdx b/docs/site/src/content/docs/reference/registryctl.mdx index e1c3d739a..b5d722b22 100644 --- a/docs/site/src/content/docs/reference/registryctl.mdx +++ b/docs/site/src/content/docs/reference/registryctl.mdx @@ -25,7 +25,9 @@ Most human-facing commands also perform a once-per-day update check; see Project authoring commands work on a Registry Stack project containing `registry-stack.yaml`, integration definitions and fixtures, and explicit environment bindings. One project defines one registry trust domain. Its deployment topology can be Relay only, Notary -only, or Relay and Notary together; independent registries use separate projects. +only, or Relay and Notary together; independent registries use separate projects. Notary-only +source-free claims are evaluation-only. Credential issuance requires the combined topology and +exact Relay-backed evaluation provenance. The [Registry Stack project authoring guide](../../tutorials/author-registry-project/) covers the complete workflow and generated trust boundary. @@ -251,7 +253,7 @@ output directly. Use the project-authoring `init --from`, `test`, `check`, and `build` commands for Relay-only, Notary-only, and combined deployments. Registry Notary does not have a direct-source project generator. Registry-backed Notary claims consume compiler-pinned Relay consultations, while a -Notary-only project contains source-free or self-attested claims. +Notary-only project contains source-free or self-attested evaluation-only claims. ## 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 5303a9b0d..9d97d1ebc 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 claims. 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`) through its credential issuance surfaces, including 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 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. 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. ### Solmara Lab @@ -162,7 +162,7 @@ The following ordered flow describes how a request moves through the stack from 4. **Runtime binding and access control.** Registry Relay starts with runtime configuration that binds the manifest's logical datasets and entities to actual data sources. Clients reach entity routes, metadata routes, and evidence-offering endpoints through configured authentication. Relay records a `Data-Purpose` header in audit envelopes where present. On governed runtime routes, Relay evaluates the supported Evidence Gateway PDP profile before disclosure and applies redaction when the decision permits only a minimized response. -5. **Claim evaluation.** Registry Notary evaluates registry-backed claims from compiler-pinned Relay consultation outputs and evaluates source-free or self-attested claims from configured inputs. It applies disclosure policy (value, predicate, or redacted), returns evaluation results as claim-result JSON or CCCEV-shaped JSON-LD, and can materialize eligible stored evaluations into SD-JWT VC credentials. +5. **Claim evaluation.** Registry Notary evaluates registry-backed claims from compiler-pinned Relay consultation outputs and evaluates source-free or self-attested claims from configured inputs. It applies disclosure policy (value, predicate, or redacted) and returns evaluation results as claim-result JSON or CCCEV-shaped JSON-LD. It can materialize only non-delegated registry-backed stored evaluations with exact dependency-closure claim pins and normalized unique Relay execution records into SD-JWT VC credentials. 6. **Static-peer delegated evaluation.** A trusted Registry Notary instance can call another trusted Registry Notary instance through `POST /federation/v1/evaluations` for signed delegated evaluation. Registry Manifest can publish discovery metadata for that relationship, but local Notary peer policy grants access. Peer lists are loaded from configuration at startup. This federation path is distinct from delegated self-attestation in the Notary citizen/OIDC flow. @@ -186,6 +186,8 @@ REQ-ARC-G-007: Registry Notary MUST own claim evaluation, disclosure policy, and REQ-ARC-G-008: Registry Notary credentials MUST use SD-JWT VC format (`application/dc+sd-jwt`). No W3C Verifiable Credentials Data Model JSON-LD envelope or namespace is present in the issued credential. +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-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-dm-claim.mdx b/docs/site/src/content/docs/spec/rs-dm-claim.mdx index 49f4ab514..8d4aab7bc 100644 --- a/docs/site/src/content/docs/spec/rs-dm-claim.mdx +++ b/docs/site/src/content/docs/spec/rs-dm-claim.mdx @@ -6,7 +6,7 @@ owner: registry-docs source_repos: - registry-notary - registry-platform -last_reviewed: "2026-07-07" +last_reviewed: "2026-07-17" doc_type: specification doc_id: RS-DM-CLAIM category: normative @@ -41,6 +41,7 @@ The key words in this document are interpreted per [RS-DOC](../rs-doc/) Section | 0.3.2 | 2026-07-14 | draft | Aligned credential eligibility with Registry Stack project authoring: profiles declare claims once, and the compiler derives the product-level reverse index. | | 0.3.3 | 2026-07-17 | draft | Made omitted claim `formats` default to the canonical claim-result JSON representation and made an explicitly empty list a configuration-load error. | | 0.3.4 | 2026-07-17 | draft | Made the evaluation response-format set closed: the canonical claim-result JSON format is required, CCCEV-shaped JSON-LD is the only additional renderer, and credential issuance formats are rejected. | +| 0.4.0 | 2026-07-17 | draft | Restricted credential membership to mutually bound registry-backed claims and made source-free claims evaluation-only. | ## 1. Scope and references @@ -83,7 +84,7 @@ flowchart TD rule -.-> creds ``` -The diagram restates the model: a claim receives either compiler-pinned Relay outputs or permitted self-attestation inputs, a single rule decides the value, and the disclosure policy, formats, and credential profiles govern the result. A claim definition describes one decision or one extracted value; a claim that tries to return a whole record over-collects and is hard to authorize. +The diagram restates the model: a claim receives either compiler-pinned Relay outputs or permitted self-attestation inputs, a single rule decides the value, and the disclosure policy and formats govern the result. Credential profiles may select only registry-backed claims. A claim definition describes one decision or one extracted value; a claim that tries to return a whole record over-collects and is hard to authorize. REQ-DM-CLAIM-001: A claim definition MUST carry a stable identifier (`id`); the `id` is the reference callers use to evaluate the claim and the reference a credential profile uses to name it. The `id` SHOULD be stable, specific, and unique across the configuration. @@ -138,8 +139,10 @@ reverse claim-to-profile index required by Registry Notary's product configurati REQ-DM-CLAIM-010: A credential profile MUST list at least one claim. The Registry Stack project compiler MUST derive each listed claim's product-level `credential_profiles` reverse index from -that membership and MUST reject unknown claims. Registry Notary MUST issue a claim only when the -compiled profile membership and derived reverse index agree. The issued credential is an SD-JWT VC +that membership and MUST reject unknown or source-free claims. Registry Notary MUST require every +credential profile claim, credential-capable subject-access allow-list entry, and OID4VCI projection +to resolve through mutually consistent bindings to non-delegated `registry_backed` evidence. Delegated relationship and dependent-claim credential bindings MUST be rejected. Registry Notary MUST issue a claim only when the +compiled profile membership and derived reverse index agree and exact compiler pins plus normalized unique Relay execution records are retained for its full dependency closure. Those private execution identifiers MUST be retained only for a credential-capable selection, not for a registry-backed evaluation-only claim. The issued credential is an SD-JWT VC bound to its holder by `did:jwk`, per REQ-PR-NOTARY-013 and REQ-PR-NOTARY-015. ## 9. Batch behavior 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 3858ea976..e4d52dc8f 100644 --- a/docs/site/src/content/docs/spec/rs-pr-notary.mdx +++ b/docs/site/src/content/docs/spec/rs-pr-notary.mdx @@ -70,7 +70,7 @@ For the motivation and worked examples behind this contract, see [Evidence issua ## 2. Service surface and discovery -Registry Notary is a standalone HTTP service for claim evaluation, disclosure policy, credential issuance, and audit (REQ-ARC-G-007). A Notary-only deployment supports source-free and self-attested claims. A registry-backed claim requires a combined deployment in which Notary calls a compiler-pinned Registry Relay consultation. +Registry Notary is a standalone HTTP service for claim evaluation, disclosure policy, credential issuance, and audit (REQ-ARC-G-007). A Notary-only deployment supports source-free and self-attested evaluation only. A registry-backed claim requires a combined deployment in which Notary calls a compiler-pinned Registry Relay consultation, and credential issuance accepts only that registry-backed mode. REQ-PR-NOTARY-001: Registry Notary MUST be independently deployable. It MUST NOT require Registry Relay to start or to evaluate source-free and self-attested claims. A claim configured as registry-backed MUST NOT become ready without its pinned Relay consultation contract. @@ -155,6 +155,10 @@ REQ-PR-NOTARY-012: CCCEV-shaped output MUST NOT assert conformance to CCCEV 2.00 Registry Notary materializes a credential from a stored evaluation through `POST /v1/credentials`. The credential is an SD-JWT VC, which gives the holder selective disclosure: the holder chooses which fields to reveal to which verifier. +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-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. @@ -163,7 +167,37 @@ REQ-PR-NOTARY-015: A credential MUST bind its holder by naming the holder's publ 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`. -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, credential profiles, subject binding, assurance requirements, and token age limits before evaluation or issuance. +REQ-PR-NOTARY-037: Before direct or OID4VCI issuance, Registry Notary MUST +reconstruct every selected root's executed registry-backed dependency closure. +It MUST require exactly one private compiler-pin record for every claim in the +combined closure and exactly one normalized execution record for every unique +executed consultation ULID, with no missing, duplicate, or extra record. Claim +pins MUST match the active claim id and version, Relay profile id and contract +hash, canonical purpose, and referenced consultation ULID. Execution records +MUST match the acquired time. Each claim pin MUST carry a deterministic +SHA-256 execution binding over the pin, execution ULID and acquisition time, +evaluation and result time, and exact claim provenance; Notary MUST recompute +that binding before signer access. Each public root result's +`relay_consultation_count` MUST equal the number of unique executed ULIDs in +that root's closure. One coalesced Relay execution MAY support several claims +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 +access. Existing stored evaluations without the private records MAY remain +readable and renderable but MUST be re-evaluated before issuance. + +The private claim pins and execution records MUST be retained only when all +selected roots share a mutually validated credential profile. A +registry-backed evaluation-only selection MUST remain evaluatable and +renderable without retaining private Relay ids or acquisition times. The +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. 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. @@ -180,13 +214,13 @@ Delegated self-attestation is distinct from Section 9 federation. It does not de REQ-PR-NOTARY-029: For delegated self-attestation, Registry Notary MUST derive the requester from the authenticated principal, derive the relationship from scoped authorization details, require the scoped authorization details to bind the same dependent target by `target.id_type` and `target.id`, and reject caller-supplied `requester`, `relationship`, or `on_behalf_of` fields before any Relay consultation. -REQ-PR-NOTARY-030: A delegated relationship MUST be enabled in `subject_access.delegation.allowed_relationships`. The relationship MUST name a registry-backed `proof_claim`. That proof claim MUST bind exactly one compiler-pinned Relay consultation whose closed input mapping includes both requester and target identifiers. Every dependent claim allowed for that relationship MUST be explicitly allow-listed and MUST list the proof claim in `depends_on`. +REQ-PR-NOTARY-030: A delegated relationship MUST be enabled in `subject_access.delegation.allowed_relationships`. The relationship MUST name a registry-backed `proof_claim`. That proof claim MUST bind exactly one compiler-pinned Relay consultation whose closed input mapping includes both requester and target identifiers. Every dependent claim allowed for that relationship MUST be explicitly allow-listed and MUST list the proof claim in `depends_on`. Delegated relationship and dependent-claim credential-profile bindings MUST be empty in 1.0; configuration load MUST reject them with remediation to remove the delegated credential capability or use a registry-backed, non-delegated credential claim. -REQ-PR-NOTARY-031: A delegated Relay capability MUST bind the requester subject and dependent target with keyed hashes. Registry Notary MUST evaluate the proof consultation before the dependent claim and MUST deny the dependent claim when the proof is missing, fails, is ambiguous, or returns anything other than boolean `true`. It MUST NOT fall back to another consultation or self-attestation. Rendering or issuing from a stored delegated evaluation MUST revalidate the current delegated authorization details, including the dependent-target binding, against the stored metadata. +REQ-PR-NOTARY-031: A delegated Relay capability MUST bind the requester subject and dependent target with keyed hashes. Registry Notary MUST evaluate the proof consultation before the dependent claim and MUST deny the dependent claim when the proof is missing, fails, is ambiguous, or returns anything other than boolean `true`. It MUST NOT fall back to another consultation or self-attestation. Rendering a stored delegated evaluation MUST revalidate the current delegated authorization details, including the dependent-target binding, against the stored metadata. Direct and OID4VCI credential issuance MUST reject delegated evaluations before signer access. ## 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 is a Notary self-attestation issuance path, not a general-purpose wallet interoperability claim. +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`. @@ -196,13 +230,13 @@ Where `oid4vci.nonce.enabled` is set (off by default), the proof MUST additional 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 claim under the self-attestation and source policies before issuance, MUST enforce the configured credential profile, and MUST NOT imply compatibility with arbitrary external wallets beyond the profiled endpoints and metadata this specification names. +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 OID4VCI credential endpoint MUST reject delegated-attestation transaction tokens. This OID4VCI profile issues only for direct self-attestation principals in this version. +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. ## 9. Delegated (federated) evaluation @@ -233,10 +267,11 @@ These constraints are stated so a reader does not infer a capability from the ro - 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). -- Delegated OID4VCI: Delegated-attestation transaction tokens are rejected by the OID4VCI credential endpoint (REQ-PR-NOTARY-032). +- 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). - 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. @@ -251,9 +286,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, with digest-based selective disclosure and `did:jwk` holder binding (REQ-PR-NOTARY-013, REQ-PR-NOTARY-014, REQ-PR-NOTARY-015); +- 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); - 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 rejects delegated-attestation transaction tokens (REQ-PR-NOTARY-016, REQ-PR-NOTARY-017, REQ-PR-NOTARY-027, REQ-PR-NOTARY-032, REQ-PR-NOTARY-036); +- 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); - 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). @@ -270,6 +305,7 @@ This specification is `verified`: every requirement describes shipped behavior a - Delegated self-attestation access modes, Relay consultation capabilities, stored metadata, and denial reasons are implemented in Registry Notary Core and Server. - Delegated relationship configuration validation is implemented in [`config.rs`](https://github.com/registrystack/registry-stack/blob/v0.10.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. - 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.10.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.10.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.10.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 d0ef8259c..6c792237a 100644 --- a/docs/site/src/content/docs/spec/rs-terms.mdx +++ b/docs/site/src/content/docs/spec/rs-terms.mdx @@ -72,7 +72,7 @@ The registry stack comprises four formal products. Product names are Title Case. **Registry Manifest** (`registry-manifest`): Rust workspace for modeling, validating, and rendering standards-facing service, registry, form, and policy metadata without running Registry Relay. Provides a library (`registry-manifest-core`) and a command-line interface (`registry-manifest-cli`). -**Registry Notary** (`registry-notary`): Standalone Rust service for Relay-backed or self-attested claim evaluation, disclosure policy, credential issuance, and audit. It has no direct registry source connector. +**Registry Notary** (`registry-notary`): Standalone Rust service for Relay-backed or self-attested claim evaluation, disclosure policy, credential issuance from exact Relay-backed evaluation provenance, and audit. Source-free claims are evaluation-only. It has no direct registry source connector. **Solmara Lab** (`solmara-lab`): Separately maintained adopter demo for the fictional Republic of Solmara. It runs published Registry Stack images in local and hosted topologies. Solmara Lab is not a formal Registry Stack product and has no conformance weight in the Registry Stack specifications. @@ -88,7 +88,7 @@ The registry stack comprises four formal products. Product names are Title Case. **wallet**: A holder-owned application that stores credentials and presents them to verifiers. Registry Notary issues credentials; the wallet that stores, unlocks, and presents them is a separate component owned by the holder. -**self-attestation**: Registry Notary access pattern where an authenticated OIDC principal is the protected requester for configured claim evaluation or credential issuance. Registry Notary has two self-attestation access modes: `self_attestation` (direct self-attestation), where the authenticated principal is the credential subject, and `delegated_attestation` (delegated self-attestation), where the authenticated principal is the requester and a configured dependent target can be evaluated only when scoped authorization details name the same target, relationship, and proof claim. Registry Stack product term. +**self-attestation**: Registry Notary access pattern where an authenticated OIDC principal is the protected requester for configured claim evaluation. Registry Notary has two self-attestation access modes: `self_attestation` (direct self-attestation), where the authenticated principal is the subject, and `delegated_attestation` (delegated self-attestation), where the authenticated principal is the requester and a configured dependent target can be evaluated only when scoped authorization details name the same target, relationship, and proof claim. Source-free and delegated claims are evaluation-only. Subject access can authorize credential issuance only for non-delegated registry-backed evidence. Registry Stack product term. **delegated self-attestation**: Registry Notary self-attestation access mode `delegated_attestation`. The requester is derived from the authenticated principal, the request target and scoped authorization details identify the same configured dependent subject, the relationship is derived from scoped authorization details, and the dependent claim can continue only after the compiler-pinned Relay proof consultation evaluates to boolean `true`. This is distinct from static-peer delegated evaluation under Notary federation. Registry Stack product term. @@ -100,7 +100,7 @@ The registry stack comprises four formal products. Product names are Title Case. **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). The hosted Solmara Lab topology uses eSignet as the authorization server a citizen signs in to before Registry Notary issues a self-attestation credential. 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 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. ## 3. Metadata and standards terms diff --git a/docs/site/src/content/docs/start/credential-tour.mdx b/docs/site/src/content/docs/start/credential-tour.mdx index 1db3675b6..3690b23f3 100644 --- a/docs/site/src/content/docs/start/credential-tour.mdx +++ b/docs/site/src/content/docs/start/credential-tour.mdx @@ -1,121 +1,29 @@ --- -title: Get a verifiable credential from the hosted lab -description: Held hosted-lab guide for Registry Notary credential issuance; use the local Notary tutorial for the current first-run route. -status: current +title: Retired hosted credential tour +description: Historical note for the removed source-free credential issuance journey. +status: historical +draft: true owner: registry-docs source_repos: - solmara-lab - registry-notary -last_reviewed: "2026-06-28" -doc_type: tutorial +last_reviewed: "2026-07-17" +doc_type: explanation locale: en standards_referenced: - oid4vci - sd-jwt-vc --- -import QuickstartMeta from '../../../components/QuickstartMeta.astro'; +The former hosted credential tour described credential issuance from a +source-free self-attested claim. That journey was removed before Registry Stack +1.0 because credential issuance now requires fresh, exact Relay-backed +evaluation provenance for every selected claim. -:::caution[Hosted first-run publication held] -Do not use this page as the current first-run route. The complete -[GH#198](https://github.com/registrystack/registry-stack/issues/198) fresh-reader run is unfinished. -Use [the local Notary tutorial](../../tutorials/verify-claim-registry-api/) instead. -::: +Source-free claims remain available for evaluation and rendering. They cannot +belong to a credential profile or OID4VCI credential configuration. -When this path passes its fresh-reader gate, -**Registry Notary** issues a signed verifiable credential to a hosted demo wallet that -answers a question without exposing the underlying record. This is a guided browser flow, not a -curl sequence: a real [OID4VCI](../../reference/glossary/) issuance needs a holder key and -a sign-in, which is wallet territory. You do not need to install a wallet: the lab hosts a demo -wallet at [wallet.lab.registrystack.org](https://wallet.lab.registrystack.org), so you can receive -the credential end to end in the browser. Everything runs in the hosted lab at -[lab.registrystack.org](https://lab.registrystack.org), so this page has no setup on your machine. - - - -This lab uses synthetic data and public demo-only credentials by design. -Do not reuse anything you copy here outside the lab. - -## What is running - -The lab runs a citizen **Notary** you will use in this tutorial: an issuer that hands out a -privacy-preserving credential instead of the raw record, at -[citizen-notary.lab.registrystack.org](https://citizen-notary.lab.registrystack.org). The lab also -runs a combined Relay and Notary health-program deployment against a demo DHIS2 health -information system. Relay owns the DHIS2 source interaction, and Notary consumes the compiled -Relay consultation at -[dhis2-notary.lab.registrystack.org](https://dhis2-notary.lab.registrystack.org). For how these -connect to the rest of the stack, see the -[architecture overview](../../explanation/architecture/). For the full set of live demo -credentials, identities, and ready-made requests, open the lab homepage at -[lab.registrystack.org](https://lab.registrystack.org). - -## Get a signed credential - -The citizen Notary issues a Selective Disclosure JWT Verifiable Credential -([SD-JWT VC](../../reference/glossary/)) of type `person_is_alive_sd_jwt`, media type -`application/dc+sd-jwt`. The credential discloses a single predicate, whether -the person is alive, as a true or false claim. It never hands over the underlying civil record. -The [quickstart](../quickstart/) Relay row-read example uses `NID-1001` / Miguel Santos, while this -wallet flow uses the lab wallet identity `NID-2001` / Maria Santos. - -Start in the **Wallet test** section of [lab.registrystack.org](https://lab.registrystack.org), -which links the credential flow end to end: start the citizen Notary flow, sign in, then paste the -generated credential offer into the hosted wallet. The Notary requires you to sign in before it -issues. The sign-in runs through eSignet, the lab's hosted demo identity provider; you do not need -an account, and the following values are synthetic demo identities: - -- National ID: `NID-2001` -- Name: Maria Santos -- Login and OTP code: `111111` -- PIN: `545411` - -After sign-in, the hosted wallet receives the signed `person_is_alive_sd_jwt` credential, with the -alive predicate set to true. If you prefer your own OID4VCI-compatible wallet, you can open the -credential offer directly instead: - -```text -https://citizen-notary.lab.registrystack.org/oid4vci/credential-offer?credential_configuration_id=person_is_alive_sd_jwt -``` - -### See the issuer refuse - -Try this in the same browser flow. Self-attestation binds the credential subject to the identity you -signed in as. If you sign in as `NID-2001` but ask for a credential bound to `NID-1001`, the Notary -refuses: it will not issue a credential for a subject you did not authenticate as. The issuer cannot -be talked into vouching for someone else. For more on this service, see -[Registry Notary](../../products/registry-notary/). - -### DHIS2 integration path - -The lab also runs a combined Relay and Notary path for API credential and claim-evaluation work. -The project uses Relay's product-neutral source-adaptation capability for DHIS2. Product and -version metadata record interoperability evidence; they do not enable Rhai or select a runtime. -Use [Integration patterns](../../explanation/integration-patterns/) for the source-adaptation -model. - -## What you built - -You watched Registry Notary issue a signed, privacy-preserving Selective Disclosure JWT Verifiable -Credential to a hosted demo wallet: the credential discloses a single true or false predicate, -whether the person is alive, without exposing the underlying civil record. Self-attestation held: signing -in as `NID-2001` and asking for a credential bound to `NID-1001` was refused, because the issuer will -not vouch for a subject you did not authenticate as. - -## Next - -- [Evaluate a claim with Registry Notary](../../tutorials/verify-claim-registry-api/): run a Notary - against the Relay you published and evaluate a claim. -- [Run a protected registry API locally](../../tutorials/publish-spreadsheet-secured-registry-api/): - stand up your own protected Relay from a sample workbook. - -## Troubleshooting - -| Symptom | Cause | Fix | -| --- | --- | --- | -| The hosted lab or a service URL does not respond | n/a | [First run with Solmara Lab](../../tutorials/first-run-with-solmara-lab/) runs a separate, fully local multi-service demo if the hosted lab is unavailable | +Use [Evaluate a claim with Registry Notary](../../tutorials/verify-claim-registry-api/) +for the current local Relay-backed journey. A hosted credential tour can return +only after the lab provides a combined Relay and Notary flow that preserves and +verifies the exact compiler-pinned Relay execution before signing. diff --git a/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx b/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx index 9c05d5d53..ad0ec17e9 100644 --- a/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx +++ b/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx @@ -246,7 +246,6 @@ so you can confirm what you ran by hand against what the lab publishes. - [Evaluate a claim with Registry Notary](../verify-claim-registry-api/): add Notary to that local project. - [Hosted Relay demo (held)](../../start/quickstart/): inspect the zero-install Relay path. -- [Hosted credential tour (held)](../../start/credential-tour/): inspect the browser issuance path. - [When to use Registry Stack](../../start/when-to-use/): decide whether Relay, Notary, or both fit your integration. - [Registry Relay](../../products/registry-relay/): route reference, auth scopes, and configuration 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 579d6ddbb..0d47d5322 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 @@ -366,8 +366,6 @@ Relay refused to start when the floor dropped under 2 on personal data. project and answer a narrow question without exposing the source row. - [Author an HTTP Registry Stack project](../author-registry-project/): adapt a source through Relay and compile Notary claims against the exact consultation contract. -- [Hosted credential tour (held)](../../start/credential-tour/): inspect the hosted issuance path; - it is not the promoted first-run route. ## Troubleshooting diff --git a/docs/site/src/data/contracts.yaml b/docs/site/src/data/contracts.yaml index b7b3fea92..e48240cec 100644 --- a/docs/site/src/data/contracts.yaml +++ b/docs/site/src/data/contracts.yaml @@ -24,7 +24,7 @@ source_of_truth: label: Registry Notary OpenAPI generator url: https://github.com/registrystack/registry-stack/blob/v0.10.0/crates/registry-notary-server/src/openapi.rs - consumer_note: Generate with `cargo run -p registry-notary -- openapi`. + 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 owner: registry-notary @@ -34,7 +34,9 @@ label: Registry Notary OID4VCI routes url: https://github.com/registrystack/registry-stack/blob/v0.10.0/crates/registry-notary-server/src/api.rs consumer_note: >- - OID4VCI flow advertising `dc+sd-jwt` credential format. Solmara Lab checks + 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 checks the hosted issuer metadata, credential offer, nonce, authorization redirect, and unauthenticated denial paths through `just hosted-smoke`. Full OID4VCI Draft 13 `vc+sd-jwt` wallet conformance is not asserted at this version. diff --git a/docs/site/src/data/generated/contracts.json b/docs/site/src/data/generated/contracts.json index e6be1ee22..f0a1f4b4f 100644 --- a/docs/site/src/data/generated/contracts.json +++ b/docs/site/src/data/generated/contracts.json @@ -33,7 +33,7 @@ "label": "Registry Notary OpenAPI generator", "url": "https://github.com/registrystack/registry-stack/blob/v0.10.0/crates/registry-notary-server/src/openapi.rs" }, - "consumer_note": "Generate with `cargo run -p registry-notary -- openapi`." + "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", @@ -45,7 +45,7 @@ "label": "Registry Notary OID4VCI routes", "url": "https://github.com/registrystack/registry-stack/blob/v0.10.0/crates/registry-notary-server/src/api.rs" }, - "consumer_note": "OID4VCI flow advertising `dc+sd-jwt` credential format. Solmara Lab checks the hosted issuer metadata, credential offer, nonce, authorization redirect, and unauthenticated denial paths through `just hosted-smoke`. Full OID4VCI Draft 13 `vc+sd-jwt` wallet conformance is not asserted at this version." + "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 checks the hosted issuer metadata, credential offer, nonce, authorization redirect, and unauthenticated denial paths through `just hosted-smoke`. Full OID4VCI Draft 13 `vc+sd-jwt` wallet conformance is not asserted at this version." }, { "id": "registry-notary.federated-evaluation", diff --git a/docs/site/src/data/generated/projects.json b/docs/site/src/data/generated/projects.json index f4e66f17c..f5bedcc31 100644 --- a/docs/site/src/data/generated/projects.json +++ b/docs/site/src/data/generated/projects.json @@ -99,13 +99,13 @@ "name": "Registry Notary", "repo_path": "../registry-notary", "target_repo_path": "../registry-notary", - "role": "Standalone service that evaluates Relay-backed or self-attested claims, serves static-peer federated delegated evaluation, and issues SD-JWT verifiable credentials with selective disclosure, composing shared auth, audit, OIDC, HTTP security, crypto, SD-JWT, and testing primitives from Registry Platform.", + "role": "Standalone service that evaluates Relay-backed or self-attested claims, serves static-peer federated delegated evaluation, and issues SD-JWT verifiable credentials only from exact Relay-backed evaluation provenance, composing shared auth, audit, OIDC, HTTP security, crypto, SD-JWT, and testing primitives from Registry Platform.", "owns": [ "Claim configuration and evaluation.", "Disclosure policy and redacted audit event semantics.", "Registry Notary API routes.", "Static-peer delegated evaluation at `/federation/v1/evaluations`.", - "Credential issuance workflow and claim-to-credential mapping.", + "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.", "Compiler-pinned Registry Relay consultation contracts for registry-backed claims.", "Source-free and self-attested claim evaluation." @@ -115,7 +115,8 @@ "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.", - "Browser inspection workflows outside the current formal v1 stack scope." + "Browser inspection workflows outside the current formal v1 stack scope.", + "Credential issuance from source-free or self-attested evidence." ], "source_docs": [ { diff --git a/docs/site/src/data/generated/sidebar.json b/docs/site/src/data/generated/sidebar.json index c75089d45..dae89e253 100644 --- a/docs/site/src/data/generated/sidebar.json +++ b/docs/site/src/data/generated/sidebar.json @@ -107,6 +107,10 @@ "label": "Credential lifecycle and status", "slug": "products/registry-notary/credential-lifecycle-status" }, + { + "label": "Credential issuance migration", + "slug": "products/registry-notary/credential-issuance-migration" + }, { "label": "Signing key provider", "slug": "products/registry-notary/signing-key-provider" diff --git a/docs/site/src/data/projects.yaml b/docs/site/src/data/projects.yaml index 3e4a67595..48b0039a9 100644 --- a/docs/site/src/data/projects.yaml +++ b/docs/site/src/data/projects.yaml @@ -68,13 +68,13 @@ name: Registry Notary repo_path: ../registry-notary target_repo_path: ../registry-notary - role: Standalone service that evaluates Relay-backed or self-attested claims, serves static-peer federated delegated evaluation, and issues SD-JWT verifiable credentials with selective disclosure, composing shared auth, audit, OIDC, HTTP security, crypto, SD-JWT, and testing primitives from Registry Platform. + role: Standalone service that evaluates Relay-backed or self-attested claims, serves static-peer federated delegated evaluation, and issues SD-JWT verifiable credentials only from exact Relay-backed evaluation provenance, composing shared auth, audit, OIDC, HTTP security, crypto, SD-JWT, and testing primitives from Registry Platform. owns: - Claim configuration and evaluation. - Disclosure policy and redacted audit event semantics. - Registry Notary API routes. - Static-peer delegated evaluation at `/federation/v1/evaluations`. - - Credential issuance workflow and claim-to-credential mapping. + - 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. - Compiler-pinned Registry Relay consultation contracts for registry-backed claims. - Source-free and self-attested claim evaluation. @@ -84,6 +84,7 @@ - Shared security and operational primitives owned by Registry Platform. - Open federation, dynamic trust-chain discovery, shared replay storage, or federated credential issuance. - Browser inspection workflows outside the current formal v1 stack scope. + - Credential issuance from source-free or self-attested evidence. source_docs: - label: README url: https://github.com/registrystack/registry-stack/blob/v0.10.0/products/notary/README.md diff --git a/docs/site/src/data/repo-docs.yaml b/docs/site/src/data/repo-docs.yaml index 35a376dfa..0d73021da 100644 --- a/docs/site/src/data/repo-docs.yaml +++ b/docs/site/src/data/repo-docs.yaml @@ -319,6 +319,14 @@ repos: - 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] standards_referenced: [sd-jwt-vc, w3c-did] last_reviewed: unreviewed + - src: products/notary/docs/credential-issuance-migration.md + dest: products/registry-notary/credential-issuance-migration + label: Credential issuance migration + nav_order: 85 + doc_type: how-to + last_reviewed: "2026-07-17" + standards_referenced: [sd-jwt-vc, oid4vci, w3c-did] + 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] - src: products/notary/docs/signing-key-provider.md archive_src: docs/signing-key-provider.md dest: products/registry-notary/signing-key-provider diff --git a/products/notary/CHANGELOG.md b/products/notary/CHANGELOG.md index d12bb4124..ee83b42f0 100644 --- a/products/notary/CHANGELOG.md +++ b/products/notary/CHANGELOG.md @@ -22,6 +22,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- BREAKING: direct and OID4VCI credential issuance now accept only newly + evaluated, non-delegated registry-backed claims with one exact compiler pin + for every claim in each selected root's dependency closure and one normalized + record per unique Relay execution. A deterministic SHA-256 binding now + cross-checks every pin against its execution and claim provenance before + signer access. Restricted Relay identifiers are retained only for evaluations + whose selected roots share a validated credential profile. Source-free, + delegated, and registry-backed evaluation-only claims cannot be + configured for credential issuance. Existing stored evaluations remain + readable and renderable but must be re-evaluated before issuance. No database + migration or correctness-state schema change is required. - BREAKING: configuration `${VAR}` expansion now rejects environment variables that are unset or empty. `${VAR:-fallback}` uses its fallback for either state, `${VAR:-}` explicitly expands to empty, and `${VAR:?message}` reports diff --git a/products/notary/README.md b/products/notary/README.md index b4f6d5073..243c8a17b 100644 --- a/products/notary/README.md +++ b/products/notary/README.md @@ -11,9 +11,14 @@ adapters. A Registry Stack project may deploy: - Relay only, for governed source access, materialization, or records APIs; -- Notary only, for source-free self-attested evidence; or +- Notary only, for source-free self-attested evaluation and rendering; or - Relay and Notary, for claims derived from Relay consultation outcomes and outputs. +Credential issuance is available only in a combined Relay and Notary project. +Every credential claim must come from a freshly executed, compiler-pinned Relay +consultation. Source-free claims remain evaluation-only and cannot belong to a +credential profile or OID4VCI configuration. + Notary keeps independent authority over caller authentication, purpose, service policy, claim evaluation, disclosure, credential issuance, and its own audit chain. Relay keeps independent authority over source acquisition, diff --git a/products/notary/docs/README.md b/products/notary/docs/README.md index 8c2c44f94..3c8eaa989 100644 --- a/products/notary/docs/README.md +++ b/products/notary/docs/README.md @@ -3,7 +3,9 @@ Registry Notary evaluates claims and issues evidence. For registry-backed evidence, it consumes only authenticated, typed Registry Relay consultation results. For Notary-only projects it can evaluate source-free self-attested -evidence. It never connects directly to a registry source. +evidence, but it cannot issue credentials from those claims. Credential +issuance requires a newly executed, compiler-pinned Relay consultation for +every selected claim. It never connects directly to a registry source. ## Understand @@ -26,6 +28,7 @@ evidence. It never connects directly to a registry source. - [Self-attestation](self-attestation-operator-guide.md) - [Federated evaluation](federated-evaluation-operator-guide.md) - [Credential lifecycle and status](credential-lifecycle-status.md) +- [Credential issuance trust-boundary migration](credential-issuance-migration.md) - [Signing key providers](signing-key-provider.md) - [Configuration trust](configuration-trust-and-integrity.md) - [Deployment hardening](deployment-hardening-runbook.md) diff --git a/products/notary/docs/client-sdk-guide.md b/products/notary/docs/client-sdk-guide.md index 62f263513..bffd199a0 100644 --- a/products/notary/docs/client-sdk-guide.md +++ b/products/notary/docs/client-sdk-guide.md @@ -755,6 +755,13 @@ Issue a credential when a successful evaluation should become a credential artifact, such as an SD-JWT VC. Credential bodies are sensitive. Do not log them, and note that Rust redacts credential bodies from `Debug`. +The evaluation must be newly created from non-delegated registry-backed claims +and retain an exact compiler pin for every claim in each selected root's +dependency closure, plus one execution record for every unique Relay +consultation ULID. Source-free, delegated, and older stored evaluations remain +renderable but are not issuable. Re-evaluate after upgrading before calling +either the direct issuance or OID4VCI credential endpoint. + ```python credential = client.issue_credential_request({ "evaluation_id": "eval-1", diff --git a/products/notary/docs/credential-issuance-migration.md b/products/notary/docs/credential-issuance-migration.md new file mode 100644 index 000000000..23f971e1b --- /dev/null +++ b/products/notary/docs/credential-issuance-migration.md @@ -0,0 +1,89 @@ +# Credential issuance trust-boundary migration + +> **Page type:** How-to · **Product:** Registry Notary · **Layer:** credential · **Audience:** operator, integrator + +Registry Notary now issues credentials only from a stored evaluation whose +selected claims were produced by fresh, exact compiler-pinned Registry Relay +consultations. This applies to `POST /v1/credentials` and +`POST /oid4vci/credential`. + +## Configuration changes + +Before upgrading, inspect every credential profile, every +`subject_access.allowed_claims` entry used by credential capability, and every +OID4VCI projection: + +- Each selected claim must use `registry_backed` evidence. +- A profile and its claims must name each other consistently. +- OID4VCI claims and projections must resolve through those same + registry-backed profile bindings. +- Remove source-free `self_attested` claims from credential profiles, + credential-capable subject-access allow-lists, and OID4VCI configurations. +- Keep a source-free service evaluation-only by disabling credential issuance + and omitting credential profiles and OID4VCI credential configurations. +- Remove `credential_profiles` from every delegated relationship and from each + delegated dependent claim. Delegated self-attestation remains available for + evaluation and rendering, but neither direct nor OID4VCI credential issuance + accepts a delegated evaluation in 1.0. +- If a dependent fact must become a credential, model a separate + registry-backed, non-delegated claim and bind that claim through + `subject_access.credential_profiles`. + +Configuration load rejects a mixed or one-sided binding. The diagnostic names +the invalid credential claim binding and the required remediation. + +## Stored evaluation compatibility + +Existing stored evaluations remain readable and renderable. Records without +the private issuance provenance and per-claim execution binding introduced by +this release cannot be used to issue a credential. Re-evaluate the +registry-backed claim under the active configuration, then retry issuance with +the new evaluation id. + +Notary retains this restricted provenance only when all selected roots share a +mutually validated credential profile. Registry-backed evaluation-only claims +remain evaluatable and renderable but store no private Relay consultation ids +or acquisition times. + +For every claim in each selected root's executed registry-backed dependency +closure, the new evaluation stores one private compiler-pin record containing +the claim id and version, Relay profile id and contract hash, canonical purpose, +and executed consultation ULID. A separate normalized execution record stores +each unique consultation ULID and acquisition time once, including when one +coalesced Relay execution supports several claims. Each claim pin also carries +an unkeyed SHA-256 execution binding over the compiler pin, execution ULID and +acquisition time, evaluation and result time, and exact claim provenance. Each public root result's +`relay_consultation_count` must equal the number of unique executed ULIDs in +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. + +The execution binding detects partial stored-record mutation, including a +changed acquisition time or consultation ids swapped between claims. It is not +a keyed authenticity proof and does not protect against an operator who can +rewrite every committed field and recompute the digest. Protect the evaluation +store with the deployment's database access controls, audit, and backup +controls. + +This is an application-data compatibility change only. It introduces no +database migration, DDL change, or correctness-state schema fingerprint +change. + +## Rollout + +1. Regenerate the project configuration and correct any credential-binding + validation errors. +2. Remove or replace source-free and delegated credential journeys. They may + continue as evaluation and rendering journeys. +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 + exact configured profile, purpose, and contract hash. + +Do not copy provenance from an old evaluation or retry with an edited stored +record. Re-evaluation is the supported recovery path. diff --git a/products/notary/docs/notary-capability-matrix.md b/products/notary/docs/notary-capability-matrix.md index 4a5495025..303e8cd54 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 claims, signs results, issues credentials, 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 | | 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 | @@ -65,11 +65,11 @@ scenario patterns](notary-scenario-patterns.md). | 11 | Citizen presents civil-status proof to a benefits service | User-presented proof | Planned | No proof profiles or verifier runtime ship yet; you cannot accept this user-presented civil-status proof | | 12 | Farmer presents landholding or farmer-registration proof | User-presented proof | Planned | No proof profiles or status/freshness policy ship yet; you cannot accept a user-presented landholding or farmer-registration proof | | 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 | Representation plus proof | Planned | No actor/subject separation or representation authority policy ships yet; you cannot let a parent or guardian request this service on a child's behalf | +| 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 | Local wallet ceremony is still demo-grade | -| 17 | Agriculture Notary issues voucher eligibility credential | Credential issuance | Supported | Local wallet ceremony is still demo-grade | -| 18 | Shared Eligibility Notary issues combined-support credential | Credential issuance plus composition | Partial | Credential issuance exists, but peer-result composition is missing | +| 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 | +| 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 | diff --git a/products/notary/docs/oid4vci-wallet-interop.md b/products/notary/docs/oid4vci-wallet-interop.md index eeb4a652e..619b17cbe 100644 --- a/products/notary/docs/oid4vci-wallet-interop.md +++ b/products/notary/docs/oid4vci-wallet-interop.md @@ -6,9 +6,13 @@ 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 current wallet facade issues source-free `self_attested` claims. A -Registry-backed wallet service requires its own compiled Relay consultation and -citizen authorization policy and is not implied by enabling this facade. +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 @@ -24,21 +28,26 @@ The facade is intentionally narrow: - Proof type is JWT. - Supported proof algorithm is `EdDSA`. - Supported holder binding method is `did:jwk`. -- Issuance is backed by self-attestation policy and source-free - `self_attested` claims. +- 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 and self-attestation -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 operator who runs Notary owns these settings; -this guide assumes they are already in place. +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 @@ -112,7 +121,8 @@ The current wallet-facing flow is: 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, evaluates the self-attested claim, and issues the SD-JWT VC. + 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 @@ -386,7 +396,11 @@ configuration overrides in your deployment notes. ## Security and privacy notes -- `self_attested` claims perform no Relay consultation. +- 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 @@ -400,8 +414,9 @@ logging boundaries, see the | Symptom | Likely cause | Check | | --- | --- | --- | -| Metadata route is unavailable | `oid4vci.enabled` is false or self-attestation is disabled | Expanded config and startup logs | -| Config fails validation | OID4VCI references a claim or credential profile outside self-attestation allow-lists | `credential_configurations`, `subject_access.allowed_claims`, `subject_access.credential_profiles` | +| 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` | diff --git a/products/notary/docs/operator-config-reference.md b/products/notary/docs/operator-config-reference.md index b6900aff0..708145126 100644 --- a/products/notary/docs/operator-config-reference.md +++ b/products/notary/docs/operator-config-reference.md @@ -47,7 +47,11 @@ Every claim uses one sealed evidence mode: A configuration may contain claims for the topology generated by the project. A Notary-only project must not configure Relay. A combined project has exactly -one logical Relay connection. +one logical Relay connection. Source-free claims are evaluation-only. Every +credential-capable claim in `subject_access.allowed_claims`, every claim in a +credential profile, and every OID4VCI projection must resolve through mutually +consistent credential-profile bindings to `registry_backed` claims. +Configuration load rejects mixed or source-free credential surfaces. ## Relay connection @@ -103,6 +107,29 @@ Delegated access must bind requester, target, relationship, purpose, and authorization details before evaluation. A delegated Relay proof consultation proves only its exact compiled edge and does not grant scopes. +Credential issuance additionally requires the stored evaluation to contain one +exact compiler pin for every registry-backed claim in each selected root's +dependency closure, plus one normalized execution record for every unique Relay +consultation ULID. Claim/version, Relay profile and contract hash, canonical +purpose, ULID, acquisition time, and each root's public unique-consultation +count must match the active configuration and evaluation. A deterministic +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 +evaluation-only; delegated relationship and claim credential-profile bindings +are rejected at configuration load. + +Notary persists these private Relay identifiers only when every selected root +shares a mutually validated credential profile. Registry-backed +evaluation-only claims store no private issuance provenance. The execution +binding detects partial mutation, not a store operator who can rewrite all +fields and recompute an unkeyed digest; database and audit controls remain the +authenticity boundary. + ## State and operations Use the typed Notary-owned PostgreSQL state schema for multi-instance or diff --git a/products/notary/docs/release-notes.md b/products/notary/docs/release-notes.md index 64af12e78..0189f01fd 100644 --- a/products/notary/docs/release-notes.md +++ b/products/notary/docs/release-notes.md @@ -2,6 +2,13 @@ ## Unreleased +- BREAKING: Direct and OID4VCI credential issuance now require a fresh, + non-delegated stored evaluation with an exact compiler pin for every claim in + each selected root's registry-backed dependency closure and one normalized + record per unique Relay execution. Source-free and delegated claims are + evaluation-only and cannot be configured for credential issuance. Existing + evaluations remain readable and renderable but must be re-evaluated before issuance. See the + [credential issuance trust-boundary migration](credential-issuance-migration.md). - BREAKING: Configuration `${VAR}` expansion now rejects environment variables that are unset or empty. `${VAR:-fallback}` uses its fallback for either state, `${VAR:-}` explicitly expands to empty, and `${VAR:?message}` reports diff --git a/products/notary/docs/self-attestation-operator-guide.md b/products/notary/docs/self-attestation-operator-guide.md index 06c74d736..a52346be1 100644 --- a/products/notary/docs/self-attestation-operator-guide.md +++ b/products/notary/docs/self-attestation-operator-guide.md @@ -1,9 +1,12 @@ # Self-attestation operator guide -> **Page type:** How-to · **Product:** Registry Notary · **Layer:** credential · **Audience:** operator +> **Page type:** How-to · **Product:** Registry Notary · **Layer:** evaluation, credential · **Audience:** operator -Self-attestation lets a citizen use their own OIDC token to evaluate, render, or -issue only the claims that policy allows. The subject is bound to the token. +Self-attestation lets a citizen use their own OIDC token to evaluate or render +only the claims that policy allows. It can authorize credential issuance only +when every credential claim is registry-backed and the stored evaluation +retains the exact compiler-pinned Relay execution provenance. Source-free +claims are evaluation-only. The subject is bound to the token. This guide is for operators configuring that flow with an identity provider and relying-party or wallet clients. Registry-backed citizen evidence requires a separately compiled Relay consultation and service policy. @@ -19,6 +22,9 @@ The core guarantee is that a citizen token can only be used for the exact self authorized by policy, and only for explicitly allowed claims, purposes, formats, disclosures, and credential profiles. +The credential-profile allow-lists apply only to registry-backed claims. They +do not turn a `self_attested` claim into issuable evidence. + Notary validates the token, checks client and audience policy, checks subject binding, checks scopes and operation allow-lists, then evaluates only the configured subject-bound claims. @@ -31,7 +37,7 @@ flowchart TD S -- "ok" --> Sc{"Scopes and operation allow-lists"} Sc -- "ok" --> Bound["Evaluate configured subject-bound claim"] Bound --> Op - Op["Evaluate, render, or issue within the allow-lists"] + Op["Evaluate or render within the allow-lists"] V -- "fail" --> X["Reject before evaluation"] C -- "fail" --> X S -- "conflicting caller identity" --> X @@ -52,7 +58,8 @@ caller-supplied identity context is rejected before claim evaluation. Use self-attestation when: - A citizen portal evaluates eligibility from the citizen's own token. -- A wallet flow issues a credential for the token-bound subject. +- A wallet flow issues a credential for the token-bound subject from a + compiler-pinned Relay-backed evaluation. - The identity provider can provide a stable, reviewed subject-binding claim. - The evidence service accepts token-bound subject access for the configured purpose. @@ -185,7 +192,8 @@ subject_access: Guidance: - Keep access-token lifetime short for public citizen flows. -- Keep evaluation age short so a credential is issued from fresh evidence. +- Keep evaluation age short so a registry-backed credential is issued from + fresh evidence. - Set credential validity to the period the issuing agency wants verifiers to accept the wallet-held VC. Use credential status or another lifecycle surface for long-lived credentials. @@ -221,6 +229,9 @@ subject_access: Rules: - Enable only operations the citizen flow actually needs. +- `issue_credential: true` requires every allowed claim and credential-profile + claim to use `registry_backed`. Use `issue_credential: false` for a + source-free service. - Include the canonical claim-result JSON format for the internal evaluation that backs issuance. The named credential profile separately selects `application/dc+sd-jwt` as the credential output. @@ -239,9 +250,16 @@ Delegated subject access is available only when `subject_access.delegation.allowed_relationships` contains the requested relationship. Each allowed relationship names one compiler-pinned Relay-backed `proof_claim` and its exact claims, purposes, formats, disclosures, and -credential profiles. Keep delegation disabled unless that relationship proof -and its scoped authorization-details contract have been reviewed. The OID4VCI -credential endpoint does not accept delegated-attestation access tokens. +no credential profiles. Delegated self-attestation is evaluation and rendering +only in 1.0. Both `/v1/credentials` and the OID4VCI credential endpoint reject +delegated evaluations. Keep delegation disabled unless that relationship proof +and its scoped authorization-details contract have been reviewed. + +Configuration load rejects a delegated relationship `credential_profiles` +entry or a credential-profile binding on a delegated claim. To keep delegated +evaluation, remove that credential capability. To issue a credential, model a +separate registry-backed, non-delegated claim and bind it through +`subject_access.credential_profiles`. ## Scope policy @@ -302,11 +320,13 @@ but public deployments should also use gateway and identity-provider controls. Confirm that: - every `self_attested` claim and dependency is source-free; +- no `self_attested` claim appears in `allowed_claims`, a credential profile, + or an OID4VCI projection when credential issuance is configured; - every `registry_backed` claim maps Relay inputs only from the authenticated requester or target identifiers; - claim and request purposes are stable and auditable; -- caller scopes, client ids, audiences, formats, disclosures, and credential - profiles are narrowly allow-listed; and +- caller scopes, client ids, audiences, formats, disclosures, and any + registry-backed credential profiles are narrowly allow-listed; and - the evidence service does not present self-attestation as registry-verified evidence. Use [`source-claim-modeling-guide.md`](source-claim-modeling-guide.md) to @@ -319,6 +339,8 @@ review the evidence boundary. - `normalize: exact` is acceptable for the identifier format. - `scope_policy: required` is used unless there is a documented reason not to. - All allow-lists are narrow and reference existing claims and profiles. +- Source-free services set `issue_credential: false` and configure no + credential profiles or OID4VCI credential configurations. - Batch is disabled. - Credential profiles use DID holder binding with proof of possession. - Wallet origins are exact HTTPS origins, or empty for non-browser flows. @@ -337,6 +359,8 @@ review the evidence boundary. | Subject mismatch | Token claim is missing or caller-supplied identity context conflicts with the derived subject | `subject_binding.token_claim`, token claims, request body identity fields | | Userinfo subject not found | `claim_source: userinfo` without a usable endpoint or issuer | `auth.oidc.userinfo_endpoint`, `userinfo_issuers` | | Delegation config rejected | Delegated authorization does not match the compiled service policy | Check requester, target, relationship, purpose, and authorization details | -| Credential issuance denied | Profile or claim missing from allow-lists | `allowed_claims`, `credential_profiles`, claim/profile cross references | +| Delegated credential config rejected | A delegated relationship or delegated claim still carries a credential-profile binding | Remove the delegated credential capability, or use a registry-backed non-delegated credential claim | +| Credential config rejected | A source-free claim or mixed profile was exposed for issuance | Use only mutually bound `registry_backed` claims in `allowed_claims`, credential profiles, and OID4VCI projections | +| Credential issuance denied after upgrade | The evaluation is legacy, source-free, or its stored Relay execution pins do not match | Re-evaluate the exact registry-backed claims under the active configuration | | Batch request denied | Batch evaluation is not supported for self-attestation | Keep `batch_evaluate: false` | | Works locally but fails active-active | `state.storage: in_memory` is process-local | Add gateway limits and install PostgreSQL correctness state | diff --git a/products/notary/openapi/registry-notary.openapi.json b/products/notary/openapi/registry-notary.openapi.json index 414195e47..0b559d618 100644 --- a/products/notary/openapi/registry-notary.openapi.json +++ b/products/notary/openapi/registry-notary.openapi.json @@ -1929,6 +1929,7 @@ "json_ld_vc_issuance", "data_integrity_proofs", "credential_status", + "delegated_credential_issuance", "mso_mdoc", "openid4vci_full_issuer" ] @@ -2923,7 +2924,7 @@ }, "/oid4vci/credential": { "post": { - "description": "Issues a dc+sd-jwt credential for an authenticated subject-access principal. Error responses use the OpenID4VCI error envelope, not RFC 9457 Problem Details.", + "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.", "operationId": "issueOid4vciCredential", "requestBody": { "content": { @@ -4112,6 +4113,7 @@ }, "/v1/credentials": { "post": { + "description": "Issues only when a fresh non-delegated registry-backed evaluation has exact compiler pins and normalized unique Relay executions for every selected root's dependency closure, matching the active configuration and public evaluation result. Source-free, delegated, and legacy evaluations remain renderable but are not issuable.", "operationId": "issueCredential", "requestBody": { "content": { diff --git a/products/notary/specs/README.md b/products/notary/specs/README.md index 67a907e8a..047649131 100644 --- a/products/notary/specs/README.md +++ b/products/notary/specs/README.md @@ -13,7 +13,9 @@ that still tracks open work. federation pairwise alignment. **[archived: reconciled design record]** - [`citizen-self-attestation-spec.md`](citizen-self-attestation-spec.md): citizen self-attestation behavior, guard order, privacy controls, rate - limits, credential issuance, and implementation status. + limits, and historical source-free credential issuance design. Current + source-free claims are evaluation-only; current credential issuance requires + exact registry-backed Relay execution provenance. **[archived: reconciled design record]** - [`federated-evaluation-mvp-spec.md`](federated-evaluation-mvp-spec.md): static-peer delegated evaluation MVP. **[implemented]** diff --git a/products/notary/specs/citizen-self-attestation-spec.md b/products/notary/specs/citizen-self-attestation-spec.md index cd195ac7a..5a171d272 100644 --- a/products/notary/specs/citizen-self-attestation-spec.md +++ b/products/notary/specs/citizen-self-attestation-spec.md @@ -9,6 +9,14 @@ > access no longer describes the delegated self-attestation work. Current > operator guidance lives in `docs/self-attestation-operator-guide.md`; the > OID4VCI facade still rejects delegated attestation transaction tokens. +> +> **Credential supersession note (2026-07-17).** Source-free credential +> issuance described below is removed. Source-free claims are evaluation-only. +> Direct and OID4VCI issuance now require a fresh, non-delegated stored +> evaluation with exact compiler pins and unique Relay execution records for +> every selected root's registry-backed dependency closure. Delegated +> 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 diff --git a/products/notary/specs/openid4vci-wallet-facade-spec.md b/products/notary/specs/openid4vci-wallet-facade-spec.md index 0dfbefe55..0c498a1ef 100644 --- a/products/notary/specs/openid4vci-wallet-facade-spec.md +++ b/products/notary/specs/openid4vci-wallet-facade-spec.md @@ -3,6 +3,11 @@ > **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. Adoption mode: profiled (OID4VCI Draft 13 subset).