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
2 changes: 1 addition & 1 deletion crates/registry-notary-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use serde::{Deserialize, Serialize};

use crate::deployment::DeploymentConfig;
use crate::model::{
is_request_variable_name, DisclosureProfile, EvidenceAuthorizationDetails,
is_request_variable_name, DisclosureProfile, EvidenceAuthorizationDetails, FORMAT_CCCEV_JSONLD,
FORMAT_CLAIM_RESULT_JSON, FORMAT_SD_JWT_VC, MAX_REQUEST_VARIABLES_V1,
SD_JWT_VC_HOLDER_BINDING_METHOD, SD_JWT_VC_SIGNING_ALG,
};
Expand Down
11 changes: 11 additions & 0 deletions crates/registry-notary-core/src/config/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ pub enum EvidenceConfigError {
application/vnd.registry-notary.claim-result+json representation, or list one or more response formats"
)]
EmptyClaimFormats { claim: String },
#[error(
"claim '{claim}' formats must include the canonical evaluation response format \
application/vnd.registry-notary.claim-result+json; add it alongside any supported additional evaluation renderers"
)]
MissingCanonicalClaimFormat { claim: String },
#[error(
"claim '{claim}' has unsupported evaluation response format '{format}' in formats; \
supported formats are application/vnd.registry-notary.claim-result+json and \
application/ld+json; profile=\"cccev\". SD-JWT VC belongs in credential_profiles, not claim formats"
)]
UnsupportedClaimFormat { claim: String, format: String },
#[error("allowed purpose must not be empty")]
InvalidPurpose,
#[error("concurrency.subjects must be >= 1")]
Expand Down
21 changes: 20 additions & 1 deletion crates/registry-notary-core/src/config/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,12 +243,31 @@ impl StandaloneRegistryNotaryConfig {
}
// REQ-DM-CLAIM-009: omitted formats deserialize to the canonical
// claim-result representation, but an authored empty list cannot
// render any response and must fail before startup.
// render any response and must fail before startup. The response
// format set is closed: credential issuance formats do not render
// evaluation responses.
if claim.formats.is_empty() {
return Err(EvidenceConfigError::EmptyClaimFormats {
claim: claim.id.clone(),
});
}
for format in &claim.formats {
if format != FORMAT_CLAIM_RESULT_JSON && format != FORMAT_CCCEV_JSONLD {
return Err(EvidenceConfigError::UnsupportedClaimFormat {
Comment on lines +254 to +256

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop selecting SD-JWT as an evaluation format

After this loop rejects application/dc+sd-jwt for every claim, credential-enabled deployments still expose it as an enabled format because runtime/catalog.rs inserts FORMAT_SD_JWT_VC whenever credential profiles exist, and api/request.rs feeds all enabled ids into evaluation content negotiation. A caller sending Accept: application/dc+sd-jwt, application/vnd.registry-notary.claim-result+json;q=0.5 will have negotiation select SD-JWT and then fail with FormatUnsupported, even though the canonical evaluation response is acceptable; the catalog/negotiation path needs to exclude credential-only formats for evaluation responses.

Useful? React with 👍 / 👎.

claim: claim.id.clone(),
format: format.clone(),
});
}
}
if !claim
.formats
.iter()
.any(|format| format == FORMAT_CLAIM_RESULT_JSON)
{
return Err(EvidenceConfigError::MissingCanonicalClaimFormat {
claim: claim.id.clone(),
});
}
}
// Registry Notary currently resolves holder material only from
// did:jwk. Reject any other configured method so discovery metadata
Expand Down
22 changes: 4 additions & 18 deletions crates/registry-notary-core/src/config/subject_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,6 @@ impl SubjectAccessConfig {
profile,
&claim_ids,
&allowed_claim_ids,
&allowed_formats,
self.token_policy.max_credential_validity_seconds,
)?;
}
Expand Down Expand Up @@ -771,7 +770,6 @@ pub(super) fn validate_subject_access_profile(
profile: &CredentialProfileConfig,
claim_ids: &HashSet<&str>,
allowed_claim_ids: &HashSet<&str>,
allowed_formats: &HashSet<&str>,
max_credential_validity_seconds: u64,
) -> Result<(), EvidenceConfigError> {
if profile.validity_seconds <= 0 {
Expand All @@ -791,12 +789,6 @@ pub(super) fn validate_subject_access_profile(
"credential profile '{profile_id}' validity_seconds must not exceed the subject-access ceiling"
));
}
if !allowed_formats.contains(profile.format.as_str()) {
return invalid_subject_access(format!(
"credential profile '{profile_id}' uses unallowed format '{}'",
profile.format
));
}
if profile.holder_binding.mode != "did" {
return invalid_subject_access(format!(
"credential profile '{profile_id}' holder_binding.mode must be did"
Expand Down Expand Up @@ -867,12 +859,9 @@ pub(super) fn validate_subject_access_allow_lists_are_supported(
let supported_by_claim = allowed_claims
.iter()
.any(|claim| claim.formats.iter().any(|candidate| candidate == format));
let supported_by_profile = allowed_profiles
.iter()
.any(|profile| profile.format == *format);
if !supported_by_claim && !supported_by_profile {
if !supported_by_claim {
Comment on lines 859 to +862

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require canonical format for subject-access issuance

When subject_access.allowed_operations.issue_credential is true, this validation still accepts an allow-list such as only application/ld+json; profile="cccev" because it only checks that some allowed claim supports the format. The OID4VCI credential path now creates its internal evaluation with FORMAT_CLAIM_RESULT_JSON, and require_subject_access_evaluation_for_operation denies that request if the canonical format is not in subject_access.allowed_formats, so the configuration loads successfully but all subject-access credential issuance is rejected with FormatDenied; validate that the canonical evaluation format is allowed whenever issuance is enabled.

Useful? React with 👍 / 👎.

return invalid_subject_access(format!(
"allowed_formats entry '{format}' is not supported by any allowed claim or profile"
"allowed_formats entry '{format}' is not supported by any allowed claim"
));
}
}
Expand Down Expand Up @@ -1046,12 +1035,9 @@ pub(super) fn validate_delegated_attestation_allow_lists_are_supported(
let supported_by_claim = allowed_claims
.iter()
.any(|claim| claim.formats.iter().any(|candidate| candidate == format));
let supported_by_profile = allowed_profiles
.iter()
.any(|profile| profile.format == *format);
if !supported_by_claim && !supported_by_profile {
if !supported_by_claim {
Comment on lines 1035 to +1038

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require canonical format for delegated credential issuance

For delegated relationships that list credential_profiles, this still accepts allowed_formats containing only the CCCEV renderer as long as an allowed claim supports it. Direct delegated credential issuance now requires the stored evaluation to be application/vnd.registry-notary.claim-result+json, so such a configuration loads but cannot produce an issuable delegated evaluation: canonical evaluation is denied by the relationship allow-list, while CCCEV evaluation is later rejected before signing. Require the canonical format whenever delegated credential profiles are configured.

Useful? React with 👍 / 👎.

return invalid_subject_access(format!(
"subject_access.delegation allowed_formats entry '{format}' is not supported by any allowed claim or profile"
"subject_access.delegation allowed_formats entry '{format}' is not supported by any allowed claim"
));
}
}
Expand Down
123 changes: 123 additions & 0 deletions crates/registry-notary-core/src/config/tests/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,129 @@ formats: []
);
}

#[test]
pub(super) fn canonical_claim_format_is_valid() {
let mut config = minimal_config();
let mut claim = minimal_claim("canonical-format");
claim.formats = vec![FORMAT_CLAIM_RESULT_JSON.to_string()];
config.evidence.claims = vec![claim];

config
.validate()
.expect("the canonical evaluation format must validate");
}

#[test]
pub(super) fn canonical_and_cccev_claim_formats_are_valid() {
let mut config = minimal_config();
let mut claim = minimal_claim("canonical-and-cccev");
claim.formats = vec![
FORMAT_CLAIM_RESULT_JSON.to_string(),
FORMAT_CCCEV_JSONLD.to_string(),
];
config.evidence.claims = vec![claim];

config
.validate()
.expect("the canonical and CCCEV evaluation formats must validate");
}

#[test]
pub(super) fn cccev_without_canonical_claim_format_is_rejected() {
let mut config = minimal_config();
let mut claim = minimal_claim("cccev-only");
claim.formats = vec![FORMAT_CCCEV_JSONLD.to_string()];
config.evidence.claims = vec![claim];

let err = config
.validate()
.expect_err("CCCEV-only formats must fail validation");
match &err {
EvidenceConfigError::MissingCanonicalClaimFormat { claim } => {
assert_eq!(claim, "cccev-only");
}
other => panic!("unexpected error variant: {other}"),
}
let message = err.to_string();
assert!(
message.contains("cccev-only") && message.contains(FORMAT_CLAIM_RESULT_JSON),
"error must name the claim and canonical format: {message}"
);
}

#[test]
pub(super) fn sd_jwt_vc_claim_format_is_rejected_before_canonical_omission() {
let mut config = minimal_config();
let mut claim = minimal_claim("sd-jwt-only");
claim.formats = vec![FORMAT_SD_JWT_VC.to_string()];
config.evidence.claims = vec![claim];

let err = config
.validate()
.expect_err("SD-JWT VC is not an evaluation response format");
match &err {
EvidenceConfigError::UnsupportedClaimFormat { claim, format } => {
assert_eq!(claim, "sd-jwt-only");
assert_eq!(format, FORMAT_SD_JWT_VC);
}
other => panic!("unexpected error variant: {other}"),
}
let message = err.to_string();
assert!(
message.contains("credential_profiles") && message.contains(FORMAT_SD_JWT_VC),
"error must identify SD-JWT VC and its configuration home: {message}"
);
}

#[test]
pub(super) fn mixed_claim_formats_reject_the_first_unsupported_format() {
let mut config = minimal_config();
let mut claim = minimal_claim("mixed-formats");
claim.formats = vec![
FORMAT_CLAIM_RESULT_JSON.to_string(),
FORMAT_SD_JWT_VC.to_string(),
"application/example+json".to_string(),
];
config.evidence.claims = vec![claim];

let err = config
.validate()
.expect_err("mixed formats must reject the unsupported SD-JWT VC entry");
match &err {
EvidenceConfigError::UnsupportedClaimFormat { claim, format } => {
assert_eq!(claim, "mixed-formats");
assert_eq!(format, FORMAT_SD_JWT_VC);
}
other => panic!("unexpected error variant: {other}"),
}
}

#[test]
pub(super) fn unknown_claim_format_is_rejected() {
let mut config = minimal_config();
let mut claim = minimal_claim("unknown-format");
claim.formats = vec![
FORMAT_CLAIM_RESULT_JSON.to_string(),
"application/example+json".to_string(),
];
config.evidence.claims = vec![claim];

let err = config
.validate()
.expect_err("unknown evaluation formats must fail validation");
match &err {
EvidenceConfigError::UnsupportedClaimFormat { claim, format } => {
assert_eq!(claim, "unknown-format");
assert_eq!(format, "application/example+json");
}
other => panic!("unexpected error variant: {other}"),
}
assert!(
err.to_string().contains("application/example+json"),
"error must name the offending format: {err}"
);
}

#[test]
pub(super) fn empty_allowed_claims_is_rejected() {
// A credential profile with an empty allowed_claims would silently
Expand Down
7 changes: 1 addition & 6 deletions crates/registry-notary-core/src/config/tests/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,6 @@ evidence:
- value
formats:
- application/vnd.registry-notary.claim-result+json
- application/dc+sd-jwt
credential_profiles:
- civil_status_sd_jwt
auth:
Expand Down Expand Up @@ -644,7 +643,6 @@ subject_access:
- date-of-birth
allowed_formats:
- application/vnd.registry-notary.claim-result+json
- application/dc+sd-jwt
allowed_disclosures:
- value
required_scopes:
Expand Down Expand Up @@ -743,10 +741,7 @@ token_file: /run/secrets/registry-notary-relay.jwt
target_id_type: Some("civil_registration_id".to_string()),
allowed_claims: vec!["dependent-date-of-birth".to_string()],
allowed_purposes: vec!["dependent_attestation".to_string()],
allowed_formats: vec![
"application/vnd.registry-notary.claim-result+json".to_string(),
"application/dc+sd-jwt".to_string(),
],
allowed_formats: vec!["application/vnd.registry-notary.claim-result+json".to_string()],
allowed_disclosures: vec!["value".to_string()],
credential_profiles: vec!["civil_status_sd_jwt".to_string()],
}],
Expand Down
4 changes: 2 additions & 2 deletions crates/registry-notary-server/src/api/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ pub(super) async fn issue_credential(
Ok(profile) => profile,
Err(error) => return evidence_error_response(error),
};
if evaluation.format != FORMAT_SD_JWT_VC {
if evaluation.format != FORMAT_CLAIM_RESULT_JSON {
return credential_denial_response_for_evaluation(
&state,
EvidenceError::EvaluationBindingMismatch,
Expand All @@ -176,7 +176,7 @@ pub(super) async fn issue_credential(
.disclosure
.as_deref()
.unwrap_or(&evaluation.disclosure),
request.format.as_deref().unwrap_or(&evaluation.format),
&evaluation.format,
Some(profile_id),
) {
return credential_denial_response_for_evaluation(
Expand Down
4 changes: 2 additions & 2 deletions crates/registry-notary-server/src/api/oid4vci/credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ pub(in crate::api) async fn oid4vci_credential(
.map(|claim_id| ClaimRef::from(claim_id.as_str()))
.collect(),
disclosure: None,
format: Some(FORMAT_SD_JWT_VC.to_string()),
format: Some(FORMAT_CLAIM_RESULT_JSON.to_string()),
purpose: None,
};
let mut request = request;
Expand Down Expand Up @@ -545,7 +545,7 @@ pub(in crate::api) fn oid4vci_issuance_authorization_details(
locations: vec![evidence.service_id.clone()],
claims,
disclosure: Some(disclosure),
format: Some(FORMAT_SD_JWT_VC.to_string()),
format: Some(FORMAT_CLAIM_RESULT_JSON.to_string()),
purpose: Some(purpose),
subject: Some(registry_notary_core::EvidenceAuthorizationSubject {
binding_claim: config.subject_binding.token_claim.clone(),
Expand Down
4 changes: 2 additions & 2 deletions crates/registry-notary-server/src/api/tests/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ async fn issue_credential_fails_closed_when_status_record_write_fails() {
claim_ids: vec!["person-is-alive".to_string()],
claim_refs: Vec::new(),
disclosure: "predicate".to_string(),
format: FORMAT_SD_JWT_VC.to_string(),
format: FORMAT_CLAIM_RESULT_JSON.to_string(),
results: vec![claim_result_view(
"eval-status-write-fails",
"person-is-alive",
Expand Down Expand Up @@ -155,7 +155,7 @@ async fn issue_credential_rejects_purpose_mismatch() {
claim_ids: vec!["person-is-alive".to_string()],
claim_refs: Vec::new(),
disclosure: "predicate".to_string(),
format: FORMAT_SD_JWT_VC.to_string(),
format: FORMAT_CLAIM_RESULT_JSON.to_string(),
results: vec![claim_result_view(
"eval-purpose-mismatch",
"person-is-alive",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ fn evaluation_access_uses_stored_claim_version_scope() {
claim_ids: vec!["person-is-alive".to_string()],
claim_refs: vec![ClaimRef::with_version("person-is-alive", "2.0")],
disclosure: "predicate".to_string(),
format: FORMAT_SD_JWT_VC.to_string(),
format: FORMAT_CLAIM_RESULT_JSON.to_string(),
results: Vec::new(),
created_at: "2026-05-23T00:00:00Z".to_string(),
expires_at: "2999-01-01T00:00:00Z".to_string(),
Expand Down
24 changes: 3 additions & 21 deletions crates/registry-notary-server/src/api/tests/oid4vci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -629,17 +629,8 @@ async fn oid4vci_token_error_fails_closed_when_denial_audit_fails() {
#[tokio::test]
async fn oid4vci_credential_issues_sd_jwt_and_rejects_nonce_replay() {
let store = Arc::new(EvidenceStore::default());
let mut subject_access = subject_access_config();
subject_access
.allowed_formats
.push(FORMAT_SD_JWT_VC.to_string());
let subject_access = subject_access_config();
let mut evidence = evidence_config();
evidence
.claims
.first_mut()
.expect("claim exists")
.formats
.push(FORMAT_SD_JWT_VC.to_string());
evidence
.claims
.first_mut()
Expand Down Expand Up @@ -829,17 +820,8 @@ async fn oid4vci_credential_issues_sd_jwt_and_rejects_nonce_replay() {
#[tokio::test]
async fn oid4vci_rejects_holder_key_equal_to_issuer_key_before_side_effects() {
let store = Arc::new(EvidenceStore::default());
let mut subject_access = subject_access_config();
subject_access
.allowed_formats
.push(FORMAT_SD_JWT_VC.to_string());
let subject_access = subject_access_config();
let mut evidence = evidence_config();
evidence
.claims
.first_mut()
.expect("claim exists")
.formats
.push(FORMAT_SD_JWT_VC.to_string());
evidence
.claims
.first_mut()
Expand Down Expand Up @@ -1013,7 +995,7 @@ fn oid4vci_issuance_authorization_details_bind_selected_configuration() {
assert_eq!(details.locations, vec![evidence.service_id.clone()]);
assert_eq!(details.claims, vec![ClaimRef::from("person-is-alive")]);
assert_eq!(details.disclosure.as_deref(), Some("predicate"));
assert_eq!(details.format.as_deref(), Some(FORMAT_SD_JWT_VC));
assert_eq!(details.format.as_deref(), Some(FORMAT_CLAIM_RESULT_JSON));
assert_eq!(details.purpose.as_deref(), Some("citizen_subject_access"));
assert_eq!(details.access_mode, Some(AccessMode::SubjectBound));
let subject = details.subject.as_ref().expect("subject binding is set");
Expand Down
Loading
Loading