From 8bc6d72e2eae254c5fac5138ebc08bf5cdc6a139 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 29 Jun 2026 13:38:02 +0700 Subject: [PATCH] Secure notary holder binding defaults Signed-off-by: Jeremi Joslin --- crates/registry-notary-core/src/config.rs | 55 +++++++++++++- crates/registry-notary-core/src/sd_jwt.rs | 34 ++++++++- .../benches/sd_jwt_bench.rs | 11 ++- crates/registry-notary-server/src/runtime.rs | 6 +- .../tests/memoization_test.rs | 12 ++- crates/registry-notary/src/main.rs | 37 +++++++++ crates/registry-notary/tests/doctor_cli.rs | 75 ++++++++++++++++++- products/notary/CHANGELOG.md | 6 ++ .../notary/docs/operator-config-reference.md | 6 +- 9 files changed, 227 insertions(+), 15 deletions(-) diff --git a/crates/registry-notary-core/src/config.rs b/crates/registry-notary-core/src/config.rs index 337c43ef..4eb238b9 100644 --- a/crates/registry-notary-core/src/config.rs +++ b/crates/registry-notary-core/src/config.rs @@ -6674,7 +6674,7 @@ pub struct HolderBindingConfig { pub mode: String, #[serde(default)] pub proof_of_possession: Option, - #[serde(default)] + #[serde(default = "default_holder_binding_allowed_did_methods")] pub allowed_did_methods: Vec, } @@ -6683,13 +6683,17 @@ impl Default for HolderBindingConfig { Self { mode: default_holder_binding_mode(), proof_of_possession: None, - allowed_did_methods: Vec::new(), + allowed_did_methods: default_holder_binding_allowed_did_methods(), } } } fn default_holder_binding_mode() -> String { - "none".to_string() + "did".to_string() +} + +fn default_holder_binding_allowed_did_methods() -> Vec { + vec![SD_JWT_VC_HOLDER_BINDING_METHOD.to_string()] } #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -8679,6 +8683,51 @@ allowed_claims: assert_eq!(profile.validity_seconds, 600); } + #[test] + fn credential_profile_default_holder_binding_is_did_jwk() { + let profile: CredentialProfileConfig = serde_norway::from_str( + r#" +format: application/dc+sd-jwt +issuer: https://issuer.example +signing_key: issuer-key +vct: https://vct.example/test +allowed_claims: + - some-claim +"#, + ) + .expect("profile YAML is valid"); + + assert_eq!(profile.holder_binding.mode, "did"); + assert_eq!( + profile.holder_binding.allowed_did_methods, + vec!["did:jwk".to_string()] + ); + assert!(profile.holder_binding.proof_of_possession.is_none()); + } + + #[test] + fn credential_profile_can_explicitly_opt_out_of_holder_binding() { + let profile: CredentialProfileConfig = serde_norway::from_str( + r#" +format: application/dc+sd-jwt +issuer: https://issuer.example +signing_key: issuer-key +vct: https://vct.example/test +holder_binding: + mode: none +allowed_claims: + - some-claim +"#, + ) + .expect("profile YAML is valid"); + + assert_eq!(profile.holder_binding.mode, "none"); + assert_eq!( + profile.holder_binding.allowed_did_methods, + vec!["did:jwk".to_string()] + ); + } + #[test] fn credential_profile_explicit_validity_is_honored() { let profile: CredentialProfileConfig = serde_norway::from_str( diff --git a/crates/registry-notary-core/src/sd_jwt.rs b/crates/registry-notary-core/src/sd_jwt.rs index 62c350fb..67d463fb 100644 --- a/crates/registry-notary-core/src/sd_jwt.rs +++ b/crates/registry-notary-core/src/sd_jwt.rs @@ -263,6 +263,7 @@ fn format_time(value: OffsetDateTime) -> String { #[cfg(test)] mod tests { use super::*; + use crate::config::HolderBindingConfig; use crate::model::{ClaimProvenance, TargetRefView, FORMAT_SD_JWT_VC, SD_JWT_VC_JWT_TYP}; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; @@ -778,6 +779,26 @@ mod tests { assert_eq!(payload["sub"], "registry-subject-ref"); } + #[test] + fn default_holder_binding_rejects_credential_without_holder() { + let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) + .expect("test issuer builds"); + let profile = default_bound_profile(); + + let err = issue( + &profile, + &issuer, + &[claim_result("first")], + "registry-subject-ref", + None, + OffsetDateTime::now_utc(), + IssueOptions::default(), + ) + .expect_err("default holder-bound profile requires holder material"); + + assert!(matches!(err, EvidenceError::HolderProofRequired)); + } + #[test] fn holder_required_profile_rejects_missing_or_unsupported_holder_binding() { let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) @@ -955,12 +976,23 @@ mod tests { signing_key: "issuer-key".to_string(), vct: "https://vct.example/test".to_string(), validity_seconds: 60, - holder_binding: Default::default(), + holder_binding: HolderBindingConfig { + mode: "none".to_string(), + proof_of_possession: None, + allowed_did_methods: Vec::new(), + }, allowed_claims: Vec::new(), disclosure: Default::default(), } } + fn default_bound_profile() -> CredentialProfileConfig { + CredentialProfileConfig { + holder_binding: Default::default(), + ..test_profile() + } + } + fn holder_required_profile() -> CredentialProfileConfig { let mut profile = test_profile(); profile.holder_binding.mode = "did".to_string(); diff --git a/crates/registry-notary-server/benches/sd_jwt_bench.rs b/crates/registry-notary-server/benches/sd_jwt_bench.rs index 9cfd5c91..eb465d66 100644 --- a/crates/registry-notary-server/benches/sd_jwt_bench.rs +++ b/crates/registry-notary-server/benches/sd_jwt_bench.rs @@ -6,8 +6,9 @@ //! - `issue` with one disclosure: the minimal single-claim credential. //! - `issue` with three disclosures: a realistic multi-claim credential. //! -//! All `issue` benches use holder_binding.mode = "none", holder_id = None, -//! and a fixed iat (OffsetDateTime::UNIX_EPOCH) to avoid wall-clock noise. +//! All `issue` benches explicitly use holder_binding.mode = "none", +//! holder_id = None, and a fixed iat (OffsetDateTime::UNIX_EPOCH) to avoid +//! wall-clock noise. use std::collections::BTreeMap; use std::hint::black_box; @@ -32,7 +33,11 @@ fn build_profile() -> CredentialProfileConfig { signing_key: "perf-key".to_string(), vct: "https://data.example.gov/credentials/smallholder/v1".to_string(), validity_seconds: 24 * 60 * 60, - holder_binding: HolderBindingConfig::default(), + holder_binding: HolderBindingConfig { + mode: "none".to_string(), + proof_of_possession: None, + allowed_did_methods: Vec::new(), + }, allowed_claims: vec![ "date-of-birth".into(), "farmer-under-4ha".into(), diff --git a/crates/registry-notary-server/src/runtime.rs b/crates/registry-notary-server/src/runtime.rs index aacf8b0d..f7c32d59 100644 --- a/crates/registry-notary-server/src/runtime.rs +++ b/crates/registry-notary-server/src/runtime.rs @@ -8319,7 +8319,11 @@ mod tests { signing_key: "issuer-key".to_string(), vct: "https://vct.example/test".to_string(), validity_seconds: 60, - holder_binding: Default::default(), + holder_binding: registry_notary_core::HolderBindingConfig { + mode: "none".to_string(), + proof_of_possession: None, + allowed_did_methods: Vec::new(), + }, allowed_claims: Vec::new(), disclosure: Default::default(), }; diff --git a/crates/registry-notary-server/tests/memoization_test.rs b/crates/registry-notary-server/tests/memoization_test.rs index dd8dbee5..eced1e1d 100644 --- a/crates/registry-notary-server/tests/memoization_test.rs +++ b/crates/registry-notary-server/tests/memoization_test.rs @@ -22,9 +22,9 @@ use registry_notary_core::{ AccessMode, BatchEvaluateItemRequest, BatchEvaluateRequest, ClaimDefinition, ClaimOperationsConfig, ClaimRef, ClaimResultView, ClaimValueConfig, ConcurrencyConfig, CredentialProfileConfig, DisclosureConfig, EvidenceConfig, EvidenceError, EvidencePrincipal, - RuleConfig, SourceBindingConfig, SourceConnectorKind, SourceFieldConfig, SourceLookupConfig, - SourceMatchingConfig, StandaloneRegistryNotaryConfig, SubjectRequest, FORMAT_CLAIM_RESULT_JSON, - FORMAT_SD_JWT_VC, + HolderBindingConfig, RuleConfig, SourceBindingConfig, SourceConnectorKind, SourceFieldConfig, + SourceLookupConfig, SourceMatchingConfig, StandaloneRegistryNotaryConfig, SubjectRequest, + FORMAT_CLAIM_RESULT_JSON, FORMAT_SD_JWT_VC, }; use registry_notary_server::{ standalone_router, BatchEvaluateOptions, EvidenceStore, MemoState, RegistryNotaryRuntime, @@ -1307,7 +1307,11 @@ fn test_credential_profile() -> CredentialProfileConfig { signing_key: "issuer-key".to_string(), vct: "https://vct.example/test".to_string(), validity_seconds: 60, - holder_binding: Default::default(), + holder_binding: HolderBindingConfig { + mode: "none".to_string(), + proof_of_possession: None, + allowed_did_methods: Vec::new(), + }, allowed_claims: Vec::new(), disclosure: Default::default(), } diff --git a/crates/registry-notary/src/main.rs b/crates/registry-notary/src/main.rs index c49b439c..81541a9a 100644 --- a/crates/registry-notary/src/main.rs +++ b/crates/registry-notary/src/main.rs @@ -381,6 +381,21 @@ impl Diagnostic { } } + fn warn_with_code( + label: impl Into, + action: impl Into, + code: impl Into, + ) -> Self { + Self { + ok: true, + warning: true, + label: label.into(), + action: Some(action.into()), + report_code: Some(code.into()), + report_severity: Some("warning"), + } + } + fn fail(label: impl Into, action: impl Into) -> Self { Self { ok: false, @@ -1263,6 +1278,7 @@ async fn doctor( } diagnostics.extend(deployment_profile_diagnostics(config, &deployment_profile)); diagnostics.extend(local_env_diagnostics(config, env_report)); + diagnostics.extend(holder_binding_diagnostics(config)); if let Some(diagnostic) = pkcs11_preflight_diagnostic(config) { diagnostics.push(diagnostic); } @@ -1371,6 +1387,27 @@ fn deployment_finding_action(finding: &EvaluatedFinding) -> String { "update deployment config or runtime settings to clear the gate".to_string() } +fn holder_binding_diagnostics(config: &StandaloneRegistryNotaryConfig) -> Vec { + let unbound_profiles = config + .evidence + .credential_profiles + .iter() + .filter(|(_, profile)| profile.holder_binding.mode == "none") + .map(|(profile_id, _)| profile_id.as_str()) + .collect::>(); + if unbound_profiles.is_empty() { + return Vec::new(); + } + vec![Diagnostic::warn_with_code( + format!( + "credential profile(s) issue unbound SD-JWT VC credentials: {}", + unbound_profiles.join(", ") + ), + "set holder_binding.mode: did with allowed_did_methods: [did:jwk], or keep mode: none only for an explicit bearer-style credential profile", + "notary.credential_profile.unbound_holder_binding", + )] +} + /// Today's date in UTC as a `YYYY-MM-DD` string, for waiver-expiry comparison. fn today_utc_date() -> String { let now = OffsetDateTime::now_utc().date(); diff --git a/crates/registry-notary/tests/doctor_cli.rs b/crates/registry-notary/tests/doctor_cli.rs index 5b29e31f..3d6f8654 100644 --- a/crates/registry-notary/tests/doctor_cli.rs +++ b/crates/registry-notary/tests/doctor_cli.rs @@ -23,6 +23,7 @@ struct TestConfigOptions<'a> { config_trust: bool, multi_instance: bool, durable_audit: Option, + unbound_credential_profile: bool, } fn write_config(tmp: &TempDir) -> PathBuf { @@ -115,6 +116,40 @@ fn write_config_with_options(tmp: &TempDir, options: TestConfigOptions<'_>) -> P ) }) .unwrap_or_default(); + let credential_profiles = if options.unbound_credential_profile { + r#" credential_profiles: + unbound_sd_jwt: + format: application/dc+sd-jwt + issuer: did:web:issuer.example + signing_key: issuer + vct: https://issuer.example/credentials/unbound + holder_binding: + mode: none + allowed_claims: + - person-is-alive +"# + .to_string() + } else { + String::new() + }; + let credential_profile_claims = if options.unbound_credential_profile { + r#" claims: + - id: person-is-alive + title: Person is alive + version: "1.0" + subject_type: person + rule: + type: cel + expression: "true" + formats: + - application/dc+sd-jwt + credential_profiles: + - unbound_sd_jwt +"# + .to_string() + } else { + String::new() + }; std::fs::write( &path, format!( @@ -139,7 +174,7 @@ server: alg: EdDSA kid: did:web:issuer.example#key-1 status: active -{source_connections} +{source_connections}{credential_profiles}{credential_profile_claims} "# ), ) @@ -950,6 +985,44 @@ fn doctor_json_local_insecure_source_url_has_no_profile_finding() { ); } +#[test] +fn doctor_json_warns_on_explicit_unbound_credential_profile() { + let tmp = TempDir::new().expect("tempdir"); + let config = write_config_with_options( + &tmp, + TestConfigOptions { + unbound_credential_profile: true, + ..TestConfigOptions::default() + }, + ); + let env_file = write_env_file(&tmp); + + let output = doctor_command(&config, Some(&env_file)) + .args(["--profile", "local", "--format", "json"]) + .output() + .expect("doctor runs"); + + assert!( + output.status.success(), + "explicit unbound profile should warn, not fail\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).expect("stdout is utf8"); + let report: Value = serde_json::from_str(&stdout).expect("doctor emits JSON"); + assert_product_diagnostic_report(&report); + assert_eq!(report["status"], "warning"); + + let diagnostic = + diagnostic_with_code(&report, "notary.credential_profile.unbound_holder_binding") + .expect("unbound holder-binding warning"); + assert_eq!(diagnostic["severity"], "warning"); + assert!(diagnostic["message"] + .as_str() + .expect("message string") + .contains("unbound_sd_jwt")); +} + #[test] fn doctor_json_reports_success_as_single_redacted_document() { let tmp = TempDir::new().expect("tempdir"); diff --git a/products/notary/CHANGELOG.md b/products/notary/CHANGELOG.md index 9e06f839..0fbdf4b7 100644 --- a/products/notary/CHANGELOG.md +++ b/products/notary/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING: credential profiles now default to holder binding.** When + `holder_binding` is omitted, Registry Notary defaults to `mode: did` with + `allowed_did_methods: [did:jwk]`, so direct SD-JWT VC issuance requires a + holder DID before it can mint a credential. Deployments that intentionally + issue unbound, bearer-style credentials must set `holder_binding.mode: none` + explicitly; `registry-notary doctor` now warns on those profiles. - Renamed the binary package from `registry-notary-bin` to `registry-notary` so the Cargo package, executable, release artifact, and visible version output use the same user-facing command name. diff --git a/products/notary/docs/operator-config-reference.md b/products/notary/docs/operator-config-reference.md index 884ac8c0..68871e3a 100644 --- a/products/notary/docs/operator-config-reference.md +++ b/products/notary/docs/operator-config-reference.md @@ -902,14 +902,16 @@ Notes: Credential profiles control SD-JWT VC issuance. -Required fields: +Profile fields: - `format: application/dc+sd-jwt`. - `issuer`: DID issuer for the credential. - `signing_key`: key id from `evidence.signing_keys`. - `vct`: credential type URL. - `allowed_claims`: explicit allow-list. Empty allow-lists are rejected. -- `holder_binding`: currently implemented holder binding is `did:jwk`. +- `holder_binding`: defaults to `mode: did` with `did:jwk` as the allowed + method. Set `mode: none` only for an explicit bearer-style credential profile; + `registry-notary doctor` reports a warning for unbound profiles. - `disclosure.allowed`: disclosure modes the profile may carry. `validity_seconds` defaults to 600 and must be between 1 and