diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e1dac84b..7349f09a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,20 +113,21 @@ cargo fmt --check cargo check --locked --workspace --all-targets cargo clippy --workspace --all-targets -- -D warnings cargo test --locked --workspace -cargo deny check bans licenses sources +cargo deny check (cd products/notary && just openapi-check) (cd crates/registry-relay && just openapi-contract) ``` -The cargo-deny advisories section is not yet in the root gate (open RUSTSEC -advisories on quick-xml have no upstream fix yet); run -`cargo deny check advisories` locally. +The root gate runs the full `cargo deny check`, advisories included. Open +RUSTSEC advisories with no upstream fix are ignored in `deny.toml` with a +scoped rationale and a review trigger; a newly published advisory fails CI +until it is fixed or gets its own documented ignore. Release and lab source checks: ```bash python3 -m unittest release/scripts/test_registry_release.py -release/scripts/registry-release validate release/manifests/registry-stack-beta-6.yaml +release/scripts/registry-release validate release/manifests/registry-stack-beta-9.yaml release/scripts/registry-release audit release/manifests/import-map-2026-06-24.yaml REGISTRY_LAB_RELEASE_SOURCE_MODE=monorepo lab/scripts/check-release-source-model.sh python3 -m unittest lab/scripts/test_check_release_source_model.py diff --git a/crates/registry-notary-core/src/config.rs b/crates/registry-notary-core/src/config.rs index e51db6c6..865e7af3 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..bc8dd6e7 100644 --- a/crates/registry-notary-server/benches/sd_jwt_bench.rs +++ b/crates/registry-notary-server/benches/sd_jwt_bench.rs @@ -32,7 +32,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/api.rs b/crates/registry-notary-server/src/api.rs index 6ac84e27..ce60e40a 100644 --- a/crates/registry-notary-server/src/api.rs +++ b/crates/registry-notary-server/src/api.rs @@ -15850,7 +15850,11 @@ evaluation_profiles: claims: Some(vec!["person-is-alive".to_string()]), disclosure: Some("predicate".to_string()), purpose: None, - holder: None, + holder: Some(HolderRequest { + binding: Some("did".to_string()), + id: Some(holder_did_jwk()), + proof: None, + }), })), ) .await; 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..dd80c898 100644 --- a/crates/registry-notary-server/tests/memoization_test.rs +++ b/crates/registry-notary-server/tests/memoization_test.rs @@ -30,6 +30,7 @@ use registry_notary_server::{ standalone_router, BatchEvaluateOptions, EvidenceStore, MemoState, RegistryNotaryRuntime, SourceReader, }; +use registry_platform_crypto::{did_jwk_from_public_jwk, PrivateJwk}; use serde_json::{json, Value}; use std::collections::BTreeMap; use std::future::Future; @@ -1243,12 +1244,13 @@ async fn subjects_sharing_memoized_read_produce_identical_iat() { .target_ref .handle .as_str(); + let holder_id = test_holder_did_jwk(); let signed_1 = sd_jwt_issue( &profile, &issuer, &results, subject_ref, - None, + Some(&holder_id), iat_anchor, IssueOptions::default(), ) @@ -1262,7 +1264,7 @@ async fn subjects_sharing_memoized_read_produce_identical_iat() { &issuer, &results, subject_ref, - None, + Some(&holder_id), iat_anchor, IssueOptions::default(), ) @@ -1300,6 +1302,13 @@ fn jwt_payload_iat(compact: &str) -> i64 { /// Same Ed25519 JWK as the core `sd_jwt` unit tests use. Test-only fixture. const TEST_ISSUER_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA"}"#; +const TEST_HOLDER_JWK: &str = r#"{"crv":"Ed25519","d":"f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys","x":"pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec","kty":"OKP","alg":"EdDSA"}"#; + +fn test_holder_did_jwk() -> String { + let holder = PrivateJwk::parse(TEST_HOLDER_JWK).expect("holder JWK parses"); + did_jwk_from_public_jwk(&holder.public()).expect("holder did:jwk encodes") +} + fn test_credential_profile() -> CredentialProfileConfig { CredentialProfileConfig { format: FORMAT_SD_JWT_VC.to_string(), diff --git a/crates/registry-notary/src/main.rs b/crates/registry-notary/src/main.rs index c49b439c..b90e8982 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(); @@ -2789,6 +2826,8 @@ fn dci_config_yaml(options: &InitDciOptions) -> String { signing_key: registry-notary-demo vct: https://registry-notary.local/credentials/dci-record allowed_claims: [{claim_id}] + holder_binding: + mode: none "# ) } else { @@ -3908,6 +3947,7 @@ ESCAPED="client \"quoted\" value" # comment with "quote" assert!( message.contains("TEST_DOCTOR_OAUTH_CLIENT_ID") + || message.contains("TEST_DOCTOR_OAUTH_CLIENT_SECRET") || message.contains("audit.hash_secret_env"), "unexpected error: {message}" ); @@ -4175,6 +4215,13 @@ evidence: let config: StandaloneRegistryNotaryConfig = serde_norway::from_str(&yaml).expect("generated config parses"); config.validate().expect("generated config validates"); + let profile = config + .evidence + .credential_profiles + .get("dci_record_sd_jwt") + .expect("demo DCI credential profile exists"); + assert_eq!(profile.holder_binding.mode, "none"); + assert!(profile.holder_binding.proof_of_possession.is_none()); } #[test] diff --git a/crates/registry-notary/tests/doctor_cli.rs b/crates/registry-notary/tests/doctor_cli.rs index 98662fd1..1637f6bd 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} "# ), ) @@ -948,6 +983,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/docs/site/package-lock.json b/docs/site/package-lock.json index d85c3c49..3a9f0403 100644 --- a/docs/site/package-lock.json +++ b/docs/site/package-lock.json @@ -9,22 +9,23 @@ "version": "0.1.0", "dependencies": { "@astrojs/sitemap": "^3.7.3", - "@astrojs/starlight": "^0.41.1", + "@astrojs/starlight": "^0.41.2", "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/public-sans": "^5.2.7", - "astro": "^7.0.2", + "astro": "7.0.2", "astro-mermaid": "^2.1.0", "mermaid": "^11.15.0", - "starlight-openapi": "^0.25.3" + "starlight-openapi": "^0.26.0" }, "devDependencies": { "@astrojs/check": "^0.9.9", - "@redocly/cli": "^2.31.4", + "@astrojs/markdown-remark": "7.2.0", + "@redocly/cli": "^2.36.0", "@vvago/vale": "^3.12.0", "linkinator": "^7.6.1", - "markdownlint-cli2": "^0.22.1", + "markdownlint-cli2": "^0.23.0", "remark-gfm": "^4.0.1", - "starlight-llms-txt": "^0.10.0", + "starlight-llms-txt": "^0.11.0", "typescript": "^6.0.3", "yaml": "^2.8.1" }, @@ -334,6 +335,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.2.0.tgz", "integrity": "sha512-+YxmVQu1Bd+MFfSzjq1rOJvD9+nIOJzz5YIIhdIH01RrxRkKbyKoEgyIqP3yv51MhzMDgd79QaPv+kCVPT8vHw==", + "devOptional": true, "license": "MIT", "dependencies": { "@astrojs/internal-helpers": "0.10.0", @@ -356,25 +358,41 @@ } }, "node_modules/@astrojs/markdown-satteri": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.2.tgz", - "integrity": "sha512-feXuUPy41gVfeM7EHT1ciUim8ozGr+YHXab9uUBc1Hk8y60DQosO8ldL+AoPXnCAoGj1OChwHfvXmmJ6XVnY9A==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.3.tgz", + "integrity": "sha512-Lje33Ittd8UQGgbIIWQvhPkj5X5c4b1sZnZWX3JQV/AWpfbuQGxVi2ONt6+ScydcwfR4egilslEWyczMclrJ1g==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.10.0", + "@astrojs/internal-helpers": "0.10.1", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "satteri": "^0.9.1" } }, + "node_modules/@astrojs/markdown-satteri/node_modules/@astrojs/internal-helpers": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.1.tgz", + "integrity": "sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.1.1", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, "node_modules/@astrojs/mdx": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-7.0.0.tgz", - "integrity": "sha512-LKwNA8nnLtEM0auoP6OfH/UnlKe1Ub59qZjbcYkZjPBGw6PkJewWkA/1qwLpECvV6gMDd6TR6eqV9p/VYZrcrQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-7.0.2.tgz", + "integrity": "sha512-l+sJY5U1KkGZUdr+bIL4Y6BefeS549qoSHVSkUSs6A9INwdCND+/0+vN0NroPBXwl5Vcg5u78t7VQRsJjePxbw==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.10.0", - "@astrojs/markdown-remark": "7.2.0", + "@astrojs/internal-helpers": "0.10.1", + "@astrojs/markdown-remark": "7.2.1", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.16.0", "es-module-lexer": "^2.0.0", @@ -392,8 +410,8 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@astrojs/markdown-satteri": "^0.3.1-alpha.0", - "astro": "^7.0.0-alpha.0" + "@astrojs/markdown-satteri": "^0.3.1", + "astro": "^7.0.0" }, "peerDependenciesMeta": { "@astrojs/markdown-satteri": { @@ -401,6 +419,47 @@ } } }, + "node_modules/@astrojs/mdx/node_modules/@astrojs/internal-helpers": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.1.tgz", + "integrity": "sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.1.1", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, + "node_modules/@astrojs/mdx/node_modules/@astrojs/markdown-remark": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.2.1.tgz", + "integrity": "sha512-jPVNIqTvk+yKviikszv/Y1U4jGUSKpp/Nw48QZV4qjWgp70j4Lkq3lhSDRbWwCfgKvEyO9GHuVbV1dM2WYXy1w==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.1", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.1.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, "node_modules/@astrojs/prism": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", @@ -425,9 +484,9 @@ } }, "node_modules/@astrojs/starlight": { - "version": "0.41.1", - "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.41.1.tgz", - "integrity": "sha512-avf2OmrVg6GdVU18juebjjIIuLa+uS3syHuJ/3yDaEFP/8it+YvcxRrYDSf7K6rC4v770UxIddba2hAqQyTeYA==", + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.41.2.tgz", + "integrity": "sha512-1h9AhFW5uWgqEoTqOVLMnXvawa0TaqLSr14UyTbnArQL6W0TolTfMWpG5hrvbw+NO6wwdSOFz5iwhVtkGUBtsw==", "license": "MIT", "dependencies": { "@astrojs/markdown-satteri": "^0.3.2", @@ -2146,9 +2205,9 @@ } }, "node_modules/@redocly/cli": { - "version": "2.35.0", - "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.35.0.tgz", - "integrity": "sha512-p25TtRIN85KewtpPVdTuakmKHCcZkmV+ygQx2dJgvpXpNqZeoyjmnNThev1oyIc4t2h2mGkompan/LgPtG/u3g==", + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.36.0.tgz", + "integrity": "sha512-0ky8u/Zzx4zT35rJZY27KOuzXMI29JLt5FMbfzFV7AtVlK3DLjDmJjqprnhqUMJlt04R+FKSXahBZo1dUF6R5A==", "dev": true, "license": "MIT", "bin": { @@ -3380,6 +3439,18 @@ } } }, + "node_modules/astro/node_modules/@astrojs/markdown-satteri": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.2.tgz", + "integrity": "sha512-feXuUPy41gVfeM7EHT1ciUim8ozGr+YHXab9uUBc1Hk8y60DQosO8ldL+AoPXnCAoGj1OChwHfvXmmJ6XVnY9A==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.0", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "satteri": "^0.9.1" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -6679,9 +6750,9 @@ } }, "node_modules/markdownlint": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.40.0.tgz", - "integrity": "sha512-UKybllYNheWac61Ia7T6fzuQNDZimFIpCg2w6hHjgV1Qu0w1TV0LlSgryUGzM0bkKQCBhy2FDhEELB73Kb0kAg==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.41.0.tgz", + "integrity": "sha512-xMUI3ChBuRuxuLF4ENvCZyS8z/+Jly1coUcZwErKLIB3sDj7ojpaTBa1e9YVPhSN4jGEIjYGQCldbTJS/hqS+A==", "dev": true, "license": "MIT", "dependencies": { @@ -6693,37 +6764,37 @@ "micromark-extension-gfm-table": "2.1.1", "micromark-extension-math": "3.1.0", "micromark-util-types": "2.0.2", - "string-width": "8.1.0" + "string-width": "8.2.1" }, "engines": { - "node": ">=20" + "node": ">=22" }, "funding": { "url": "https://github.com/sponsors/DavidAnson" } }, "node_modules/markdownlint-cli2": { - "version": "0.22.1", - "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.22.1.tgz", - "integrity": "sha512-X14ZbytybDCXAViDmtN4DKLt9ZTrRn+oOrxTYlg3a65jS6QcYYbAkGPh/En2L/GDNbFYJ6lKaQSUNrrbN1bPrw==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/markdownlint-cli2/-/markdownlint-cli2-0.23.0.tgz", + "integrity": "sha512-1nmgQmU/ZTMRVwYCDs7i1HI3zfBISnT2NNRv+9V01oOLZbAtqL+a7tldpPhBWBVBten3FqhMCGV6EUh9McqutQ==", "dev": true, "license": "MIT", "dependencies": { "globby": "16.2.0", - "js-yaml": "4.1.1", + "js-yaml": "5.2.0", "jsonc-parser": "3.3.1", "jsonpointer": "5.0.1", - "markdown-it": "14.1.1", - "markdownlint": "0.40.0", + "markdown-it": "14.2.0", + "markdownlint": "0.41.0", "markdownlint-cli2-formatter-default": "0.0.6", "micromatch": "4.0.8", - "smol-toml": "1.6.1" + "smol-toml": "1.7.0" }, "bin": { "markdownlint-cli2": "markdownlint-cli2-bin.mjs" }, "engines": { - "node": ">=20" + "node": ">=22" }, "funding": { "url": "https://github.com/sponsors/DavidAnson" @@ -6742,19 +6813,6 @@ "markdownlint-cli2": ">=0.0.4" } }, - "node_modules/markdownlint-cli2/node_modules/smol-toml": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", - "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 18" - }, - "funding": { - "url": "https://github.com/sponsors/cyyynthia" - } - }, "node_modules/marked": { "version": "17.0.6", "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", @@ -9377,13 +9435,13 @@ } }, "node_modules/starlight-llms-txt": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/starlight-llms-txt/-/starlight-llms-txt-0.10.0.tgz", - "integrity": "sha512-LgkSjkvdACsGHkFq1ES00F0BU4lRepjJoaUmOgxBxNWx4txwpySVPtntKdAvDvlhinyN0ZBRpnAsN/sVQ1UEfA==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/starlight-llms-txt/-/starlight-llms-txt-0.11.0.tgz", + "integrity": "sha512-83TU5zPgJhL9fXIWAOWjQjyQ6EKocshEjJyA4zOJjOqu/oxQsrTpYGYxjHLyfZe4LRD52N9eCuykSkaIYwUyDw==", "dev": true, "license": "MIT", "dependencies": { - "@astrojs/mdx": "^5.0.4", + "@astrojs/mdx": "^7.0.0", "@types/hast": "^3.0.4", "@types/micromatch": "^4.0.10", "github-slugger": "^2.0.0", @@ -9397,82 +9455,14 @@ "unist-util-remove": "^4.0.0" }, "peerDependencies": { - "@astrojs/starlight": ">=0.38.0", - "astro": "^6.0.0" - } - }, - "node_modules/starlight-llms-txt/node_modules/@astrojs/internal-helpers": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.9.1.tgz", - "integrity": "sha512-1pWuARqYom/TzuU3+0ZugsTrKlUydWKuULmDqSMTuonY+9IRDUEGKX/8PXQ1nBxRq3w85uGtd9q9SXfqEldMIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^4.0.4" - } - }, - "node_modules/starlight-llms-txt/node_modules/@astrojs/markdown-remark": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.1.2.tgz", - "integrity": "sha512-caXZ4Dc2St2dW8luEg22GlP0gupLdztCTQE4EzZOxW1pqWXz9mbeJEuHUkgDYcKWW8tjIHkydYDhWLVoxJ327Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/internal-helpers": "0.9.1", - "@astrojs/prism": "4.0.2", - "github-slugger": "^2.0.0", - "hast-util-from-html": "^2.0.3", - "hast-util-to-text": "^4.0.2", - "js-yaml": "^4.1.1", - "mdast-util-definitions": "^6.0.0", - "rehype-raw": "^7.0.0", - "rehype-stringify": "^10.0.1", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.1.2", - "remark-smartypants": "^3.0.2", - "retext-smartypants": "^6.2.0", - "shiki": "^4.0.0", - "smol-toml": "^1.6.0", - "unified": "^11.0.5", - "unist-util-remove-position": "^5.0.0", - "unist-util-visit": "^5.1.0", - "unist-util-visit-parents": "^6.0.2", - "vfile": "^6.0.3" - } - }, - "node_modules/starlight-llms-txt/node_modules/@astrojs/mdx": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-5.0.6.tgz", - "integrity": "sha512-4dKe0ZMmqujofPNDHahzClkwinn9f8jHPcaXcgdGvPAlboD2mjzkUCofli2cBnxYAkdfhC6d50gBJ8i/cH8gHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@astrojs/markdown-remark": "7.1.2", - "@mdx-js/mdx": "^3.1.1", - "acorn": "^8.16.0", - "es-module-lexer": "^2.0.0", - "estree-util-visit": "^2.0.0", - "hast-util-to-html": "^9.0.5", - "piccolore": "^0.1.3", - "rehype-raw": "^7.0.0", - "remark-gfm": "^4.0.1", - "remark-smartypants": "^3.0.2", - "source-map": "^0.7.6", - "unist-util-visit": "^5.1.0", - "vfile": "^6.0.3" - }, - "engines": { - "node": ">=22.12.0" - }, - "peerDependencies": { - "astro": "^6.0.0" + "@astrojs/starlight": ">=0.41.0", + "astro": "^7.0.0" } }, "node_modules/starlight-openapi": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/starlight-openapi/-/starlight-openapi-0.25.3.tgz", - "integrity": "sha512-erFtGyU1NI1mHmlRrVo/SWLdVvjFr1Qsxwz1bnOss/rOSBH3Tgq39rSlkXr9HWbSTnbjIpZkitgYw+s73zk5pA==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/starlight-openapi/-/starlight-openapi-0.26.0.tgz", + "integrity": "sha512-+WJnS7nIW42KfgmtBvTdj0Sx0LR+3kYPCdXMoiiMxKWDTF9TBd8KhTRDqNiPPrksej+gItZV/ROKwsXoF2/qZQ==", "license": "MIT", "dependencies": { "@readme/openapi-parser": "^4.1.2", @@ -9484,9 +9474,9 @@ "node": ">=22.12.0" }, "peerDependencies": { - "@astrojs/markdown-remark": ">=7.0.0", - "@astrojs/starlight": ">=0.38.0", - "astro": ">=6.0.0" + "@astrojs/markdown-satteri": ">=0.3.2", + "@astrojs/starlight": ">=0.41.0", + "astro": ">=7.0.2" } }, "node_modules/stream-combiner": { @@ -9516,14 +9506,14 @@ } }, "node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { "node": ">=20" diff --git a/docs/site/package.json b/docs/site/package.json index 85aade7c..b9e796b4 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -32,22 +32,23 @@ }, "dependencies": { "@astrojs/sitemap": "^3.7.3", - "@astrojs/starlight": "^0.41.1", + "@astrojs/starlight": "^0.41.2", "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/public-sans": "^5.2.7", - "astro": "^7.0.2", + "astro": "7.0.2", "astro-mermaid": "^2.1.0", "mermaid": "^11.15.0", - "starlight-openapi": "^0.25.3" + "starlight-openapi": "^0.26.0" }, "devDependencies": { "@astrojs/check": "^0.9.9", - "@redocly/cli": "^2.31.4", + "@astrojs/markdown-remark": "7.2.0", + "@redocly/cli": "^2.36.0", "@vvago/vale": "^3.12.0", "linkinator": "^7.6.1", - "markdownlint-cli2": "^0.22.1", + "markdownlint-cli2": "^0.23.0", "remark-gfm": "^4.0.1", - "starlight-llms-txt": "^0.10.0", + "starlight-llms-txt": "^0.11.0", "typescript": "^6.0.3", "yaml": "^2.8.1" }, 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 23d42b3c..28d5f880 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 @@ -44,7 +44,7 @@ Minimization also applies to what leaves the boundary. A claim's result can be s The redacted mode is worth a reviewer's attention because it shows minimization without loss of accountability: a redacted result carries neither the underlying value nor the satisfaction outcome, yet the evaluation stays referenceable and verifiable through an evaluation identifier, a verification identifier, and a claim hash. You can audit that an evaluation happened and verify it later without the result itself disclosing anything about the person. -When the system issues a credential rather than returning a direct answer, minimization continues at presentation time. 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. Selective disclosure here is at the claim (or configured-projection) level: by default each claim becomes one disclosure carrying its whole value, so an object-valued claim is revealed as a unit unless an explicit projection splits it into separately-disclosable fields. Holder binding (tying the credential to a holder key with `did:jwk`) is a per-profile setting that defaults to off, though the self-attestation (wallet) issuance path requires it; the direct claim or evaluation result is not a credential and is never holder-bound. One caveat for a reviewer: the issuance surface is a profiled, partial subset, not a full credential-issuer implementation, and should not be read as general wallet interoperability or full standards conformance. +When the system issues a credential rather than returning a direct answer, minimization continues at presentation time. 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. Selective disclosure here is at the claim (or configured-projection) level: by default each claim becomes one disclosure carrying its whole value, so an object-valued claim is revealed as a unit unless an explicit projection splits it into separately-disclosable fields. Holder binding (tying the credential to a holder key with `did:jwk`) is enabled by default per profile, and the self-attestation (wallet) issuance path requires proof of possession; an operator can explicitly configure `holder_binding.mode: none` for a bearer-style credential profile. The direct claim or evaluation result is not a credential and is never holder-bound. One caveat for a reviewer: the issuance surface is a profiled, partial subset, not a full credential-issuer implementation, and should not be read as general wallet interoperability or full standards conformance. A further minimization detail guards against a subtler leak. A failed subject match collapses by default to a single public reason (evidence not available), with the granular reason kept only in the audit record, so the lookup surface cannot by default be used to confirm whether a person exists in a registry. diff --git a/docs/site/src/content/docs/explanation/disclosure-modes-and-computed-answers.mdx b/docs/site/src/content/docs/explanation/disclosure-modes-and-computed-answers.mdx index 4c00b700..38545142 100644 --- a/docs/site/src/content/docs/explanation/disclosure-modes-and-computed-answers.mdx +++ b/docs/site/src/content/docs/explanation/disclosure-modes-and-computed-answers.mdx @@ -25,7 +25,7 @@ Per-claim disclosure control is enforced at runtime by the service. It is not le ## The evaluation pipeline: computed answers, not record handoffs -The reason an answer can stand in for a record is that the caller never supplies the value being asked about. To evaluate a claim, the caller sends a **subject identifier** and a **claim id** over a REST call authenticated the way the rest of the service is: an API key or bearer token in static-credential mode, or an OIDC token, whichever your deployment is configured for. Registry Notary then performs the evaluation against its own configured sources. The caller does not supply the evaluated value, and cannot inject one. +The reason an answer can stand in for a record is that the caller never supplies the value being asked about. To evaluate a claim, the caller sends a **claim id** plus the subject-resolution inputs the claim policy admits: a subject identifier, and, when matching policy requires them, target or requester attributes, further identifiers, or relationship attributes. The REST call is authenticated the way the rest of the service is: an API key or bearer token in static-credential mode, or an OIDC token, whichever your deployment is configured for. Registry Notary then performs the evaluation against its own configured sources. The caller does not supply the evaluated value, and cannot inject one. The evaluation runs as a pipeline: a source connector resolves facts about the subject, a rule computes the configured condition, a disclosure mode shapes what leaves the service, and a response format carries the result. Because Notary computes the answer itself, it can return that computed answer rather than handing back the source record it read. @@ -37,11 +37,11 @@ The disclosure mode fixes how much of the computed answer the caller receives: | Mode | What the caller receives | |------|--------------------------| -| `value` | the full evaluated value | -| `predicate` | only the true/false satisfaction of the rule | +| `value` | the evaluated value, less any object fields the policy redacts | +| `predicate` | only the true/false satisfaction of a boolean result | | `redacted` | no value and no satisfaction outcome | -`value` returns the full value, so this mode does hand over the computed result in full; it is the least private of the three and is appropriate only where the use case calls for the actual value. `predicate` returns only the boolean satisfaction. `redacted` returns neither the underlying value nor the yes/no outcome. +`value` returns the computed result, minus configured object-field redactions, so this mode is the least private of the three and is appropriate only where the use case calls for the actual value. `predicate` returns only the boolean satisfaction. `redacted` returns neither the underlying value nor the yes/no outcome in the standard claim-result body. Every evaluation result records which disclosure mode was applied, so a downstream system can tell how much was revealed. @@ -57,7 +57,7 @@ The difference between the two matters. `predicate` is not "zero disclosure"; it ## How disclosure policy is configured per claim -The caller does not freely pick from all three modes. Each claim configures a **disclosure policy**: a `default` mode plus an `allowed` set drawn from the three modes. A caller may request a mode: Notary honours it when it is in that claim's allowed set and refuses it (`claim.disclosure_not_allowed`) otherwise; when the caller requests no mode, Notary applies the claim's default. So the policy bounds which modes are allowed, and the caller chooses among the allowed modes. A privacy-sensitive claim can be configured to default to the least-revealing mode that still answers the question. +The caller does not freely pick from all three modes. Each claim configures a **disclosure policy**: a `default` mode, an `allowed` set drawn from the three modes, and a `downgrade` policy. A caller may request a mode: Notary honours it when it is in that claim's allowed set. Under the default `deny` downgrade, a disallowed request is refused (`claim.disclosure_not_allowed`); a `default` or `redacted` downgrade substitutes that fallback mode when the fallback is itself allowed. When the caller requests no mode, Notary applies the claim's default. So the policy bounds which modes are allowed, and the caller chooses among the allowed modes. A privacy-sensitive claim can be configured to default to the least-revealing mode that still answers the question. ## What a redacted result still reveals (and to whom) @@ -80,6 +80,7 @@ A few boundaries matter when you evaluate this design: - The caller cannot inject a value, but matching strictness is configured: The "computed answer avoids sharing the record" guarantee rests on Notary computing the answer from a subject id and claim id. Identity matching is only as strict as its configured policy. - A sidecar token is not a per-subject boundary: A source adapter sidecar's internal static bearer token is not by itself an end-user, tenant, or subject authorization boundary; the sidecar hop does not enforce per-subject access. - Disclosure modes are not credential selective disclosure: A holder's later choice of which credential fields to present is a separate mechanism from Notary's three evaluation modes. Do not read `value`/`predicate`/`redacted` as the same thing as a wallet holder selectively disclosing credential fields. +- Credential selective disclosure is claim-output scoped by default: without an explicit projection, each claim becomes one disclosure carrying the whole value, so an object-valued output is revealed as a unit. - Aligning with a standard is not conforming to it: This page describes Notary's own disclosure behaviour. It is not an endorsement of, or conformance to, any outside specification. ## Related 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 8a8106d6..4dfadd4c 100644 --- a/docs/site/src/content/docs/explanation/dpi-safeguards-alignment.mdx +++ b/docs/site/src/content/docs/explanation/dpi-safeguards-alignment.mdx @@ -99,7 +99,7 @@ principles; the remaining principles are governance and institutional work outsi | Transparency and accountability (F4) | Portable metadata, runtime metadata, OpenAPI references, stable error codes, audit records, signed responses, credentials, and the [standards register](../../reference/standards/) make behavior reviewable. | Public governance, remedies, procurement discipline, and independent accountability mechanisms are institutional controls. | | Evolve with evidence (O2) | [ITB and SEMIC validation evidence](../../reference/itb-semic-evidence/), the [security self-assessment](../../security/self-assessment/), and [OpenSSF status and release verification evidence](../../security/openssf-evidence/) give assessors material they can re-run and re-check. | Independent assessments, audits, engagement with affected people, and acting on findings remain program and institutional responsibilities. | | Build and share open assets (O9) | The stack emits or uses open, standards-shaped artifacts and documented HTTP contracts rather than one-off integration agreements. | External conformance and certification require separate validation against each standard or program rule. | -| Autonomy and agency (F6) | Notary credentials support holder binding through `did:jwk` when the credential profile enables it (off by default); a bound credential can be presented only by the key holder; claim results and credentials can avoid exposing more than the configured disclosure mode allows. | The stack does not provide a complete wallet, consent journey, opt-out path, appeal process, or assisted service channel. | +| Autonomy and agency (F6) | Notary credentials default to holder binding through `did:jwk`; a bound credential can be presented only by the key holder; claim results and credentials can avoid exposing more than the configured disclosure mode allows. Operators can still configure an explicit unbound credential profile. | The stack does not provide a complete wallet, consent journey, opt-out path, appeal process, or assisted service channel. | | Inclusion and non-discrimination (F2, F3, O6) | Metadata, audit, and claim evidence can help reviewers inspect what a service exposes and how it behaves. | Accessibility, language access, community participation, gender or disability inclusion, and non-digital alternatives are not solved by these components. | ## Fit boundaries diff --git a/docs/site/src/content/docs/explanation/known-limitations.mdx b/docs/site/src/content/docs/explanation/known-limitations.mdx index eeca3373..301f4dc8 100644 --- a/docs/site/src/content/docs/explanation/known-limitations.mdx +++ b/docs/site/src/content/docs/explanation/known-limitations.mdx @@ -55,7 +55,7 @@ For the rationale behind each of these, follow the linked specifications in each Registry Notary issues credentials, but the issuance surface is deliberately narrow. Read the full context in the [Registry Notary protocol](../../spec/rs-pr-notary/). -- One credential format, one binding method when binding is enabled: Issued Registry Notary credentials are [SD-JWT VC](../../reference/standards/) only. Holder binding is a per-credential-profile setting that defaults to off (`mode: none`), in which case the issued credential is unbound; when a profile turns it on (`mode: did`), `did:jwk` is the single supported proof-of-possession method, and no other format or binding method is issued. The self-attestation (wallet) issuance path requires a profile with binding enabled, so a credential issued down that path is always `did:jwk`-bound with proof-of-possession. +- One credential format, one binding method: Issued Registry Notary credentials are [SD-JWT VC](../../reference/standards/) only. Holder binding is a per-credential-profile setting that defaults to `mode: did` with `did:jwk` as the supported holder DID method. Direct issuance requires a holder DID by default; it verifies a fresh holder proof only when `proof_of_possession: required` is configured. An operator can explicitly set `holder_binding.mode: none` for a bearer-style credential profile, in which case `registry-notary doctor` reports a warning and the issued credential is unbound. The self-attestation (wallet) issuance path requires binding, so a credential issued down that path is always `did:jwk`-bound with proof-of-possession. - A profiled issuance subset, not a full issuer: The [OID4VCI](../../reference/standards/) surface is a scoped self-attestation issuance profile (a profiled subset of Draft 13 using the `dc+sd-jwt` format), not a full OID4VCI issuer and not a claim of general external-wallet interoperability. 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. - 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 public status endpoint reads a credential's status, and only the admin status endpoint (which requires the `registry:notary:admin` scope) can mark a credential `revoked`. There is no `/.well-known/jwt-vc-issuer` endpoint and no built-in data-subject erasure workflow. A rotated-out signing key may remain published for verification, which is not revocation. These are documented pilot limitations, recorded in `SECURITY.md`. diff --git a/docs/site/src/content/docs/explanation/records-stay-home.mdx b/docs/site/src/content/docs/explanation/records-stay-home.mdx index 2e18a93a..b16695ac 100644 --- a/docs/site/src/content/docs/explanation/records-stay-home.mdx +++ b/docs/site/src/content/docs/explanation/records-stay-home.mdx @@ -16,22 +16,26 @@ An institution that runs a civil registry, a social-protection database, or a he registry already holds the records it needs. Registry Stack lets it **answer questions about those records** (*is this person alive? is this household eligible?*) and return a result another system can trust, while the records -themselves are **read where they already live, never written back, and never handed -over**. +themselves are **read where they already live, never written back, and not copied into a +central exchange**. This page explains what that means in practice: what stays inside the institution's boundary, what crosses it, and (equally important) what the design does and does not guarantee. ## A question goes in, an answer comes out -The mental model is one sentence: **a scoped question crosses into the institution, the -record is read in place, and only a computed answer crosses back out.** +The Notary mental model is one sentence: **a scoped question crosses into the +institution, the record is read in place, and a computed answer crosses back out.** -A caller never sends the value it is asking about and never receives the underlying -record. It sends a subject identifier and the id of a *claim* (a single, pre-modelled -question) and receives one of a few narrow shapes of answer: a yes/no, a single value, a -machine-readable evaluation result, or a credential the subject can carry in a wallet. The source row that the -answer was computed from stays behind. +A Notary caller never sends the value it is asking about and never receives the +underlying record as the answer. +It sends the id of a *claim* (a single, pre-modelled question) and the inputs that +claim needs to resolve the subject: a subject identifier, plus, where the claim's +matching policy requires them, target or requester attributes, further identifiers, or +relationship attributes. +It receives one of a few narrow shapes of answer: a yes/no, a single value, a +machine-readable evaluation result, or a credential the subject can carry in a wallet. +The source row that the answer was computed from stays behind. ## The boundary @@ -50,9 +54,11 @@ flowchart LR end caller["Caller / verifier"] holder(["Subject / wallet"]) - caller == "request: subject id + claim id + scope" ==> notary + caller == "request: claim id + subject inputs + scope" ==> notary notary == "answer: yes/no · value · evaluation result" ==> caller - notary == "holder-bound credential" ==> holder + notary == "issued credential" ==> holder + caller == "scoped record read" ==> relay + relay == "authorized source records" ==> caller classDef inside fill:#eef,stroke:#334,stroke-width:1px; classDef outside fill:#f7f7f7,stroke:#777,stroke-dasharray:3 3; @@ -60,11 +66,15 @@ flowchart LR class caller,holder outside; ``` -*A request and an answer cross the boundary. The source record does not.* Registry Relay -turns an existing file or database table into a read-only, access-controlled API without -replacing the source. Registry Notary evaluates one modelled question against that source -and returns a shaped result; it is the only component that evaluates claims, applies -disclosure policy, and issues credentials. +*Two governed surfaces can cross the boundary.* Registry Relay turns an existing file or +database table into a read-only, access-controlled API without replacing the source. +Its scoped record routes can return source records to authorized callers that hold the +dataset's row-read permission. +Registry Notary evaluates one modelled question against that source and returns a shaped +answer; it is the only component that evaluates claims, applies disclosure policy, and +issues credentials. +Notary is the strongest minimization surface. +Relay record reads are scoped and audited, not open data. ## What stays home @@ -87,8 +97,14 @@ disclosure policy, and issues credentials. ## What crosses the boundary -Only a computed answer crosses out: never the source row. The answer takes one of a few -shapes: +What crosses depends on the surface. +Registry Relay can return scoped source records to an authorized caller through a +governed, audited read bounded by the caller's per-dataset row scope and the dataset's +configured filters and limits. +Registry Notary returns the answer a rule computes rather than the source row; keeping +that answer narrow is a modelling discipline, because a well-modelled claim returns one +decision or one extracted value. +A Notary answer takes one of a few shapes: - A yes/no: only the true/false satisfaction of the modelled rule. - A single value: the evaluated value itself, when the claim's disclosure mode is @@ -96,9 +112,10 @@ shapes: - A machine-readable evaluation result: a claim-result document carrying *provenance metadata*: which evaluation produced it, under which policy, across how many sources. This provenance lets a receiving system trace the result; it is not a cryptographic signature. -- A holder-bound credential: an SD-JWT VC the subject can store in a wallet and present - later. Unlike the plain result, the credential is cryptographically verifiable against the - issuer's published keys. +- An issued credential: an SD-JWT VC the subject can store in a wallet and present later. + Unlike the plain result, the credential is cryptographically verifiable against the + issuer's published keys. Credential profiles are holder-bound by default, but an + operator can explicitly configure an unbound profile. Across a federation boundary (one institution's Notary asking another's), what crosses is a scoped, signed evaluation result, never a credential. @@ -110,33 +127,49 @@ receives. There are exactly three: | Mode | Discloses | Withholds | |------|-----------|-----------| -| `value` | the full evaluated value | nothing about the value | -| `predicate` | only the true/false satisfaction | the underlying value | +| `value` | the evaluated value, less any object fields the policy redacts | nothing beyond policy-redacted object fields | +| `predicate` | only the true/false satisfaction, for a claim whose rule yields a boolean | the underlying value | | `redacted` | neither: the result carries no value **and** no yes/no | the value *and* the outcome | -The policy bounds which modes are allowed, and the caller chooses among them: a claim -defines an `allowed` set and a `default`, the service honours a requested mode when it is in -the allowed set and refuses it otherwise, applies the default when none is requested, and -every result records which mode was applied. A privacy-sensitive claim is expected to default -to the least-revealing mode that still answers the question. +The mode is policy-bound: a claim defines an `allowed` set, a `default`, and a downgrade +policy. +A caller may request a mode. +Under the default `deny` downgrade, the service refuses a requested mode outside the +allowed set; a `default` or `redacted` downgrade substitutes that fallback mode when the +fallback is itself allowed. +The default mode applies when the caller requests none, and every result records which +mode was applied. +A privacy-sensitive claim is expected to default to the least-revealing mode that still +answers the question. This is the mechanism behind "prove a fact without sharing the record". To check whether a person has a registered record, model the question as an *existence* rule and disclose it -as a `predicate`: the caller learns `true` or `false`, and the row never crosses the -boundary. To check eligibility without exposing an income figure, derive the decision with -an expression rule and disclose the eligibility boolean as a `predicate`; the income value +as a `predicate`: a resolved record returns `true`, and the row never crosses the +boundary. +By default, a record that does not resolve collapses to a single not-available reason +rather than a `false`, which hides the reason matching failed. An authorized caller can +still distinguish a resolved record from a not-available response, so use this pattern for +minimization, not for hiding whether a requested subject matched. +To check eligibility without exposing an income figure, derive the decision with an +expression rule and disclose the eligibility boolean as a `predicate`; the income value stays home. ## Why the answer is not the record A credential is not a copy of the record. It is an **SD-JWT VC**: the signed body carries a SHA-256 *digest* of each selectively disclosable field rather than the field value, so a -field the holder does not present stays hidden. It **can be** holder-bound (tied to the -holder's key so it is not presentable without the matching private key) when the issuing -profile enables binding (the wallet issuance path does); the default profile issues an unbound -credential. Either way, the holder chooses which fields to reveal to which verifier. Anyone can verify it against the issuer's -published public keys, served without authentication so a verifier needs no credential of -its own. The issued credential carries no full record payload. +field the holder does not present stays hidden. +Holder binding is set by the credential profile. +By default, a profile with no `holder_binding` block uses `did:jwk` holder binding. +A holder-bound profile ties the credential to the holder's key so it is not presentable +without the matching private key; an operator that intentionally needs a bearer-style +credential can set `holder_binding.mode: none`. +Each selectively disclosable field is a whole claim output, so an object-valued output is +revealed as a unit unless an explicit projection splits it into separately disclosable +fields. +Anyone can verify the credential against the issuer's published public keys, served +without authentication so a verifier needs no credential of its own. +The issued credential carries no full record payload. ## How the boundary is enforced @@ -146,10 +179,10 @@ Security material: - Scope-before-source, deny-by-default: A service checks the caller's scope *before* it reads any source or evaluates any claim, and does not widen a caller's reach at request time beyond what its configuration grants. Anything that touches a record or a claim - requires authentication; the routes reachable without it are the operational, discovery, - and protocol-bootstrap surfaces: liveness and readiness probes, the public verification - keys, credential-issuance discovery metadata, the OID4VCI wallet-flow endpoints, the docs, - public credential-status reads, and credential type metadata. + requires authentication. Routes reachable without it return no record or claim result on + their own: liveness and readiness probes, public verification keys, public metadata, and, + where OID4VCI issuance or credential status is enabled, the protocol surfaces that run + their own flow checks. - A permit, or a closed door: On a governed read, the policy decision point must return a permit before data is returned; a denial fails closed with a stable reason rather than falling back to an ungoverned read. @@ -166,9 +199,11 @@ mistake, so the limits are stated plainly here. - It is not "data never moves" and not "air-gapped": The promise is *read-in-place, no write-back, retained custody*. Authorized, minimized answers do leave the boundary by design: that is the point of the system. -- Minimization is modelled, not automatic: `value` mode discloses the full value. A - claim reveals only what its author configured it to reveal; least disclosure is a design - choice the claim makes, not a property the stack imposes on every answer. +- Minimization is modelled, not automatic: `value` mode discloses the evaluated value, + less any object fields the policy marks for redaction; it is not constrained to a + scalar, so a claim modelled to return an object or extracted record returns one. A claim + reveals only what its author configured it to reveal; least disclosure is a design choice + the claim makes, not a property the stack imposes on every answer. - Correctness depends on the source: Notary reports what the configured source says; it does not independently vouch for whether the source is correct or current. - A plain result is provenance-tagged, not signed: The everyday evaluation response @@ -177,8 +212,8 @@ mistake, so the limits are stated plainly here. that must verify an answer cryptographically uses the credential, not the default response. - Matching is only as strict as it is configured: Notary resolves a subject through its configured matching policy and does not independently verify identity beyond that. By - default a matching failure collapses to a single public reason, so the lookup surface - cannot be used as an existence oracle. + default a matching failure collapses to a single public reason, but an authorized caller + can still tell a resolved record from a not-available response. - This is not zero-knowledge: A `predicate` answer is a policy-enforced boolean computed inside the service; SD-JWT selective disclosure is digest omission. Neither is a zero-knowledge proof, and the documentation should not imply one. @@ -200,6 +235,6 @@ mistake, so the limits are stated plainly here. - The security model and protocol contracts: [RS-SEC-G](../../spec/rs-sec-g/), [RS-PR-RELAY](../../spec/rs-pr-relay/), [RS-PR-NOTARY](../../spec/rs-pr-notary/), [RS-DM-CLAIM](../../spec/rs-dm-claim/) -- Evidence issuance, end to end *(explanation)* -- Disclosure and minimization in depth *(Trust & Security)* -- The threat model and security posture *(Trust & Security)* +- [Evidence issuance, end to end](../evidence-issuance/) +- [Disclosure modes and computed answers](../disclosure-modes-and-computed-answers/) +- [Threat model](../threat-model/) diff --git a/docs/site/src/content/docs/explanation/threat-model.mdx b/docs/site/src/content/docs/explanation/threat-model.mdx index 9021a686..443a55a9 100644 --- a/docs/site/src/content/docs/explanation/threat-model.mdx +++ b/docs/site/src/content/docs/explanation/threat-model.mdx @@ -138,16 +138,19 @@ leakage, and privacy regressions that expose raw subject identifiers. stable codes only. Bearer tokens, private keys, source values, filesystem paths, and internal error chains stay in protected operator logs, never in responses; principal-scoped responses are not public cache entries. -- Over-collection and value injection in claim evaluation: The caller supplies only a - subject id and a claim id and must not supply the evaluated value; bindings read only the - fields they need and reject input paths outside a declared allow-list. +- Over-collection and value injection in claim evaluation: The caller supplies a claim id + plus the subject-resolution inputs the claim policy admits, but never the evaluated + value; bindings read only the fields they need and reject input paths outside a declared + allow-list. - Disclosure leakage: A redacted result carries neither the value nor the predicate outcome. Selective-disclosure credentials carry SHA-256 digests of unselected fields, so a - holder cannot present an undisclosed field. Holder binding is configurable per credential - profile and defaults to off; where a profile enables it (using `did:jwk`), and on the - self-attestation issuance path, which requires it, the holder is bound by a fresh - audience-bound proof-of-possession. Binding applies only to an issued credential; the plain - evaluation result is not a credential and is never holder-bound. + holder cannot present an undisclosed field. Holder binding defaults to `did:jwk` for + credential profiles; where a profile keeps binding enabled, and on the self-attestation + issuance path, which requires it, the holder is bound by a fresh audience-bound + proof-of-possession. An operator can explicitly configure `holder_binding.mode: none` for + a bearer-style credential profile, and `registry-notary doctor` warns on that choice. + Binding applies only to an issued credential; the plain evaluation result is not a + credential and is never holder-bound. - Self-attested data crossing a trust gate: Self-attestation and delegated self-attestation derive subject, requester, and relationship from the authenticated principal and scoped authorization details; caller-supplied `requester`, `relationship`, and @@ -171,14 +174,14 @@ leakage, and privacy regressions that expose raw subject identifiers. freshness checks are backed by a real replay-store primitive that tracks one-time JWT `jti` and nonce values (federation request `jti`, OID4VCI `c_nonce`, and holder-proof `jti`). -**Audit as a control.** Every request touching person-level data is recorded (principal, -request id, and `Data-Purpose`) and a deployment can run audit fail-closed, so an -unrecordable request does not return success. Two caveats for the auditor. First, this is a -capability a deployment can turn on, not a guarantee that every route in a given build has -been individually audited; treat per-route audit coverage as something to verify in your -deployment. Second, the security model expects the audit record to capture the scopes -exercised as well, but Notary's audit record does not yet include that field (Relay's does), -so Notary audit alone cannot reconstruct which scopes authorized a request. +**Audit as a control.** Every request touching person-level data can be recorded with the +hashed principal, request id, the principal's granted scopes, and `Data-Purpose` context +when present, and a deployment can run audit fail-closed, so an unrecordable request does +not return success. Two caveats for the auditor. First, the recorded scope list is not a +per-route proof of which scope check authorized the request, and per-route audit coverage +is something to verify in your deployment. Second, Notary records hashed principal and +correlation identifiers, so operator-side investigation depends on retaining the hashing +secret and request context. ## Residual risks and what is left to the operator diff --git a/docs/site/src/content/docs/security/index.mdx b/docs/site/src/content/docs/security/index.mdx index d0636e6f..3b05deae 100644 --- a/docs/site/src/content/docs/security/index.mdx +++ b/docs/site/src/content/docs/security/index.mdx @@ -29,7 +29,7 @@ The normative source for everything on this page is the security model specifica | Only the public half of a signing key is ever published | Built in | | Person-level access is written to an audit log | Built in (fail-closed is yours to switch on) | | Three disclosure modes, with `redacted` revealing neither value nor answer | Built in | -| Selective-disclosure credentials, with holder binding configurable per profile (off by default) | Built in | +| Selective-disclosure credentials, with `did:jwk` holder binding by default per profile | Built in | | Federation limited to peers you configure | Built in | | Hardened HTTP headers and outbound-traffic limits | Recommended, confirm your build applies them | | Key custody, retention, isolation, TLS, rate limiting | Your deployment's job | @@ -59,16 +59,20 @@ authenticate, authorize, serve, audit. audit fail-closed, so a request whose audit record cannot be written does not succeed (REQ-SEC-G-009). -Only liveness and readiness probes and public verification-key discovery are served without +Every route that returns a record, evaluates a claim, or issues from a stored evaluation requires authentication. +Operational probes, public verification keys, discovery metadata, docs or OpenAPI when configured, +OID4VCI bootstrap routes, credential-status reads, and Relay provenance support routes may be +public because they do not return record or claim results on their own. Issuers sign with asymmetric keys and publish only the public half, so any verifier can check an issued credential without holding a credential of its own (REQ-SEC-G-007). When Notary issues a credential it is an SD-JWT VC: the signed body carries hashes of each disclosable field rather than the values, so the holder reveals only what they choose. Holder binding, which cryptographically ties a credential to a key the holder controls, is -configurable per credential profile and defaults to off; the self-attestation -(wallet) issuance path requires it. +enabled by default with `did:jwk`; an operator can set `holder_binding.mode: none` only for an +explicit bearer-style credential profile, and `registry-notary doctor` warns on that choice. +The self-attestation (wallet) issuance path requires holder proof of possession. See [Evidence issuance, end to end](../explanation/evidence-issuance/) for the full credential lifecycle. diff --git a/lab/config/notary/openfn-civil-notary.yaml b/lab/config/notary/openfn-civil-notary.yaml index 4f530b6a..506a9a55 100644 --- a/lab/config/notary/openfn-civil-notary.yaml +++ b/lab/config/notary/openfn-civil-notary.yaml @@ -62,6 +62,8 @@ evidence: validity_seconds: 600 allowed_claims: - date-of-birth + holder_binding: + mode: none disclosure: allowed: - value diff --git a/lab/scripts/test_openfn_sidecar_lab_config.py b/lab/scripts/test_openfn_sidecar_lab_config.py index 33b81bc5..3abdc8b2 100644 --- a/lab/scripts/test_openfn_sidecar_lab_config.py +++ b/lab/scripts/test_openfn_sidecar_lab_config.py @@ -51,6 +51,11 @@ def claim_source_bindings(path: Path) -> list[dict[str, object]]: ] +def credential_profile(path: Path, profile_id: str) -> dict[str, object]: + config = yaml.safe_load(read(path)) + return config["evidence"]["credential_profiles"][profile_id] + + class BuiltinSidecarLabConfigTest(unittest.TestCase): def test_local_sidecars_use_unsigned_dev_escape_hatch(self) -> None: body = read(LOCAL_COMPOSE) @@ -93,6 +98,9 @@ def test_smoke_scripts_mirror_just_source_defaults(self) -> None: self.assertIn('default_source_dir "../registry-platform" "vendor/registry-platform"', body) def test_openfn_notary_bindings_use_sidecar_connector(self) -> None: + civil_profile = credential_profile(LOCAL_CIVIL_NOTARY, "openfn_civil_sd_jwt") + self.assertEqual({"mode": "none"}, civil_profile["holder_binding"]) + civil_bindings = claim_source_bindings(LOCAL_CIVIL_NOTARY) self.assertEqual(1, len(civil_bindings)) self.assertEqual("openfn_civil", civil_bindings[0]["connection"]) diff --git a/products/notary/CHANGELOG.md b/products/notary/CHANGELOG.md index 98501437..5a9f15c3 100644 --- a/products/notary/CHANGELOG.md +++ b/products/notary/CHANGELOG.md @@ -15,6 +15,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/opencrvs-dci-standalone-tutorial.md b/products/notary/docs/opencrvs-dci-standalone-tutorial.md index 61880b64..50bb5bd3 100644 --- a/products/notary/docs/opencrvs-dci-standalone-tutorial.md +++ b/products/notary/docs/opencrvs-dci-standalone-tutorial.md @@ -243,6 +243,8 @@ Expected result: The credential is a demo SD-JWT VC issued by Registry Notary from OpenCRVS evidence. It is not an OpenCRVS-issued credential. +The generated `dci_record_sd_jwt` profile sets `holder_binding.mode: none`, so +this direct demo request does not require wallet holder material. ## Issue a birth-attributes credential @@ -459,3 +461,7 @@ that the test UIN exists in that environment. - Do not commit OpenCRVS client credentials, bearer tokens, test UINs, or generated issuer private keys. - Do not store OpenCRVS access tokens in the config or env file. +- The generated demo credential profile uses `holder_binding.mode: none` for + direct local issuance. For citizen-wallet issuance, create a profile with + `holder_binding.mode: did`, `proof_of_possession: required`, and + `allowed_did_methods: [did:jwk]`. diff --git a/products/notary/docs/operator-config-reference.md b/products/notary/docs/operator-config-reference.md index 7a7bf2d2..d352f480 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