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: 2 additions & 0 deletions crates/registry-notary-core/src/config/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ pub enum EvidenceConfigError {
claim; an empty list would permit any claim at issuance"
)]
EmptyAllowedClaims { profile: String },
#[error("invalid credential claim binding: {reason}")]
InvalidCredentialClaimBinding { reason: String },
/// Registry Notary currently issues only SD-JWT VC credentials using the
/// current `application/dc+sd-jwt` media type. Reject aliases and profile
/// labels so operator config cannot drift from the wire contract.
Expand Down
17 changes: 0 additions & 17 deletions crates/registry-notary-core/src/config/evidence/claims.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,23 +457,6 @@ pub(in crate::config) fn validate_self_attested_dependency_modes(
Ok(())
}

pub(in crate::config) fn validate_registry_backed_dependency_modes(
claims: &[ClaimDefinition],
) -> Result<(), EvidenceConfigError> {
for claim in claims
.iter()
.filter(|claim| claim.evidence_mode.is_registry_backed())
{
if !claim.depends_on.is_empty() {
return invalid_claim_evidence_mode(
claim,
"the initial registry_backed journey cannot declare depends_on; one claim maps only its pinned Relay consultation",
);
}
}
Ok(())
}

pub(in crate::config) fn validate_relay_activation_shape(
claims: &[ClaimDefinition],
) -> Result<(), EvidenceConfigError> {
Expand Down
5 changes: 5 additions & 0 deletions crates/registry-notary-core/src/config/oid4vci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,11 @@ pub(super) fn validate_oid4vci_credential_claim_reference<'a>(
"credential configuration '{configuration_id}' references unknown claim '{claim_id}'"
),
})?;
if !claim.evidence_mode.is_registry_backed() {
return invalid_oid4vci(format!(
"credential configuration '{configuration_id}' maps source-free claim '{claim_id}' to credential profile '{credential_profile_id}'; OID4VCI credential claims must be registry_backed"
));
}
if !claim
.credential_profiles
.iter()
Expand Down
115 changes: 114 additions & 1 deletion crates/registry-notary-core/src/config/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,10 +360,10 @@ impl StandaloneRegistryNotaryConfig {
&self.evidence.claims,
&self.subject_access.delegation,
)?;
validate_registry_backed_dependency_modes(&self.evidence.claims)?;
validate_relay_activation_shape(&self.evidence.claims)?;
self.subject_access.validate(&self.auth, &self.evidence)?;
self.validate_oid4vci_cross_block()?;
validate_credential_claim_bindings(&self.evidence)?;

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 Reject unsupported delegated-proof dependencies

After the global registry_backed dependency ban was removed, this replacement validation only checks credential claim closures. A delegated relationship can now configure its registry-backed proof_claim with a registry-backed depends_on and still pass startup, but delegated runtime capability only permits Relay consultation for the proof claim itself (require_relay_consultation_capability), so every delegated evaluation using that relationship fails at preflight instead of being rejected as an invalid config. Please either validate delegated proof closures here or explicitly reject proof-claim dependencies.

Useful? React with 👍 / 👎.

self.validate_access_token_signing_cross_block()?;
self.federation.validate(&self.evidence)?;
self.validate_signing_key_alg_usage()?;
Expand Down Expand Up @@ -692,6 +692,119 @@ impl StandaloneRegistryNotaryConfig {
}
}

/// Close both sides of every credential claim/profile binding at load time.
///
/// A credential profile is a signing capability. Keeping this validation at
/// the shared root prevents direct issuance, subject-access issuance, and
/// OID4VCI from interpreting a one-sided or source-free binding differently.
/// Source-free claims remain valid evaluation inputs when neither side grants
/// them credential capability.
fn validate_credential_claim_bindings(
evidence: &EvidenceConfig,
) -> Result<(), EvidenceConfigError> {
for (profile_id, profile) in &evidence.credential_profiles {
for claim_id in &profile.allowed_claims {
let claim = evidence
.claims
.iter()
.find(|claim| claim.id == *claim_id)
.ok_or_else(|| EvidenceConfigError::InvalidCredentialClaimBinding {
reason: format!(
"credential profile '{profile_id}' allowed_claims references unknown claim '{claim_id}'"
),
})?;
if !claim.evidence_mode.is_registry_backed() {
return Err(EvidenceConfigError::InvalidCredentialClaimBinding {
reason: format!(
"credential profile '{profile_id}' allowed_claims references source-free claim '{claim_id}'; credential claims must be registry_backed"
),
});
}
if !claim
.credential_profiles
.iter()
.any(|candidate| candidate == profile_id)
{
return Err(EvidenceConfigError::InvalidCredentialClaimBinding {
reason: format!(
"credential profile '{profile_id}' allows claim '{claim_id}', but the claim does not reference that profile"
),
});
}
let mut pending = claim
.depends_on
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
let mut visited = HashSet::new();
while let Some(dependency_id) = pending.pop() {
if !visited.insert(dependency_id) {
continue;
}
let dependency = evidence
.claims
.iter()
.find(|candidate| candidate.id == dependency_id)
.ok_or_else(|| EvidenceConfigError::InvalidCredentialClaimBinding {
reason: format!(
"credential profile '{profile_id}' claim '{claim_id}' dependency closure references unknown claim '{dependency_id}'"
),
})?;
if !dependency.evidence_mode.is_registry_backed() {
return Err(EvidenceConfigError::InvalidCredentialClaimBinding {
reason: format!(
"credential profile '{profile_id}' claim '{claim_id}' dependency closure contains source-free claim '{dependency_id}'; credential roots and all dependencies must be registry_backed"
),
});
}
if dependency.purpose != claim.purpose {
return Err(EvidenceConfigError::InvalidCredentialClaimBinding {
reason: format!(
"credential profile '{profile_id}' claim '{claim_id}' dependency '{dependency_id}' must declare the same canonical purpose"
),
});
}
pending.extend(dependency.depends_on.iter().map(String::as_str));
}
}
}

for claim in &evidence.claims {
for profile_id in &claim.credential_profiles {
if !claim.evidence_mode.is_registry_backed() {
return Err(EvidenceConfigError::InvalidCredentialClaimBinding {
reason: format!(
"source-free claim '{}' references credential profile '{profile_id}'; source-free claims may be evaluated but cannot have credential capability",
claim.id
),
});
}
let profile = evidence
.credential_profiles
.get(profile_id)
.ok_or_else(|| EvidenceConfigError::InvalidCredentialClaimBinding {
reason: format!(
"claim '{}' references unknown credential profile '{profile_id}'",
claim.id
),
})?;
if !profile
.allowed_claims
.iter()
.any(|candidate| candidate == &claim.id)
{
return Err(EvidenceConfigError::InvalidCredentialClaimBinding {
reason: format!(
"claim '{}' references credential profile '{profile_id}', but the profile allowed_claims does not include that claim",
claim.id
),
});
}
}
}
Ok(())
}

pub(super) fn validate_static_credential_ids(
api_keys: &[EvidenceCredentialConfig],
bearer_tokens: &[EvidenceCredentialConfig],
Expand Down
57 changes: 13 additions & 44 deletions crates/registry-notary-core/src/config/subject_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,12 @@ impl SubjectAccessDelegatedRelationshipConfig {
"subject_access.delegation.credential_profiles",
&self.credential_profiles,
)?;
if !self.credential_profiles.is_empty() {
return invalid_subject_access(format!(
"subject_access.delegation.allowed_relationships relationship '{}' credential_profiles must be empty in 1.0; delegated attestation is evaluation-only. Remove credential_profiles to keep delegated evaluation, or issue a registry-backed non-delegated claim through subject_access.credential_profiles",
self.relationship_type
));
}
let allowed_purposes: HashSet<&str> =
self.allowed_purposes.iter().map(String::as_str).collect();
let allowed_formats: HashSet<&str> =
Expand All @@ -363,11 +369,6 @@ impl SubjectAccessDelegatedRelationshipConfig {
.iter()
.map(String::as_str)
.collect();
let allowed_profiles: HashSet<&str> = self
.credential_profiles
.iter()
.map(String::as_str)
.collect();
for claim_id in &self.allowed_claims {
if !claim_ids.contains(claim_id.as_str()) {
return Err(EvidenceConfigError::InvalidSubjectAccessConfig {
Expand Down Expand Up @@ -397,24 +398,18 @@ impl SubjectAccessDelegatedRelationshipConfig {
self.proof_claim
));
}
if !claim.credential_profiles.is_empty() {
return invalid_subject_access(format!(
"delegated claim '{claim_id}' credential_profiles must be empty in 1.0; delegated attestation is evaluation-only. Remove the claim credential_profiles binding to keep delegated evaluation, or issue a registry-backed non-delegated claim"
));
}
validate_delegated_attestation_claim(
self,
claim,
&allowed_purposes,
&allowed_formats,
&allowed_disclosures,
&allowed_profiles,
)?;
}
for profile_id in &self.credential_profiles {
if !evidence.credential_profiles.contains_key(profile_id) {
return Err(EvidenceConfigError::InvalidSubjectAccessConfig {
reason: format!(
"subject_access.delegation credential_profiles references unknown profile '{profile_id}'"
),
});
}
}
validate_delegated_attestation_allow_lists_are_supported(self, evidence)?;
Ok(())
}
Expand Down Expand Up @@ -940,12 +935,10 @@ pub(super) fn validate_delegated_proof_claim_binding(
}

pub(super) fn validate_delegated_attestation_claim(
relationship: &SubjectAccessDelegatedRelationshipConfig,
claim: &ClaimDefinition,
allowed_purposes: &HashSet<&str>,
allowed_formats: &HashSet<&str>,
allowed_disclosures: &HashSet<&str>,
allowed_profiles: &HashSet<&str>,
) -> Result<(), EvidenceConfigError> {
if !claim.operations.evaluate.enabled {
return invalid_subject_access(format!(
Expand Down Expand Up @@ -991,17 +984,6 @@ pub(super) fn validate_delegated_attestation_claim(
claim.id
));
}
if !relationship.credential_profiles.is_empty()
&& !claim
.credential_profiles
.iter()
.any(|profile| allowed_profiles.contains(profile.as_str()))
{
return invalid_subject_access(format!(
"delegated claim '{}' must reference an allowed credential profile",
claim.id
));
}
Ok(())
}

Expand All @@ -1014,12 +996,6 @@ pub(super) fn validate_delegated_attestation_allow_lists_are_supported(
.iter()
.filter_map(|claim_id| evidence.claims.iter().find(|claim| claim.id == *claim_id))
.collect();
let allowed_profiles: Vec<&CredentialProfileConfig> = relationship
.credential_profiles
.iter()
.filter_map(|profile_id| evidence.credential_profiles.get(profile_id))
.collect();

for purpose in &relationship.allowed_purposes {
if !allowed_claims
.iter()
Expand Down Expand Up @@ -1050,16 +1026,9 @@ pub(super) fn validate_delegated_attestation_allow_lists_are_supported(
.iter()
.any(|candidate| candidate == disclosure)
});
let supported_by_profile = allowed_profiles.iter().any(|profile| {
profile
.disclosure
.allowed
.iter()
.any(|candidate| candidate == disclosure)
});
if !supported_by_claim && !supported_by_profile {
if !supported_by_claim {
return invalid_subject_access(format!(
"subject_access.delegation allowed_disclosures entry '{disclosure}' is not supported by any allowed claim or profile"
"subject_access.delegation allowed_disclosures entry '{disclosure}' is not supported by any allowed claim"
));
}
}
Expand Down
4 changes: 4 additions & 0 deletions crates/registry-notary-core/src/config/tests/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ allowed_claims:
.evidence
.credential_profiles
.insert("test-profile".to_string(), profile);
add_registry_credential_claim(&mut config, "some-claim", "test-profile");
assert!(
config.validate().is_ok(),
"did:jwk only should pass validation"
Expand Down Expand Up @@ -242,6 +243,7 @@ allowed_claims:
.evidence
.credential_profiles
.insert("test-profile".to_string(), profile);
add_registry_credential_claim(&mut config, "some-claim", "test-profile");

config
.validate()
Expand Down Expand Up @@ -398,6 +400,7 @@ allowed_claims:
.evidence
.credential_profiles
.insert("test-profile".to_string(), profile);
add_registry_credential_claim(&mut config, "some-claim", "test-profile");

config.validate().expect("PKCS#11 key shape validates");
}
Expand Down Expand Up @@ -431,6 +434,7 @@ allowed_claims:
.evidence
.credential_profiles
.insert("test-profile".to_string(), profile);
add_registry_credential_claim(&mut config, "some-claim", "test-profile");

config.validate().expect("file-watch key shape validates");
}
Expand Down
Loading
Loading