Skip to content
Merged
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
11 changes: 6 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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()
Comment thread
jeremi marked this conversation as resolved.
Comment thread
jeremi marked this conversation as resolved.
Comment thread
jeremi marked this conversation as resolved.
Comment thread
jeremi marked this conversation as resolved.
}

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
6 changes: 5 additions & 1 deletion crates/registry-notary-server/benches/sd_jwt_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
6 changes: 5 additions & 1 deletion crates/registry-notary-server/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
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
13 changes: 11 additions & 2 deletions crates/registry-notary-server/tests/memoization_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
)
Expand All @@ -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(),
)
Expand Down Expand Up @@ -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(),
Expand Down
47 changes: 47 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 Expand Up @@ -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 {
Expand Down Expand Up @@ -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}"
);
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading