Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions crates/registry-notary-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6674,7 +6674,7 @@ pub struct HolderBindingConfig {
pub mode: String,
#[serde(default)]
pub proof_of_possession: Option<String>,
#[serde(default)]
#[serde(default = "default_holder_binding_allowed_did_methods")]
pub allowed_did_methods: Vec<String>,
}

Expand All @@ -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<String> {
vec![SD_JWT_VC_HOLDER_BINDING_METHOD.to_string()]
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
Expand Down Expand Up @@ -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(
Expand Down
34 changes: 33 additions & 1 deletion crates/registry-notary-core/src/sd_jwt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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();
Expand Down
11 changes: 8 additions & 3 deletions crates/registry-notary-server/benches/sd_jwt_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(),
Expand Down
6 changes: 5 additions & 1 deletion crates/registry-notary-server/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
Expand Down
12 changes: 8 additions & 4 deletions crates/registry-notary-server/tests/memoization_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
}
Expand Down
37 changes: 37 additions & 0 deletions crates/registry-notary/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,21 @@ impl Diagnostic {
}
}

fn warn_with_code(
label: impl Into<String>,
action: impl Into<String>,
code: impl Into<String>,
) -> 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<String>, action: impl Into<String>) -> Self {
Self {
ok: false,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<Diagnostic> {
let unbound_profiles = config
.evidence
.credential_profiles
.iter()
.filter(|(_, profile)| profile.holder_binding.mode == "none")
.map(|(profile_id, _)| profile_id.as_str())
.collect::<Vec<_>>();
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();
Expand Down
75 changes: 74 additions & 1 deletion crates/registry-notary/tests/doctor_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct TestConfigOptions<'a> {
config_trust: bool,
multi_instance: bool,
durable_audit: Option<bool>,
unbound_credential_profile: bool,
}

fn write_config(tmp: &TempDir) -> PathBuf {
Expand Down Expand Up @@ -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!(
Expand All @@ -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}
"#
),
)
Expand Down Expand Up @@ -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");
Expand Down
6 changes: 6 additions & 0 deletions products/notary/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions products/notary/docs/operator-config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading