From a49ece84b2f748536f5dcca05616f7eeef9b4fb7 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 12 Jul 2026 00:17:23 +0700 Subject: [PATCH] feat(consent): add signed artifact verification Signed-off-by: Jeremi Joslin --- Cargo.lock | 14 + Cargo.toml | 2 + crates/registry-platform-consent/Cargo.toml | 19 + crates/registry-platform-consent/README.md | 18 + crates/registry-platform-consent/src/lib.rs | 731 ++++++++++++++++++ .../tests/consent.rs | 435 +++++++++++ .../tests/fixtures/valid-ed25519.commitment | 1 + .../tests/fixtures/valid-ed25519.jws | 1 + .../tests/fixtures/valid-ed25519.payload.json | 1 + .../tests/fixtures/valid-ed25519.public.jwk | 1 + crates/registryctl/CHANGELOG.md | 6 + crates/registryctl/Cargo.toml | 2 + crates/registryctl/README.md | 25 + crates/registryctl/src/lib.rs | 594 ++++++++++++++ crates/registryctl/src/main.rs | 85 +- 15 files changed, 1933 insertions(+), 2 deletions(-) create mode 100644 crates/registry-platform-consent/Cargo.toml create mode 100644 crates/registry-platform-consent/README.md create mode 100644 crates/registry-platform-consent/src/lib.rs create mode 100644 crates/registry-platform-consent/tests/consent.rs create mode 100644 crates/registry-platform-consent/tests/fixtures/valid-ed25519.commitment create mode 100644 crates/registry-platform-consent/tests/fixtures/valid-ed25519.jws create mode 100644 crates/registry-platform-consent/tests/fixtures/valid-ed25519.payload.json create mode 100644 crates/registry-platform-consent/tests/fixtures/valid-ed25519.public.jwk diff --git a/Cargo.lock b/Cargo.lock index 4b1581bb0..278c1f81b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5311,6 +5311,18 @@ dependencies = [ "time", ] +[[package]] +name = "registry-platform-consent" +version = "0.9.0" +dependencies = [ + "base64", + "registry-platform-crypto", + "serde", + "serde_json", + "thiserror 2.0.18", + "zeroize", +] + [[package]] name = "registry-platform-crypto" version = "0.9.0" @@ -5559,6 +5571,7 @@ dependencies = [ "registry-notary-core", "registry-platform-authcommon", "registry-platform-config", + "registry-platform-consent", "registry-platform-crypto", "serde", "serde_json", @@ -5567,6 +5580,7 @@ dependencies = [ "time", "ureq", "url", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d0b190957..236737fd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/registry-platform-authcommon", "crates/registry-platform-cache", "crates/registry-platform-config", + "crates/registry-platform-consent", "crates/registry-platform-crypto", "crates/registry-platform-httpsec", "crates/registry-platform-httputil", @@ -63,6 +64,7 @@ registry-platform-audit = { path = "crates/registry-platform-audit", version = " registry-platform-authcommon = { path = "crates/registry-platform-authcommon", version = "0.9.0" } registry-platform-cache = { path = "crates/registry-platform-cache", version = "0.9.0" } registry-platform-config = { path = "crates/registry-platform-config", version = "0.9.0" } +registry-platform-consent = { path = "crates/registry-platform-consent", version = "0.9.0" } registry-platform-crypto = { path = "crates/registry-platform-crypto", version = "0.9.0" } registry-platform-httpsec = { path = "crates/registry-platform-httpsec", version = "0.9.0" } registry-platform-httputil = { path = "crates/registry-platform-httputil", version = "0.9.0" } diff --git a/crates/registry-platform-consent/Cargo.toml b/crates/registry-platform-consent/Cargo.toml new file mode 100644 index 000000000..1acb388f2 --- /dev/null +++ b/crates/registry-platform-consent/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "registry-platform-consent" +version.workspace = true +edition.workspace = true +license.workspace = true +readme = "README.md" +repository.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +base64.workspace = true +registry-platform-crypto = { workspace = true } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +zeroize.workspace = true diff --git a/crates/registry-platform-consent/README.md b/crates/registry-platform-consent/README.md new file mode 100644 index 000000000..31d6f53d4 --- /dev/null +++ b/crates/registry-platform-consent/README.md @@ -0,0 +1,18 @@ +# registry-platform-consent + +Shared, offline verification for Registry Stack consent evidence. + +The first implementation slice accepts one compact Ed25519 JWS per evaluation. +It validates the closed `ConsentEvidenceV1` payload, verifies the signature +against deployment-pinned JWKs, and enforces exact subject, recipient, +assurance, purpose, time, and optional profile bindings. It intentionally has +no source-status adapter, resolver, network access, or consent collection UI. + +Raw compact artifacts are bounded at 8 KiB and deliberately do not implement +`Debug` or `Serialize`. Callers should retain only `VerifiedConsent`, and put +only the domain-separated keyed commitment in audit data. + +The portable fixtures under `tests/fixtures/` publish the exact payload, +public JWK, compact JWS, and keyed-commitment result used by the verification +tests. The maximum-field test separately proves that the largest valid Ed25519 +artifact remains below the 8 KiB wire limit. diff --git a/crates/registry-platform-consent/src/lib.rs b/crates/registry-platform-consent/src/lib.rs new file mode 100644 index 000000000..1ae1c8189 --- /dev/null +++ b/crates/registry-platform-consent/src/lib.rs @@ -0,0 +1,731 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Closed consent-evidence parsing and offline verification. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use registry_platform_crypto::{ + hmac_sha256_base64url_no_pad, sign, verify, PrivateJwk, PublicJwk, SigningAlgorithm, +}; +use serde::de::{MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::fmt; +use thiserror::Error; +use zeroize::{Zeroize, Zeroizing}; + +pub const MAX_CONSENT_EVIDENCE_BYTES: usize = 8 * 1024; +pub const CONSENT_EVIDENCE_JWS_TYPE: &str = "consent-evidence+jws"; +const MAX_IDENTIFIER_TYPE_BYTES: usize = 64; +const MAX_IDENTIFIER_VALUE_BYTES: usize = 256; +const MAX_PURPOSES: usize = 8; +const MAX_PURPOSE_BYTES: usize = 128; +const MAX_ORGANIZATION_BYTES: usize = 256; +const MAX_METHOD_BYTES: usize = 64; +const MAX_CONTEXT_BYTES: usize = 512; +const MAX_REFERENCE_BYTES: usize = 256; +const MAX_LANGUAGE_BYTES: usize = 35; +const MAX_TARGET_PROFILE_BYTES: usize = 256; +const MAX_STATUS_REVISION_BYTES: usize = 64; +const MAX_KID_BYTES: usize = 128; + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ExactIdentifier { + pub identifier_type: String, + pub value: String, +} + +impl Zeroize for ExactIdentifier { + fn zeroize(&mut self) { + self.identifier_type.zeroize(); + self.value.zeroize(); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConsentingPartyRelationship { + #[serde(rename = "self")] + Self_, + Parent, + Guardian, + AuthorizedRepresentative, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ConsentingParty { + pub identifier: ExactIdentifier, + pub relationship: ConsentingPartyRelationship, +} + +impl Zeroize for ConsentingParty { + fn zeroize(&mut self) { + self.identifier.zeroize(); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConsentAssurance { + SystemOfRecordSigned, + OrganizationAttested, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum NoticeModality { + Written, + Audio, + Pictorial, + Interpreted, + Assisted, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TargetProfileBindingKind { + ProfileId, + ContractHash, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TargetProfileBinding { + pub kind: TargetProfileBindingKind, + pub value: String, +} + +impl Zeroize for TargetProfileBinding { + fn zeroize(&mut self) { + self.value.zeroize(); + } +} + +/// The compact, published ConsentEvidenceV1 payload schema. +/// +/// It is serializable for the signing helper, but intentionally is not +/// `Debug`; callers must not copy assertion payloads into diagnostics. +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ConsentEvidenceV1 { + pub version: u8, + pub subject: ExactIdentifier, + pub consenting_party: ConsentingParty, + pub purposes: Vec, + pub recipient: String, + pub controller: String, + pub assurance: ConsentAssurance, + pub collection_method: String, + pub collection_context: String, + pub collected_at: i64, + pub issued_at: i64, + pub expires_at: i64, + pub consent_id: String, + pub notice_reference: String, + pub notice_language: String, + /// `sha256:` plus the lowercase hex digest of the immutable notice content. + pub notice_content_digest: String, + pub notice_modality: NoticeModality, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verifier_section_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_revision: Option, +} + +impl ConsentEvidenceV1 { + pub fn parse_json(input: &[u8]) -> Result { + if input.len() > MAX_CONSENT_EVIDENCE_BYTES { + return Err(ConsentError::Malformed); + } + reject_duplicate_members(input)?; + let evidence: Self = serde_json::from_slice(input).map_err(|_| ConsentError::Malformed)?; + evidence.validate()?; + Ok(evidence) + } + + pub fn to_json_bytes(&self) -> Result, ConsentError> { + self.validate()?; + serde_json::to_vec(self).map_err(|_| ConsentError::Malformed) + } + + pub fn sign_compact(&self, key: &PrivateJwk) -> Result { + self.validate()?; + if key.algorithm().map_err(|_| ConsentError::InvalidKey)? != SigningAlgorithm::EdDsa { + return Err(ConsentError::InvalidKey); + } + let kid = key + .kid + .as_deref() + .filter(|kid| valid_text(kid, MAX_KID_BYTES)) + .ok_or(ConsentError::InvalidKey)?; + let header = ProtectedHeader { + alg: JwsAlgorithm::EdDsa, + typ: CONSENT_EVIDENCE_JWS_TYPE.to_string(), + kid: kid.to_string(), + }; + let protected = URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&header).map_err(|_| ConsentError::Malformed)?); + let payload = URL_SAFE_NO_PAD.encode(self.to_json_bytes()?); + let signing_input = format!("{protected}.{payload}"); + let signature = + sign(signing_input.as_bytes(), key).map_err(|_| ConsentError::InvalidKey)?; + ConsentArtifact::parse(format!( + "{signing_input}.{}", + URL_SAFE_NO_PAD.encode(signature) + )) + } + + fn validate(&self) -> Result<(), ConsentError> { + if self.version != 1 + || !valid_identifier(&self.subject) + || !valid_identifier(&self.consenting_party.identifier) + || self.purposes.is_empty() + || self.purposes.len() > MAX_PURPOSES + || self + .purposes + .iter() + .any(|purpose| !valid_text(purpose, MAX_PURPOSE_BYTES)) + || self.purposes.iter().collect::>().len() != self.purposes.len() + || !valid_text(&self.recipient, MAX_ORGANIZATION_BYTES) + || !valid_text(&self.controller, MAX_ORGANIZATION_BYTES) + || !valid_text(&self.collection_method, MAX_METHOD_BYTES) + || !valid_text(&self.collection_context, MAX_CONTEXT_BYTES) + || self.collected_at <= 0 + || self.issued_at <= 0 + || self.collected_at > self.issued_at + || self.issued_at >= self.expires_at + || !valid_text(&self.consent_id, MAX_REFERENCE_BYTES) + || !valid_text(&self.notice_reference, MAX_REFERENCE_BYTES) + || !valid_language(&self.notice_language) + || !valid_sha256_digest(&self.notice_content_digest) + || self + .target_profile + .as_ref() + .is_some_and(|binding| match binding.kind { + TargetProfileBindingKind::ProfileId => { + !valid_text(&binding.value, MAX_TARGET_PROFILE_BYTES) + } + TargetProfileBindingKind::ContractHash => !valid_sha256_digest(&binding.value), + }) + || self + .verifier_section_hash + .as_deref() + .is_some_and(|value| !valid_sha256_digest(value)) + || self + .status_revision + .as_deref() + .is_some_and(|value| !valid_text(value, MAX_STATUS_REVISION_BYTES)) + { + return Err(ConsentError::InvalidPayload); + } + if self.consenting_party.relationship == ConsentingPartyRelationship::Self_ + && self.consenting_party.identifier != self.subject + { + return Err(ConsentError::InvalidPayload); + } + Ok(()) + } +} + +impl Drop for ConsentEvidenceV1 { + fn drop(&mut self) { + self.subject.zeroize(); + self.consenting_party.zeroize(); + self.purposes.zeroize(); + self.recipient.zeroize(); + self.controller.zeroize(); + self.collection_method.zeroize(); + self.collection_context.zeroize(); + self.consent_id.zeroize(); + self.notice_reference.zeroize(); + self.notice_language.zeroize(); + self.notice_content_digest.zeroize(); + self.target_profile.zeroize(); + self.verifier_section_hash.zeroize(); + self.status_revision.zeroize(); + } +} + +fn valid_identifier(identifier: &ExactIdentifier) -> bool { + valid_text(&identifier.identifier_type, MAX_IDENTIFIER_TYPE_BYTES) + && valid_text(&identifier.value, MAX_IDENTIFIER_VALUE_BYTES) +} + +fn valid_text(value: &str, max_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= max_bytes + && value.trim() == value + && !value.chars().any(char::is_control) +} + +fn valid_language(value: &str) -> bool { + valid_text(value, MAX_LANGUAGE_BYTES) + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') +} + +fn valid_sha256_digest(value: &str) -> bool { + value.len() == 71 + && value.starts_with("sha256:") + && value[7..] + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn reject_duplicate_members(input: &[u8]) -> Result<(), ConsentError> { + let mut deserializer = serde_json::Deserializer::from_slice(input); + RejectDuplicateValue::deserialize(&mut deserializer).map_err(|_| ConsentError::Malformed)?; + deserializer.end().map_err(|_| ConsentError::Malformed) +} + +struct RejectDuplicateValue; + +impl<'de> Deserialize<'de> for RejectDuplicateValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(RejectDuplicateVisitor) + } +} + +struct RejectDuplicateVisitor; + +impl<'de> Visitor<'de> for RejectDuplicateVisitor { + type Value = RejectDuplicateValue; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("JSON without duplicate object members") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut names = HashSet::new(); + while let Some(name) = map.next_key::()? { + if !names.insert(name) { + return Err(serde::de::Error::custom("duplicate JSON member")); + } + map.next_value::()?; + } + Ok(RejectDuplicateValue) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + while sequence.next_element::()?.is_some() {} + Ok(RejectDuplicateValue) + } + + fn visit_bool(self, _: bool) -> Result { + Ok(RejectDuplicateValue) + } + fn visit_i64(self, _: i64) -> Result { + Ok(RejectDuplicateValue) + } + fn visit_u64(self, _: u64) -> Result { + Ok(RejectDuplicateValue) + } + fn visit_f64(self, _: f64) -> Result { + Ok(RejectDuplicateValue) + } + fn visit_str(self, _: &str) -> Result { + Ok(RejectDuplicateValue) + } + fn visit_string(self, _: String) -> Result { + Ok(RejectDuplicateValue) + } + fn visit_none(self) -> Result { + Ok(RejectDuplicateValue) + } + fn visit_unit(self) -> Result { + Ok(RejectDuplicateValue) + } +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ProtectedHeader { + alg: JwsAlgorithm, + typ: String, + kid: String, +} + +impl Drop for ProtectedHeader { + fn drop(&mut self) { + self.typ.zeroize(); + self.kid.zeroize(); + } +} + +#[derive(Clone, Copy, Deserialize, Serialize)] +enum JwsAlgorithm { + #[serde(rename = "EdDSA")] + EdDsa, +} + +/// Size-bounded compact consent JWS. Raw material is intentionally opaque. +pub struct ConsentArtifact { + compact: Zeroizing, + protected: ProtectedHeader, + evidence: ConsentEvidenceV1, + signing_input_len: usize, +} + +impl ConsentArtifact { + pub fn parse(compact: String) -> Result { + if compact.is_empty() || compact.len() > MAX_CONSENT_EVIDENCE_BYTES || !compact.is_ascii() { + return Err(ConsentError::Malformed); + } + let mut segments = compact.split('.'); + let protected_segment = segments.next().ok_or(ConsentError::Malformed)?; + let payload_segment = segments.next().ok_or(ConsentError::Malformed)?; + let signature_segment = segments.next().ok_or(ConsentError::Malformed)?; + if segments.next().is_some() + || protected_segment.is_empty() + || payload_segment.is_empty() + || signature_segment.is_empty() + { + return Err(ConsentError::Malformed); + } + let protected_bytes = Zeroizing::new( + URL_SAFE_NO_PAD + .decode(protected_segment) + .map_err(|_| ConsentError::Malformed)?, + ); + reject_duplicate_members(&protected_bytes)?; + let protected: ProtectedHeader = + serde_json::from_slice(&protected_bytes).map_err(|_| ConsentError::Malformed)?; + if protected.typ != CONSENT_EVIDENCE_JWS_TYPE || !valid_text(&protected.kid, MAX_KID_BYTES) + { + return Err(ConsentError::Malformed); + } + let payload = Zeroizing::new( + URL_SAFE_NO_PAD + .decode(payload_segment) + .map_err(|_| ConsentError::Malformed)?, + ); + let evidence = ConsentEvidenceV1::parse_json(&payload)?; + let signature = Zeroizing::new( + URL_SAFE_NO_PAD + .decode(signature_segment) + .map_err(|_| ConsentError::Malformed)?, + ); + if signature.len() != 64 { + return Err(ConsentError::Malformed); + } + let signing_input_len = protected_segment.len() + 1 + payload_segment.len(); + Ok(Self { + compact: Zeroizing::new(compact), + protected, + evidence, + signing_input_len, + }) + } + + #[must_use] + pub fn as_str(&self) -> &str { + self.compact.as_str() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RevocationModel { + LifetimeOnly, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum UnavailableBehavior { + Deny, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SubjectBindingRule { + ExactIdentifier, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PurposeCoverageRule { + AllRequired, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ConsentVerifierSpec { + pub verifier_id: String, + pub revision: String, + pub evidence_format_profile: String, + pub evidence_format_version: u8, + pub maximum_evidence_age_seconds: u64, + pub revocation: RevocationModel, + pub revocation_propagation_seconds: u64, + pub unavailable: UnavailableBehavior, + pub subject_binding: SubjectBindingRule, + pub accepted_assurance: BTreeSet, + pub purpose_coverage: PurposeCoverageRule, +} + +impl ConsentVerifierSpec { + pub fn validate(&self) -> Result<(), ConsentError> { + if !valid_text(&self.verifier_id, 128) + || !valid_text(&self.revision, 128) + || self.evidence_format_profile != "registry.consent-evidence" + || self.evidence_format_version != 1 + || self.maximum_evidence_age_seconds == 0 + || self.revocation_propagation_seconds == 0 + || self.maximum_evidence_age_seconds > self.revocation_propagation_seconds + || self.accepted_assurance.is_empty() + { + return Err(ConsentError::InvalidVerifierSpec); + } + Ok(()) + } +} + +pub struct PinnedConsentKeys { + by_kid: BTreeMap, +} + +impl PinnedConsentKeys { + pub fn new(keys: impl IntoIterator) -> Result { + let mut by_kid = BTreeMap::new(); + for key in keys { + if key.algorithm().map_err(|_| ConsentError::InvalidKey)? != SigningAlgorithm::EdDsa { + return Err(ConsentError::InvalidKey); + } + let kid = key + .kid + .as_deref() + .filter(|kid| valid_text(kid, MAX_KID_BYTES)) + .ok_or(ConsentError::InvalidKey)? + .to_string(); + if by_kid.insert(kid, key).is_some() { + return Err(ConsentError::DuplicatePinnedKeyId); + } + } + if by_kid.is_empty() { + return Err(ConsentError::InvalidKey); + } + Ok(Self { by_kid }) + } +} + +pub struct VerificationContext<'a> { + pub subject: &'a ExactIdentifier, + pub recipient: &'a str, + pub required_purposes: &'a BTreeSet, + pub now: i64, + pub required_target_profile: Option<&'a TargetProfileBinding>, + pub required_verifier_section_hash: Option<&'a str>, + pub required_status_revision: Option<&'a str>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedConsent { + pub verifier_id: String, + pub verifier_revision: String, + pub signer_key_id: String, + pub checked_at: i64, + pub expires_at: i64, + pub assurance: ConsentAssurance, + pub revocation: RevocationModel, +} + +pub fn verify_consent( + artifact: &ConsentArtifact, + keys: &PinnedConsentKeys, + spec: &ConsentVerifierSpec, + context: &VerificationContext<'_>, +) -> Result { + spec.validate()?; + validate_verification_context(context)?; + let key = keys + .by_kid + .get(&artifact.protected.kid) + .ok_or(ConsentError::Denied)?; + let signature_segment = artifact + .compact + .rsplit_once('.') + .map(|(_, signature)| signature) + .ok_or(ConsentError::Malformed)?; + let signature = Zeroizing::new( + URL_SAFE_NO_PAD + .decode(signature_segment) + .map_err(|_| ConsentError::Malformed)?, + ); + verify( + &artifact.compact.as_bytes()[..artifact.signing_input_len], + &signature, + key, + ) + .map_err(|_| ConsentError::Denied)?; + + let evidence = &artifact.evidence; + let age = context + .now + .checked_sub(evidence.issued_at) + .ok_or(ConsentError::Denied)?; + if &evidence.subject != context.subject + || evidence.recipient != context.recipient + || !spec.accepted_assurance.contains(&evidence.assurance) + || context + .required_purposes + .iter() + .any(|required| !evidence.purposes.contains(required)) + || age < 0 + || u64::try_from(age).map_err(|_| ConsentError::Denied)? > spec.maximum_evidence_age_seconds + || context.now >= evidence.expires_at + || context + .required_target_profile + .is_some_and(|required| evidence.target_profile.as_ref() != Some(required)) + || binding_mismatch( + context.required_verifier_section_hash, + evidence.verifier_section_hash.as_deref(), + ) + || binding_mismatch( + context.required_status_revision, + evidence.status_revision.as_deref(), + ) + { + return Err(ConsentError::Denied); + } + + Ok(VerifiedConsent { + verifier_id: spec.verifier_id.clone(), + verifier_revision: spec.revision.clone(), + signer_key_id: artifact.protected.kid.clone(), + checked_at: context.now, + expires_at: evidence.expires_at, + assurance: evidence.assurance, + revocation: spec.revocation, + }) +} + +fn validate_verification_context(context: &VerificationContext<'_>) -> Result<(), ConsentError> { + if !valid_identifier(context.subject) + || !valid_text(context.recipient, MAX_ORGANIZATION_BYTES) + || context.required_purposes.is_empty() + || context.required_purposes.len() > MAX_PURPOSES + || context + .required_purposes + .iter() + .any(|purpose| !valid_text(purpose, MAX_PURPOSE_BYTES)) + || context.now <= 0 + || context + .required_target_profile + .is_some_and(|binding| match binding.kind { + TargetProfileBindingKind::ProfileId => { + !valid_text(&binding.value, MAX_TARGET_PROFILE_BYTES) + } + TargetProfileBindingKind::ContractHash => !valid_sha256_digest(&binding.value), + }) + || context + .required_verifier_section_hash + .is_some_and(|value| !valid_sha256_digest(value)) + || context + .required_status_revision + .is_some_and(|value| !valid_text(value, MAX_STATUS_REVISION_BYTES)) + { + return Err(ConsentError::InvalidVerificationContext); + } + Ok(()) +} + +fn binding_mismatch(required: Option<&str>, actual: Option<&str>) -> bool { + required.is_some_and(|required| actual != Some(required)) +} + +/// Evaluation-scoped evidence. Its shape cannot represent per-claim artifacts. +pub struct EvaluationConsentEvidence(Option); + +impl EvaluationConsentEvidence { + #[must_use] + pub const fn none() -> Self { + Self(None) + } + + #[must_use] + pub const fn one(artifact: ConsentArtifact) -> Self { + Self(Some(artifact)) + } + + pub fn from_artifacts( + artifacts: impl IntoIterator, + ) -> Result { + let mut artifacts = artifacts.into_iter(); + let first = artifacts.next(); + if artifacts.next().is_some() { + return Err(ConsentError::TooManyArtifacts); + } + Ok(Self(first)) + } + + #[must_use] + pub const fn artifact(&self) -> Option<&ConsentArtifact> { + self.0.as_ref() + } + + pub fn validate_requirement(&self, consent_required: bool) -> Result<(), ConsentError> { + if consent_required != self.0.is_some() { + return Err(ConsentError::InvalidEvaluationEvidence); + } + Ok(()) + } +} + +/// Produce the only audit-safe representation of a raw compact artifact. +pub fn consent_evidence_commitment( + key: &[u8], + runtime_domain: &str, + verifier_id: &str, + artifact: &ConsentArtifact, +) -> Result { + if !valid_text(runtime_domain, 128) || !valid_text(verifier_id, 128) { + return Err(ConsentError::InvalidVerifierSpec); + } + let mut input = Zeroizing::new(Vec::with_capacity( + runtime_domain.len() + verifier_id.len() + artifact.compact.len() + 2, + )); + input.extend_from_slice(runtime_domain.as_bytes()); + input.push(0); + input.extend_from_slice(verifier_id.as_bytes()); + input.push(0); + input.extend_from_slice(artifact.compact.as_bytes()); + Ok(format!( + "hmac-sha256:{}", + hmac_sha256_base64url_no_pad(key, &input) + )) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ConsentError { + #[error("malformed consent evidence")] + Malformed, + #[error("invalid consent evidence payload")] + InvalidPayload, + #[error("invalid consent verifier specification")] + InvalidVerifierSpec, + #[error("invalid consent verification context")] + InvalidVerificationContext, + #[error("invalid pinned consent key")] + InvalidKey, + #[error("duplicate pinned consent key id")] + DuplicatePinnedKeyId, + #[error("consent evidence denied")] + Denied, + #[error("at most one consent artifact is allowed per evaluation")] + TooManyArtifacts, + #[error("consent evidence does not match the evaluation requirement")] + InvalidEvaluationEvidence, +} diff --git a/crates/registry-platform-consent/tests/consent.rs b/crates/registry-platform-consent/tests/consent.rs new file mode 100644 index 000000000..6ba7a3296 --- /dev/null +++ b/crates/registry-platform-consent/tests/consent.rs @@ -0,0 +1,435 @@ +// SPDX-License-Identifier: Apache-2.0 + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use registry_platform_consent::{ + consent_evidence_commitment, verify_consent, ConsentArtifact, ConsentAssurance, ConsentError, + ConsentEvidenceV1, ConsentVerifierSpec, ConsentingParty, ConsentingPartyRelationship, + EvaluationConsentEvidence, ExactIdentifier, NoticeModality, PinnedConsentKeys, + PurposeCoverageRule, RevocationModel, SubjectBindingRule, TargetProfileBinding, + TargetProfileBindingKind, UnavailableBehavior, VerificationContext, MAX_CONSENT_EVIDENCE_BYTES, +}; +use registry_platform_crypto::{PrivateJwk, PublicJwk}; +use std::collections::BTreeSet; + +const PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"consent-test-key-1"}"#; + +fn identifier(value: &str) -> ExactIdentifier { + ExactIdentifier { + identifier_type: "programme_id".to_string(), + value: value.to_string(), + } +} + +fn target_profile() -> TargetProfileBinding { + TargetProfileBinding { + kind: TargetProfileBindingKind::ProfileId, + value: "humanitarian-referral-v1".to_string(), + } +} + +fn evidence() -> ConsentEvidenceV1 { + ConsentEvidenceV1 { + version: 1, + subject: identifier("P-123"), + consenting_party: ConsentingParty { + identifier: identifier("P-123"), + relationship: ConsentingPartyRelationship::Self_, + }, + purposes: ["humanitarian.referral".to_string()].into(), + recipient: "referral-partner.example".to_string(), + controller: "programme.example".to_string(), + assurance: ConsentAssurance::SystemOfRecordSigned, + collection_method: "registration_desk".to_string(), + collection_context: "voluntary referral choice".to_string(), + collected_at: 1_783_731_600, + issued_at: 1_783_731_660, + expires_at: 1_783_732_260, + consent_id: "consent-2026-0001".to_string(), + notice_reference: "notice/referral/v1".to_string(), + notice_language: "en".to_string(), + notice_content_digest: format!("sha256:{}", "a".repeat(64)), + notice_modality: NoticeModality::Written, + target_profile: Some(target_profile()), + verifier_section_hash: Some(format!("sha256:{}", "b".repeat(64))), + status_revision: Some("42".to_string()), + } +} + +fn keypair() -> (PrivateJwk, PublicJwk) { + let private = PrivateJwk::parse(PRIVATE_JWK).expect("private key"); + let public = private.public(); + (private, public) +} + +fn spec() -> ConsentVerifierSpec { + ConsentVerifierSpec { + verifier_id: "humanitarian-referral-sor".to_string(), + revision: "2026-07-11".to_string(), + evidence_format_profile: "registry.consent-evidence".to_string(), + evidence_format_version: 1, + maximum_evidence_age_seconds: 300, + revocation: RevocationModel::LifetimeOnly, + revocation_propagation_seconds: 300, + unavailable: UnavailableBehavior::Deny, + subject_binding: SubjectBindingRule::ExactIdentifier, + accepted_assurance: [ConsentAssurance::SystemOfRecordSigned].into(), + purpose_coverage: PurposeCoverageRule::AllRequired, + } +} + +#[test] +fn golden_vector_parses_and_verifies() { + let (private, public) = keypair(); + assert_eq!( + evidence().to_json_bytes().expect("payload"), + include_bytes!("fixtures/valid-ed25519.payload.json") + .strip_suffix(b"\n") + .expect("fixture newline") + ); + assert_eq!( + public, + PublicJwk::parse(include_str!("fixtures/valid-ed25519.public.jwk").trim()) + .expect("golden public key") + ); + let artifact = evidence().sign_compact(&private).expect("sign"); + assert_eq!( + artifact.as_str(), + include_str!("fixtures/valid-ed25519.jws").trim() + ); + let parsed = ConsentArtifact::parse(artifact.as_str().to_string()).expect("parse"); + let keys = PinnedConsentKeys::new([public]).expect("keys"); + let required_purposes = ["humanitarian.referral".to_string()].into(); + let subject = identifier("P-123"); + let target_profile = target_profile(); + let verified = verify_consent( + &parsed, + &keys, + &spec(), + &VerificationContext { + subject: &subject, + recipient: "referral-partner.example", + required_purposes: &required_purposes, + now: 1_783_731_900, + required_target_profile: Some(&target_profile), + required_verifier_section_hash: Some( + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ), + required_status_revision: Some("42"), + }, + ) + .expect("verify"); + assert_eq!(verified.signer_key_id, "consent-test-key-1"); + assert_eq!(verified.expires_at, 1_783_732_260); +} + +#[test] +fn parse_rejects_unknown_and_duplicate_payload_members() { + let json = evidence().to_json_bytes().expect("json"); + let mut value = String::from_utf8(json).expect("utf8"); + value.insert_str(value.len() - 1, ",\"unexpected\":true"); + assert_eq!( + ConsentEvidenceV1::parse_json(value.as_bytes()).err(), + Some(ConsentError::Malformed) + ); + + let duplicate = value.replace(",\"unexpected\":true", ",\"version\":1"); + assert_eq!( + ConsentEvidenceV1::parse_json(duplicate.as_bytes()).err(), + Some(ConsentError::Malformed) + ); +} + +#[test] +fn parse_rejects_unknown_and_duplicate_protected_header_members() { + let (private, _) = keypair(); + let artifact = evidence().sign_compact(&private).expect("sign"); + let segments: Vec<_> = artifact.as_str().split('.').collect(); + for header in [ + r#"{"alg":"EdDSA","typ":"consent-evidence+jws","kid":"consent-test-key-1","jku":"https://attacker.test"}"#, + r#"{"alg":"EdDSA","alg":"EdDSA","typ":"consent-evidence+jws","kid":"consent-test-key-1"}"#, + ] { + let compact = format!( + "{}.{}.{}", + URL_SAFE_NO_PAD.encode(header), + segments[1], + segments[2] + ); + assert_eq!( + ConsentArtifact::parse(compact).err(), + Some(ConsentError::Malformed) + ); + } +} + +#[test] +fn verification_denies_each_bound_dimension() { + let (private, public) = keypair(); + let artifact = evidence().sign_compact(&private).expect("sign"); + let keys = PinnedConsentKeys::new([public]).expect("keys"); + let purposes: BTreeSet<_> = ["humanitarian.referral".to_string()].into(); + let subject = identifier("P-123"); + let target_profile = target_profile(); + let verify = |subject: &ExactIdentifier, recipient: &str, purposes: &BTreeSet, now| { + verify_consent( + &artifact, + &keys, + &spec(), + &VerificationContext { + subject, + recipient, + required_purposes: purposes, + now, + required_target_profile: Some(&target_profile), + required_verifier_section_hash: Some( + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ), + required_status_revision: Some("42"), + }, + ) + }; + assert_eq!( + verify( + &identifier("other"), + "referral-partner.example", + &purposes, + 1_783_731_900 + ), + Err(ConsentError::Denied) + ); + assert_eq!( + verify(&subject, "other.example", &purposes, 1_783_731_900), + Err(ConsentError::Denied) + ); + assert_eq!( + verify( + &subject, + "referral-partner.example", + &["other".to_string()].into(), + 1_783_731_900 + ), + Err(ConsentError::Denied) + ); + assert_eq!( + verify( + &subject, + "referral-partner.example", + &purposes, + 1_783_731_961 + ), + Err(ConsentError::Denied) + ); + assert_eq!( + verify( + &subject, + "referral-partner.example", + &purposes, + 1_783_732_260 + ), + Err(ConsentError::Denied) + ); + + let mut wrong_assurance = spec(); + wrong_assurance.accepted_assurance = [ConsentAssurance::OrganizationAttested].into(); + assert_eq!( + verify_consent( + &artifact, + &keys, + &wrong_assurance, + &VerificationContext { + subject: &subject, + recipient: "referral-partner.example", + required_purposes: &purposes, + now: 1_783_731_900, + required_target_profile: Some(&target_profile), + required_verifier_section_hash: None, + required_status_revision: None, + } + ), + Err(ConsentError::Denied) + ); + let wrong_target = TargetProfileBinding { + kind: TargetProfileBindingKind::ContractHash, + value: format!("sha256:{}", "c".repeat(64)), + }; + assert_eq!( + verify_consent( + &artifact, + &keys, + &spec(), + &VerificationContext { + subject: &subject, + recipient: "referral-partner.example", + required_purposes: &purposes, + now: 1_783_731_900, + required_target_profile: Some(&wrong_target), + required_verifier_section_hash: None, + required_status_revision: None, + } + ), + Err(ConsentError::Denied) + ); +} + +#[test] +fn pinned_keys_reject_duplicate_kids_and_unknown_signer_denies() { + let (private, public) = keypair(); + assert!(matches!( + PinnedConsentKeys::new([public.clone(), public]), + Err(ConsentError::DuplicatePinnedKeyId) + )); + + let mut other = private.public(); + other.kid = Some("other-key".to_string()); + let keys = PinnedConsentKeys::new([other]).expect("other pin"); + let artifact = evidence().sign_compact(&private).expect("sign"); + let purposes = ["humanitarian.referral".to_string()].into(); + let subject = identifier("P-123"); + assert_eq!( + verify_consent( + &artifact, + &keys, + &spec(), + &VerificationContext { + subject: &subject, + recipient: "referral-partner.example", + required_purposes: &purposes, + now: 1_783_731_900, + required_target_profile: None, + required_verifier_section_hash: None, + required_status_revision: None, + } + ), + Err(ConsentError::Denied) + ); +} + +#[test] +fn evaluation_cardinality_and_closed_surface_are_enforced() { + let (private, _) = keypair(); + let first = evidence().sign_compact(&private).expect("first"); + let second = evidence().sign_compact(&private).expect("second"); + assert!(matches!( + EvaluationConsentEvidence::from_artifacts([first, second]), + Err(ConsentError::TooManyArtifacts) + )); + assert_eq!( + EvaluationConsentEvidence::none().validate_requirement(true), + Err(ConsentError::InvalidEvaluationEvidence) + ); + let supplied = EvaluationConsentEvidence::one(evidence().sign_compact(&private).expect("one")); + assert_eq!( + supplied.validate_requirement(false), + Err(ConsentError::InvalidEvaluationEvidence) + ); +} + +#[test] +fn verifier_and_runtime_context_fail_closed_when_incomplete() { + let (private, public) = keypair(); + let artifact = evidence().sign_compact(&private).expect("sign"); + let keys = PinnedConsentKeys::new([public]).expect("keys"); + let subject = identifier("P-123"); + + let mut invalid_spec = spec(); + invalid_spec.maximum_evidence_age_seconds = 301; + assert_eq!( + verify_consent( + &artifact, + &keys, + &invalid_spec, + &VerificationContext { + subject: &subject, + recipient: "referral-partner.example", + required_purposes: &["humanitarian.referral".to_string()].into(), + now: 1_783_731_900, + required_target_profile: None, + required_verifier_section_hash: None, + required_status_revision: None, + }, + ), + Err(ConsentError::InvalidVerifierSpec) + ); + + assert_eq!( + verify_consent( + &artifact, + &keys, + &spec(), + &VerificationContext { + subject: &subject, + recipient: "referral-partner.example", + required_purposes: &BTreeSet::new(), + now: 1_783_731_900, + required_target_profile: None, + required_verifier_section_hash: None, + required_status_revision: None, + }, + ), + Err(ConsentError::InvalidVerificationContext) + ); +} + +#[test] +fn maximum_size_vector_exercises_every_field_cap_and_fits_compact_jws() { + let (private, _) = keypair(); + let exact = ExactIdentifier { + identifier_type: "t".repeat(64), + value: "v".repeat(256), + }; + let maximum = ConsentEvidenceV1 { + version: 1, + subject: exact.clone(), + consenting_party: ConsentingParty { + identifier: exact, + relationship: ConsentingPartyRelationship::Self_, + }, + purposes: (0..8) + .map(|index| format!("{index}{}", "p".repeat(127))) + .collect(), + recipient: "r".repeat(256), + controller: "c".repeat(256), + assurance: ConsentAssurance::SystemOfRecordSigned, + collection_method: "m".repeat(64), + collection_context: "x".repeat(512), + collected_at: 1, + issued_at: 2, + expires_at: i64::MAX, + consent_id: "i".repeat(256), + notice_reference: "n".repeat(256), + notice_language: "a".repeat(35), + notice_content_digest: format!("sha256:{}", "a".repeat(64)), + notice_modality: NoticeModality::Interpreted, + target_profile: Some(TargetProfileBinding { + kind: TargetProfileBindingKind::ProfileId, + value: "t".repeat(256), + }), + verifier_section_hash: Some(format!("sha256:{}", "b".repeat(64))), + status_revision: Some("s".repeat(64)), + }; + let artifact = maximum.sign_compact(&private).expect("maximum signs"); + assert_eq!(artifact.as_str().len(), 5_971); + assert!(artifact.as_str().len() <= MAX_CONSENT_EVIDENCE_BYTES); + + let oversized = "a".repeat(MAX_CONSENT_EVIDENCE_BYTES + 1); + assert_eq!( + ConsentArtifact::parse(oversized).err(), + Some(ConsentError::Malformed) + ); +} + +#[test] +fn commitment_is_domain_separated_and_stable() { + let (private, _) = keypair(); + let artifact = evidence().sign_compact(&private).expect("sign"); + assert_eq!( + consent_evidence_commitment( + b"01234567890123456789012345678901", + "registry-relay:consent-evidence:v1", + "humanitarian-referral-sor", + &artifact, + ) + .expect("commitment"), + include_str!("fixtures/valid-ed25519.commitment").trim() + ); +} diff --git a/crates/registry-platform-consent/tests/fixtures/valid-ed25519.commitment b/crates/registry-platform-consent/tests/fixtures/valid-ed25519.commitment new file mode 100644 index 000000000..3d6f3a7eb --- /dev/null +++ b/crates/registry-platform-consent/tests/fixtures/valid-ed25519.commitment @@ -0,0 +1 @@ +hmac-sha256:uudPMoz13Sru35R3Qg0NjiQDPbT_4GQB1QqlE9kjamY diff --git a/crates/registry-platform-consent/tests/fixtures/valid-ed25519.jws b/crates/registry-platform-consent/tests/fixtures/valid-ed25519.jws new file mode 100644 index 000000000..559cbb106 --- /dev/null +++ b/crates/registry-platform-consent/tests/fixtures/valid-ed25519.jws @@ -0,0 +1 @@ +eyJhbGciOiJFZERTQSIsInR5cCI6ImNvbnNlbnQtZXZpZGVuY2UrandzIiwia2lkIjoiY29uc2VudC10ZXN0LWtleS0xIn0.eyJ2ZXJzaW9uIjoxLCJzdWJqZWN0Ijp7ImlkZW50aWZpZXJfdHlwZSI6InByb2dyYW1tZV9pZCIsInZhbHVlIjoiUC0xMjMifSwiY29uc2VudGluZ19wYXJ0eSI6eyJpZGVudGlmaWVyIjp7ImlkZW50aWZpZXJfdHlwZSI6InByb2dyYW1tZV9pZCIsInZhbHVlIjoiUC0xMjMifSwicmVsYXRpb25zaGlwIjoic2VsZiJ9LCJwdXJwb3NlcyI6WyJodW1hbml0YXJpYW4ucmVmZXJyYWwiXSwicmVjaXBpZW50IjoicmVmZXJyYWwtcGFydG5lci5leGFtcGxlIiwiY29udHJvbGxlciI6InByb2dyYW1tZS5leGFtcGxlIiwiYXNzdXJhbmNlIjoic3lzdGVtX29mX3JlY29yZF9zaWduZWQiLCJjb2xsZWN0aW9uX21ldGhvZCI6InJlZ2lzdHJhdGlvbl9kZXNrIiwiY29sbGVjdGlvbl9jb250ZXh0Ijoidm9sdW50YXJ5IHJlZmVycmFsIGNob2ljZSIsImNvbGxlY3RlZF9hdCI6MTc4MzczMTYwMCwiaXNzdWVkX2F0IjoxNzgzNzMxNjYwLCJleHBpcmVzX2F0IjoxNzgzNzMyMjYwLCJjb25zZW50X2lkIjoiY29uc2VudC0yMDI2LTAwMDEiLCJub3RpY2VfcmVmZXJlbmNlIjoibm90aWNlL3JlZmVycmFsL3YxIiwibm90aWNlX2xhbmd1YWdlIjoiZW4iLCJub3RpY2VfY29udGVudF9kaWdlc3QiOiJzaGEyNTY6YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYSIsIm5vdGljZV9tb2RhbGl0eSI6IndyaXR0ZW4iLCJ0YXJnZXRfcHJvZmlsZSI6eyJraW5kIjoicHJvZmlsZV9pZCIsInZhbHVlIjoiaHVtYW5pdGFyaWFuLXJlZmVycmFsLXYxIn0sInZlcmlmaWVyX3NlY3Rpb25faGFzaCI6InNoYTI1NjpiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiIiwic3RhdHVzX3JldmlzaW9uIjoiNDIifQ.uku7qAjDkUEHZNpv_Nn7GbjqzOea4tTs2FzClJYan15GyY-iGNRXxT5pYoqFMgiBXWuTAk6gNqfakh3dEoJaBA diff --git a/crates/registry-platform-consent/tests/fixtures/valid-ed25519.payload.json b/crates/registry-platform-consent/tests/fixtures/valid-ed25519.payload.json new file mode 100644 index 000000000..3e71bbedb --- /dev/null +++ b/crates/registry-platform-consent/tests/fixtures/valid-ed25519.payload.json @@ -0,0 +1 @@ +{"version":1,"subject":{"identifier_type":"programme_id","value":"P-123"},"consenting_party":{"identifier":{"identifier_type":"programme_id","value":"P-123"},"relationship":"self"},"purposes":["humanitarian.referral"],"recipient":"referral-partner.example","controller":"programme.example","assurance":"system_of_record_signed","collection_method":"registration_desk","collection_context":"voluntary referral choice","collected_at":1783731600,"issued_at":1783731660,"expires_at":1783732260,"consent_id":"consent-2026-0001","notice_reference":"notice/referral/v1","notice_language":"en","notice_content_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","notice_modality":"written","target_profile":{"kind":"profile_id","value":"humanitarian-referral-v1"},"verifier_section_hash":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","status_revision":"42"} diff --git a/crates/registry-platform-consent/tests/fixtures/valid-ed25519.public.jwk b/crates/registry-platform-consent/tests/fixtures/valid-ed25519.public.jwk new file mode 100644 index 000000000..550ab47ee --- /dev/null +++ b/crates/registry-platform-consent/tests/fixtures/valid-ed25519.public.jwk @@ -0,0 +1 @@ +{"kty":"OKP","kid":"consent-test-key-1","alg":"EdDSA","crv":"Ed25519","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc"} diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md index 85922dbcc..403bad0b2 100644 --- a/crates/registryctl/CHANGELOG.md +++ b/crates/registryctl/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added + +- `registryctl consent keygen` creates a local Ed25519 consent keypair with a + `0600` private JWK, and `registryctl consent sign` validates and + signs one bounded `ConsentEvidenceV1` payload as compact JWS. + ## [0.9.0] - 2026-07-10 ### Added diff --git a/crates/registryctl/Cargo.toml b/crates/registryctl/Cargo.toml index 7e2b2d367..e9a428534 100644 --- a/crates/registryctl/Cargo.toml +++ b/crates/registryctl/Cargo.toml @@ -18,6 +18,7 @@ getrandom = "0.4" registry-config-report = { workspace = true } registry-platform-authcommon = { workspace = true } registry-platform-config = { workspace = true } +registry-platform-consent = { workspace = true } registry-platform-crypto = { workspace = true } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -25,6 +26,7 @@ serde_yaml = "0.9" time = { workspace = true } ureq = "2" url = "2" +zeroize = "1" [dev-dependencies] registry-notary-core.workspace = true diff --git a/crates/registryctl/README.md b/crates/registryctl/README.md index c2056115d..d2fa55dcc 100644 --- a/crates/registryctl/README.md +++ b/crates/registryctl/README.md @@ -87,6 +87,31 @@ runtime commands. They keep using the immutable image references already stored in `registryctl.yaml` and `compose.yaml`. A later `init` or `add` is a generation operation and requires the lock for that registryctl version. +## Consent evidence keys and signing + +Create a dedicated Ed25519 keypair for signed consent evidence in one command: + +```sh +registryctl consent keygen --out-dir consent-keys +``` + +The command creates a new directory instead of overwriting an existing path. +Keep `consent-keys/private.jwk` secret; `consent-keys/public.jwk` is the key to +pin in the governed private binding. On Unix, the private key is mode `0600`. + +Validate and sign one `ConsentEvidenceV1` JSON payload: + +```sh +registryctl consent sign \ + --payload consent.json \ + --key consent-keys/private.jwk \ + --out consent.jws +``` + +The signer rejects symlinked, non-regular, oversized, or invalid inputs and +never prints private key material or the consent artifact to standard output. +The output path must be new and is mode `0600` on Unix. + ## Update checks `registryctl` checks GitHub releases at most once per day for normal diff --git a/crates/registryctl/src/lib.rs b/crates/registryctl/src/lib.rs index 6321d394b..0f3f587eb 100644 --- a/crates/registryctl/src/lib.rs +++ b/crates/registryctl/src/lib.rs @@ -23,12 +23,14 @@ use registry_platform_config::{ ConfigBundleSignature, ConfigBundleSignatureEnvelope, ConfigTrustAnchor, ConfigTrustAnchorSigner, MAX_BUNDLE_FILE_BYTES, MAX_CONFIG_BUNDLE_SEQUENCE, }; +use registry_platform_consent::{ConsentEvidenceV1, MAX_CONSENT_EVIDENCE_BYTES}; use registry_platform_crypto::{ canonicalize_json, sign as sign_payload, PrivateJwk, PublicJwk, SigningAlgorithm, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use zeroize::Zeroizing; pub use crate::sample::Sample; @@ -71,6 +73,7 @@ const UPDATE_CHECK_CACHE_SECONDS: u64 = 60 * 60 * 24; const PROJECT_SCHEMA_VERSION: &str = "registryctl/v1"; const CONFIG_BUNDLE_SIGNATURE_SCHEMA: &str = "registry.platform.config_bundle_signatures.v1"; const CONFIG_TRUST_ANCHOR_SCHEMA: &str = "registry.platform.config_trust_anchor.v1"; +const CONSENT_PRIVATE_JWK_MAX_BYTES: u64 = 16 * 1024; #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(deny_unknown_fields)] @@ -488,6 +491,37 @@ pub struct BundleSignOptions { pub out: PathBuf, } +#[derive(Debug)] +pub struct ConsentKeygenOptions { + pub out_dir: PathBuf, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ConsentKeygenReport { + pub schema_version: String, + pub key_id: String, + pub alg: String, + pub private_key_path: PathBuf, + pub public_key_path: PathBuf, + pub public_jwk: PublicJwk, +} + +#[derive(Debug)] +pub struct ConsentSignOptions { + pub payload: PathBuf, + pub key: PathBuf, + pub out: PathBuf, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ConsentSignReport { + pub schema_version: String, + pub key_id: String, + pub alg: String, + pub output_path: PathBuf, + pub compact_jws_bytes: usize, +} + #[derive(Debug, Clone, Serialize)] pub struct AnchorReport { pub schema_version: String, @@ -762,6 +796,317 @@ pub fn sign_config_bundle(options: BundleSignOptions) -> Result Result { + let parent = options + .out_dir + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; + let parent_metadata = fs::symlink_metadata(parent) + .with_context(|| format!("failed to stat {}", parent.display()))?; + if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { + bail!( + "consent key output parent must be a regular directory, not a symlink: {}", + parent.display() + ); + } + + create_private_directory(&options.out_dir).with_context(|| { + format!( + "consent key output directory must be new: {}", + options.out_dir.display() + ) + })?; + + let generated = (|| -> Result { + let mut secret = [0_u8; 32]; + getrandom::fill(&mut secret) + .map_err(|error| anyhow!("random generation failed: {error}"))?; + let signing_key = SigningKey::from_bytes(&secret); + let x = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(signing_key.verifying_key().to_bytes()); + let d = Zeroizing::new( + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(secret.as_slice()), + ); + secret.fill(0); + + let public_without_kid = PublicJwk::parse(&format!( + r#"{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","x":"{x}"}}"# + )) + .context("failed to build generated consent public JWK")?; + let key_id = public_without_kid + .jkt() + .context("failed to compute consent key id")?; + let public_jwk = PublicJwk::parse(&format!( + r#"{{"kty":"OKP","kid":"{key_id}","crv":"Ed25519","alg":"EdDSA","x":"{x}"}}"# + )) + .context("failed to build generated consent public JWK")?; + let private_json = Zeroizing::new(format!( + r#"{{"kty":"OKP","kid":"{key_id}","crv":"Ed25519","alg":"EdDSA","d":"{}","x":"{x}"}}"#, + d.as_str() + )); + PrivateJwk::parse(private_json.as_str()) + .context("generated consent private JWK failed validation")?; + + let private_key_path = options.out_dir.join("private.jwk"); + let public_key_path = options.out_dir.join("public.jwk"); + write_new_sensitive_file(&private_key_path, private_json.as_bytes())?; + let public_json = serde_json::to_vec_pretty(&public_jwk) + .context("failed to render generated consent public JWK")?; + write_new_public_file(&public_key_path, &public_json)?; + + Ok(ConsentKeygenReport { + schema_version: "registryctl.consent_keygen.v1".to_string(), + key_id, + alg: "EdDSA".to_string(), + private_key_path, + public_key_path, + public_jwk, + }) + })(); + + if generated.is_err() { + let _ = fs::remove_dir_all(&options.out_dir); + } + generated +} + +pub fn sign_consent_evidence(options: ConsentSignOptions) -> Result { + let payload_bytes = read_bounded_regular_file( + &options.payload, + MAX_CONSENT_EVIDENCE_BYTES as u64, + "consent payload", + false, + )?; + let evidence = ConsentEvidenceV1::parse_json(payload_bytes.as_slice()).with_context(|| { + format!( + "invalid ConsentEvidenceV1 payload {}", + options.payload.display() + ) + })?; + + let private_jwk_bytes = read_bounded_regular_file( + &options.key, + CONSENT_PRIVATE_JWK_MAX_BYTES, + "consent private JWK", + true, + )?; + let private_jwk_text = + std::str::from_utf8(private_jwk_bytes.as_slice()).with_context(|| { + format!( + "consent private JWK is not UTF-8 JSON: {}", + options.key.display() + ) + })?; + let private_jwk = PrivateJwk::parse(private_jwk_text) + .with_context(|| format!("invalid consent private JWK: {}", options.key.display()))?; + let key_id = private_jwk + .kid + .as_deref() + .filter(|key_id| !key_id.trim().is_empty()) + .ok_or_else(|| anyhow!("consent private JWK must contain a non-empty kid"))? + .to_string(); + if private_jwk + .algorithm() + .context("invalid consent signing key algorithm")? + != SigningAlgorithm::EdDsa + { + bail!("consent signing requires an Ed25519 EdDSA private JWK"); + } + + let artifact = evidence + .sign_compact(&private_jwk) + .context("failed to sign ConsentEvidenceV1")?; + let artifact_bytes = artifact.as_str().as_bytes(); + if artifact_bytes.len() > MAX_CONSENT_EVIDENCE_BYTES { + bail!( + "signed consent evidence is {} bytes and exceeds the {}-byte limit", + artifact_bytes.len(), + MAX_CONSENT_EVIDENCE_BYTES + ); + } + + ensure_consent_output_parent(&options.out)?; + write_new_sensitive_file(&options.out, artifact_bytes)?; + Ok(ConsentSignReport { + schema_version: "registryctl.consent_sign.v1".to_string(), + key_id, + alg: "EdDSA".to_string(), + output_path: options.out, + compact_jws_bytes: artifact_bytes.len(), + }) +} + +fn read_bounded_regular_file( + path: &Path, + max_bytes: u64, + label: &str, + require_private_permissions: bool, +) -> Result>> { + let path_metadata = fs::symlink_metadata(path) + .with_context(|| format!("failed to stat {label} {}", path.display()))?; + if path_metadata.file_type().is_symlink() || !path_metadata.is_file() { + bail!( + "{label} must be a regular file, not a symlink or directory: {}", + path.display() + ); + } + if path_metadata.len() > max_bytes { + bail!( + "{label} exceeds the {max_bytes}-byte limit: {}", + path.display() + ); + } + + let file = fs::File::open(path) + .with_context(|| format!("failed to open {label} {}", path.display()))?; + let opened_metadata = file + .metadata() + .with_context(|| format!("failed to stat opened {label} {}", path.display()))?; + if !opened_metadata.is_file() || opened_metadata.len() > max_bytes { + bail!("{label} is not a bounded regular file: {}", path.display()); + } + validate_opened_file_identity( + path, + label, + &path_metadata, + &opened_metadata, + require_private_permissions, + )?; + let mut bytes = Zeroizing::new(Vec::with_capacity(opened_metadata.len() as usize)); + file.take(max_bytes + 1) + .read_to_end(&mut bytes) + .with_context(|| format!("failed to read {label} {}", path.display()))?; + if bytes.len() as u64 > max_bytes { + bail!( + "{label} exceeds the {max_bytes}-byte limit: {}", + path.display() + ); + } + Ok(bytes) +} + +#[cfg(unix)] +fn validate_opened_file_identity( + path: &Path, + label: &str, + path_metadata: &fs::Metadata, + opened_metadata: &fs::Metadata, + require_private_permissions: bool, +) -> Result<()> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + if path_metadata.dev() != opened_metadata.dev() || path_metadata.ino() != opened_metadata.ino() + { + bail!( + "{label} changed while it was being opened: {}", + path.display() + ); + } + if require_private_permissions && opened_metadata.permissions().mode() & 0o077 != 0 { + bail!( + "consent private JWK must not be readable or writable by group or others: {}", + path.display() + ); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_opened_file_identity( + _path: &Path, + _label: &str, + _path_metadata: &fs::Metadata, + _opened_metadata: &fs::Metadata, + _require_private_permissions: bool, +) -> Result<()> { + Ok(()) +} + +fn ensure_consent_output_parent(path: &Path) -> Result<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; + let metadata = fs::symlink_metadata(parent) + .with_context(|| format!("failed to stat {}", parent.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!( + "consent output parent must be a regular directory, not a symlink: {}", + parent.display() + ); + } + Ok(()) +} + +#[cfg(unix)] +fn create_private_directory(path: &Path) -> Result<()> { + use std::os::unix::fs::DirBuilderExt; + + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700); + builder + .create(path) + .with_context(|| format!("failed to create {}", path.display())) +} + +#[cfg(not(unix))] +fn create_private_directory(path: &Path) -> Result<()> { + fs::create_dir(path).with_context(|| format!("failed to create {}", path.display())) +} + +#[cfg(unix)] +fn write_new_sensitive_file(path: &Path, bytes: &[u8]) -> Result<()> { + use std::os::unix::fs::OpenOptionsExt; + + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .with_context(|| format!("refusing to overwrite consent output {}", path.display()))?; + file.write_all(bytes) + .with_context(|| format!("failed to write {}", path.display())) +} + +#[cfg(not(unix))] +fn write_new_sensitive_file(path: &Path, bytes: &[u8]) -> Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .with_context(|| format!("refusing to overwrite consent output {}", path.display()))?; + file.write_all(bytes) + .with_context(|| format!("failed to write {}", path.display())) +} + +#[cfg(unix)] +fn write_new_public_file(path: &Path, bytes: &[u8]) -> Result<()> { + use std::os::unix::fs::OpenOptionsExt; + + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o644) + .open(path) + .with_context(|| format!("refusing to overwrite consent output {}", path.display()))?; + file.write_all(bytes) + .with_context(|| format!("failed to write {}", path.display())) +} + +#[cfg(not(unix))] +fn write_new_public_file(path: &Path, bytes: &[u8]) -> Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .with_context(|| format!("refusing to overwrite consent output {}", path.display()))?; + file.write_all(bytes) + .with_context(|| format!("failed to write {}", path.display())) +} + pub fn init_config_anchor( anchor_path: &Path, product: String, @@ -5356,6 +5701,7 @@ mod tests { use super::*; const TEST_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"registryctl-test-private-key"}"#; + const TEST_CONSENT_PAYLOAD: &str = r#"{"version":1,"subject":{"identifier_type":"programme_id","value":"P-1"},"consenting_party":{"identifier":{"identifier_type":"programme_id","value":"P-1"},"relationship":"self"},"purposes":["humanitarian.referral"],"recipient":"partner.example","controller":"ministry.example","assurance":"system_of_record_signed","collection_method":"registration_desk","collection_context":"referral choice","collected_at":1783731600,"issued_at":1783731660,"expires_at":1783731960,"consent_id":"consent-1","notice_reference":"notice-1","notice_language":"en","notice_content_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","notice_modality":"written"}"#; const TEST_RELAY_IMAGE: &str = "ghcr.io/registrystack/registry-relay@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const TEST_NOTARY_IMAGE: &str = "ghcr.io/registrystack/registry-notary@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; @@ -5602,6 +5948,254 @@ mod tests { assert_eq!(verified.config_path, bundle_dir.join("config/notary.yaml")); } + #[test] + fn consent_keygen_writes_a_private_ed25519_key_and_safe_public_report() { + let temp = TempDir::new().unwrap(); + let out_dir = temp.path().join("consent-keys"); + + let report = generate_consent_keypair(ConsentKeygenOptions { + out_dir: out_dir.clone(), + }) + .unwrap(); + + let private_json = fs::read_to_string(out_dir.join("private.jwk")).unwrap(); + let private = PrivateJwk::parse(&private_json).unwrap(); + assert_eq!(private.algorithm().unwrap(), SigningAlgorithm::EdDsa); + assert_eq!(private.kid.as_deref(), Some(report.key_id.as_str())); + let public_json = fs::read_to_string(out_dir.join("public.jwk")).unwrap(); + let public = PublicJwk::parse(&public_json).unwrap(); + assert_eq!(public, private.public()); + assert_eq!(report.public_jwk, public); + + let serialized_report = serde_json::to_string(&report).unwrap(); + assert!(!serialized_report.contains("\"d\"")); + assert!(!serialized_report.contains(private.d.as_deref().unwrap())); + assert!(!format!("{report:?}").contains(private.d.as_deref().unwrap())); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + assert_eq!( + fs::metadata(out_dir.join("private.jwk")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(&out_dir).unwrap().permissions().mode() & 0o777, + 0o700 + ); + } + } + + #[test] + fn consent_keygen_refuses_overwrite_without_changing_existing_keys() { + let temp = TempDir::new().unwrap(); + let out_dir = temp.path().join("consent-keys"); + generate_consent_keypair(ConsentKeygenOptions { + out_dir: out_dir.clone(), + }) + .unwrap(); + let original_private = fs::read(out_dir.join("private.jwk")).unwrap(); + let original_public = fs::read(out_dir.join("public.jwk")).unwrap(); + + let error = generate_consent_keypair(ConsentKeygenOptions { + out_dir: out_dir.clone(), + }) + .unwrap_err(); + + assert!(error.to_string().contains("must be new")); + assert_eq!( + fs::read(out_dir.join("private.jwk")).unwrap(), + original_private + ); + assert_eq!( + fs::read(out_dir.join("public.jwk")).unwrap(), + original_public + ); + } + + #[cfg(unix)] + #[test] + fn consent_keygen_refuses_symlink_output_directory() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let target = temp.path().join("target"); + fs::create_dir(&target).unwrap(); + let out_dir = temp.path().join("consent-keys"); + symlink(&target, &out_dir).unwrap(); + + let error = generate_consent_keypair(ConsentKeygenOptions { out_dir }).unwrap_err(); + + assert!(error.to_string().contains("must be new")); + assert!(fs::read_dir(target).unwrap().next().is_none()); + } + + #[test] + fn consent_sign_validates_payload_and_writes_bounded_compact_jws() { + let temp = TempDir::new().unwrap(); + let key_dir = temp.path().join("consent-keys"); + generate_consent_keypair(ConsentKeygenOptions { + out_dir: key_dir.clone(), + }) + .unwrap(); + let payload = temp.path().join("consent.json"); + fs::write(&payload, TEST_CONSENT_PAYLOAD).unwrap(); + let output = temp.path().join("consent.jws"); + + let report = sign_consent_evidence(ConsentSignOptions { + payload, + key: key_dir.join("private.jwk"), + out: output.clone(), + }) + .unwrap(); + + let compact = fs::read_to_string(&output).unwrap(); + assert_eq!(compact.split('.').count(), 3); + assert_eq!(report.compact_jws_bytes, compact.len()); + assert!(compact.len() <= MAX_CONSENT_EVIDENCE_BYTES); + assert!(!serde_json::to_string(&report).unwrap().contains(&compact)); + assert!(!format!("{report:?}").contains(&compact)); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + assert_eq!( + fs::metadata(output).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + #[test] + fn consent_sign_rejects_invalid_or_oversized_payload_before_output() { + let temp = TempDir::new().unwrap(); + let key_dir = temp.path().join("consent-keys"); + generate_consent_keypair(ConsentKeygenOptions { + out_dir: key_dir.clone(), + }) + .unwrap(); + let payload = temp.path().join("consent.json"); + fs::write(&payload, r#"{"version":1,"unexpected":true}"#).unwrap(); + let output = temp.path().join("invalid.jws"); + + let error = sign_consent_evidence(ConsentSignOptions { + payload: payload.clone(), + key: key_dir.join("private.jwk"), + out: output.clone(), + }) + .unwrap_err(); + assert!(error.to_string().contains("invalid ConsentEvidenceV1")); + assert!(!output.exists()); + + fs::write(&payload, vec![b' '; MAX_CONSENT_EVIDENCE_BYTES + 1]).unwrap(); + let error = sign_consent_evidence(ConsentSignOptions { + payload, + key: key_dir.join("private.jwk"), + out: output.clone(), + }) + .unwrap_err(); + assert!(error.to_string().contains("exceeds the 8192-byte limit")); + assert!(!output.exists()); + } + + #[test] + fn consent_sign_refuses_to_overwrite_artifact() { + let temp = TempDir::new().unwrap(); + let key_dir = temp.path().join("consent-keys"); + generate_consent_keypair(ConsentKeygenOptions { + out_dir: key_dir.clone(), + }) + .unwrap(); + let payload = temp.path().join("consent.json"); + fs::write(&payload, TEST_CONSENT_PAYLOAD).unwrap(); + let output = temp.path().join("consent.jws"); + fs::write(&output, b"keep-me").unwrap(); + + let error = sign_consent_evidence(ConsentSignOptions { + payload, + key: key_dir.join("private.jwk"), + out: output.clone(), + }) + .unwrap_err(); + + assert!(error.to_string().contains("refusing to overwrite")); + assert_eq!(fs::read(output).unwrap(), b"keep-me"); + } + + #[cfg(unix)] + #[test] + fn consent_sign_rejects_private_key_with_broad_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp = TempDir::new().unwrap(); + let key_dir = temp.path().join("consent-keys"); + generate_consent_keypair(ConsentKeygenOptions { + out_dir: key_dir.clone(), + }) + .unwrap(); + let key = key_dir.join("private.jwk"); + let mut permissions = fs::metadata(&key).unwrap().permissions(); + permissions.set_mode(0o644); + fs::set_permissions(&key, permissions).unwrap(); + let payload = temp.path().join("consent.json"); + fs::write(&payload, TEST_CONSENT_PAYLOAD).unwrap(); + let output = temp.path().join("consent.jws"); + + let error = sign_consent_evidence(ConsentSignOptions { + payload, + key, + out: output.clone(), + }) + .unwrap_err(); + + assert!(error.to_string().contains("group or others")); + assert!(!output.exists()); + } + + #[cfg(unix)] + #[test] + fn consent_sign_rejects_symlinked_payload_and_private_key() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let key_dir = temp.path().join("consent-keys"); + generate_consent_keypair(ConsentKeygenOptions { + out_dir: key_dir.clone(), + }) + .unwrap(); + let payload_target = temp.path().join("payload-target.json"); + fs::write(&payload_target, TEST_CONSENT_PAYLOAD).unwrap(); + let payload_link = temp.path().join("payload-link.json"); + symlink(&payload_target, &payload_link).unwrap(); + let output = temp.path().join("consent.jws"); + + let error = sign_consent_evidence(ConsentSignOptions { + payload: payload_link, + key: key_dir.join("private.jwk"), + out: output.clone(), + }) + .unwrap_err(); + assert!(error.to_string().contains("must be a regular file")); + assert!(!output.exists()); + + let key_link = temp.path().join("private-link.jwk"); + symlink(key_dir.join("private.jwk"), &key_link).unwrap(); + let error = sign_consent_evidence(ConsentSignOptions { + payload: payload_target, + key: key_link, + out: output.clone(), + }) + .unwrap_err(); + assert!(error.to_string().contains("must be a regular file")); + assert!(!output.exists()); + } + #[test] fn config_anchor_remove_key_updates_anchor_without_private_material() { let temp = TempDir::new().unwrap(); diff --git a/crates/registryctl/src/main.rs b/crates/registryctl/src/main.rs index 4f20f453a..3b2ce6b1e 100644 --- a/crates/registryctl/src/main.rs +++ b/crates/registryctl/src/main.rs @@ -4,8 +4,9 @@ use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use registryctl::{ - BundleSignOptions, DeploymentProfile, DoctorFormat, NotaryInitOptions, NotaryInitSourceKind, - NotarySource, OpenFnBatchMode, OpenFnConvertOptions, OpenFnImportOptions, Sample, + BundleSignOptions, ConsentKeygenOptions, ConsentSignOptions, DeploymentProfile, DoctorFormat, + NotaryInitOptions, NotaryInitSourceKind, NotarySource, OpenFnBatchMode, OpenFnConvertOptions, + OpenFnImportOptions, Sample, }; fn main() -> Result<()> { @@ -162,6 +163,20 @@ fn main() -> Result<()> { print_json(®istryctl::remove_config_anchor_key(&anchor_path, &kid)?)?; } }, + Commands::Consent { command } => match command { + ConsentCommand::Keygen { out_dir } => { + print_json(®istryctl::generate_consent_keypair( + ConsentKeygenOptions { out_dir }, + )?)?; + } + ConsentCommand::Sign { payload, key, out } => { + print_json(®istryctl::sign_consent_evidence(ConsentSignOptions { + payload, + key, + out, + })?)?; + } + }, Commands::Openfn { command } => match *command { OpenFnCommand::Import { input, @@ -390,6 +405,11 @@ enum Commands { #[command(subcommand)] command: AnchorCommand, }, + /// Generate keys and sign verified consent evidence. + Consent { + #[command(subcommand)] + command: ConsentCommand, + }, /// Work with OpenFn workflow exports. Openfn { #[command(subcommand)] @@ -409,6 +429,7 @@ impl Commands { Self::Doctor { .. } | Self::Bundle { .. } | Self::Anchor { .. } + | Self::Consent { .. } | Self::UpdateCheck | Self::UpdateCheckRefresh ) @@ -642,6 +663,44 @@ mod tests { } )); } + + #[test] + fn consent_cli_accepts_keygen_and_sign() { + let keygen = Cli::try_parse_from([ + "registryctl", + "consent", + "keygen", + "--out-dir", + "consent-keys", + ]) + .unwrap(); + assert!(matches!( + keygen.command, + Commands::Consent { + command: ConsentCommand::Keygen { .. } + } + )); + assert!(!keygen.command.should_check_for_updates()); + + let sign = Cli::try_parse_from([ + "registryctl", + "consent", + "sign", + "--payload", + "consent.json", + "--key", + "consent-keys/private.jwk", + "--out", + "consent.jws", + ]) + .unwrap(); + assert!(matches!( + sign.command, + Commands::Consent { + command: ConsentCommand::Sign { .. } + } + )); + } } #[derive(Debug, Subcommand)] @@ -817,6 +876,28 @@ enum AnchorCommand { }, } +#[derive(Debug, Subcommand)] +enum ConsentCommand { + /// Generate an Ed25519 consent signing keypair in a new directory. + Keygen { + /// New directory that will receive private.jwk and public.jwk. + #[arg(long, default_value = "consent-keys")] + out_dir: PathBuf, + }, + /// Validate and sign a ConsentEvidenceV1 JSON payload as compact JWS. + Sign { + /// ConsentEvidenceV1 payload JSON file. + #[arg(long)] + payload: PathBuf, + /// Ed25519 private JWK file created by consent keygen. + #[arg(long)] + key: PathBuf, + /// New compact-JWS output file. + #[arg(long)] + out: PathBuf, + }, +} + #[derive(Debug, Subcommand)] enum OpenFnCommand { /// Import an OpenFn workflow URL or exported YAML into a sidecar manifest.