From d5c97013a0e68663b2954edddc6a553a6b5dd199 Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Thu, 13 Aug 2026 20:04:22 -0500 Subject: [PATCH 01/15] feat(protected-content): add canonical custody envelope contracts --- .../src/custody_envelope.rs | 845 ++++++++++++++++++ .../src/lib.rs | 6 + 2 files changed, 851 insertions(+) create mode 100644 elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs diff --git a/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs b/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs new file mode 100644 index 000000000..f05d69f9a --- /dev/null +++ b/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs @@ -0,0 +1,845 @@ +use crate::canonical::{CanonicalBody, ContractError, Decoder, Encoder}; +use crate::{ + CanonicalContract, Digest32, EncryptedContentIdentityV1, KeyEnvelopeIdentityV1, NodePublicKey, + NodeSetV1, ProtectedContentBindingV1, RecipientKeyIdentityV1, ThresholdV1, MAX_THRESHOLD_NODES, +}; + +pub const CUSTODY_HPKE_SUITE_ID_V1: &str = "hpke-rfc9180-base-x25519-hkdf-sha256-aes256gcm/v1"; +pub const STORED_SHARE_HPKE_INFO_V1: &[u8] = b"elastos.protected-content.stored-share/v1"; +pub const RELEASED_SHARE_HPKE_INFO_V1: &[u8] = b"elastos.protected-content.released-share/v1"; +pub const HPKE_ENCAPPED_KEY_BYTES: usize = 32; +pub const HPKE_SEALED_SHARE_BYTES: usize = 48; + +const X25519_MODULUS: [u8; 32] = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct NodeCustodyPublicKeyV1([u8; 32]); + +impl NodeCustodyPublicKeyV1 { + pub fn new(bytes: [u8; 32]) -> Result { + validate_canonical_x25519_public_key(bytes, "node_custody_public_key")?; + Ok(Self(bytes)) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn matches_public_key_bytes(&self, bytes: &[u8; 32]) -> bool { + &self.0 == bytes + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ShareCoordinateV1(u8); + +impl ShareCoordinateV1 { + pub fn new(value: u8) -> Result { + if value == 0 { + return Err(ContractError::InvalidField("share_coordinate")); + } + Ok(Self(value)) + } + + pub const fn get(self) -> u8 { + self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct CustodyNodeIdentityV1 { + node_public_key: NodePublicKey, + custody_public_key: NodeCustodyPublicKeyV1, + share_coordinate: ShareCoordinateV1, +} + +impl CustodyNodeIdentityV1 { + pub fn new( + node_public_key: NodePublicKey, + custody_public_key: NodeCustodyPublicKeyV1, + share_coordinate: ShareCoordinateV1, + ) -> Result { + let value = Self { + node_public_key, + custody_public_key, + share_coordinate, + }; + value.validate()?; + Ok(value) + } + + pub const fn node_public_key(&self) -> NodePublicKey { + self.node_public_key + } + + pub const fn custody_public_key(&self) -> NodeCustodyPublicKeyV1 { + self.custody_public_key + } + + pub const fn share_coordinate(&self) -> ShareCoordinateV1 { + self.share_coordinate + } + + pub fn stored_share_aad_bytes( + &self, + manifest_hash: Digest32, + ) -> Result, ContractError> { + StoredShareAadV1::new(manifest_hash, self.clone())?.canonical_bytes() + } + + pub fn released_share_aad_bytes( + &self, + release_request_hash: Digest32, + binding: &ProtectedContentBindingV1, + decision_hash: Digest32, + recipient: &RecipientKeyIdentityV1, + ) -> Result, ContractError> { + ReleasedShareAadV1::new( + release_request_hash, + binding.clone(), + decision_hash, + self.clone(), + recipient.clone(), + )? + .canonical_bytes() + } +} + +impl CanonicalBody for CustodyNodeIdentityV1 { + const DOMAIN: &'static str = "elastos.protected-content.custody-node-identity/v1"; + + fn validate(&self) -> Result<(), ContractError> { + NodePublicKey::new(*self.node_public_key.as_bytes())?; + NodeCustodyPublicKeyV1::new(*self.custody_public_key.as_bytes())?; + ShareCoordinateV1::new(self.share_coordinate.get())?; + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.fixed(self.node_public_key.as_bytes()); + encoder.fixed(self.custody_public_key.as_bytes()); + encoder.u8(self.share_coordinate.get()); + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Self::new( + NodePublicKey::new(decoder.fixed()?)?, + NodeCustodyPublicKeyV1::new(decoder.fixed()?)?, + ShareCoordinateV1::new(decoder.u8()?)?, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustodyEnvelopeManifestV1 { + encrypted_content: EncryptedContentIdentityV1, + threshold: ThresholdV1, + nodes: Vec, +} + +impl CustodyEnvelopeManifestV1 { + pub fn new( + encrypted_content: EncryptedContentIdentityV1, + threshold: ThresholdV1, + mut nodes: Vec, + ) -> Result { + nodes.sort_unstable_by_key(|node| node.node_public_key()); + for (index, node) in nodes.iter_mut().enumerate() { + node.share_coordinate = ShareCoordinateV1::new( + u8::try_from(index + 1) + .map_err(|_| ContractError::InvalidField("share_coordinate"))?, + )?; + } + let value = Self { + encrypted_content, + threshold, + nodes, + }; + value.validate()?; + Ok(value) + } + + pub fn encrypted_content(&self) -> &EncryptedContentIdentityV1 { + &self.encrypted_content + } + + pub const fn threshold(&self) -> ThresholdV1 { + self.threshold + } + + pub fn nodes(&self) -> &[CustodyNodeIdentityV1] { + &self.nodes + } + + pub fn manifest_hash(&self) -> Result { + self.canonical_hash() + } + + pub fn node_set(&self) -> Result { + NodeSetV1::new( + self.threshold, + self.nodes + .iter() + .map(|node| node.node_public_key()) + .collect(), + ) + } + + pub fn node_index(&self, node_public_key: NodePublicKey) -> Option { + self.nodes + .binary_search_by_key(&node_public_key, |node| node.node_public_key()) + .ok() + } + + pub fn node(&self, node_public_key: NodePublicKey) -> Option<&CustodyNodeIdentityV1> { + self.node_index(node_public_key) + .map(|index| &self.nodes[index]) + } + + pub fn stored_share_aad_bytes_for_node( + &self, + node_public_key: NodePublicKey, + ) -> Result, ContractError> { + let node = self + .node(node_public_key) + .ok_or(ContractError::InvalidField("custody_node"))?; + node.stored_share_aad_bytes(self.manifest_hash()?) + } +} + +impl CanonicalBody for CustodyEnvelopeManifestV1 { + const DOMAIN: &'static str = "elastos.protected-content.custody-envelope-manifest/v1"; + + fn validate(&self) -> Result<(), ContractError> { + self.encrypted_content.validate()?; + self.threshold.validate()?; + if self.nodes.len() != usize::from(self.threshold.total()) + || self.nodes.len() > usize::from(MAX_THRESHOLD_NODES) + || self + .nodes + .windows(2) + .any(|window| window[0].node_public_key() >= window[1].node_public_key()) + { + return Err(ContractError::InvalidField("custody_manifest.nodes")); + } + for (index, node) in self.nodes.iter().enumerate() { + node.canonical_bytes()?; + let expected_coordinate = ShareCoordinateV1::new( + u8::try_from(index + 1) + .map_err(|_| ContractError::InvalidField("share_coordinate"))?, + )?; + if node.share_coordinate() != expected_coordinate { + return Err(ContractError::InvalidField("share_coordinate")); + } + } + if has_duplicate_custody_keys(&self.nodes) { + return Err(ContractError::InvalidField("node_custody_public_key")); + } + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.nested(&self.encrypted_content)?; + self.threshold.encode(encoder); + encoder.u8(self.nodes.len() as u8); + for node in &self.nodes { + encoder.nested(node)?; + } + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + let encrypted_content = decoder.nested("encrypted_content")?; + let threshold = ThresholdV1::decode(decoder)?; + let count = usize::from(decoder.u8()?); + let mut nodes = Vec::with_capacity(count); + for _ in 0..count { + nodes.push(decoder.nested("custody_node")?); + } + Self::new(encrypted_content, threshold, nodes) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct HpkeCiphertextV1 { + encapped_key: [u8; HPKE_ENCAPPED_KEY_BYTES], + ciphertext: [u8; HPKE_SEALED_SHARE_BYTES], +} + +impl std::fmt::Debug for HpkeCiphertextV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("HpkeCiphertextV1") + .field("encapped_key", &"[redacted]") + .field("ciphertext", &"[redacted]") + .finish() + } +} + +impl HpkeCiphertextV1 { + pub fn new( + encapped_key: [u8; HPKE_ENCAPPED_KEY_BYTES], + ciphertext: [u8; HPKE_SEALED_SHARE_BYTES], + ) -> Result { + let value = Self { + encapped_key, + ciphertext, + }; + value.validate()?; + Ok(value) + } + + pub const fn encapped_key(&self) -> &[u8; HPKE_ENCAPPED_KEY_BYTES] { + &self.encapped_key + } + + pub const fn ciphertext(&self) -> &[u8; HPKE_SEALED_SHARE_BYTES] { + &self.ciphertext + } +} + +impl CanonicalBody for HpkeCiphertextV1 { + const DOMAIN: &'static str = "elastos.protected-content.hpke-ciphertext/v1"; + + fn validate(&self) -> Result<(), ContractError> { + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.fixed(&self.encapped_key); + encoder.fixed(&self.ciphertext); + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Self::new(decoder.fixed()?, decoder.fixed()?) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustodyEnvelopeV1 { + manifest: CustodyEnvelopeManifestV1, + stored_shares: Vec, +} + +impl CustodyEnvelopeV1 { + pub fn new( + manifest: CustodyEnvelopeManifestV1, + stored_shares: Vec, + ) -> Result { + let value = Self { + manifest, + stored_shares, + }; + value.validate()?; + Ok(value) + } + + pub fn manifest(&self) -> &CustodyEnvelopeManifestV1 { + &self.manifest + } + + pub fn stored_shares(&self) -> &[HpkeCiphertextV1] { + &self.stored_shares + } + + pub fn stored_share_for_node( + &self, + node_public_key: NodePublicKey, + ) -> Option<&HpkeCiphertextV1> { + self.manifest + .node_index(node_public_key) + .map(|index| &self.stored_shares[index]) + } + + pub fn key_envelope_identity(&self) -> Result { + KeyEnvelopeIdentityV1::new( + self.manifest.encrypted_content.clone(), + self.canonical_hash()?, + u32::try_from(self.canonical_bytes()?.len()) + .map_err(|_| ContractError::FieldTooLong("custody_envelope"))?, + self.manifest.node_set()?.node_set_id()?, + self.manifest.threshold, + ) + } + + pub fn matches_key_envelope_identity( + &self, + key_envelope: &KeyEnvelopeIdentityV1, + ) -> Result { + Ok(self.key_envelope_identity()? == *key_envelope) + } +} + +impl CanonicalBody for CustodyEnvelopeV1 { + const DOMAIN: &'static str = "elastos.protected-content.custody-envelope/v1"; + + fn validate(&self) -> Result<(), ContractError> { + self.manifest.canonical_bytes()?; + if self.stored_shares.len() != self.manifest.nodes.len() { + return Err(ContractError::InvalidField("stored_shares")); + } + for stored_share in &self.stored_shares { + stored_share.canonical_bytes()?; + } + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.nested(&self.manifest)?; + encoder.u8(self.stored_shares.len() as u8); + for stored_share in &self.stored_shares { + encoder.nested(stored_share)?; + } + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + let manifest = decoder.nested("custody_manifest")?; + let count = usize::from(decoder.u8()?); + let mut stored_shares = Vec::with_capacity(count); + for _ in 0..count { + stored_shares.push(decoder.nested("stored_share")?); + } + Self::new(manifest, stored_shares) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct StoredShareAadV1 { + manifest_hash: Digest32, + node: CustodyNodeIdentityV1, +} + +impl StoredShareAadV1 { + fn new(manifest_hash: Digest32, node: CustodyNodeIdentityV1) -> Result { + let value = Self { + manifest_hash, + node, + }; + value.validate()?; + Ok(value) + } +} + +impl CanonicalBody for StoredShareAadV1 { + const DOMAIN: &'static str = "elastos.protected-content.stored-share-aad/v1"; + + fn validate(&self) -> Result<(), ContractError> { + self.node.canonical_bytes()?; + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.fixed(self.manifest_hash.as_bytes()); + encoder.nested(&self.node)?; + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Self::new( + Digest32::new(decoder.fixed()?), + decoder.nested("custody_node")?, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ReleasedShareAadV1 { + release_request_hash: Digest32, + binding: ProtectedContentBindingV1, + decision_hash: Digest32, + node: CustodyNodeIdentityV1, + recipient: RecipientKeyIdentityV1, +} + +impl ReleasedShareAadV1 { + fn new( + release_request_hash: Digest32, + binding: ProtectedContentBindingV1, + decision_hash: Digest32, + node: CustodyNodeIdentityV1, + recipient: RecipientKeyIdentityV1, + ) -> Result { + let value = Self { + release_request_hash, + binding, + decision_hash, + node, + recipient, + }; + value.validate()?; + Ok(value) + } +} + +impl CanonicalBody for ReleasedShareAadV1 { + const DOMAIN: &'static str = "elastos.protected-content.released-share-aad/v1"; + + fn validate(&self) -> Result<(), ContractError> { + self.binding.canonical_bytes()?; + self.node.canonical_bytes()?; + self.recipient.canonical_bytes()?; + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.fixed(self.release_request_hash.as_bytes()); + encoder.nested(&self.binding)?; + encoder.fixed(self.decision_hash.as_bytes()); + encoder.nested(&self.node)?; + encoder.nested(&self.recipient)?; + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Self::new( + Digest32::new(decoder.fixed()?), + decoder.nested("binding")?, + Digest32::new(decoder.fixed()?), + decoder.nested("custody_node")?, + decoder.nested("recipient")?, + ) + } +} + +fn has_duplicate_custody_keys(nodes: &[CustodyNodeIdentityV1]) -> bool { + for (index, node) in nodes.iter().enumerate() { + if nodes[index + 1..] + .iter() + .any(|other| node.custody_public_key() == other.custody_public_key()) + { + return true; + } + } + false +} + +fn validate_canonical_x25519_public_key( + bytes: [u8; 32], + field: &'static str, +) -> Result<(), ContractError> { + if bytes == [0; 32] || bytes[31] & 0x80 != 0 || le_bytes_ge(bytes, X25519_MODULUS) { + return Err(ContractError::InvalidField(field)); + } + Ok(()) +} + +fn le_bytes_ge(lhs: [u8; 32], rhs: [u8; 32]) -> bool { + for index in (0..32).rev() { + if lhs[index] != rhs[index] { + return lhs[index] > rhs[index]; + } + } + true +} + +#[cfg(test)] +mod tests { + use ed25519_dalek::SigningKey; + use hex::decode; + + use super::*; + + fn digest(byte: u8) -> Digest32 { + Digest32::new([byte; 32]) + } + + fn node_public_key(seed: u8) -> NodePublicKey { + let key = SigningKey::from_bytes(&[seed; 32]); + NodePublicKey::new(key.verifying_key().to_bytes()).unwrap() + } + + fn custody_public_key(seed: u8) -> NodeCustodyPublicKeyV1 { + let mut bytes = [0u8; 32]; + bytes[0] = seed; + NodeCustodyPublicKeyV1::new(bytes).unwrap() + } + + fn node(seed: u8, coordinate: u8) -> CustodyNodeIdentityV1 { + CustodyNodeIdentityV1::new( + node_public_key(seed), + custody_public_key(seed), + ShareCoordinateV1::new(coordinate).unwrap(), + ) + .unwrap() + } + + fn encrypted_content(byte: u8) -> EncryptedContentIdentityV1 { + EncryptedContentIdentityV1::new(digest(byte), 4096).unwrap() + } + + fn manifest() -> CustodyEnvelopeManifestV1 { + CustodyEnvelopeManifestV1::new( + encrypted_content(0x11), + ThresholdV1::new(2, 3).unwrap(), + vec![node(3, 3), node(1, 1), node(2, 2)], + ) + .unwrap() + } + + fn stored_share(seed: u8) -> HpkeCiphertextV1 { + let mut encapped_key = [0u8; HPKE_ENCAPPED_KEY_BYTES]; + encapped_key.fill(seed); + let mut ciphertext = [0u8; HPKE_SEALED_SHARE_BYTES]; + ciphertext.fill(seed.wrapping_add(0x40)); + HpkeCiphertextV1::new(encapped_key, ciphertext).unwrap() + } + + fn recipient(seed: u8) -> RecipientKeyIdentityV1 { + RecipientKeyIdentityV1::new(CUSTODY_HPKE_SUITE_ID_V1, digest(seed)).unwrap() + } + + #[test] + fn custody_manifest_sorts_nodes_and_assigns_deterministic_coordinates() { + let manifest = manifest(); + let mut expected = vec![node_public_key(1), node_public_key(2), node_public_key(3)]; + expected.sort_unstable(); + assert_eq!( + manifest + .nodes() + .iter() + .map(|node| node.node_public_key()) + .collect::>(), + expected + ); + assert_eq!( + manifest + .nodes() + .iter() + .map(|node| node.share_coordinate().get()) + .collect::>(), + vec![1, 2, 3] + ); + } + + #[test] + fn custody_envelope_round_trip_and_identity_vector_are_stable() { + let envelope = CustodyEnvelopeV1::new( + manifest(), + vec![stored_share(1), stored_share(2), stored_share(3)], + ) + .unwrap(); + let canonical = envelope.canonical_bytes().unwrap(); + let decoded = CustodyEnvelopeV1::from_canonical_bytes(&canonical).unwrap(); + assert_eq!(decoded, envelope); + + let expected_hash = + decode("0248dd5145d55696c6c8502f142fe614db027445d0d72887474d4a26dcd7a0b7").unwrap(); + assert_eq!( + envelope.canonical_hash().unwrap().as_bytes(), + expected_hash.as_slice() + ); + + let key_envelope = envelope.key_envelope_identity().unwrap(); + assert_eq!( + key_envelope.encrypted_content(), + envelope.manifest().encrypted_content() + ); + assert_eq!( + key_envelope.node_set_id(), + envelope + .manifest() + .node_set() + .unwrap() + .node_set_id() + .unwrap() + ); + } + + #[test] + fn custody_manifest_rejects_duplicate_keys_bad_coordinates_and_bad_counts() { + assert_eq!( + ShareCoordinateV1::new(0), + Err(ContractError::InvalidField("share_coordinate")) + ); + + let threshold = ThresholdV1::new(2, 3).unwrap(); + assert_eq!( + CustodyEnvelopeManifestV1::new( + encrypted_content(0x11), + threshold, + vec![ + node(1, 1), + CustodyNodeIdentityV1::new( + node_public_key(2), + custody_public_key(1), + ShareCoordinateV1::new(2).unwrap() + ) + .unwrap(), + node(3, 3), + ], + ), + Err(ContractError::InvalidField("node_custody_public_key")) + ); + + assert_eq!( + CustodyEnvelopeManifestV1::new( + encrypted_content(0x11), + threshold, + vec![node(1, 1), node(2, 2)], + ), + Err(ContractError::InvalidField("custody_manifest.nodes")) + ); + } + + #[test] + fn custody_public_key_rejects_zero_and_noncanonical_alias_bytes() { + assert_eq!( + NodeCustodyPublicKeyV1::new([0; 32]), + Err(ContractError::InvalidField("node_custody_public_key")) + ); + assert_eq!( + NodeCustodyPublicKeyV1::new(X25519_MODULUS), + Err(ContractError::InvalidField("node_custody_public_key")) + ); + let mut aliased = X25519_MODULUS; + aliased[0] = aliased[0].wrapping_add(1); + assert_eq!( + NodeCustodyPublicKeyV1::new(aliased), + Err(ContractError::InvalidField("node_custody_public_key")) + ); + } + + #[test] + fn custody_envelope_rejects_wrong_share_count_and_noncanonical_lengths() { + let manifest = manifest(); + assert_eq!( + CustodyEnvelopeV1::new(manifest.clone(), vec![stored_share(1), stored_share(2)]), + Err(ContractError::InvalidField("stored_shares")) + ); + + let canonical = stored_share(1).canonical_bytes().unwrap(); + assert_eq!( + HpkeCiphertextV1::from_canonical_bytes(&canonical[..canonical.len() - 1]), + Err(ContractError::UnexpectedEnd) + ); + } + + #[test] + fn envelope_identity_changes_when_bound_fields_mutate() { + let envelope = CustodyEnvelopeV1::new( + manifest(), + vec![stored_share(1), stored_share(2), stored_share(3)], + ) + .unwrap(); + let changed_content = CustodyEnvelopeV1::new( + CustodyEnvelopeManifestV1::new( + encrypted_content(0x12), + ThresholdV1::new(2, 3).unwrap(), + vec![node(1, 1), node(2, 2), node(3, 3)], + ) + .unwrap(), + vec![stored_share(1), stored_share(2), stored_share(3)], + ) + .unwrap(); + let changed_threshold = CustodyEnvelopeV1::new( + CustodyEnvelopeManifestV1::new( + encrypted_content(0x11), + ThresholdV1::new(3, 3).unwrap(), + vec![node(1, 1), node(2, 2), node(3, 3)], + ) + .unwrap(), + vec![stored_share(1), stored_share(2), stored_share(3)], + ) + .unwrap(); + let changed_stored_share = CustodyEnvelopeV1::new( + manifest(), + vec![stored_share(1), stored_share(2), stored_share(4)], + ) + .unwrap(); + let changed_custody_key = CustodyEnvelopeV1::new( + CustodyEnvelopeManifestV1::new( + encrypted_content(0x11), + ThresholdV1::new(2, 3).unwrap(), + vec![ + node(1, 1), + CustodyNodeIdentityV1::new( + node_public_key(2), + custody_public_key(9), + ShareCoordinateV1::new(2).unwrap(), + ) + .unwrap(), + node(3, 3), + ], + ) + .unwrap(), + vec![stored_share(1), stored_share(2), stored_share(3)], + ) + .unwrap(); + + assert_ne!( + envelope.key_envelope_identity().unwrap().envelope_sha256(), + changed_content + .key_envelope_identity() + .unwrap() + .envelope_sha256() + ); + assert_ne!( + envelope.key_envelope_identity().unwrap().envelope_sha256(), + changed_threshold + .key_envelope_identity() + .unwrap() + .envelope_sha256() + ); + assert_ne!( + envelope.key_envelope_identity().unwrap().envelope_sha256(), + changed_stored_share + .key_envelope_identity() + .unwrap() + .envelope_sha256() + ); + assert_ne!( + envelope.key_envelope_identity().unwrap().envelope_sha256(), + changed_custody_key + .key_envelope_identity() + .unwrap() + .envelope_sha256() + ); + } + + #[test] + fn stored_and_released_share_aad_are_mutation_sensitive() { + let manifest = manifest(); + let node = manifest.node(node_public_key(1)).unwrap(); + let stored_aad = node + .stored_share_aad_bytes(manifest.manifest_hash().unwrap()) + .unwrap(); + let changed_stored_aad = node + .stored_share_aad_bytes(Digest32::new([0x77; 32])) + .unwrap(); + assert_ne!(stored_aad, changed_stored_aad); + + let binding = { + let envelope = CustodyEnvelopeV1::new( + manifest.clone(), + vec![stored_share(1), stored_share(2), stored_share(3)], + ) + .unwrap(); + let profile_key = SigningKey::from_bytes(&[0x26; 32]); + ProtectedContentBindingV1::new( + manifest.encrypted_content().clone(), + envelope.key_envelope_identity().unwrap(), + crate::RightsPolicyIdentityV1::new(digest(0x44), 384).unwrap(), + crate::ProfileIdentityV1::from_public_key_bytes( + profile_key.verifying_key().to_bytes(), + ) + .unwrap(), + crate::WalletAddress::new([0x55; 20]), + crate::RuntimeSessionBindingV1::new(digest(0x66)).unwrap(), + ) + .unwrap() + }; + let released_aad = node + .released_share_aad_bytes(digest(0x10), &binding, digest(0x20), &recipient(0x30)) + .unwrap(); + let changed_released_aad = node + .released_share_aad_bytes(digest(0x11), &binding, digest(0x20), &recipient(0x30)) + .unwrap(); + assert_ne!(released_aad, changed_released_aad); + } +} diff --git a/elastos/crates/elastos-protected-content-contracts/src/lib.rs b/elastos/crates/elastos-protected-content-contracts/src/lib.rs index 4a778a043..dbb93ce2b 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/lib.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/lib.rs @@ -5,6 +5,7 @@ //! content keys, or expose capsule workflows. mod canonical; +mod custody_envelope; mod identity; mod node_contribution; mod node_decision; @@ -21,6 +22,11 @@ mod authority_tests; mod test_support; pub use canonical::{CanonicalContract, ContractError}; +pub use custody_envelope::{ + CustodyEnvelopeManifestV1, CustodyEnvelopeV1, CustodyNodeIdentityV1, HpkeCiphertextV1, + NodeCustodyPublicKeyV1, ShareCoordinateV1, CUSTODY_HPKE_SUITE_ID_V1, HPKE_ENCAPPED_KEY_BYTES, + HPKE_SEALED_SHARE_BYTES, RELEASED_SHARE_HPKE_INFO_V1, STORED_SHARE_HPKE_INFO_V1, +}; pub use identity::{ Digest32, EncryptedContentIdentityV1, KeyEnvelopeIdentityV1, ProfileIdentityV1, ProtectedContentBindingV1, RecipientKeyIdentityV1, RightsPolicyIdentityV1, From f15854ba5299f81c179049a504ba34ee64402360 Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Thu, 13 Aug 2026 20:32:23 -0500 Subject: [PATCH 02/15] feat(protected-content): seal custody shares for verified releases --- elastos/Cargo.lock | 233 +++++++-- elastos/Cargo.toml | 1 + .../src/lib.rs | 6 +- .../src/node_contribution.rs | 37 +- .../Cargo.toml | 23 + .../src/hpke_helpers.rs | 73 +++ .../src/lib.rs | 46 ++ .../src/provision.rs | 212 ++++++++ .../src/release.rs | 472 ++++++++++++++++++ .../src/secrets.rs | 171 +++++++ .../src/test_support.rs | 269 ++++++++++ 11 files changed, 1496 insertions(+), 47 deletions(-) create mode 100644 elastos/crates/elastos-protected-content-custody/Cargo.toml create mode 100644 elastos/crates/elastos-protected-content-custody/src/hpke_helpers.rs create mode 100644 elastos/crates/elastos-protected-content-custody/src/lib.rs create mode 100644 elastos/crates/elastos-protected-content-custody/src/provision.rs create mode 100644 elastos/crates/elastos-protected-content-custody/src/release.rs create mode 100644 elastos/crates/elastos-protected-content-custody/src/secrets.rs create mode 100644 elastos/crates/elastos-protected-content-custody/src/test_support.rs diff --git a/elastos/Cargo.lock b/elastos/Cargo.lock index c1d47ee0a..e69c38f78 100644 --- a/elastos/Cargo.lock +++ b/elastos/Cargo.lock @@ -52,7 +52,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "crypto-common 0.1.7", - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -140,7 +140,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -151,7 +151,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -501,7 +501,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -839,6 +839,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1071,19 +1077,35 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "subtle", "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "typenum", ] @@ -1114,6 +1136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -1227,7 +1250,7 @@ dependencies = [ "asn1-rs", "displaydoc", "nom", - "num-bigint", + "num-bigint 0.4.8", "num-traits", "rusticata-macros", ] @@ -1431,7 +1454,7 @@ checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der 0.7.10", "digest 0.10.7", - "elliptic-curve", + "elliptic-curve 0.13.8", "rfc6979", "signature 2.2.0", "spki 0.7.3", @@ -1634,6 +1657,24 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "elastos-protected-content-custody" +version = "0.6.0" +dependencies = [ + "ed25519-dalek 2.2.0", + "elastos-auth", + "elastos-protected-content-contracts", + "hpke", + "k256", + "rand 0.10.2", + "rand 0.9.5", + "sha2 0.10.9", + "sha3", + "thiserror 1.0.69", + "vsss-rs", + "zeroize", +] + [[package]] name = "elastos-runtime" version = "0.6.0" @@ -1805,20 +1846,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct 0.2.0", - "crypto-bigint", + "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", - "generic-array", - "group", + "ff 0.13.1", + "generic-array 0.14.7", + "group 0.13.0", "hkdf", "pem-rfc7468 0.7.0", "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", "subtle", "zeroize", ] +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve-tools" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0d5e534f103b079a71ef1d66c6e62c89413f1b62709ca11f744429b4afe5b4" +dependencies = [ + "elliptic-curve 0.14.1", + "heapless 0.9.3", + "serde", + "serdect", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -1864,7 +1936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1889,6 +1961,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2162,6 +2244,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "generic-array" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" +dependencies = [ + "rustversion", + "typenum", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2242,11 +2334,22 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "h2" version = "0.4.15" @@ -2286,6 +2389,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -2314,13 +2426,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" dependencies = [ "atomic-polyfill", - "hash32", + "hash32 0.2.1", "rustc_version", "serde", "spin 0.9.9", "stable_deref_trait", ] +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -2438,7 +2560,7 @@ dependencies = [ "aes-gcm", "chacha20poly1305", "digest 0.10.7", - "generic-array", + "generic-array 0.14.7", "hkdf", "hmac", "p256", @@ -2506,7 +2628,9 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -2747,7 +2871,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -3156,7 +3280,7 @@ checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", "ecdsa", - "elliptic-curve", + "elliptic-curve 0.13.8", "once_cell", "sha2 0.10.9", "signature 2.2.0", @@ -3719,7 +3843,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3732,6 +3856,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3956,7 +4090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ "ecdsa", - "elliptic-curve", + "elliptic-curve 0.13.8", "primeorder", "sha2 0.10.9", ] @@ -4196,7 +4330,7 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", - "heapless", + "heapless 0.7.17", "postcard-derive", "serde", ] @@ -4263,7 +4397,7 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.13.8", ] [[package]] @@ -4374,7 +4508,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4718,7 +4852,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4777,7 +4911,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4848,12 +4982,26 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct 0.2.0", "der 0.7.10", - "generic-array", + "generic-array 0.14.7", "pkcs8 0.10.2", "subtle", "zeroize", ] +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.1", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4884,7 +5032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5167,7 +5315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5369,10 +5517,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5953,6 +6101,27 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsss-rs" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bfc736cfd88115aedb95ba84bc2d428fe351e92a56f69fce090af301402d91" +dependencies = [ + "crypto-bigint 0.7.5", + "elliptic-curve 0.14.1", + "elliptic-curve-tools", + "ff 0.14.0", + "generic-array 1.4.5", + "hex", + "hybrid-array", + "num-bigint 0.5.1", + "num-traits", + "rand_core 0.10.1", + "serde", + "subtle", + "zeroize", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -6522,7 +6691,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/elastos/Cargo.toml b/elastos/Cargo.toml index 29836af63..7e0937a0d 100644 --- a/elastos/Cargo.toml +++ b/elastos/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/elastos-common", "crates/elastos-protected-content-contracts", + "crates/elastos-protected-content-custody", "crates/elastos-storage", "crates/elastos-compute", "crates/elastos-crosvm", diff --git a/elastos/crates/elastos-protected-content-contracts/src/lib.rs b/elastos/crates/elastos-protected-content-contracts/src/lib.rs index dbb93ce2b..a2f64acba 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/lib.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/lib.rs @@ -35,9 +35,9 @@ pub use identity::{ MAX_THRESHOLD_NODES, }; pub use node_contribution::{ - NodeContributionStatementV1, RecipientSealedContributionV1, SignedNodeContributionV1, - VerifiedNodeContributionV1, MAX_NODE_CONTRIBUTION_LIFETIME_SECS, - MAX_RECIPIENT_SEALED_CONTRIBUTION_BYTES, + validate_node_contribution_active_window, NodeContributionStatementV1, + RecipientSealedContributionV1, SignedNodeContributionV1, VerifiedNodeContributionV1, + MAX_NODE_CONTRIBUTION_LIFETIME_SECS, MAX_RECIPIENT_SEALED_CONTRIBUTION_BYTES, }; pub use node_decision::{ NodeRightsDecisionStatementV1, SignedNodeRightsDecisionV1, VerifiedNodeRightsDecisionV1, diff --git a/elastos/crates/elastos-protected-content-contracts/src/node_contribution.rs b/elastos/crates/elastos-protected-content-contracts/src/node_contribution.rs index 59840dfc8..f54ea7a83 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/node_contribution.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/node_contribution.rs @@ -7,7 +7,7 @@ use crate::rights::{validate_active, validate_time_window}; use crate::{ CanonicalContract, Digest32, KeyReleaseError, NodePublicKey, NodeSetV1, ProtectedContentBindingV1, RecipientKeyIdentityV1, RightsDecisionV1, - SignedNodeRightsDecisionV1, VerifiedKeyReleaseRequestV1, + SignedNodeRightsDecisionV1, VerifiedKeyReleaseRequestV1, VerifiedNodeRightsDecisionV1, }; pub const MAX_NODE_CONTRIBUTION_LIFETIME_SECS: u64 = 60; @@ -139,6 +139,23 @@ impl NodeContributionStatementV1 { } } +pub fn validate_node_contribution_active_window( + issued_at: u64, + expires_at: u64, + request: &VerifiedKeyReleaseRequestV1, + decision: &VerifiedNodeRightsDecisionV1, + now: u64, +) -> Result<(), KeyReleaseError> { + if issued_at < request.issued_at() || expires_at > request.expires_at() { + return Err(KeyReleaseError::BindingMismatch("node_contribution_window")); + } + validate_active(issued_at, expires_at, now)?; + if issued_at < decision.issued_at() || expires_at > decision.expires_at() { + return Err(KeyReleaseError::BindingMismatch("node_decision_window")); + } + Ok(()) +} + impl CanonicalBody for NodeContributionStatementV1 { const DOMAIN: &'static str = "elastos.protected-content.node-contribution/v1"; @@ -222,23 +239,19 @@ impl SignedNodeContributionV1 { if statement.recipient_sealed_contribution.recipient() != request.recipient() { return Err(KeyReleaseError::BindingMismatch("recipient_key_identity")); } - if statement.issued_at < request.issued_at() || statement.expires_at > request.expires_at() - { - return Err(KeyReleaseError::BindingMismatch("node_contribution_window")); - } - validate_active(statement.issued_at, statement.expires_at, now)?; - let decision = statement .signed_rights_decision .verify(request, node_set, now)?; if decision.decision() != RightsDecisionV1::Allowed { return Err(KeyReleaseError::RightsDenied); } - if statement.issued_at < decision.issued_at() - || statement.expires_at > decision.expires_at() - { - return Err(KeyReleaseError::BindingMismatch("node_decision_window")); - } + validate_node_contribution_active_window( + statement.issued_at, + statement.expires_at, + request, + &decision, + now, + )?; let key = validate_ed25519_public_key(*decision.node_public_key().as_bytes(), "node_public_key") diff --git a/elastos/crates/elastos-protected-content-custody/Cargo.toml b/elastos/crates/elastos-protected-content-custody/Cargo.toml new file mode 100644 index 000000000..0ec8bb0fe --- /dev/null +++ b/elastos/crates/elastos-protected-content-custody/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "elastos-protected-content-custody" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Source-only protected-content custody crypto helpers" + +[dependencies] +ed25519-dalek = "2.1" +elastos-protected-content-contracts = { path = "../elastos-protected-content-contracts" } +hpke = { version = "0.13.0", default-features = false, features = ["std", "x25519"] } +# hpke 0.13 and vsss-rs 6.0.1 depend on different rand_core generations. +rand09 = { package = "rand", version = "0.9.5" } +rand10 = { package = "rand", version = "0.10" } +sha2 = "0.10" +thiserror = "1.0" +vsss-rs = { version = "6.0.1", default-features = false, features = ["std", "zeroize"] } +zeroize = "1.9" + +[dev-dependencies] +elastos-auth = { path = "../elastos-auth" } +k256 = { version = "0.13", features = ["ecdsa"] } +sha3 = "0.10" diff --git a/elastos/crates/elastos-protected-content-custody/src/hpke_helpers.rs b/elastos/crates/elastos-protected-content-custody/src/hpke_helpers.rs new file mode 100644 index 000000000..a5b199a57 --- /dev/null +++ b/elastos/crates/elastos-protected-content-custody/src/hpke_helpers.rs @@ -0,0 +1,73 @@ +use hpke::{ + aead::{AeadTag, AesGcm256}, + kdf::HkdfSha256, + kem::{Kem as KemTrait, X25519HkdfSha256}, + rand_core::{CryptoRng, RngCore}, + single_shot_open_in_place_detached, single_shot_seal_in_place_detached, Deserializable, + OpModeR, OpModeS, Serializable, +}; +use zeroize::Zeroizing; + +use elastos_protected_content_contracts::{ + CanonicalContract, HpkeCiphertextV1, NodeCustodyPublicKeyV1, HPKE_SEALED_SHARE_BYTES, +}; + +use crate::{CustodyError, CONTENT_KEY_BYTES, RELEASED_SHARE_TAG_BYTES}; + +pub(crate) type HpkeKem = X25519HkdfSha256; +type HpkeAead = AesGcm256; +type HpkeKdf = HkdfSha256; + +pub(crate) fn seal_share( + public_key_bytes: &[u8; 32], + info: &[u8], + aad: &[u8], + plaintext_share: &[u8; CONTENT_KEY_BYTES], + rng: &mut R, +) -> Result { + let public_key = ::PublicKey::from_bytes(public_key_bytes)?; + let mut ciphertext = Zeroizing::new([0u8; CONTENT_KEY_BYTES]); + ciphertext.copy_from_slice(plaintext_share); + let (encapped_key, tag) = single_shot_seal_in_place_detached::( + &OpModeS::Base, + &public_key, + info, + &mut *ciphertext, + aad, + rng, + )?; + let mut sealed = [0u8; HPKE_SEALED_SHARE_BYTES]; + sealed[..CONTENT_KEY_BYTES].copy_from_slice(&ciphertext[..]); + sealed[CONTENT_KEY_BYTES..].copy_from_slice(tag.to_bytes().as_slice()); + HpkeCiphertextV1::new(encapped_key.to_bytes().into(), sealed).map_err(Into::into) +} + +pub(crate) fn open_share( + ciphertext: &HpkeCiphertextV1, + secret_key_bytes: &[u8; 32], + info: &[u8], + aad: &[u8], +) -> Result, CustodyError> { + NodeCustodyPublicKeyV1::new(*ciphertext.encapped_key()) + .map_err(|_| CustodyError::MalformedShare("hpke_encapped_key"))?; + let secret_key = ::PrivateKey::from_bytes(secret_key_bytes)?; + let encapped_key = ::EncappedKey::from_bytes(ciphertext.encapped_key())?; + let tag = AeadTag::::from_bytes(&ciphertext.ciphertext()[CONTENT_KEY_BYTES..])?; + let mut plaintext = Zeroizing::new([0u8; CONTENT_KEY_BYTES]); + plaintext.copy_from_slice(&ciphertext.ciphertext()[..CONTENT_KEY_BYTES]); + debug_assert_eq!(RELEASED_SHARE_TAG_BYTES, AeadTag::::size()); + single_shot_open_in_place_detached::( + &OpModeR::Base, + &secret_key, + &encapped_key, + info, + &mut *plaintext, + aad, + &tag, + )?; + Ok(plaintext) +} + +pub(crate) fn decode_ciphertext_bytes(bytes: &[u8]) -> Result { + HpkeCiphertextV1::from_canonical_bytes(bytes).map_err(Into::into) +} diff --git a/elastos/crates/elastos-protected-content-custody/src/lib.rs b/elastos/crates/elastos-protected-content-custody/src/lib.rs new file mode 100644 index 000000000..21367e8b6 --- /dev/null +++ b/elastos/crates/elastos-protected-content-custody/src/lib.rs @@ -0,0 +1,46 @@ +#![forbid(unsafe_code)] + +mod hpke_helpers; +mod provision; +mod release; +mod secrets; + +#[cfg(test)] +mod test_support; + +use thiserror::Error; + +pub use provision::provision_custody_envelope; +pub use release::produce_node_contribution; +pub use secrets::{ + ContentEncryptionKeyV1, NodeCustodySecretKeyV1, RecipientPublicKeyV1, RecipientSecretKeyV1, +}; + +use elastos_protected_content_contracts::{ContractError, KeyReleaseError}; + +pub(crate) const CONTENT_KEY_BYTES: usize = 32; +pub(crate) const RELEASED_SHARE_TAG_BYTES: usize = 16; + +#[derive(Debug, Error)] +pub enum CustodyError { + #[error(transparent)] + Contract(#[from] ContractError), + #[error(transparent)] + Release(#[from] KeyReleaseError), + #[error("hpke operation failed")] + Hpke(#[from] hpke::HpkeError), + #[error("shamir operation failed: {0}")] + Shamir(vsss_rs::Error), + #[error("custody binding mismatch: {0}")] + BindingMismatch(&'static str), + #[error("malformed custody share: {0}")] + MalformedShare(&'static str), + #[error("required cryptographic randomness is unavailable")] + RandomnessUnavailable, +} + +impl From for CustodyError { + fn from(value: vsss_rs::Error) -> Self { + Self::Shamir(value) + } +} diff --git a/elastos/crates/elastos-protected-content-custody/src/provision.rs b/elastos/crates/elastos-protected-content-custody/src/provision.rs new file mode 100644 index 000000000..0726c89d4 --- /dev/null +++ b/elastos/crates/elastos-protected-content-custody/src/provision.rs @@ -0,0 +1,212 @@ +use elastos_protected_content_contracts::{ + CustodyEnvelopeManifestV1, CustodyEnvelopeV1, CustodyNodeIdentityV1, + EncryptedContentIdentityV1, NodeCustodyPublicKeyV1, NodePublicKey, ShareCoordinateV1, + ThresholdV1, +}; +use hpke::rand_core::{CryptoRng as HpkeCryptoRng, RngCore as HpkeRngCore}; +use rand09::{rngs::StdRng as HpkeStdRng, SeedableRng as _}; +use rand10::{ + rngs::{StdRng as ShamirStdRng, SysRng as ShamirSysRng}, + CryptoRng as CryptoRng10, SeedableRng as _, +}; +use vsss_rs::Gf256; + +use crate::{ + hpke_helpers::seal_share, secrets::ContentEncryptionKeyV1, CustodyError, CONTENT_KEY_BYTES, +}; + +pub fn provision_custody_envelope( + encrypted_content: EncryptedContentIdentityV1, + content_key: &ContentEncryptionKeyV1, + threshold: ThresholdV1, + node_keys: Vec<(NodePublicKey, NodeCustodyPublicKeyV1)>, +) -> Result { + let mut hpke_rng = + HpkeStdRng::try_from_os_rng().map_err(|_| CustodyError::RandomnessUnavailable)?; + let mut shamir_rng = ShamirStdRng::try_from_rng(&mut ShamirSysRng) + .map_err(|_| CustodyError::RandomnessUnavailable)?; + provision_custody_envelope_with_rng( + encrypted_content, + content_key, + threshold, + node_keys, + &mut hpke_rng, + &mut shamir_rng, + ) +} + +pub(crate) fn provision_custody_envelope_with_rng( + encrypted_content: EncryptedContentIdentityV1, + content_key: &ContentEncryptionKeyV1, + threshold: ThresholdV1, + node_keys: Vec<(NodePublicKey, NodeCustodyPublicKeyV1)>, + hpke_rng: &mut RHpke, + shamir_rng: &mut RShamir, +) -> Result +where + RHpke: HpkeCryptoRng + HpkeRngCore, + RShamir: CryptoRng10, +{ + let manifest = CustodyEnvelopeManifestV1::new( + encrypted_content, + threshold, + node_keys + .into_iter() + .enumerate() + .map(|(index, (node_public_key, custody_public_key))| { + CustodyNodeIdentityV1::new( + node_public_key, + custody_public_key, + ShareCoordinateV1::new( + u8::try_from(index + 1) + .map_err(|_| CustodyError::MalformedShare("share_coordinate"))?, + )?, + ) + .map_err(CustodyError::from) + }) + .collect::, _>>()?, + )?; + let manifest_hash = manifest.manifest_hash()?; + let share_values = split_content_key(content_key, manifest.threshold(), shamir_rng)?; + let mut stored_shares = Vec::with_capacity(share_values.len()); + for (node, share_value) in manifest.nodes().iter().zip(share_values.iter()) { + let aad = node.stored_share_aad_bytes(manifest_hash)?; + stored_shares.push(seal_share( + node.custody_public_key().as_bytes(), + elastos_protected_content_contracts::STORED_SHARE_HPKE_INFO_V1, + &aad, + share_value, + hpke_rng, + )?); + } + CustodyEnvelopeV1::new(manifest, stored_shares).map_err(Into::into) +} + +fn split_content_key( + content_key: &ContentEncryptionKeyV1, + threshold: ThresholdV1, + shamir_rng: &mut R, +) -> Result>, CustodyError> { + let raw_shares = zeroize::Zeroizing::new(content_key.with_bytes(|bytes| { + Gf256::split_bytes( + usize::from(threshold.required()), + usize::from(threshold.total()), + bytes, + shamir_rng, + ) + })?); + normalize_share_values(raw_shares, threshold.total()) +} + +fn normalize_share_values( + mut raw_shares: zeroize::Zeroizing>>, + total: u8, +) -> Result>, CustodyError> { + let mut ordered = vec![None; usize::from(total)]; + while let Some(share) = raw_shares.pop() { + let share = zeroize::Zeroizing::new(share); + if share.len() != CONTENT_KEY_BYTES + 1 { + return Err(CustodyError::MalformedShare("split_share_length")); + } + let coordinate = share[0]; + if coordinate == 0 || coordinate > total { + return Err(CustodyError::MalformedShare("split_share_coordinate")); + } + let mut value = zeroize::Zeroizing::new([0u8; CONTENT_KEY_BYTES]); + value.copy_from_slice(&share[1..]); + let slot = &mut ordered[usize::from(coordinate - 1)]; + if slot.replace(value).is_some() { + return Err(CustodyError::MalformedShare( + "duplicate_split_share_coordinate", + )); + } + } + ordered + .into_iter() + .map(|share| share.ok_or(CustodyError::MalformedShare("missing_split_share"))) + .collect() +} + +#[cfg(test)] +mod tests { + use rand09::{rngs::StdRng as HpkeStdRng, SeedableRng as _}; + use rand10::{rngs::StdRng as ShamirStdRng, SeedableRng as _}; + + use super::*; + use crate::test_support::{content_key, custody_nodes, digest}; + + #[test] + fn provision_is_deterministic_under_test_rng_and_binds_identity() { + let mut hpke_rng_a = HpkeStdRng::from_seed([0x41; 32]); + let mut shamir_rng_a = ShamirStdRng::from_seed([0x42; 32]); + let mut hpke_rng_b = HpkeStdRng::from_seed([0x41; 32]); + let mut shamir_rng_b = ShamirStdRng::from_seed([0x42; 32]); + let envelope_a = provision_custody_envelope_with_rng( + EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(), + &content_key(), + ThresholdV1::new(2, 3).unwrap(), + custody_nodes(), + &mut hpke_rng_a, + &mut shamir_rng_a, + ) + .unwrap(); + let envelope_b = provision_custody_envelope_with_rng( + EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(), + &content_key(), + ThresholdV1::new(2, 3).unwrap(), + custody_nodes(), + &mut hpke_rng_b, + &mut shamir_rng_b, + ) + .unwrap(); + assert_eq!(envelope_a, envelope_b); + assert!(envelope_a + .matches_key_envelope_identity(&envelope_a.key_envelope_identity().unwrap()) + .unwrap()); + } + + #[test] + fn provision_rejects_duplicate_custody_keys() { + let nodes = { + let mut nodes = custody_nodes(); + nodes[1].1 = nodes[0].1; + nodes + }; + let err = provision_custody_envelope( + EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(), + &content_key(), + ThresholdV1::new(2, 3).unwrap(), + nodes, + ) + .unwrap_err(); + assert!(matches!( + err, + CustodyError::Contract( + elastos_protected_content_contracts::ContractError::InvalidField( + "node_custody_public_key" + ) + ) + )); + } + + #[test] + fn provision_rejects_low_order_node_custody_public_key_at_hpke_boundary() { + let low_order = { + let mut bytes = [0u8; 32]; + bytes[0] = 1; + NodeCustodyPublicKeyV1::new(bytes).unwrap() + }; + let err = provision_custody_envelope( + EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(), + &content_key(), + ThresholdV1::new(2, 3).unwrap(), + vec![ + (crate::test_support::node_public_key(1), low_order), + custody_nodes()[1], + custody_nodes()[2], + ], + ) + .unwrap_err(); + assert!(matches!(err, CustodyError::Hpke(_))); + } +} diff --git a/elastos/crates/elastos-protected-content-custody/src/release.rs b/elastos/crates/elastos-protected-content-custody/src/release.rs new file mode 100644 index 000000000..a388a40fc --- /dev/null +++ b/elastos/crates/elastos-protected-content-custody/src/release.rs @@ -0,0 +1,472 @@ +use ed25519_dalek::{Signer as _, SigningKey}; +use hpke::rand_core::{CryptoRng as HpkeCryptoRng, RngCore as HpkeRngCore}; +use rand09::{rngs::StdRng, SeedableRng as _}; + +use elastos_protected_content_contracts::{ + validate_node_contribution_active_window, CanonicalContract, CustodyEnvelopeV1, + NodeContributionStatementV1, NodePublicKey, RecipientSealedContributionV1, RightsDecisionV1, + SignedNodeContributionV1, SignedNodeRightsDecisionV1, VerifiedKeyReleaseRequestV1, +}; + +use crate::{ + hpke_helpers::seal_share, + secrets::{NodeCustodySecretKeyV1, RecipientPublicKeyV1}, + CustodyError, +}; + +#[allow(clippy::too_many_arguments)] +pub fn produce_node_contribution( + request: &VerifiedKeyReleaseRequestV1, + signed_rights_decision: &SignedNodeRightsDecisionV1, + envelope: &CustodyEnvelopeV1, + node_signing_key: &SigningKey, + node_custody_secret: &NodeCustodySecretKeyV1, + recipient_public_key: &RecipientPublicKeyV1, + issued_at: u64, + expires_at: u64, + now: u64, +) -> Result { + let mut hpke_rng = + StdRng::try_from_os_rng().map_err(|_| CustodyError::RandomnessUnavailable)?; + produce_node_contribution_with_rng( + request, + signed_rights_decision, + envelope, + node_signing_key, + node_custody_secret, + recipient_public_key, + issued_at, + expires_at, + now, + &mut hpke_rng, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn produce_node_contribution_with_rng( + request: &VerifiedKeyReleaseRequestV1, + signed_rights_decision: &SignedNodeRightsDecisionV1, + envelope: &CustodyEnvelopeV1, + node_signing_key: &SigningKey, + node_custody_secret: &NodeCustodySecretKeyV1, + recipient_public_key: &RecipientPublicKeyV1, + issued_at: u64, + expires_at: u64, + now: u64, + hpke_rng: &mut R, +) -> Result { + if !envelope.matches_key_envelope_identity(request.binding().key_envelope())? { + return Err(CustodyError::BindingMismatch("key_envelope")); + } + if request.recipient().encryption_suite_id() + != elastos_protected_content_contracts::CUSTODY_HPKE_SUITE_ID_V1 + { + return Err(CustodyError::BindingMismatch( + "recipient_encryption_suite_id", + )); + } + if recipient_public_key.identity()? != *request.recipient() { + return Err(CustodyError::BindingMismatch("recipient_key_identity")); + } + + let node_set = envelope.manifest().node_set()?; + let decision = signed_rights_decision.verify(request, &node_set, now)?; + if decision.decision() != RightsDecisionV1::Allowed { + return Err(CustodyError::Release( + elastos_protected_content_contracts::KeyReleaseError::RightsDenied, + )); + } + validate_node_contribution_active_window(issued_at, expires_at, request, &decision, now)?; + let node_public_key = NodePublicKey::new(node_signing_key.verifying_key().to_bytes())?; + if node_public_key != decision.node_public_key() { + return Err(CustodyError::BindingMismatch("node_public_key")); + } + + let node_entry = envelope + .manifest() + .node(node_public_key) + .ok_or(CustodyError::BindingMismatch("custody_node"))?; + node_custody_secret.matches_node_entry( + node_public_key, + node_entry.custody_public_key(), + node_signing_key, + )?; + + let stored_share = envelope + .stored_share_for_node(node_public_key) + .ok_or(CustodyError::BindingMismatch("stored_share"))?; + let stored_aad = envelope + .manifest() + .stored_share_aad_bytes_for_node(node_public_key)?; + let plaintext_share = crate::hpke_helpers::open_share( + stored_share, + node_custody_secret.secret_bytes(), + elastos_protected_content_contracts::STORED_SHARE_HPKE_INFO_V1, + &stored_aad, + )?; + let released_aad = node_entry.released_share_aad_bytes( + request.request_hash(), + request.binding(), + decision.decision_hash(), + request.recipient(), + )?; + let released_ciphertext = seal_share( + recipient_public_key.as_bytes(), + elastos_protected_content_contracts::RELEASED_SHARE_HPKE_INFO_V1, + &released_aad, + &plaintext_share, + hpke_rng, + )?; + let recipient_sealed_contribution = RecipientSealedContributionV1::new( + request.recipient().clone(), + released_ciphertext.canonical_bytes()?, + )?; + let statement = NodeContributionStatementV1::new( + request.request_hash(), + request.binding().clone(), + signed_rights_decision.clone(), + recipient_sealed_contribution, + issued_at, + expires_at, + )?; + let signature = node_signing_key + .sign(&statement.canonical_bytes()?) + .to_bytes() + .to_vec(); + let signed = SignedNodeContributionV1::new(statement, signature)?; + signed.verify(request, &node_set, now)?; + Ok(signed) +} + +#[cfg(test)] +mod tests { + use rand09::{rngs::StdRng, SeedableRng as _}; + + use super::*; + use crate::test_support::{ + node_custody_secret, node_signing_key, provisioned_envelope, recipient_public_key, + signed_node_decision, verified_release_request, verified_release_request_for_envelope, + verified_release_request_with_suite, + }; + use elastos_protected_content_contracts::{ + KeyReleaseError, RightsError, RIGHTS_CLOCK_SKEW_SECS, + }; + + fn node_share_index(envelope: &CustodyEnvelopeV1, seed: u8) -> usize { + let node_public_key = elastos_protected_content_contracts::NodePublicKey::new( + node_signing_key(seed).verifying_key().to_bytes(), + ) + .unwrap(); + envelope.manifest().node_index(node_public_key).unwrap() + } + + #[test] + fn release_produces_a_verified_node_contribution() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let signed = produce_node_contribution( + &request, + &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), + &envelope, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + crate::test_support::NOW + 5, + crate::test_support::NOW + 45, + crate::test_support::NOW + 6, + ) + .unwrap(); + let verified = signed + .verify( + &request, + &envelope.manifest().node_set().unwrap(), + crate::test_support::NOW + 6, + ) + .unwrap(); + assert_eq!( + verified.node_public_key(), + elastos_protected_content_contracts::NodePublicKey::new( + node_signing_key(1).verifying_key().to_bytes() + ) + .unwrap() + ); + } + + #[test] + fn release_rejects_wrong_signing_key() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let err = produce_node_contribution( + &request, + &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), + &envelope, + &node_signing_key(2), + &node_custody_secret(1), + &recipient_public_key(0x30), + crate::test_support::NOW + 5, + crate::test_support::NOW + 45, + crate::test_support::NOW + 6, + ) + .unwrap_err(); + assert!(matches!( + err, + CustodyError::BindingMismatch("node_public_key") + )); + } + + #[test] + fn release_rejects_wrong_custody_secret() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let err = produce_node_contribution( + &request, + &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), + &envelope, + &node_signing_key(1), + &node_custody_secret(2), + &recipient_public_key(0x30), + crate::test_support::NOW + 5, + crate::test_support::NOW + 45, + crate::test_support::NOW + 6, + ) + .unwrap_err(); + assert!(matches!( + err, + CustodyError::BindingMismatch("node_custody_public_key") + )); + } + + #[test] + fn release_rejects_wrong_recipient_suite_or_public_key() { + let wrong_suite_request = + verified_release_request_with_suite("x25519-hkdf-sha256-aes256gcm/v1"); + let envelope = provisioned_envelope(); + let err = produce_node_contribution( + &wrong_suite_request, + &signed_node_decision(&wrong_suite_request, 1, RightsDecisionV1::Allowed), + &envelope, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + crate::test_support::NOW + 5, + crate::test_support::NOW + 45, + crate::test_support::NOW + 6, + ) + .unwrap_err(); + assert!(matches!( + err, + CustodyError::BindingMismatch("recipient_encryption_suite_id") + )); + + let request = verified_release_request(); + let err = produce_node_contribution( + &request, + &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), + &envelope, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x31), + crate::test_support::NOW + 5, + crate::test_support::NOW + 45, + crate::test_support::NOW + 6, + ) + .unwrap_err(); + assert!(matches!( + err, + CustodyError::BindingMismatch("recipient_key_identity") + )); + } + + #[test] + fn release_rejects_denied_decision() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let err = produce_node_contribution( + &request, + &signed_node_decision(&request, 1, RightsDecisionV1::Denied), + &envelope, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + crate::test_support::NOW + 5, + crate::test_support::NOW + 45, + crate::test_support::NOW + 6, + ) + .unwrap_err(); + assert!(matches!( + err, + CustodyError::Release( + elastos_protected_content_contracts::KeyReleaseError::RightsDenied + ) + )); + } + + #[test] + fn release_rejects_post_binding_envelope_mutation_before_decrypt() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let mut stored_shares = envelope.stored_shares().to_vec(); + let index = node_share_index(&envelope, 1); + let mut ciphertext = *stored_shares[index].ciphertext(); + ciphertext[0] ^= 0x55; + stored_shares[index] = elastos_protected_content_contracts::HpkeCiphertextV1::new( + *stored_shares[index].encapped_key(), + ciphertext, + ) + .unwrap(); + let tampered = CustodyEnvelopeV1::new(envelope.manifest().clone(), stored_shares).unwrap(); + let err = produce_node_contribution_with_rng( + &request, + &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), + &tampered, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + crate::test_support::NOW + 5, + crate::test_support::NOW + 45, + crate::test_support::NOW + 6, + &mut StdRng::from_seed([0x71; 32]), + ) + .unwrap_err(); + assert!(matches!(err, CustodyError::BindingMismatch("key_envelope"))); + } + + #[test] + fn release_reaches_hpke_failure_for_request_bound_to_invalid_ciphertext() { + let envelope = provisioned_envelope(); + let mut stored_shares = envelope.stored_shares().to_vec(); + let index = node_share_index(&envelope, 1); + let mut ciphertext = *stored_shares[index].ciphertext(); + ciphertext[0] ^= 0x55; + stored_shares[index] = elastos_protected_content_contracts::HpkeCiphertextV1::new( + *stored_shares[index].encapped_key(), + ciphertext, + ) + .unwrap(); + let tampered = CustodyEnvelopeV1::new(envelope.manifest().clone(), stored_shares).unwrap(); + let request = verified_release_request_for_envelope(&tampered); + let err = produce_node_contribution_with_rng( + &request, + &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), + &tampered, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + crate::test_support::NOW + 5, + crate::test_support::NOW + 45, + crate::test_support::NOW + 6, + &mut StdRng::from_seed([0x71; 32]), + ) + .unwrap_err(); + assert!(matches!(err, CustodyError::Hpke(_))); + } + + #[test] + fn release_checks_active_window_before_opening_bound_invalid_ciphertext() { + let envelope = provisioned_envelope(); + let mut stored_shares = envelope.stored_shares().to_vec(); + let index = node_share_index(&envelope, 1); + let mut ciphertext = *stored_shares[index].ciphertext(); + ciphertext[0] ^= 0x55; + stored_shares[index] = elastos_protected_content_contracts::HpkeCiphertextV1::new( + *stored_shares[index].encapped_key(), + ciphertext, + ) + .unwrap(); + let tampered = CustodyEnvelopeV1::new(envelope.manifest().clone(), stored_shares).unwrap(); + let request = verified_release_request_for_envelope(&tampered); + let err = produce_node_contribution_with_rng( + &request, + &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), + &tampered, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + crate::test_support::NOW + 5, + crate::test_support::NOW + 6, + crate::test_support::NOW + 6, + &mut StdRng::from_seed([0x71; 32]), + ) + .unwrap_err(); + assert!(matches!( + err, + CustodyError::Release(KeyReleaseError::Rights(RightsError::Expired)) + )); + } + + #[test] + fn release_rejects_noncanonical_encapped_key_before_hpke_open() { + let envelope = provisioned_envelope(); + let mut stored_shares = envelope.stored_shares().to_vec(); + let index = node_share_index(&envelope, 1); + let noncanonical = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x7f, + ]; + stored_shares[index] = elastos_protected_content_contracts::HpkeCiphertextV1::new( + noncanonical, + *stored_shares[index].ciphertext(), + ) + .unwrap(); + let tampered = CustodyEnvelopeV1::new(envelope.manifest().clone(), stored_shares).unwrap(); + let request = verified_release_request_for_envelope(&tampered); + let err = produce_node_contribution_with_rng( + &request, + &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), + &tampered, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + crate::test_support::NOW + 5, + crate::test_support::NOW + 45, + crate::test_support::NOW + 6, + &mut StdRng::from_seed([0x71; 32]), + ) + .unwrap_err(); + assert!(matches!( + err, + CustodyError::MalformedShare("hpke_encapped_key") + )); + } + + #[test] + fn release_accepts_the_contract_clock_skew_boundary() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + produce_node_contribution( + &request, + &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), + &envelope, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + crate::test_support::NOW + 6 + RIGHTS_CLOCK_SKEW_SECS, + crate::test_support::NOW + 40, + crate::test_support::NOW + 6, + ) + .unwrap(); + } + + #[test] + fn release_rejects_expires_at_equal_to_now() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let err = produce_node_contribution( + &request, + &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), + &envelope, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + crate::test_support::NOW + 5, + crate::test_support::NOW + 6, + crate::test_support::NOW + 6, + ) + .unwrap_err(); + assert!(matches!( + err, + CustodyError::Release(KeyReleaseError::Rights(RightsError::Expired)) + )); + } +} diff --git a/elastos/crates/elastos-protected-content-custody/src/secrets.rs b/elastos/crates/elastos-protected-content-custody/src/secrets.rs new file mode 100644 index 000000000..b57d0963b --- /dev/null +++ b/elastos/crates/elastos-protected-content-custody/src/secrets.rs @@ -0,0 +1,171 @@ +use ed25519_dalek::SigningKey; +use hpke::{kem::Kem as KemTrait, Deserializable, Serializable}; +use rand09::{rngs::StdRng, RngCore as _, SeedableRng as _}; +use sha2::{Digest as _, Sha256}; +use zeroize::Zeroizing; + +use elastos_protected_content_contracts::{ + Digest32, NodeCustodyPublicKeyV1, NodePublicKey, RecipientKeyIdentityV1, + CUSTODY_HPKE_SUITE_ID_V1, +}; + +use crate::{hpke_helpers::HpkeKem, CustodyError, CONTENT_KEY_BYTES}; + +pub struct ContentEncryptionKeyV1(Zeroizing<[u8; CONTENT_KEY_BYTES]>); + +impl ContentEncryptionKeyV1 { + pub fn generate() -> Result { + Ok(Self(random_bytes()?)) + } + + pub(crate) fn with_bytes(&self, callback: impl FnOnce(&[u8; CONTENT_KEY_BYTES]) -> T) -> T { + callback(&self.0) + } + + pub(crate) fn from_guarded_bytes(bytes: Zeroizing<[u8; CONTENT_KEY_BYTES]>) -> Self { + Self(bytes) + } + + #[cfg(test)] + pub(crate) fn from_test_bytes(bytes: [u8; CONTENT_KEY_BYTES]) -> Self { + Self(Zeroizing::new(bytes)) + } +} + +impl std::fmt::Debug for ContentEncryptionKeyV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ContentEncryptionKeyV1([redacted])") + } +} + +pub struct NodeCustodySecretKeyV1(Zeroizing<[u8; 32]>); + +impl NodeCustodySecretKeyV1 { + pub fn generate() -> Result { + Ok(Self(random_bytes()?)) + } + + pub fn public_key(&self) -> Result { + NodeCustodyPublicKeyV1::new(public_key_bytes_from_secret(&self.0)?).map_err(Into::into) + } + + pub fn matches_node_entry( + &self, + node_public_key: NodePublicKey, + entry_key: NodeCustodyPublicKeyV1, + signing_key: &SigningKey, + ) -> Result<(), CustodyError> { + let derived_signer = NodePublicKey::new(signing_key.verifying_key().to_bytes())?; + if derived_signer != node_public_key { + return Err(CustodyError::BindingMismatch("node_public_key")); + } + if self.public_key()? != entry_key { + return Err(CustodyError::BindingMismatch("node_custody_public_key")); + } + Ok(()) + } + + pub(crate) fn secret_bytes(&self) -> &[u8; 32] { + &self.0 + } + + #[cfg(test)] + pub(crate) fn from_test_bytes(bytes: [u8; 32]) -> Self { + Self(Zeroizing::new(bytes)) + } +} + +impl std::fmt::Debug for NodeCustodySecretKeyV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("NodeCustodySecretKeyV1([redacted])") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RecipientPublicKeyV1([u8; 32]); + +impl RecipientPublicKeyV1 { + pub fn new(bytes: [u8; 32]) -> Result { + NodeCustodyPublicKeyV1::new(bytes) + .map(|_| Self(bytes)) + .map_err(|_| CustodyError::BindingMismatch("recipient_public_key")) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn identity(&self) -> Result { + Ok(RecipientKeyIdentityV1::new( + CUSTODY_HPKE_SUITE_ID_V1, + Digest32::new(Sha256::digest(self.0).into()), + )?) + } +} + +pub struct RecipientSecretKeyV1(Zeroizing<[u8; 32]>); + +impl RecipientSecretKeyV1 { + pub fn generate() -> Result { + Ok(Self(random_bytes()?)) + } + + pub fn public_key(&self) -> Result { + RecipientPublicKeyV1::new(public_key_bytes_from_secret(&self.0)?) + } + + pub fn identity(&self) -> Result { + self.public_key()?.identity() + } + + pub(crate) fn secret_bytes(&self) -> &[u8; 32] { + &self.0 + } + + #[cfg(test)] + pub(crate) fn from_test_bytes(bytes: [u8; 32]) -> Self { + Self(Zeroizing::new(bytes)) + } +} + +impl std::fmt::Debug for RecipientSecretKeyV1 { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("RecipientSecretKeyV1([redacted])") + } +} + +fn public_key_bytes_from_secret(secret_bytes: &[u8; 32]) -> Result<[u8; 32], CustodyError> { + let secret = ::PrivateKey::from_bytes(secret_bytes)?; + Ok(::sk_to_pk(&secret).to_bytes().into()) +} + +fn random_bytes() -> Result, CustodyError> { + let mut bytes = Zeroizing::new([0u8; N]); + StdRng::try_from_os_rng() + .map_err(|_| CustodyError::RandomnessUnavailable)? + .fill_bytes(&mut *bytes); + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn secret_debug_output_is_redacted() { + let content_key = ContentEncryptionKeyV1::from_test_bytes([0x11; CONTENT_KEY_BYTES]); + let node_secret = NodeCustodySecretKeyV1::from_test_bytes([0x22; 32]); + let recipient_secret = RecipientSecretKeyV1::from_test_bytes([0x33; 32]); + + let content_debug = format!("{content_key:?}"); + let node_debug = format!("{node_secret:?}"); + let recipient_debug = format!("{recipient_secret:?}"); + + assert!(content_debug.contains("[redacted]")); + assert!(node_debug.contains("[redacted]")); + assert!(recipient_debug.contains("[redacted]")); + assert!(!content_debug.contains("11")); + assert!(!node_debug.contains("22")); + assert!(!recipient_debug.contains("33")); + } +} diff --git a/elastos/crates/elastos-protected-content-custody/src/test_support.rs b/elastos/crates/elastos-protected-content-custody/src/test_support.rs new file mode 100644 index 000000000..d0a314587 --- /dev/null +++ b/elastos/crates/elastos-protected-content-custody/src/test_support.rs @@ -0,0 +1,269 @@ +use ed25519_dalek::{Signer as _, SigningKey}; +use k256::ecdsa::SigningKey as WalletSigningKey; +use rand09::{rngs::StdRng as HpkeStdRng, SeedableRng as _}; +use rand10::{rngs::StdRng as ShamirStdRng, SeedableRng as _}; +use sha2::Digest as _; +use sha3::Keccak256; + +use elastos_auth::ethereum_signed_message_hash; +use elastos_protected_content_contracts::{ + AtomicReplayClaimer, CanonicalContract, CustodyEnvelopeV1, Digest32, + EncryptedContentIdentityV1, KeyReleaseRequestV1, NodeCustodyPublicKeyV1, NodePublicKey, + ProtectedContentBindingV1, RecipientKeyIdentityV1, ReplayClaimError, ReplayClaimKeyV1, + ReplayNonce16, RightsActionV1, RightsDecisionV1, RightsRequestV1, RightsVerificationContextV1, + RuntimeSessionBindingV1, SignedNodeRightsDecisionV1, ThresholdV1, VerifiedKeyReleaseRequestV1, + VerifiedRightsRequestV1, WalletAddress, WalletSignedRightsRequestV1, CUSTODY_HPKE_SUITE_ID_V1, +}; + +use crate::{ + provision::provision_custody_envelope_with_rng, ContentEncryptionKeyV1, NodeCustodySecretKeyV1, + RecipientPublicKeyV1, RecipientSecretKeyV1, +}; + +pub(crate) const NOW: u64 = 2_000_000_000; + +#[derive(Debug, Default)] +struct TestReplayClaims(std::collections::HashMap); + +impl AtomicReplayClaimer for TestReplayClaims { + fn claim( + &mut self, + key: ReplayClaimKeyV1, + expires_at: u64, + now: u64, + ) -> Result<(), ReplayClaimError> { + self.0.retain(|_, expiry| *expiry > now); + if self.0.contains_key(&key) { + return Err(ReplayClaimError::AlreadyClaimed); + } + self.0.insert(key, expires_at); + Ok(()) + } +} + +pub(crate) fn digest(byte: u8) -> Digest32 { + Digest32::new([byte; 32]) +} + +pub(crate) fn node_signing_key(seed: u8) -> SigningKey { + SigningKey::from_bytes(&[seed; 32]) +} + +pub(crate) fn node_public_key(seed: u8) -> NodePublicKey { + NodePublicKey::new(node_signing_key(seed).verifying_key().to_bytes()).unwrap() +} + +pub(crate) fn node_custody_secret(seed: u8) -> NodeCustodySecretKeyV1 { + NodeCustodySecretKeyV1::from_test_bytes([seed; 32]) +} + +pub(crate) fn recipient_secret(seed: u8) -> RecipientSecretKeyV1 { + RecipientSecretKeyV1::from_test_bytes([seed; 32]) +} + +pub(crate) fn recipient_public_key(seed: u8) -> RecipientPublicKeyV1 { + recipient_secret(seed).public_key().unwrap() +} + +fn recipient_identity_with_suite(seed: u8, suite: &str) -> RecipientKeyIdentityV1 { + let key = recipient_public_key(seed); + RecipientKeyIdentityV1::new(suite, digest_key(key.as_bytes())).unwrap() +} + +fn digest_key(bytes: &[u8; 32]) -> Digest32 { + Digest32::new(sha2::Sha256::digest(bytes).into()) +} + +pub(crate) fn content_key() -> ContentEncryptionKeyV1 { + ContentEncryptionKeyV1::from_test_bytes([0x22; 32]) +} + +pub(crate) fn custody_nodes() -> Vec<(NodePublicKey, NodeCustodyPublicKeyV1)> { + vec![ + ( + node_public_key(1), + node_custody_secret(1).public_key().unwrap(), + ), + ( + node_public_key(2), + node_custody_secret(2).public_key().unwrap(), + ), + ( + node_public_key(3), + node_custody_secret(3).public_key().unwrap(), + ), + ] +} + +fn wallet(seed: u8) -> WalletAddress { + let key = WalletSigningKey::from_slice(&[seed; 32]).unwrap(); + let encoded = key.verifying_key().to_encoded_point(false); + let digest = Keccak256::digest(&encoded.as_bytes()[1..]); + WalletAddress::new(digest[12..].try_into().unwrap()) +} + +fn binding_for_wallet_with_envelope( + wallet: WalletAddress, + envelope: &CustodyEnvelopeV1, +) -> ProtectedContentBindingV1 { + let content = EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(); + ProtectedContentBindingV1::new( + content.clone(), + envelope.key_envelope_identity().unwrap(), + elastos_protected_content_contracts::RightsPolicyIdentityV1::new(digest(0x44), 384) + .unwrap(), + elastos_protected_content_contracts::ProfileIdentityV1::from_public_key_bytes( + SigningKey::from_bytes(&[0x26; 32]) + .verifying_key() + .to_bytes(), + ) + .unwrap(), + wallet, + RuntimeSessionBindingV1::new(digest(0x66)).unwrap(), + ) + .unwrap() +} + +fn binding_for_wallet(wallet: WalletAddress) -> ProtectedContentBindingV1 { + binding_for_wallet_with_envelope(wallet, &provisioned_envelope()) +} + +fn signed_rights_request_with_suite(suite: &str) -> WalletSignedRightsRequestV1 { + let wallet = wallet(7); + let request = RightsRequestV1::new( + binding_for_wallet(wallet), + RightsActionV1::View, + recipient_identity_with_suite(0x30, suite), + NOW, + NOW + 180, + ReplayNonce16::new([0x55; 16]), + ) + .unwrap(); + let key = WalletSigningKey::from_slice(&[7; 32]).unwrap(); + let (signature, recovery_id) = key + .sign_prehash_recoverable(ðereum_signed_message_hash( + &request.canonical_bytes().unwrap(), + )) + .unwrap(); + let mut signature_bytes = signature.to_bytes().to_vec(); + signature_bytes.push(recovery_id.to_byte()); + WalletSignedRightsRequestV1::new(request, signature_bytes).unwrap() +} + +fn verified_rights_request_with_suite(suite: &str) -> VerifiedRightsRequestV1 { + let signed = signed_rights_request_with_suite(suite); + let context = RightsVerificationContextV1::new( + signed.request().binding().clone(), + signed.request().action(), + signed.request().recipient().clone(), + NOW + 1, + ); + signed + .verify(&context, &mut TestReplayClaims::default()) + .unwrap() +} + +pub(crate) fn verified_release_request() -> VerifiedKeyReleaseRequestV1 { + verified_release_request_with_suite(CUSTODY_HPKE_SUITE_ID_V1) +} + +pub(crate) fn verified_release_request_with_suite(suite: &str) -> VerifiedKeyReleaseRequestV1 { + verified_release_request_for_envelope_with_suite(&provisioned_envelope(), suite) +} + +pub(crate) fn verified_release_request_for_envelope( + envelope: &CustodyEnvelopeV1, +) -> VerifiedKeyReleaseRequestV1 { + verified_release_request_for_envelope_with_suite(envelope, CUSTODY_HPKE_SUITE_ID_V1) +} + +fn verified_release_request_for_envelope_with_suite( + envelope: &CustodyEnvelopeV1, + suite: &str, +) -> VerifiedKeyReleaseRequestV1 { + let rights = { + let signed = signed_rights_request_with_suite_for_envelope(suite, envelope); + let context = RightsVerificationContextV1::new( + signed.request().binding().clone(), + signed.request().action(), + signed.request().recipient().clone(), + NOW + 1, + ); + signed + .verify(&context, &mut TestReplayClaims::default()) + .unwrap() + }; + KeyReleaseRequestV1::new( + rights.binding().clone(), + rights.request_hash(), + rights.action(), + rights.recipient().clone(), + NOW + 2, + NOW + 60, + ReplayNonce16::new([0x66; 16]), + ) + .unwrap() + .verify(&rights, NOW + 3, &mut TestReplayClaims::default()) + .unwrap() +} + +fn signed_rights_request_with_suite_for_envelope( + suite: &str, + envelope: &CustodyEnvelopeV1, +) -> WalletSignedRightsRequestV1 { + let wallet = wallet(7); + let request = RightsRequestV1::new( + binding_for_wallet_with_envelope(wallet, envelope), + RightsActionV1::View, + recipient_identity_with_suite(0x30, suite), + NOW, + NOW + 180, + ReplayNonce16::new([0x55; 16]), + ) + .unwrap(); + let key = WalletSigningKey::from_slice(&[7; 32]).unwrap(); + let (signature, recovery_id) = key + .sign_prehash_recoverable(ðereum_signed_message_hash( + &request.canonical_bytes().unwrap(), + )) + .unwrap(); + let mut signature_bytes = signature.to_bytes().to_vec(); + signature_bytes.push(recovery_id.to_byte()); + WalletSignedRightsRequestV1::new(request, signature_bytes).unwrap() +} + +pub(crate) fn signed_node_decision( + request: &VerifiedKeyReleaseRequestV1, + node_seed: u8, + decision: RightsDecisionV1, +) -> SignedNodeRightsDecisionV1 { + let statement = elastos_protected_content_contracts::NodeRightsDecisionStatementV1::new( + request.request_hash(), + request.rights_request_hash(), + request.binding().clone(), + request.action(), + node_public_key(node_seed), + decision, + digest(0x80 + node_seed), + NOW + 4, + NOW + 50, + ) + .unwrap(); + let signature = node_signing_key(node_seed) + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(); + SignedNodeRightsDecisionV1::new(statement, signature).unwrap() +} + +pub(crate) fn provisioned_envelope() -> CustodyEnvelopeV1 { + provision_custody_envelope_with_rng( + EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(), + &content_key(), + ThresholdV1::new(2, 3).unwrap(), + custody_nodes(), + &mut HpkeStdRng::from_seed([0x41; 32]), + &mut ShamirStdRng::from_seed([0x42; 32]), + ) + .unwrap() +} From 401d6d06c922df47d374eae8d3d204570d808f33 Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Thu, 13 Aug 2026 20:53:02 -0500 Subject: [PATCH 03/15] feat(protected-content): reconstruct threshold content keys --- TASKS.md | 23 +- docs/PROTECTED_CONTENT.md | 124 ++-- docs/PROTECTED_CONTENT_CONTRACTS_V1.md | 34 +- .../src/lib.rs | 2 + .../src/reconstruct.rs | 589 ++++++++++++++++++ .../src/test_support.rs | 78 +-- state.md | 31 +- 7 files changed, 720 insertions(+), 161 deletions(-) create mode 100644 elastos/crates/elastos-protected-content-custody/src/reconstruct.rs diff --git a/TASKS.md b/TASKS.md index 8f171734e..9b2610ac4 100644 --- a/TASKS.md +++ b/TASKS.md @@ -709,8 +709,9 @@ and review items pass. ### Trusted content and access rights - [ ] Obtain external cryptographic, custody, and contract review of - `docs/PROTECTED_CONTENT_CONTRACTS_V1.md` and the source-only - `elastos-protected-content-contracts` crate before any provider or product + `docs/PROTECTED_CONTENT_CONTRACTS_V1.md`, the source-only + `elastos-protected-content-contracts` crate, and the source-only + `elastos-protected-content-custody` crate before any provider or product integration. The branch-local source review already closed with no code findings after the shared strict DID codec and Carrier codec consolidation; that was not an external cryptographic audit or production security @@ -720,19 +721,23 @@ and review items pass. atomic replay keys, node decisions, terminal issuer signatures, nested authority windows including contribution-to-terminal settlement, shared canonical Ed25519 validation (including noncanonical-encoding rejection), - unique contribution commitments at threshold settlement, and golden vectors. + custody-envelope AAD domains, pinned HPKE share sealing, GF256 Shamir share + handling, threshold reconstruction zeroization, unique contribution + commitments at threshold settlement, and golden vectors. - [ ] Add a typed protected-content Wallet operation that verifies an externally completed signature, canonicalizes a `27/28` recovery byte to `0/1` after signer verification and before `WalletSignedRightsRequestV1` construction, and covers managed, MetaMask, and WalletConnect paths with exact tests. The v1 contract itself continues to accept only canonical `0/1`. -- [ ] Decide and review the custody architecture, node admission and rotation, - threshold algorithm, recipient encryption suite, contribution protection, - durable per-node replay claims, revocation, audit retention, issuer-key - lifecycle, and recovery operations. The v1 contract binds recipient-labeled - bytes but does not implement or prove encryption, custody, or threshold - reconstruction. +- [ ] Complete the remaining custody review and operational design: node + admission and rotation, review or replacement of the pinned threshold and + recipient-encryption suites, durable per-node replay claims, revocation, + audit retention, issuer-key lifecycle, and recovery operations. The + source-only `elastos-protected-content-custody` crate now implements new + content share provisioning, recipient-sealed node release, and recipient-side + threshold reconstruction, but it does not prove operational custody safety, + durable storage/replay, provider wiring, or product integration. - [ ] Define how Runtime derives the recipient public key from the authenticated Profile and session, proves key possession, and selects an approved encryption suite. The caller must not provide endpoint, device, route, or Carrier diff --git a/docs/PROTECTED_CONTENT.md b/docs/PROTECTED_CONTENT.md index 180cc2723..780ed0aea 100644 --- a/docs/PROTECTED_CONTENT.md +++ b/docs/PROTECTED_CONTENT.md @@ -6,12 +6,16 @@ authority. The source tree currently has two protected-content layers: -- The canonical v1 review candidate is the source-only - `elastos-protected-content-contracts` crate, documented in - [Protected-content v1 contracts](PROTECTED_CONTENT_CONTRACTS_V1.md). It is - not yet wired into Runtime orchestration, provider integration, custody, - threshold reconstruction, recipient encryption proof, decryption, playback, - installation, or deployment. +- The canonical v1 source-only review stack is the + `elastos-protected-content-contracts` crate plus the companion + `elastos-protected-content-custody` crate, documented in + [Protected-content v1 contracts](PROTECTED_CONTENT_CONTRACTS_V1.md). That + stack defines canonical authority bindings plus source-only custody helpers + for custody-envelope provisioning, recipient-sealed node release, and + recipient-side threshold reconstruction for new content. It is not yet wired + into Runtime orchestration, provider integration, durable replay storage, + recipient key-possession proof, decryption, playback, installation, or + deployment. - The current installed/provider surface is the older provisional `elastos_common::protected_content` DTO set plus the fail-closed `drm-provider`, `rights-provider`, `key-provider`, and `decrypt-provider` @@ -51,9 +55,9 @@ production DRM: `sealed.json`, payload, rights policy, availability receipt, provenance, and approved key-envelope algorithms are required -This is intentional. The first safe steps are to make the authority boundary -unambiguous in the canonical v1 contract and keep the current provider chain -fail closed until the reviewed integration slice exists. +This is intentional. The first safe steps are to make the authority and +custody boundaries unambiguous in the source-only v1 crates and keep the +current provider chain fail closed until the reviewed integration slice exists. PC2's dDRM contracts and WASM decrypt/render/media crates are useful implementation references. They should enter Runtime only as provider-internal @@ -76,7 +80,8 @@ sealed-material rail: - `decrypt-provider` creates a per-session one-time public key for the decrypt sandbox. - `key-provider` or the dKMS release backend seals the CEK to that one-time - decrypt-session public key, using the approved PQ-hybrid envelope profile. + decrypt-session public key, using a separately reviewed decrypt-handoff + suite. - The decrypt step receives sealed material in the decrypt-session request, unwraps it inside the sandbox, decrypts/renders, zeroizes the live CEK, and returns only scoped output. @@ -85,11 +90,10 @@ sealed-material rail: authority surface. This keeps the rights/key/decrypt separation while avoiding an outbound -authority grant to the highest-risk boundary. It also explains the current -schema gap: `ReleaseReceiptV1` proves authorization, but it intentionally carries -no key material; the next contract addition should add a sealed decrypt material -envelope to the key/decrypt handoff instead of sending a raw CEK or letting -decrypt fetch one. +authority grant to the highest-risk boundary. The current source-only custody +crate already defines canonical custody envelopes plus stored-share and +released-share sealing for new content, but the Runtime/provider handoff to a +decrypt boundary is still future integration work. Other options were considered and rejected as the normal Runtime path: @@ -111,68 +115,25 @@ current `ddrm-decrypt` WASM pattern proves the containment invariant, but its P-256 and Lit/Chipotle details are implementation references rather than Runtime product truth. -## Protected Object Shape - -New protected objects should publish as sealed SmartWeb objects: - -```json -{ - "schema": "elastos.sealed.object/v1", - "payload_cid": "bafy...", - "rights_policy_cid": "bafy...", - "availability_receipt_cid": "bafy...", - "key_envelope": { - "scheme": "elastos-pq-hybrid-threshold-v0", - "kid": "...", - "wrapped_cek": "...", - "policy_hash": "sha256:...", - "algorithms": { - "cipher": "aes-256-gcm", - "signature": ["ed25519", "ml-dsa-65"], - "kem": ["x25519", "ml-kem-768"], - "share_scheme": "shamir-t-of-n" - } - }, - "viewer": { - "required_interface": "elastos.viewer/document@1" - } -} -``` - -`payload_cid` can be publicly reachable because protected payload bytes must be -encrypted before replication. Access is enforced by rights checks and key release, -not by hiding CIDs. - -## Crypto Agility And dKMS Direction - -FROST is a threshold Schnorr protocol, so it is classical ECC security, not a -post-quantum root. ElastOS may use FROST for short/medium-term receipt or cohort -signing, but new dKMS content must not depend on FROST as the long-term key -security foundation. - -New protected content should use algorithm-agile sealed objects: - -- Encrypt payload bytes with AES-256-GCM or ChaCha20-Poly1305. -- Split the AES-256 CEK into `t-of-n` shares. -- Wrap each share to an approved dKMS node with hybrid X25519 + ML-KEM-768. -- Sign release receipts with classical + PQ signatures where practical, starting - with Ed25519 plus ML-DSA; use SLH-DSA for conservative hash-based signatures - where size and speed are acceptable. -- Reconstruct the CEK only inside the key/decrypt provider boundary, then return - scoped render/decrypt output to the viewer instead of raw CEKs. -- When the decrypt engine is wired, prefer dKMS-direct sealing to the decrypt - session key. If an intermediate key-provider re-seal is used during migration, - it must remain provider-internal, signed, auditable, and short-lived. - -Current EVM/BTC/ELA wallet proofs and dDRM chain state are still classical. They -are useful authorization inputs today, but they should not be the only permanent -identity or access root for long-lived encrypted assets. - -References: [NIST PQC standards announcement](https://www.nist.gov/news-events/news/2024/08/nist-releases-first-3-finalized-post-quantum-encryption-standards), -[FIPS 203 ML-KEM](https://csrc.nist.gov/pubs/fips/203/final), -[FIPS 204 ML-DSA](https://csrc.nist.gov/pubs/fips/204/final), -[FIPS 205 SLH-DSA](https://csrc.nist.gov/pubs/fips/205/final), -and [RFC 9591 FROST](https://www.rfc-editor.org/rfc/rfc9591). +## Current source-only custody helper + +The `elastos-protected-content-custody` crate is a source-only helper for new +protected content: + +- It provisions canonical custody envelopes from the reviewed v1 contract. +- It uses one pinned recipient-sealing suite for stored and released shares: + RFC 9180 base mode X25519 + HKDF-SHA256 + AES-256-GCM. +- It uses GF256 Shamir splitting through `vsss-rs` for new content only. +- It returns only opaque, redacted secret wrappers; it does not expose a + capsule-visible raw-key API. +- It is source-only. There are no running custody nodes, Runtime/provider + routes, durable replay stores, recipient key-possession proof, decrypt/render + product flow, installation, or deployment in this branch. + +This helper does not make a PQ claim. PQ-hybrid custody, dKMS node lifecycle, +and product wiring remain future work. The current HPKE dependency is `hpke` +0.13, whose upstream documentation says it has not been formally audited. This +branch therefore makes no external cryptographic audit claim. ## Provider Boundary @@ -198,8 +159,9 @@ The provider plane should expose typed questions instead: ## Remaining sequence -1. Finish independent review of the source-only canonical v1 contract and the - related custody, encryption, replay, recipient-key, and policy decisions. +1. Finish independent review of the source-only canonical v1 contract and + source-only custody helper, then close the remaining custody, replay, + recipient-key, and policy design decisions. 2. Replace the provisional `elastos_common::protected_content` DTO/provider surface atomically with Runtime-owned orchestration over the reviewed v1 contract: content status/fetch, typed rights checks, rights-bound key @@ -210,7 +172,9 @@ The provider plane should expose typed questions instead: 4. Wire real protected-content producers to the existing sealed-object publish contract after payload encryption, rights policy, availability receipt, provenance, key-envelope, and viewer-interface generation exist. -5. Add a permissioned ElastOS PQ-hybrid dKMS v0 for new content only. +5. Add a reviewed custody-node lifecycle and, only after that, decide whether + a permissioned ElastOS PQ-hybrid dKMS layer should replace the pinned + source-only helper for new content. Visible protected-content UI may ship only as a disabled/read-only readiness rail until fail-closed provider tests and capability-resource checks cover the diff --git a/docs/PROTECTED_CONTENT_CONTRACTS_V1.md b/docs/PROTECTED_CONTENT_CONTRACTS_V1.md index 837d1a7f7..62e505fd7 100644 --- a/docs/PROTECTED_CONTENT_CONTRACTS_V1.md +++ b/docs/PROTECTED_CONTENT_CONTRACTS_V1.md @@ -1,8 +1,10 @@ # Protected-content v1 contracts -Status: source-only contract foundation. The normative implementation is the -`elastos-protected-content-contracts` crate. This document describes that -review candidate; it does not claim running rights providers, key custody, +Status: source-only authority and canonical-wire foundation. The normative +authority types are in the `elastos-protected-content-contracts` crate, and the +companion source-only custody helper lives in +`elastos-protected-content-custody`. This document describes the reviewed +contract layer; it does not claim running rights providers, custody nodes, content availability, decryption, playback, or product integration. ## Authority boundaries @@ -151,11 +153,13 @@ node, wrong node set, wrong threshold, or escaped child window fails. The contribution payload is provider-private `RecipientSealedContributionV1`: exact recipient key identity plus bounded opaque bytes. The contract requires that recipient to equal the signed release -recipient and authenticates the bytes and commitment. It does not implement -encryption and cannot prove those bytes were cryptographically sealed. That -proof belongs to the deferred custody implementation and external -cryptographic review. No production confidentiality claim follows from this -source-only type. +recipient and authenticates the bytes and commitment. The contract type itself +does not implement encryption. The companion source-only +`elastos-protected-content-custody` crate implements a pinned HPKE + GF256 +Shamir helper for new-content share release and threshold reconstruction, but +that helper is not yet wired into Runtime/provider/product flows and has not +received an external cryptographic audit. No production confidentiality claim +follows from this contract type alone. ## Terminal result @@ -182,12 +186,14 @@ a denied or cross-node decision, cross-request contribution reuse, duplicate node/decision/contribution/commitment evidence, insufficient threshold, malformed canonical input, and noncanonical Wallet/Profile encodings. -These contracts do not solve malicious custody nodes, rights-policy correctness, -key-share generation/storage/rotation, contribution encryption, durable replay -storage, issuer-key lifecycle, revocation, availability, decryption, rendering, -or product workflow safety. They provide bounded statements for those systems -to implement and audit. Remaining custody and product work is tracked in -`TASKS.md`. +These contracts plus the source-only `elastos-protected-content-custody` helper +do not solve malicious custody nodes, rights-policy correctness, durable replay +storage, issuer-key lifecycle, recipient key-possession proof, node +admission/rotation/recovery, availability, decryption, rendering, or product +workflow safety. The custody helper uses `hpke` 0.13, whose upstream +documentation says it has not been formally audited. These source-only crates +provide bounded statements and helper operations for later systems to audit and +integrate. Remaining custody and product work is tracked in `TASKS.md`. The parent branch's provisional `elastos_common::protected_content` DTOs are not this canonical contract. Integration must replace that surface atomically diff --git a/elastos/crates/elastos-protected-content-custody/src/lib.rs b/elastos/crates/elastos-protected-content-custody/src/lib.rs index 21367e8b6..a261707fa 100644 --- a/elastos/crates/elastos-protected-content-custody/src/lib.rs +++ b/elastos/crates/elastos-protected-content-custody/src/lib.rs @@ -2,6 +2,7 @@ mod hpke_helpers; mod provision; +mod reconstruct; mod release; mod secrets; @@ -11,6 +12,7 @@ mod test_support; use thiserror::Error; pub use provision::provision_custody_envelope; +pub use reconstruct::reconstruct_content_key; pub use release::produce_node_contribution; pub use secrets::{ ContentEncryptionKeyV1, NodeCustodySecretKeyV1, RecipientPublicKeyV1, RecipientSecretKeyV1, diff --git a/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs b/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs new file mode 100644 index 000000000..f3c109ea6 --- /dev/null +++ b/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs @@ -0,0 +1,589 @@ +use std::collections::HashSet; + +use vsss_rs::Gf256; +use zeroize::Zeroizing; + +use elastos_protected_content_contracts::{ + CustodyEnvelopeV1, CustodyNodeIdentityV1, KeyReleaseError, KeyReleaseOutcomeV1, + SignedNodeContributionV1, SignedTerminalReceiptV1, TerminalReceiptIssuerKey, + VerifiedKeyReleaseRequestV1, +}; + +use crate::{ + hpke_helpers::{decode_ciphertext_bytes, open_share}, + secrets::{ContentEncryptionKeyV1, RecipientSecretKeyV1}, + CustodyError, CONTENT_KEY_BYTES, +}; + +pub fn reconstruct_content_key( + request: &VerifiedKeyReleaseRequestV1, + envelope: &CustodyEnvelopeV1, + contributions: &[SignedNodeContributionV1], + terminal_receipt: &SignedTerminalReceiptV1, + expected_terminal_issuer: TerminalReceiptIssuerKey, + recipient_secret: &RecipientSecretKeyV1, + now: u64, +) -> Result { + if !envelope.matches_key_envelope_identity(request.binding().key_envelope())? { + return Err(CustodyError::BindingMismatch("key_envelope")); + } + if recipient_secret.identity()? != *request.recipient() { + return Err(CustodyError::BindingMismatch("recipient_key_identity")); + } + + let required = usize::from(request.binding().key_envelope().threshold().required()); + if contributions.len() < required { + return Err(CustodyError::Release( + KeyReleaseError::InsufficientContributions, + )); + } + + let node_set = envelope.manifest().node_set()?; + let mut seen_nodes = HashSet::with_capacity(contributions.len()); + let mut verified_contributions = Vec::with_capacity(contributions.len()); + let mut ordered_contributions = Vec::with_capacity(contributions.len()); + + for contribution in contributions { + let verified = contribution.verify(request, &node_set, now)?; + if !seen_nodes.insert(verified.node_public_key()) { + return Err(CustodyError::BindingMismatch("duplicate_contribution_node")); + } + let node_entry = envelope + .manifest() + .node(verified.node_public_key()) + .ok_or(CustodyError::BindingMismatch("custody_node"))? + .clone(); + verified_contributions.push(verified.clone()); + ordered_contributions.push(( + node_entry.share_coordinate().get(), + node_entry, + verified, + contribution, + )); + } + + terminal_receipt.verify( + request, + &verified_contributions, + expected_terminal_issuer, + now, + )?; + if terminal_receipt.statement().outcome() != KeyReleaseOutcomeV1::Released { + return Err(CustodyError::BindingMismatch("terminal_receipt_outcome")); + } + + ordered_contributions.sort_unstable_by_key(|(coordinate, _, _, _)| *coordinate); + + let mut plaintext_shares = + Zeroizing::new(Vec::>::with_capacity(ordered_contributions.len())); + for (_, node_entry, verified, contribution) in ordered_contributions { + let plaintext_share = open_released_share( + request, + &node_entry, + &verified, + contribution, + recipient_secret, + )?; + let share_index = plaintext_shares.len(); + plaintext_shares.push(vec![0u8; CONTENT_KEY_BYTES + 1]); + let share = &mut plaintext_shares[share_index]; + share[0] = node_entry.share_coordinate().get(); + share[1..].copy_from_slice(&plaintext_share[..]); + } + + let reconstructed = Zeroizing::new(Gf256::combine_bytes(&*plaintext_shares)?); + if reconstructed.len() != CONTENT_KEY_BYTES { + return Err(CustodyError::MalformedShare("reconstructed_content_key")); + } + + let mut content_key = Zeroizing::new([0u8; CONTENT_KEY_BYTES]); + content_key.copy_from_slice(&reconstructed[..]); + Ok(ContentEncryptionKeyV1::from_guarded_bytes(content_key)) +} + +fn open_released_share( + request: &VerifiedKeyReleaseRequestV1, + node_entry: &CustodyNodeIdentityV1, + verified: &elastos_protected_content_contracts::VerifiedNodeContributionV1, + contribution: &SignedNodeContributionV1, + recipient_secret: &RecipientSecretKeyV1, +) -> Result, CustodyError> { + let released_aad = node_entry.released_share_aad_bytes( + request.request_hash(), + request.binding(), + verified.decision_hash(), + request.recipient(), + )?; + let ciphertext = decode_ciphertext_bytes( + contribution + .statement() + .recipient_sealed_contribution() + .sealed_bytes(), + )?; + let plaintext_share = open_share( + &ciphertext, + recipient_secret.secret_bytes(), + elastos_protected_content_contracts::RELEASED_SHARE_HPKE_INFO_V1, + &released_aad, + )?; + Ok(plaintext_share) +} + +#[cfg(test)] +mod tests { + use ed25519_dalek::{Signer as _, SigningKey}; + use rand09::{rngs::StdRng, SeedableRng as _}; + use rand10::SeedableRng as _; + + use elastos_protected_content_contracts::{ + CanonicalContract, EncryptedContentIdentityV1, HpkeCiphertextV1, KeyReleaseError, + KeyReleaseOutcomeV1, NodeContributionRefV1, NodeContributionStatementV1, + RecipientSealedContributionV1, SignedNodeContributionV1, SignedTerminalReceiptV1, + TerminalReceiptIssuerKey, TerminalReceiptStatementV1, ThresholdV1, + VerifiedKeyReleaseRequestV1, + }; + + use crate::{ + provision::provision_custody_envelope_with_rng, + release::produce_node_contribution_with_rng, + test_support::{ + content_key, custody_nodes, digest, node_custody_secret, node_signing_key, + provisioned_envelope, recipient_public_key, recipient_secret, signed_node_decision, + verified_release_request, verified_release_request_for_envelope, + verified_release_request_for_envelope_and_recipient_seed, NOW, + }, + }; + + use super::*; + + fn released_contribution( + request: &VerifiedKeyReleaseRequestV1, + envelope: &CustodyEnvelopeV1, + node_seed: u8, + recipient_seed: u8, + hpke_seed: u8, + ) -> SignedNodeContributionV1 { + produce_node_contribution_with_rng( + request, + &signed_node_decision( + request, + node_seed, + elastos_protected_content_contracts::RightsDecisionV1::Allowed, + ), + envelope, + &node_signing_key(node_seed), + &node_custody_secret(node_seed), + &recipient_public_key(recipient_seed), + NOW + 5, + NOW + 45, + NOW + 6, + &mut StdRng::from_seed([hpke_seed; 32]), + ) + .unwrap() + } + + fn terminal_receipt( + request: &VerifiedKeyReleaseRequestV1, + envelope: &CustodyEnvelopeV1, + contributions: &[SignedNodeContributionV1], + issuer_seed: u8, + outcome: KeyReleaseOutcomeV1, + ) -> SignedTerminalReceiptV1 { + let verified = contributions + .iter() + .map(|contribution| { + contribution + .verify(request, &envelope.manifest().node_set().unwrap(), NOW + 7) + .unwrap() + }) + .collect::>(); + let issuer_key = SigningKey::from_bytes(&[issuer_seed; 32]); + let issuer = TerminalReceiptIssuerKey::new(issuer_key.verifying_key().to_bytes()).unwrap(); + let refs = match outcome { + KeyReleaseOutcomeV1::Denied => Vec::new(), + KeyReleaseOutcomeV1::Released => { + verified.iter().map(NodeContributionRefV1::from).collect() + } + }; + let statement = TerminalReceiptStatementV1::new( + request.request_hash(), + request.binding().clone(), + issuer, + outcome, + refs, + NOW + 7, + NOW + 40, + ) + .unwrap(); + let signature = issuer_key + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(); + SignedTerminalReceiptV1::new(statement, signature).unwrap() + } + + fn tampered_ciphertext_contribution( + request: &VerifiedKeyReleaseRequestV1, + envelope: &CustodyEnvelopeV1, + node_seed: u8, + recipient_seed: u8, + hpke_seed: u8, + ) -> SignedNodeContributionV1 { + let valid = released_contribution(request, envelope, node_seed, recipient_seed, hpke_seed); + let mut ciphertext = decode_ciphertext_bytes( + valid + .statement() + .recipient_sealed_contribution() + .sealed_bytes(), + ) + .unwrap(); + let mut sealed = *ciphertext.ciphertext(); + sealed[0] ^= 0x55; + ciphertext = HpkeCiphertextV1::new(*ciphertext.encapped_key(), sealed).unwrap(); + let sealed = RecipientSealedContributionV1::new( + request.recipient().clone(), + ciphertext.canonical_bytes().unwrap(), + ) + .unwrap(); + let statement = NodeContributionStatementV1::new( + request.request_hash(), + request.binding().clone(), + signed_node_decision( + request, + node_seed, + elastos_protected_content_contracts::RightsDecisionV1::Allowed, + ), + sealed, + NOW + 5, + NOW + 45, + ) + .unwrap(); + let signature = node_signing_key(node_seed) + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(); + SignedNodeContributionV1::new(statement, signature).unwrap() + } + + fn alternate_envelope() -> CustodyEnvelopeV1 { + provision_custody_envelope_with_rng( + EncryptedContentIdentityV1::new(digest(0x99), 4096).unwrap(), + &content_key(), + ThresholdV1::new(2, 3).unwrap(), + custody_nodes(), + &mut StdRng::from_seed([0x51; 32]), + &mut rand10::rngs::StdRng::from_seed([0x52; 32]), + ) + .unwrap() + } + + #[test] + fn reconstructs_threshold_content_key() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let contributions = vec![ + released_contribution(&request, &envelope, 1, 0x30, 0x71), + released_contribution(&request, &envelope, 2, 0x30, 0x72), + ]; + let terminal = terminal_receipt( + &request, + &envelope, + &contributions, + 0x21, + KeyReleaseOutcomeV1::Released, + ); + + let recovered = reconstruct_content_key( + &request, + &envelope, + &contributions, + &terminal, + terminal.statement().issuer(), + &recipient_secret(0x30), + NOW + 8, + ) + .unwrap(); + + assert_eq!( + recovered.with_bytes(|bytes| *bytes), + content_key().with_bytes(|bytes| *bytes) + ); + } + + #[test] + fn insufficient_shares_fail_before_open() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let contribution = released_contribution(&request, &envelope, 1, 0x30, 0x71); + let terminal = + terminal_receipt(&request, &envelope, &[], 0x21, KeyReleaseOutcomeV1::Denied); + + let err = reconstruct_content_key( + &request, + &envelope, + &[contribution], + &terminal, + terminal.statement().issuer(), + &recipient_secret(0x30), + NOW + 8, + ) + .unwrap_err(); + + assert!(matches!( + err, + CustodyError::Release(KeyReleaseError::InsufficientContributions) + )); + } + + #[test] + fn duplicate_node_contributions_fail_before_open() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let contribution = released_contribution(&request, &envelope, 1, 0x30, 0x71); + let terminal = + terminal_receipt(&request, &envelope, &[], 0x21, KeyReleaseOutcomeV1::Denied); + + let err = reconstruct_content_key( + &request, + &envelope, + &[contribution.clone(), contribution], + &terminal, + terminal.statement().issuer(), + &recipient_secret(0x30), + NOW + 8, + ) + .unwrap_err(); + + assert!(matches!( + err, + CustodyError::BindingMismatch("duplicate_contribution_node") + )); + } + + #[test] + fn wrong_recipient_secret_is_rejected() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let contributions = vec![ + released_contribution(&request, &envelope, 1, 0x30, 0x71), + released_contribution(&request, &envelope, 2, 0x30, 0x72), + ]; + let terminal = terminal_receipt( + &request, + &envelope, + &contributions, + 0x21, + KeyReleaseOutcomeV1::Released, + ); + + let err = reconstruct_content_key( + &request, + &envelope, + &contributions, + &terminal, + terminal.statement().issuer(), + &recipient_secret(0x31), + NOW + 8, + ) + .unwrap_err(); + + assert!(matches!( + err, + CustodyError::BindingMismatch("recipient_key_identity") + )); + } + + #[test] + fn wrong_request_contribution_is_rejected() { + let envelope = provisioned_envelope(); + let request_a = verified_release_request_for_envelope(&envelope); + let request_b = verified_release_request_for_envelope_and_recipient_seed(&envelope, 0x31); + let contributions = vec![ + released_contribution(&request_a, &envelope, 1, 0x30, 0x71), + released_contribution(&request_b, &envelope, 2, 0x31, 0x72), + ]; + let terminal = terminal_receipt( + &request_a, + &envelope, + &[], + 0x21, + KeyReleaseOutcomeV1::Denied, + ); + + let err = reconstruct_content_key( + &request_a, + &envelope, + &contributions, + &terminal, + terminal.statement().issuer(), + &recipient_secret(0x30), + NOW + 8, + ) + .unwrap_err(); + + assert!(matches!( + err, + CustodyError::Release(KeyReleaseError::BindingMismatch(_)) + )); + } + + #[test] + fn tampered_ciphertext_is_rejected() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let contributions = vec![ + tampered_ciphertext_contribution(&request, &envelope, 1, 0x30, 0x71), + released_contribution(&request, &envelope, 2, 0x30, 0x72), + ]; + let terminal = terminal_receipt( + &request, + &envelope, + &contributions, + 0x21, + KeyReleaseOutcomeV1::Released, + ); + + let err = reconstruct_content_key( + &request, + &envelope, + &contributions, + &terminal, + terminal.statement().issuer(), + &recipient_secret(0x30), + NOW + 8, + ) + .unwrap_err(); + + assert!(matches!(err, CustodyError::Hpke(_))); + } + + #[test] + fn expiry_is_rejected() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let contributions = vec![ + released_contribution(&request, &envelope, 1, 0x30, 0x71), + released_contribution(&request, &envelope, 2, 0x30, 0x72), + ]; + let terminal = terminal_receipt( + &request, + &envelope, + &contributions, + 0x21, + KeyReleaseOutcomeV1::Released, + ); + + let err = reconstruct_content_key( + &request, + &envelope, + &contributions, + &terminal, + terminal.statement().issuer(), + &recipient_secret(0x30), + NOW + 40, + ) + .unwrap_err(); + + assert!(matches!( + err, + CustodyError::Release(KeyReleaseError::Rights( + elastos_protected_content_contracts::RightsError::Expired + )) + )); + } + + #[test] + fn contributions_are_sorted_by_manifest_coordinate() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let contributions = vec![ + released_contribution(&request, &envelope, 2, 0x30, 0x72), + released_contribution(&request, &envelope, 1, 0x30, 0x71), + ]; + let terminal = terminal_receipt( + &request, + &envelope, + &contributions, + 0x21, + KeyReleaseOutcomeV1::Released, + ); + + let recovered = reconstruct_content_key( + &request, + &envelope, + &contributions, + &terminal, + terminal.statement().issuer(), + &recipient_secret(0x30), + NOW + 8, + ) + .unwrap(); + + assert_eq!( + recovered.with_bytes(|bytes| *bytes), + content_key().with_bytes(|bytes| *bytes) + ); + } + + #[test] + fn reconstructed_key_debug_output_is_redacted() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let contributions = vec![ + released_contribution(&request, &envelope, 1, 0x30, 0x71), + released_contribution(&request, &envelope, 2, 0x30, 0x72), + ]; + let terminal = terminal_receipt( + &request, + &envelope, + &contributions, + 0x21, + KeyReleaseOutcomeV1::Released, + ); + + let key = reconstruct_content_key( + &request, + &envelope, + &contributions, + &terminal, + terminal.statement().issuer(), + &recipient_secret(0x30), + NOW + 8, + ) + .unwrap(); + let debug = format!("{key:?}"); + + assert!(debug.contains("[redacted]")); + assert!(!debug.contains("22")); + } + + #[test] + fn wrong_envelope_binding_is_rejected() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let contributions = vec![ + released_contribution(&request, &envelope, 1, 0x30, 0x71), + released_contribution(&request, &envelope, 2, 0x30, 0x72), + ]; + let terminal = terminal_receipt( + &request, + &envelope, + &contributions, + 0x21, + KeyReleaseOutcomeV1::Released, + ); + + let err = reconstruct_content_key( + &request, + &alternate_envelope(), + &contributions, + &terminal, + terminal.statement().issuer(), + &recipient_secret(0x30), + NOW + 8, + ) + .unwrap_err(); + + assert!(matches!(err, CustodyError::BindingMismatch("key_envelope"))); + } +} diff --git a/elastos/crates/elastos-protected-content-custody/src/test_support.rs b/elastos/crates/elastos-protected-content-custody/src/test_support.rs index d0a314587..4e8edbfc4 100644 --- a/elastos/crates/elastos-protected-content-custody/src/test_support.rs +++ b/elastos/crates/elastos-protected-content-custody/src/test_support.rs @@ -12,7 +12,7 @@ use elastos_protected_content_contracts::{ ProtectedContentBindingV1, RecipientKeyIdentityV1, ReplayClaimError, ReplayClaimKeyV1, ReplayNonce16, RightsActionV1, RightsDecisionV1, RightsRequestV1, RightsVerificationContextV1, RuntimeSessionBindingV1, SignedNodeRightsDecisionV1, ThresholdV1, VerifiedKeyReleaseRequestV1, - VerifiedRightsRequestV1, WalletAddress, WalletSignedRightsRequestV1, CUSTODY_HPKE_SUITE_ID_V1, + WalletAddress, WalletSignedRightsRequestV1, CUSTODY_HPKE_SUITE_ID_V1, }; use crate::{ @@ -124,65 +124,50 @@ fn binding_for_wallet_with_envelope( .unwrap() } -fn binding_for_wallet(wallet: WalletAddress) -> ProtectedContentBindingV1 { - binding_for_wallet_with_envelope(wallet, &provisioned_envelope()) -} - -fn signed_rights_request_with_suite(suite: &str) -> WalletSignedRightsRequestV1 { - let wallet = wallet(7); - let request = RightsRequestV1::new( - binding_for_wallet(wallet), - RightsActionV1::View, - recipient_identity_with_suite(0x30, suite), - NOW, - NOW + 180, - ReplayNonce16::new([0x55; 16]), - ) - .unwrap(); - let key = WalletSigningKey::from_slice(&[7; 32]).unwrap(); - let (signature, recovery_id) = key - .sign_prehash_recoverable(ðereum_signed_message_hash( - &request.canonical_bytes().unwrap(), - )) - .unwrap(); - let mut signature_bytes = signature.to_bytes().to_vec(); - signature_bytes.push(recovery_id.to_byte()); - WalletSignedRightsRequestV1::new(request, signature_bytes).unwrap() -} - -fn verified_rights_request_with_suite(suite: &str) -> VerifiedRightsRequestV1 { - let signed = signed_rights_request_with_suite(suite); - let context = RightsVerificationContextV1::new( - signed.request().binding().clone(), - signed.request().action(), - signed.request().recipient().clone(), - NOW + 1, - ); - signed - .verify(&context, &mut TestReplayClaims::default()) - .unwrap() -} - pub(crate) fn verified_release_request() -> VerifiedKeyReleaseRequestV1 { - verified_release_request_with_suite(CUSTODY_HPKE_SUITE_ID_V1) + verified_release_request_for_envelope_with_suite_and_recipient_seed( + &provisioned_envelope(), + CUSTODY_HPKE_SUITE_ID_V1, + 0x30, + ) } pub(crate) fn verified_release_request_with_suite(suite: &str) -> VerifiedKeyReleaseRequestV1 { - verified_release_request_for_envelope_with_suite(&provisioned_envelope(), suite) + verified_release_request_for_envelope_with_suite_and_recipient_seed( + &provisioned_envelope(), + suite, + 0x30, + ) } pub(crate) fn verified_release_request_for_envelope( envelope: &CustodyEnvelopeV1, ) -> VerifiedKeyReleaseRequestV1 { - verified_release_request_for_envelope_with_suite(envelope, CUSTODY_HPKE_SUITE_ID_V1) + verified_release_request_for_envelope_with_suite_and_recipient_seed( + envelope, + CUSTODY_HPKE_SUITE_ID_V1, + 0x30, + ) +} + +pub(crate) fn verified_release_request_for_envelope_and_recipient_seed( + envelope: &CustodyEnvelopeV1, + recipient_seed: u8, +) -> VerifiedKeyReleaseRequestV1 { + verified_release_request_for_envelope_with_suite_and_recipient_seed( + envelope, + CUSTODY_HPKE_SUITE_ID_V1, + recipient_seed, + ) } -fn verified_release_request_for_envelope_with_suite( +fn verified_release_request_for_envelope_with_suite_and_recipient_seed( envelope: &CustodyEnvelopeV1, suite: &str, + recipient_seed: u8, ) -> VerifiedKeyReleaseRequestV1 { let rights = { - let signed = signed_rights_request_with_suite_for_envelope(suite, envelope); + let signed = signed_rights_request_with_suite_for_envelope(suite, envelope, recipient_seed); let context = RightsVerificationContextV1::new( signed.request().binding().clone(), signed.request().action(), @@ -210,12 +195,13 @@ fn verified_release_request_for_envelope_with_suite( fn signed_rights_request_with_suite_for_envelope( suite: &str, envelope: &CustodyEnvelopeV1, + recipient_seed: u8, ) -> WalletSignedRightsRequestV1 { let wallet = wallet(7); let request = RightsRequestV1::new( binding_for_wallet_with_envelope(wallet, envelope), RightsActionV1::View, - recipient_identity_with_suite(0x30, suite), + recipient_identity_with_suite(recipient_seed, suite), NOW, NOW + 180, ReplayNonce16::new([0x55; 16]), diff --git a/state.md b/state.md index 8a5a32859..f71298293 100644 --- a/state.md +++ b/state.md @@ -1,6 +1,6 @@ # State -Last updated: 2026-08-13 UTC +Last updated: 2026-08-14 UTC This file records public-safe current truth for released 0.6.0 and active unpublished work. Historical @@ -20,22 +20,29 @@ the public repository. - `codex/post-0.6-consolidation` remains an unpublished local integration line for additional collaboration and UI work. It has no upstream and is not installed or published product truth. -- `feat/protected-content-contracts` is an unpublished source-only contract - branch stacked on `origin/review/collaboration-candidate`. It adds the - canonical `elastos-protected-content-contracts` crate, the related - documentation, and the shared strict DID/Carrier codec correction required by - that contract surface. It does not integrate Runtime orchestration, provider - replacement, custody, threshold reconstruction, recipient encryption proof, +- `origin/feat/protected-content-contracts` is the published source-only + protected-content contract branch stacked on + `origin/review/collaboration-candidate`. It adds the canonical + `elastos-protected-content-contracts` crate, the related documentation, and + the shared strict DID/Carrier codec correction required by that contract + surface. It does not integrate Runtime orchestration, provider replacement, decryption, playback, installation, or deployment. -- Released 0.6 and the parent collaboration source retain the older +- `feat/protected-content-custody` is an unpublished local child branch stacked + on `origin/feat/protected-content-contracts`. It adds the source-only + `elastos-protected-content-custody` crate plus custody-envelope provisioning, + recipient-sealed node release, and recipient-side threshold reconstruction + for new content. It does not integrate Runtime/provider/Carrier + orchestration, durable replay storage, recipient key-possession proof, + decrypt/render product flows, installation, or deployment. +- Released 0.6 and the published collaboration review stack retain the older provisional `elastos_common::protected_content` DTOs plus fail-closed `drm-provider`, `rights-provider`, `key-provider`, and `decrypt-provider` capsules. That surface does not consume or prove the new v1 contract. Installed-target truth requires separate target evidence. -- An independent branch-local source/contract review of - `feat/protected-content-contracts` completed with no code findings after the - strict DID codec and Carrier codec consolidation. This is not an external - cryptographic audit or production security approval. +- An independent branch-local source/contract review of the published + `origin/feat/protected-content-contracts` branch completed with no code + findings after the strict DID codec and Carrier codec consolidation. This is + not an external cryptographic audit or production security approval. - The collaboration review stack adds Runtime-backed People/Chat collaboration and selected shell UI work. The source boundary is complete for review: Profile authority, Runtime lifecycle, Carrier routing, People/Chat From 3e3caf2207580500c9f25c0ba9b1e3d8ba80d38a Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Fri, 14 Aug 2026 11:48:39 -0500 Subject: [PATCH 04/15] fix(protected-content): reject invalid X25519 contract keys --- elastos/Cargo.lock | 1 + .../Cargo.toml | 1 + .../src/custody_envelope.rs | 86 +++++++++++++++---- .../src/provision.rs | 29 +++---- .../src/release.rs | 29 ++----- .../src/secrets.rs | 32 +++++++ 6 files changed, 119 insertions(+), 59 deletions(-) diff --git a/elastos/Cargo.lock b/elastos/Cargo.lock index e69c38f78..aac9a0fe8 100644 --- a/elastos/Cargo.lock +++ b/elastos/Cargo.lock @@ -1646,6 +1646,7 @@ dependencies = [ name = "elastos-protected-content-contracts" version = "0.6.0" dependencies = [ + "curve25519-dalek 5.0.0", "ed25519-dalek 2.2.0", "elastos-auth", "elastos-identity", diff --git a/elastos/crates/elastos-protected-content-contracts/Cargo.toml b/elastos/crates/elastos-protected-content-contracts/Cargo.toml index 918d053f9..c1bbf7abb 100644 --- a/elastos/crates/elastos-protected-content-contracts/Cargo.toml +++ b/elastos/crates/elastos-protected-content-contracts/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true description = "Canonical source-only protected-content v1 contracts" [dependencies] +curve25519-dalek = { version = "5.0", default-features = false } ed25519-dalek = "2.1" elastos-auth = { path = "../elastos-auth" } elastos-identity = { path = "../elastos-identity" } diff --git a/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs b/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs index f05d69f9a..641e37b87 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs @@ -1,3 +1,5 @@ +use curve25519_dalek::{constants::X25519_LOW_ORDER_POINTS, montgomery::MontgomeryPoint}; + use crate::canonical::{CanonicalBody, ContractError, Decoder, Encoder}; use crate::{ CanonicalContract, Digest32, EncryptedContentIdentityV1, KeyEnvelopeIdentityV1, NodePublicKey, @@ -306,6 +308,7 @@ impl CanonicalBody for HpkeCiphertextV1 { const DOMAIN: &'static str = "elastos.protected-content.hpke-ciphertext/v1"; fn validate(&self) -> Result<(), ContractError> { + validate_canonical_x25519_public_key(self.encapped_key, "hpke_encapped_key")?; Ok(()) } @@ -523,7 +526,11 @@ fn validate_canonical_x25519_public_key( bytes: [u8; 32], field: &'static str, ) -> Result<(), ContractError> { - if bytes == [0; 32] || bytes[31] & 0x80 != 0 || le_bytes_ge(bytes, X25519_MODULUS) { + if bytes[31] & 0x80 != 0 || le_bytes_ge(bytes, X25519_MODULUS) { + return Err(ContractError::InvalidField(field)); + } + let point = MontgomeryPoint(bytes); + if X25519_LOW_ORDER_POINTS.iter().any(|low_order| point == *low_order) { return Err(ContractError::InvalidField(field)); } Ok(()) @@ -540,8 +547,12 @@ fn le_bytes_ge(lhs: [u8; 32], rhs: [u8; 32]) -> bool { #[cfg(test)] mod tests { + use curve25519_dalek::{ + constants::X25519_LOW_ORDER_POINTS, + montgomery::MontgomeryPoint, + }; use ed25519_dalek::SigningKey; - use hex::decode; + use hex::encode; use super::*; @@ -555,9 +566,7 @@ mod tests { } fn custody_public_key(seed: u8) -> NodeCustodyPublicKeyV1 { - let mut bytes = [0u8; 32]; - bytes[0] = seed; - NodeCustodyPublicKeyV1::new(bytes).unwrap() + NodeCustodyPublicKeyV1::new(valid_x25519_public_key_bytes(seed)).unwrap() } fn node(seed: u8, coordinate: u8) -> CustodyNodeIdentityV1 { @@ -583,13 +592,16 @@ mod tests { } fn stored_share(seed: u8) -> HpkeCiphertextV1 { - let mut encapped_key = [0u8; HPKE_ENCAPPED_KEY_BYTES]; - encapped_key.fill(seed); + let encapped_key = valid_x25519_public_key_bytes(seed.wrapping_add(0x40)); let mut ciphertext = [0u8; HPKE_SEALED_SHARE_BYTES]; ciphertext.fill(seed.wrapping_add(0x40)); HpkeCiphertextV1::new(encapped_key, ciphertext).unwrap() } + fn valid_x25519_public_key_bytes(seed: u8) -> [u8; 32] { + MontgomeryPoint::mul_base_clamped([seed; 32]).to_bytes() + } + fn recipient(seed: u8) -> RecipientKeyIdentityV1 { RecipientKeyIdentityV1::new(CUSTODY_HPKE_SUITE_ID_V1, digest(seed)).unwrap() } @@ -628,11 +640,9 @@ mod tests { let decoded = CustodyEnvelopeV1::from_canonical_bytes(&canonical).unwrap(); assert_eq!(decoded, envelope); - let expected_hash = - decode("0248dd5145d55696c6c8502f142fe614db027445d0d72887474d4a26dcd7a0b7").unwrap(); assert_eq!( - envelope.canonical_hash().unwrap().as_bytes(), - expected_hash.as_slice() + encode(envelope.canonical_hash().unwrap().as_bytes()), + "059072cd112a899827770607fe96268a8338d700db49fda12661364429d8a590" ); let key_envelope = envelope.key_envelope_identity().unwrap(); @@ -688,23 +698,36 @@ mod tests { } #[test] - fn custody_public_key_rejects_zero_and_noncanonical_alias_bytes() { + fn custody_public_key_rejects_low_order_noncanonical_and_high_bit_alias_bytes() { + for low_order in X25519_LOW_ORDER_POINTS.iter() { + assert_eq!( + NodeCustodyPublicKeyV1::new(low_order.to_bytes()), + Err(ContractError::InvalidField("node_custody_public_key")) + ); + } assert_eq!( - NodeCustodyPublicKeyV1::new([0; 32]), + NodeCustodyPublicKeyV1::new(X25519_MODULUS), Err(ContractError::InvalidField("node_custody_public_key")) ); + let mut p_plus_one = X25519_MODULUS; + p_plus_one[0] = p_plus_one[0].wrapping_add(1); assert_eq!( - NodeCustodyPublicKeyV1::new(X25519_MODULUS), + NodeCustodyPublicKeyV1::new(p_plus_one), Err(ContractError::InvalidField("node_custody_public_key")) ); - let mut aliased = X25519_MODULUS; - aliased[0] = aliased[0].wrapping_add(1); + let mut high_bit_alias = valid_x25519_public_key_bytes(0x51); + high_bit_alias[31] |= 0x80; assert_eq!( - NodeCustodyPublicKeyV1::new(aliased), + NodeCustodyPublicKeyV1::new(high_bit_alias), Err(ContractError::InvalidField("node_custody_public_key")) ); } + #[test] + fn custody_public_key_accepts_generated_valid_bytes() { + assert!(NodeCustodyPublicKeyV1::new(valid_x25519_public_key_bytes(0x41)).is_ok()); + } + #[test] fn custody_envelope_rejects_wrong_share_count_and_noncanonical_lengths() { let manifest = manifest(); @@ -720,6 +743,35 @@ mod tests { ); } + #[test] + fn hpke_ciphertext_rejects_invalid_encapped_keys_at_constructor_and_decode() { + let ciphertext = [0x55; HPKE_SEALED_SHARE_BYTES]; + assert_eq!( + HpkeCiphertextV1::new([0; HPKE_ENCAPPED_KEY_BYTES], ciphertext), + Err(ContractError::InvalidField("hpke_encapped_key")) + ); + assert_eq!( + HpkeCiphertextV1::new(X25519_MODULUS, ciphertext), + Err(ContractError::InvalidField("hpke_encapped_key")) + ); + let mut high_bit_alias = valid_x25519_public_key_bytes(0x61); + high_bit_alias[31] |= 0x80; + assert_eq!( + HpkeCiphertextV1::new(high_bit_alias, ciphertext), + Err(ContractError::InvalidField("hpke_encapped_key")) + ); + + let valid = HpkeCiphertextV1::new(valid_x25519_public_key_bytes(0x62), ciphertext).unwrap(); + let mut canonical = valid.canonical_bytes().unwrap(); + let offset = ::DOMAIN.len() + 1; + canonical[offset..offset + HPKE_ENCAPPED_KEY_BYTES] + .copy_from_slice(X25519_LOW_ORDER_POINTS[2].as_bytes()); + assert_eq!( + HpkeCiphertextV1::from_canonical_bytes(&canonical), + Err(ContractError::InvalidField("hpke_encapped_key")) + ); + } + #[test] fn envelope_identity_changes_when_bound_fields_mutate() { let envelope = CustodyEnvelopeV1::new( diff --git a/elastos/crates/elastos-protected-content-custody/src/provision.rs b/elastos/crates/elastos-protected-content-custody/src/provision.rs index 0726c89d4..f487f7b63 100644 --- a/elastos/crates/elastos-protected-content-custody/src/provision.rs +++ b/elastos/crates/elastos-protected-content-custody/src/provision.rs @@ -190,23 +190,16 @@ mod tests { } #[test] - fn provision_rejects_low_order_node_custody_public_key_at_hpke_boundary() { - let low_order = { - let mut bytes = [0u8; 32]; - bytes[0] = 1; - NodeCustodyPublicKeyV1::new(bytes).unwrap() - }; - let err = provision_custody_envelope( - EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(), - &content_key(), - ThresholdV1::new(2, 3).unwrap(), - vec![ - (crate::test_support::node_public_key(1), low_order), - custody_nodes()[1], - custody_nodes()[2], - ], - ) - .unwrap_err(); - assert!(matches!(err, CustodyError::Hpke(_))); + fn contract_rejects_low_order_node_custody_public_key_before_provision() { + let mut low_order = [0u8; 32]; + low_order[0] = 1; + assert_eq!( + NodeCustodyPublicKeyV1::new(low_order), + Err( + elastos_protected_content_contracts::ContractError::InvalidField( + "node_custody_public_key" + ) + ) + ); } } diff --git a/elastos/crates/elastos-protected-content-custody/src/release.rs b/elastos/crates/elastos-protected-content-custody/src/release.rs index a388a40fc..5f0aeca7a 100644 --- a/elastos/crates/elastos-protected-content-custody/src/release.rs +++ b/elastos/crates/elastos-protected-content-custody/src/release.rs @@ -149,7 +149,7 @@ mod tests { verified_release_request_with_suite, }; use elastos_protected_content_contracts::{ - KeyReleaseError, RightsError, RIGHTS_CLOCK_SKEW_SECS, + ContractError, KeyReleaseError, RightsError, RIGHTS_CLOCK_SKEW_SECS, }; fn node_share_index(envelope: &CustodyEnvelopeV1, seed: u8) -> usize { @@ -395,39 +395,20 @@ mod tests { } #[test] - fn release_rejects_noncanonical_encapped_key_before_hpke_open() { + fn release_contract_rejects_noncanonical_encapped_key_before_hpke_open() { let envelope = provisioned_envelope(); - let mut stored_shares = envelope.stored_shares().to_vec(); let index = node_share_index(&envelope, 1); let noncanonical = [ 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, ]; - stored_shares[index] = elastos_protected_content_contracts::HpkeCiphertextV1::new( + let err = elastos_protected_content_contracts::HpkeCiphertextV1::new( noncanonical, - *stored_shares[index].ciphertext(), - ) - .unwrap(); - let tampered = CustodyEnvelopeV1::new(envelope.manifest().clone(), stored_shares).unwrap(); - let request = verified_release_request_for_envelope(&tampered); - let err = produce_node_contribution_with_rng( - &request, - &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), - &tampered, - &node_signing_key(1), - &node_custody_secret(1), - &recipient_public_key(0x30), - crate::test_support::NOW + 5, - crate::test_support::NOW + 45, - crate::test_support::NOW + 6, - &mut StdRng::from_seed([0x71; 32]), + *envelope.stored_shares()[index].ciphertext(), ) .unwrap_err(); - assert!(matches!( - err, - CustodyError::MalformedShare("hpke_encapped_key") - )); + assert_eq!(err, ContractError::InvalidField("hpke_encapped_key")); } #[test] diff --git a/elastos/crates/elastos-protected-content-custody/src/secrets.rs b/elastos/crates/elastos-protected-content-custody/src/secrets.rs index b57d0963b..9a452fa6b 100644 --- a/elastos/crates/elastos-protected-content-custody/src/secrets.rs +++ b/elastos/crates/elastos-protected-content-custody/src/secrets.rs @@ -168,4 +168,36 @@ mod tests { assert!(!node_debug.contains("22")); assert!(!recipient_debug.contains("33")); } + + #[test] + fn recipient_public_key_rejects_invalid_x25519_bytes() { + let low_order = { + let mut bytes = [0u8; 32]; + bytes[0] = 1; + bytes + }; + let mut high_bit_alias = RecipientSecretKeyV1::from_test_bytes([0x41; 32]) + .public_key() + .unwrap() + .0; + high_bit_alias[31] |= 0x80; + let valid = RecipientSecretKeyV1::from_test_bytes([0x42; 32]) + .public_key() + .unwrap() + .0; + + assert!(matches!( + RecipientPublicKeyV1::new([0; 32]), + Err(CustodyError::BindingMismatch("recipient_public_key")) + )); + assert!(matches!( + RecipientPublicKeyV1::new(low_order), + Err(CustodyError::BindingMismatch("recipient_public_key")) + )); + assert!(matches!( + RecipientPublicKeyV1::new(high_bit_alias), + Err(CustodyError::BindingMismatch("recipient_public_key")) + )); + assert!(RecipientPublicKeyV1::new(valid).is_ok()); + } } From 088e4f7e2616ee4a26863f2e6f88a1c43455985b Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Fri, 14 Aug 2026 12:02:09 -0500 Subject: [PATCH 05/15] fix(protected-content): bind exact threshold reconstruction --- TASKS.md | 33 ++-- docs/PROTECTED_CONTENT.md | 12 +- docs/PROTECTED_CONTENT_CONTRACTS_V1.md | 28 +++- elastos/Cargo.lock | 1 + .../src/authority_tests.rs | 22 +++ .../src/custody_envelope.rs | 54 ++++++- .../src/lib.rs | 5 +- .../src/terminal_receipt.rs | 30 +++- .../Cargo.toml | 1 + .../src/lib.rs | 2 + .../src/provision.rs | 1 + .../src/reconstruct.rs | 146 +++++++++++++++++- .../src/release.rs | 61 ++++++++ .../src/secrets.rs | 22 ++- state.md | 22 ++- 15 files changed, 397 insertions(+), 43 deletions(-) diff --git a/TASKS.md b/TASKS.md index 9b2610ac4..51580929e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -715,15 +715,21 @@ and review items pass. integration. The branch-local source review already closed with no code findings after the shared strict DID codec and Carrier codec consolidation; that was not an external cryptographic audit or production security - approval. Review the canonical codec, domain tags, field bounds, EIP-191 - recovery (`0/1` only), canonical Profile `did:key`, policy identity, - owner-Wallet recovery, Runtime-selected and Wallet-signed recipient binding, - atomic replay keys, node decisions, terminal issuer signatures, nested - authority windows including contribution-to-terminal settlement, shared - canonical Ed25519 validation (including noncanonical-encoding rejection), - custody-envelope AAD domains, pinned HPKE share sealing, GF256 Shamir share - handling, threshold reconstruction zeroization, unique contribution - commitments at threshold settlement, and golden vectors. + approval. An independent AI/model review later found and prompted the local + custody remediation for invalid X25519 contract bytes, exact released + threshold settlement, and post-reconstruction CEK commitment checking; that + review is also not a professional external audit. Review the canonical + codec, domain tags, field bounds, EIP-191 recovery (`0/1` only), canonical + Profile `did:key`, policy identity, owner-Wallet recovery, Runtime-selected + and Wallet-signed recipient binding, atomic replay keys, node decisions, + terminal issuer signatures, nested authority windows including + contribution-to-terminal settlement, shared canonical Ed25519 validation + (including noncanonical-encoding rejection), the stricter local canonical + X25519 contract-key rule, custody-envelope AAD domains, pinned HPKE share + sealing, GF256 Shamir share handling, exact-threshold release settlement, + threshold reconstruction zeroization, manifest-bound reconstructed-key + commitment checking, unique contribution commitments at threshold settlement, + and golden vectors. - [ ] Add a typed protected-content Wallet operation that verifies an externally completed signature, canonicalizes a `27/28` recovery byte to `0/1` after signer verification and before `WalletSignedRightsRequestV1` @@ -737,7 +743,14 @@ and review items pass. source-only `elastos-protected-content-custody` crate now implements new content share provisioning, recipient-sealed node release, and recipient-side threshold reconstruction, but it does not prove operational custody safety, - durable storage/replay, provider wiring, or product integration. + durable storage/replay, provider wiring, or product integration. Its + manifest-bound CEK commitment detects a wrong reconstructed key; it does not + identify the malicious node or add verifiable secret sharing. +- [ ] Add professionally reviewed wrapper-level known-answer coverage for the + pinned HPKE share framing if a grounded upstream vector path becomes + available. The current source keeps tamper and binding tests, but the pinned + upstream `hpke` 0.13 vector set does not cover our exact fixed 32-byte share + wrapper framing. - [ ] Define how Runtime derives the recipient public key from the authenticated Profile and session, proves key possession, and selects an approved encryption suite. The caller must not provide endpoint, device, route, or Carrier diff --git a/docs/PROTECTED_CONTENT.md b/docs/PROTECTED_CONTENT.md index 780ed0aea..22f7b1920 100644 --- a/docs/PROTECTED_CONTENT.md +++ b/docs/PROTECTED_CONTENT.md @@ -121,9 +121,17 @@ The `elastos-protected-content-custody` crate is a source-only helper for new protected content: - It provisions canonical custody envelopes from the reviewed v1 contract. +- It binds each custody manifest to a domain-separated CEK commitment, then + checks that commitment after threshold reconstruction before returning an + opaque content-key wrapper. - It uses one pinned recipient-sealing suite for stored and released shares: RFC 9180 base mode X25519 + HKDF-SHA256 + AES-256-GCM. +- It rejects noncanonical and low-order X25519 contract key bytes as a stricter + local canonical-identity rule before HPKE use. This is stronger than the RFC + 7748 primitive requirement; it is not claimed as an RFC mandate. - It uses GF256 Shamir splitting through `vsss-rs` for new content only. +- Released terminal settlement and recipient reconstruction require exactly the + bound threshold count. Required-plus-one contributions are rejected. - It returns only opaque, redacted secret wrappers; it does not expose a capsule-visible raw-key API. - It is source-only. There are no running custody nodes, Runtime/provider @@ -133,7 +141,9 @@ protected content: This helper does not make a PQ claim. PQ-hybrid custody, dKMS node lifecycle, and product wiring remain future work. The current HPKE dependency is `hpke` 0.13, whose upstream documentation says it has not been formally audited. This -branch therefore makes no external cryptographic audit claim. +branch therefore makes no external cryptographic audit claim. The manifest +commitment only detects that threshold reconstruction produced the wrong key; it +does not identify the malicious node and it is not verifiable secret sharing. ## Provider Boundary diff --git a/docs/PROTECTED_CONTENT_CONTRACTS_V1.md b/docs/PROTECTED_CONTENT_CONTRACTS_V1.md index 62e505fd7..8c5cb8d95 100644 --- a/docs/PROTECTED_CONTENT_CONTRACTS_V1.md +++ b/docs/PROTECTED_CONTENT_CONTRACTS_V1.md @@ -55,6 +55,12 @@ methods, alternate spellings, noncanonical Edwards encodings, and weak Ed25519 public keys fail. Node and receipt-issuer authority uses that same shared validator at construction. +The custody helper also applies a stricter local canonical-identity rule for +X25519 contract bytes than RFC 7748 requires at the primitive boundary. +Contract node custody keys, recipient public keys, and HPKE encapped keys must +use one exact canonical byte encoding and must reject low-order points before +HPKE runs. + ## Canonical wire form JSON serialization is an inspection projection and is never signed or hashed. @@ -156,10 +162,12 @@ opaque bytes. The contract requires that recipient to equal the signed release recipient and authenticates the bytes and commitment. The contract type itself does not implement encryption. The companion source-only `elastos-protected-content-custody` crate implements a pinned HPKE + GF256 -Shamir helper for new-content share release and threshold reconstruction, but -that helper is not yet wired into Runtime/provider/product flows and has not -received an external cryptographic audit. No production confidentiality claim -follows from this contract type alone. +Shamir helper for new-content share release and threshold reconstruction, +including a manifest-bound reconstructed-key commitment check that detects a +wrong reconstructed key. It does not identify the malicious node, it is not +verifiable secret sharing, it is not yet wired into Runtime/provider/product +flows, and it has not received an external cryptographic audit. No production +confidentiality claim follows from this contract type alone. ## Terminal result @@ -172,9 +180,11 @@ The statement names an Ed25519 issuer and is signed. Verification requires the Runtime-selected expected issuer, the verified release request, and the exact set of verified contribution references. Every verified contribution must retain and match the exact release-request hash and recipient. A successful -result requires the bound threshold. Contributions from another request or -recipient cannot be reused. The receipt is audit/result evidence, not a -portable grant that can authorize another release. +result requires exactly the bound threshold. Required-plus-one released +contributions are rejected, not treated as extra success evidence. +Contributions from another request or recipient cannot be reused. The receipt +is audit/result evidence, not a portable grant that can authorize another +release. ## Threat model and fail-closed rules @@ -184,7 +194,9 @@ recipient, expiry, replay, and post-signature mutation. They also reject a forged allow receipt, nonce reuse after changing fields, child lifetime escape, a denied or cross-node decision, cross-request contribution reuse, duplicate node/decision/contribution/commitment evidence, insufficient threshold, -malformed canonical input, and noncanonical Wallet/Profile encodings. +required-plus-one released evidence, malformed canonical input, noncanonical +Wallet/Profile encodings, and noncanonical/low-order X25519 contract key +encodings. These contracts plus the source-only `elastos-protected-content-custody` helper do not solve malicious custody nodes, rights-policy correctness, durable replay diff --git a/elastos/Cargo.lock b/elastos/Cargo.lock index aac9a0fe8..470d30737 100644 --- a/elastos/Cargo.lock +++ b/elastos/Cargo.lock @@ -1671,6 +1671,7 @@ dependencies = [ "rand 0.9.5", "sha2 0.10.9", "sha3", + "subtle", "thiserror 1.0.69", "vsss-rs", "zeroize", diff --git a/elastos/crates/elastos-protected-content-contracts/src/authority_tests.rs b/elastos/crates/elastos-protected-content-contracts/src/authority_tests.rs index d3f21b37d..325c11ffd 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/authority_tests.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/authority_tests.rs @@ -571,6 +571,28 @@ fn terminal_receipt_carries_only_authenticated_hash_references() { .all(|reference| reference.contribution_hash() != Digest32::new([0; 32]))); } +#[test] +fn terminal_receipt_preserves_exact_reference_verification_when_extra_contributions_are_passed() { + let request = verified_release(); + let contributions = vec![ + verified_contribution(&request, 1), + verified_contribution(&request, 2), + ]; + let extra = verified_contribution(&request, 3); + let receipt = terminal_receipt(&request, &contributions, 21); + let issuer = receipt.statement().issuer(); + + assert_eq!( + receipt.verify( + &request, + &[contributions[0].clone(), contributions[1].clone(), extra], + issuer, + NOW + 8, + ), + Err(KeyReleaseError::BindingMismatch("node_contribution_refs")) + ); +} + #[test] fn duplicate_contribution_commitment_cannot_count_toward_threshold() { let request = verified_release(); diff --git a/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs b/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs index 641e37b87..af355ab31 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs @@ -7,6 +7,8 @@ use crate::{ }; pub const CUSTODY_HPKE_SUITE_ID_V1: &str = "hpke-rfc9180-base-x25519-hkdf-sha256-aes256gcm/v1"; +pub const CONTENT_KEY_COMMITMENT_DOMAIN_V1: &[u8] = + b"elastos.protected-content.content-key-commitment/v1"; pub const STORED_SHARE_HPKE_INFO_V1: &[u8] = b"elastos.protected-content.stored-share/v1"; pub const RELEASED_SHARE_HPKE_INFO_V1: &[u8] = b"elastos.protected-content.released-share/v1"; pub const HPKE_ENCAPPED_KEY_BYTES: usize = 32; @@ -140,6 +142,7 @@ impl CanonicalBody for CustodyNodeIdentityV1 { pub struct CustodyEnvelopeManifestV1 { encrypted_content: EncryptedContentIdentityV1, threshold: ThresholdV1, + content_key_commitment: Digest32, nodes: Vec, } @@ -147,6 +150,7 @@ impl CustodyEnvelopeManifestV1 { pub fn new( encrypted_content: EncryptedContentIdentityV1, threshold: ThresholdV1, + content_key_commitment: Digest32, mut nodes: Vec, ) -> Result { nodes.sort_unstable_by_key(|node| node.node_public_key()); @@ -159,6 +163,7 @@ impl CustodyEnvelopeManifestV1 { let value = Self { encrypted_content, threshold, + content_key_commitment, nodes, }; value.validate()?; @@ -173,6 +178,10 @@ impl CustodyEnvelopeManifestV1 { self.threshold } + pub const fn content_key_commitment(&self) -> Digest32 { + self.content_key_commitment + } + pub fn nodes(&self) -> &[CustodyNodeIdentityV1] { &self.nodes } @@ -247,6 +256,7 @@ impl CanonicalBody for CustodyEnvelopeManifestV1 { fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { encoder.nested(&self.encrypted_content)?; self.threshold.encode(encoder); + encoder.fixed(self.content_key_commitment.as_bytes()); encoder.u8(self.nodes.len() as u8); for node in &self.nodes { encoder.nested(node)?; @@ -257,12 +267,13 @@ impl CanonicalBody for CustodyEnvelopeManifestV1 { fn decode_fields(decoder: &mut Decoder<'_>) -> Result { let encrypted_content = decoder.nested("encrypted_content")?; let threshold = ThresholdV1::decode(decoder)?; + let content_key_commitment = Digest32::new(decoder.fixed()?); let count = usize::from(decoder.u8()?); let mut nodes = Vec::with_capacity(count); for _ in 0..count { nodes.push(decoder.nested("custody_node")?); } - Self::new(encrypted_content, threshold, nodes) + Self::new(encrypted_content, threshold, content_key_commitment, nodes) } } @@ -526,11 +537,15 @@ fn validate_canonical_x25519_public_key( bytes: [u8; 32], field: &'static str, ) -> Result<(), ContractError> { + // RFC 7748 X25519 accepts non-canonical u-coordinate encodings at the + // primitive boundary. This contract intentionally applies a stricter local + // canonical-identity rule for stored public keys and HPKE encapped keys: + // exact canonical bytes only, plus low-order rejection before HPKE use. if bytes[31] & 0x80 != 0 || le_bytes_ge(bytes, X25519_MODULUS) { return Err(ContractError::InvalidField(field)); } let point = MontgomeryPoint(bytes); - if X25519_LOW_ORDER_POINTS.iter().any(|low_order| point == *low_order) { + if X25519_LOW_ORDER_POINTS.contains(&point) { return Err(ContractError::InvalidField(field)); } Ok(()) @@ -547,10 +562,7 @@ fn le_bytes_ge(lhs: [u8; 32], rhs: [u8; 32]) -> bool { #[cfg(test)] mod tests { - use curve25519_dalek::{ - constants::X25519_LOW_ORDER_POINTS, - montgomery::MontgomeryPoint, - }; + use curve25519_dalek::{constants::X25519_LOW_ORDER_POINTS, montgomery::MontgomeryPoint}; use ed25519_dalek::SigningKey; use hex::encode; @@ -582,10 +594,15 @@ mod tests { EncryptedContentIdentityV1::new(digest(byte), 4096).unwrap() } + fn content_key_commitment(seed: u8) -> Digest32 { + digest(seed) + } + fn manifest() -> CustodyEnvelopeManifestV1 { CustodyEnvelopeManifestV1::new( encrypted_content(0x11), ThresholdV1::new(2, 3).unwrap(), + content_key_commitment(0x19), vec![node(3, 3), node(1, 1), node(2, 2)], ) .unwrap() @@ -642,7 +659,7 @@ mod tests { assert_eq!( encode(envelope.canonical_hash().unwrap().as_bytes()), - "059072cd112a899827770607fe96268a8338d700db49fda12661364429d8a590" + "158bbfdde78acc999faffc5c668f3dc674f08fe07e6f04c414aa9722f6b447ea" ); let key_envelope = envelope.key_envelope_identity().unwrap(); @@ -673,6 +690,7 @@ mod tests { CustodyEnvelopeManifestV1::new( encrypted_content(0x11), threshold, + content_key_commitment(0x19), vec![ node(1, 1), CustodyNodeIdentityV1::new( @@ -691,6 +709,7 @@ mod tests { CustodyEnvelopeManifestV1::new( encrypted_content(0x11), threshold, + content_key_commitment(0x19), vec![node(1, 1), node(2, 2)], ), Err(ContractError::InvalidField("custody_manifest.nodes")) @@ -783,6 +802,7 @@ mod tests { CustodyEnvelopeManifestV1::new( encrypted_content(0x12), ThresholdV1::new(2, 3).unwrap(), + content_key_commitment(0x19), vec![node(1, 1), node(2, 2), node(3, 3)], ) .unwrap(), @@ -793,6 +813,7 @@ mod tests { CustodyEnvelopeManifestV1::new( encrypted_content(0x11), ThresholdV1::new(3, 3).unwrap(), + content_key_commitment(0x19), vec![node(1, 1), node(2, 2), node(3, 3)], ) .unwrap(), @@ -804,10 +825,22 @@ mod tests { vec![stored_share(1), stored_share(2), stored_share(4)], ) .unwrap(); + let changed_commitment = CustodyEnvelopeV1::new( + CustodyEnvelopeManifestV1::new( + encrypted_content(0x11), + ThresholdV1::new(2, 3).unwrap(), + content_key_commitment(0x1a), + vec![node(1, 1), node(2, 2), node(3, 3)], + ) + .unwrap(), + vec![stored_share(1), stored_share(2), stored_share(3)], + ) + .unwrap(); let changed_custody_key = CustodyEnvelopeV1::new( CustodyEnvelopeManifestV1::new( encrypted_content(0x11), ThresholdV1::new(2, 3).unwrap(), + content_key_commitment(0x19), vec![ node(1, 1), CustodyNodeIdentityV1::new( @@ -845,6 +878,13 @@ mod tests { .unwrap() .envelope_sha256() ); + assert_ne!( + envelope.key_envelope_identity().unwrap().envelope_sha256(), + changed_commitment + .key_envelope_identity() + .unwrap() + .envelope_sha256() + ); assert_ne!( envelope.key_envelope_identity().unwrap().envelope_sha256(), changed_custody_key diff --git a/elastos/crates/elastos-protected-content-contracts/src/lib.rs b/elastos/crates/elastos-protected-content-contracts/src/lib.rs index a2f64acba..e20c7d206 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/lib.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/lib.rs @@ -24,8 +24,9 @@ mod test_support; pub use canonical::{CanonicalContract, ContractError}; pub use custody_envelope::{ CustodyEnvelopeManifestV1, CustodyEnvelopeV1, CustodyNodeIdentityV1, HpkeCiphertextV1, - NodeCustodyPublicKeyV1, ShareCoordinateV1, CUSTODY_HPKE_SUITE_ID_V1, HPKE_ENCAPPED_KEY_BYTES, - HPKE_SEALED_SHARE_BYTES, RELEASED_SHARE_HPKE_INFO_V1, STORED_SHARE_HPKE_INFO_V1, + NodeCustodyPublicKeyV1, ShareCoordinateV1, CONTENT_KEY_COMMITMENT_DOMAIN_V1, + CUSTODY_HPKE_SUITE_ID_V1, HPKE_ENCAPPED_KEY_BYTES, HPKE_SEALED_SHARE_BYTES, + RELEASED_SHARE_HPKE_INFO_V1, STORED_SHARE_HPKE_INFO_V1, }; pub use identity::{ Digest32, EncryptedContentIdentityV1, KeyEnvelopeIdentityV1, ProfileIdentityV1, diff --git a/elastos/crates/elastos-protected-content-contracts/src/terminal_receipt.rs b/elastos/crates/elastos-protected-content-contracts/src/terminal_receipt.rs index f5c952fa0..947b05a01 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/terminal_receipt.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/terminal_receipt.rs @@ -171,7 +171,7 @@ impl CanonicalBody for TerminalReceiptStatementV1 { } KeyReleaseOutcomeV1::Released if self.contribution_refs.len() - < usize::from(self.binding.key_envelope().threshold().required()) => + != usize::from(self.binding.key_envelope().threshold().required()) => { Err(ContractError::InvalidField("release_threshold")) } @@ -304,11 +304,14 @@ impl SignedTerminalReceiptV1 { if expected_refs != statement.contribution_refs { return Err(KeyReleaseError::BindingMismatch("node_contribution_refs")); } - if statement.outcome == KeyReleaseOutcomeV1::Released - && expected_refs.len() - < usize::from(request.binding().key_envelope().threshold().required()) - { - return Err(KeyReleaseError::InsufficientContributions); + if statement.outcome == KeyReleaseOutcomeV1::Released { + let required = usize::from(request.binding().key_envelope().threshold().required()); + if expected_refs.len() < required { + return Err(KeyReleaseError::InsufficientContributions); + } + if expected_refs.len() != required { + return Err(KeyReleaseError::BindingMismatch("release_threshold")); + } } let key = validate_ed25519_public_key(*statement.issuer.as_bytes(), "terminal_receipt_issuer") @@ -400,6 +403,21 @@ mod tests { assert_eq!(err, ContractError::InvalidField("release_threshold")); } + #[test] + fn terminal_receipt_rejects_more_than_required_refs_for_released_outcome() { + let err = statement_with_refs( + vec![ + reference(1, 0x31, 0x41, 0x51), + reference(2, 0x32, 0x42, 0x52), + reference(3, 0x33, 0x43, 0x53), + ], + KeyReleaseOutcomeV1::Released, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::InvalidField("release_threshold")); + } + #[test] fn terminal_receipt_rejects_duplicate_node_reference() { let err = statement_with_refs( diff --git a/elastos/crates/elastos-protected-content-custody/Cargo.toml b/elastos/crates/elastos-protected-content-custody/Cargo.toml index 0ec8bb0fe..db15483db 100644 --- a/elastos/crates/elastos-protected-content-custody/Cargo.toml +++ b/elastos/crates/elastos-protected-content-custody/Cargo.toml @@ -13,6 +13,7 @@ hpke = { version = "0.13.0", default-features = false, features = ["std", "x2551 rand09 = { package = "rand", version = "0.9.5" } rand10 = { package = "rand", version = "0.10" } sha2 = "0.10" +subtle = "2.6" thiserror = "1.0" vsss-rs = { version = "6.0.1", default-features = false, features = ["std", "zeroize"] } zeroize = "1.9" diff --git a/elastos/crates/elastos-protected-content-custody/src/lib.rs b/elastos/crates/elastos-protected-content-custody/src/lib.rs index a261707fa..054d9f69f 100644 --- a/elastos/crates/elastos-protected-content-custody/src/lib.rs +++ b/elastos/crates/elastos-protected-content-custody/src/lib.rs @@ -37,6 +37,8 @@ pub enum CustodyError { BindingMismatch(&'static str), #[error("malformed custody share: {0}")] MalformedShare(&'static str), + #[error("reconstructed content key does not match the envelope commitment")] + ContentKeyCommitmentMismatch, #[error("required cryptographic randomness is unavailable")] RandomnessUnavailable, } diff --git a/elastos/crates/elastos-protected-content-custody/src/provision.rs b/elastos/crates/elastos-protected-content-custody/src/provision.rs index f487f7b63..b79a2b84c 100644 --- a/elastos/crates/elastos-protected-content-custody/src/provision.rs +++ b/elastos/crates/elastos-protected-content-custody/src/provision.rs @@ -50,6 +50,7 @@ where let manifest = CustodyEnvelopeManifestV1::new( encrypted_content, threshold, + content_key.commitment(), node_keys .into_iter() .enumerate() diff --git a/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs b/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs index f3c109ea6..f5ae2d3dd 100644 --- a/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs +++ b/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs @@ -37,6 +37,9 @@ pub fn reconstruct_content_key( KeyReleaseError::InsufficientContributions, )); } + if contributions.len() != required { + return Err(CustodyError::BindingMismatch("release_threshold")); + } let node_set = envelope.manifest().node_set()?; let mut seen_nodes = HashSet::with_capacity(contributions.len()); @@ -98,7 +101,11 @@ pub fn reconstruct_content_key( let mut content_key = Zeroizing::new([0u8; CONTENT_KEY_BYTES]); content_key.copy_from_slice(&reconstructed[..]); - Ok(ContentEncryptionKeyV1::from_guarded_bytes(content_key)) + let content_key = ContentEncryptionKeyV1::from_guarded_bytes(content_key); + if !content_key.matches_commitment(envelope.manifest().content_key_commitment()) { + return Err(CustodyError::ContentKeyCommitmentMismatch); + } + Ok(content_key) } fn open_released_share( @@ -144,6 +151,7 @@ mod tests { }; use crate::{ + hpke_helpers::{open_share, seal_share}, provision::provision_custody_envelope_with_rng, release::produce_node_contribution_with_rng, test_support::{ @@ -265,6 +273,78 @@ mod tests { SignedNodeContributionV1::new(statement, signature).unwrap() } + fn malicious_wrong_share_contribution( + request: &VerifiedKeyReleaseRequestV1, + envelope: &CustodyEnvelopeV1, + node_seed: u8, + recipient_seed: u8, + hpke_seed: u8, + ) -> SignedNodeContributionV1 { + let valid = released_contribution(request, envelope, node_seed, recipient_seed, hpke_seed); + let verified = valid + .verify(request, &envelope.manifest().node_set().unwrap(), NOW + 7) + .unwrap(); + let node_entry = envelope + .manifest() + .node(verified.node_public_key()) + .unwrap() + .clone(); + let released_aad = node_entry + .released_share_aad_bytes( + request.request_hash(), + request.binding(), + verified.decision_hash(), + request.recipient(), + ) + .unwrap(); + let original = decode_ciphertext_bytes( + valid + .statement() + .recipient_sealed_contribution() + .sealed_bytes(), + ) + .unwrap(); + let mut plaintext = open_share( + &original, + recipient_secret(recipient_seed).secret_bytes(), + elastos_protected_content_contracts::RELEASED_SHARE_HPKE_INFO_V1, + &released_aad, + ) + .unwrap(); + plaintext[0] ^= 0x01; + let resealed = seal_share( + recipient_public_key(recipient_seed).as_bytes(), + elastos_protected_content_contracts::RELEASED_SHARE_HPKE_INFO_V1, + &released_aad, + &plaintext, + &mut StdRng::from_seed([hpke_seed.wrapping_add(0x30); 32]), + ) + .unwrap(); + let sealed = RecipientSealedContributionV1::new( + request.recipient().clone(), + resealed.canonical_bytes().unwrap(), + ) + .unwrap(); + let statement = NodeContributionStatementV1::new( + request.request_hash(), + request.binding().clone(), + signed_node_decision( + request, + node_seed, + elastos_protected_content_contracts::RightsDecisionV1::Allowed, + ), + sealed, + NOW + 5, + NOW + 45, + ) + .unwrap(); + let signature = node_signing_key(node_seed) + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(); + SignedNodeContributionV1::new(statement, signature).unwrap() + } + fn alternate_envelope() -> CustodyEnvelopeV1 { provision_custody_envelope_with_rng( EncryptedContentIdentityV1::new(digest(0x99), 4096).unwrap(), @@ -335,6 +415,40 @@ mod tests { )); } + #[test] + fn required_plus_one_shares_fail_before_open() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let contributions = vec![ + released_contribution(&request, &envelope, 1, 0x30, 0x71), + released_contribution(&request, &envelope, 2, 0x30, 0x72), + released_contribution(&request, &envelope, 3, 0x30, 0x73), + ]; + let terminal = terminal_receipt( + &request, + &envelope, + &contributions[..2], + 0x21, + KeyReleaseOutcomeV1::Released, + ); + + let err = reconstruct_content_key( + &request, + &envelope, + &contributions, + &terminal, + terminal.statement().issuer(), + &recipient_secret(0x30), + NOW + 8, + ) + .unwrap_err(); + + assert!(matches!( + err, + CustodyError::BindingMismatch("release_threshold") + )); + } + #[test] fn duplicate_node_contributions_fail_before_open() { let request = verified_release_request(); @@ -457,6 +571,36 @@ mod tests { assert!(matches!(err, CustodyError::Hpke(_))); } + #[test] + fn malicious_signed_wrong_share_is_rejected_by_content_key_commitment() { + let request = verified_release_request(); + let envelope = provisioned_envelope(); + let contributions = vec![ + released_contribution(&request, &envelope, 1, 0x30, 0x71), + malicious_wrong_share_contribution(&request, &envelope, 2, 0x30, 0x72), + ]; + let terminal = terminal_receipt( + &request, + &envelope, + &contributions, + 0x21, + KeyReleaseOutcomeV1::Released, + ); + + let err = reconstruct_content_key( + &request, + &envelope, + &contributions, + &terminal, + terminal.statement().issuer(), + &recipient_secret(0x30), + NOW + 8, + ) + .unwrap_err(); + + assert!(matches!(err, CustodyError::ContentKeyCommitmentMismatch)); + } + #[test] fn expiry_is_rejected() { let request = verified_release_request(); diff --git a/elastos/crates/elastos-protected-content-custody/src/release.rs b/elastos/crates/elastos-protected-content-custody/src/release.rs index 5f0aeca7a..926fd7210 100644 --- a/elastos/crates/elastos-protected-content-custody/src/release.rs +++ b/elastos/crates/elastos-protected-content-custody/src/release.rs @@ -143,6 +143,7 @@ mod tests { use rand09::{rngs::StdRng, SeedableRng as _}; use super::*; + use crate::hpke_helpers::{open_share, seal_share}; use crate::test_support::{ node_custody_secret, node_signing_key, provisioned_envelope, recipient_public_key, signed_node_decision, verified_release_request, verified_release_request_for_envelope, @@ -361,6 +362,66 @@ mod tests { assert!(matches!(err, CustodyError::Hpke(_))); } + #[test] + fn release_reaches_hpke_failure_for_request_bound_to_wrong_node_aad_stored_share() { + let envelope = provisioned_envelope(); + let node_public_key = elastos_protected_content_contracts::NodePublicKey::new( + node_signing_key(1).verifying_key().to_bytes(), + ) + .unwrap(); + let stored_share = envelope.stored_share_for_node(node_public_key).unwrap(); + let correct_aad = envelope + .manifest() + .stored_share_aad_bytes_for_node(node_public_key) + .unwrap(); + let wrong_aad = envelope + .manifest() + .stored_share_aad_bytes_for_node( + elastos_protected_content_contracts::NodePublicKey::new( + node_signing_key(2).verifying_key().to_bytes(), + ) + .unwrap(), + ) + .unwrap(); + let plaintext = open_share( + stored_share, + node_custody_secret(1).secret_bytes(), + elastos_protected_content_contracts::STORED_SHARE_HPKE_INFO_V1, + &correct_aad, + ) + .unwrap(); + + let mut stored_shares = envelope.stored_shares().to_vec(); + let index = node_share_index(&envelope, 1); + stored_shares[index] = seal_share( + envelope.manifest().nodes()[index] + .custody_public_key() + .as_bytes(), + elastos_protected_content_contracts::STORED_SHARE_HPKE_INFO_V1, + &wrong_aad, + &plaintext, + &mut StdRng::from_seed([0x74; 32]), + ) + .unwrap(); + let tampered = CustodyEnvelopeV1::new(envelope.manifest().clone(), stored_shares).unwrap(); + let request = verified_release_request_for_envelope(&tampered); + + let err = produce_node_contribution_with_rng( + &request, + &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), + &tampered, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + crate::test_support::NOW + 5, + crate::test_support::NOW + 45, + crate::test_support::NOW + 6, + &mut StdRng::from_seed([0x71; 32]), + ) + .unwrap_err(); + assert!(matches!(err, CustodyError::Hpke(_))); + } + #[test] fn release_checks_active_window_before_opening_bound_invalid_ciphertext() { let envelope = provisioned_envelope(); diff --git a/elastos/crates/elastos-protected-content-custody/src/secrets.rs b/elastos/crates/elastos-protected-content-custody/src/secrets.rs index 9a452fa6b..5bed64633 100644 --- a/elastos/crates/elastos-protected-content-custody/src/secrets.rs +++ b/elastos/crates/elastos-protected-content-custody/src/secrets.rs @@ -2,11 +2,12 @@ use ed25519_dalek::SigningKey; use hpke::{kem::Kem as KemTrait, Deserializable, Serializable}; use rand09::{rngs::StdRng, RngCore as _, SeedableRng as _}; use sha2::{Digest as _, Sha256}; +use subtle::ConstantTimeEq; use zeroize::Zeroizing; use elastos_protected_content_contracts::{ Digest32, NodeCustodyPublicKeyV1, NodePublicKey, RecipientKeyIdentityV1, - CUSTODY_HPKE_SUITE_ID_V1, + CONTENT_KEY_COMMITMENT_DOMAIN_V1, CUSTODY_HPKE_SUITE_ID_V1, }; use crate::{hpke_helpers::HpkeKem, CustodyError, CONTENT_KEY_BYTES}; @@ -26,6 +27,17 @@ impl ContentEncryptionKeyV1 { Self(bytes) } + pub(crate) fn commitment(&self) -> Digest32 { + self.with_bytes(content_key_commitment) + } + + pub(crate) fn matches_commitment(&self, expected: Digest32) -> bool { + self.commitment() + .as_bytes() + .ct_eq(expected.as_bytes()) + .into() + } + #[cfg(test)] pub(crate) fn from_test_bytes(bytes: [u8; CONTENT_KEY_BYTES]) -> Self { Self(Zeroizing::new(bytes)) @@ -139,6 +151,14 @@ fn public_key_bytes_from_secret(secret_bytes: &[u8; 32]) -> Result<[u8; 32], Cus Ok(::sk_to_pk(&secret).to_bytes().into()) } +fn content_key_commitment(bytes: &[u8; CONTENT_KEY_BYTES]) -> Digest32 { + let mut hasher = Sha256::new(); + hasher.update(CONTENT_KEY_COMMITMENT_DOMAIN_V1); + hasher.update([0u8]); + hasher.update(bytes); + Digest32::new(hasher.finalize().into()) +} + fn random_bytes() -> Result, CustodyError> { let mut bytes = Zeroizing::new([0u8; N]); StdRng::try_from_os_rng() diff --git a/state.md b/state.md index f71298293..b75019fde 100644 --- a/state.md +++ b/state.md @@ -27,13 +27,17 @@ the public repository. the shared strict DID/Carrier codec correction required by that contract surface. It does not integrate Runtime orchestration, provider replacement, decryption, playback, installation, or deployment. -- `feat/protected-content-custody` is an unpublished local child branch stacked - on `origin/feat/protected-content-contracts`. It adds the source-only - `elastos-protected-content-custody` crate plus custody-envelope provisioning, - recipient-sealed node release, and recipient-side threshold reconstruction - for new content. It does not integrate Runtime/provider/Carrier - orchestration, durable replay storage, recipient key-possession proof, - decrypt/render product flows, installation, or deployment. +- `feat/protected-content-custody` is the source-only child review line + stacked on `origin/feat/protected-content-contracts`. It adds the + `elastos-protected-content-custody` crate plus custody-envelope + provisioning, recipient-sealed node release, and recipient-side threshold + reconstruction for new content. Its current reviewed behavior rejects + invalid X25519 contract key bytes before HPKE use, requires exactly the + bound released threshold, and checks a manifest-bound CEK commitment after + reconstruction to detect a wrong reconstructed key. It does not integrate + Runtime/provider/Carrier orchestration, durable replay storage, recipient + key-possession proof, decrypt/render product flows, installation, or + deployment. - Released 0.6 and the published collaboration review stack retain the older provisional `elastos_common::protected_content` DTOs plus fail-closed `drm-provider`, `rights-provider`, `key-provider`, and `decrypt-provider` @@ -43,6 +47,10 @@ the public repository. `origin/feat/protected-content-contracts` branch completed with no code findings after the strict DID codec and Carrier codec consolidation. This is not an external cryptographic audit or production security approval. +- An independent AI/model review found the invalid-X25519 acceptance, + released-threshold mismatch, and missing reconstructed-key commitment check + now corrected on the custody review line. That review is useful source + review evidence, not a professional external cryptographic audit. - The collaboration review stack adds Runtime-backed People/Chat collaboration and selected shell UI work. The source boundary is complete for review: Profile authority, Runtime lifecycle, Carrier routing, People/Chat From 927dfd671c893f2ea147a7c5be5502e415342773 Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Fri, 14 Aug 2026 13:25:44 -0500 Subject: [PATCH 06/15] feat(protected-content): define policy and recipient authority --- .../src/custody_envelope.rs | 2 +- .../src/lib.rs | 13 + .../src/policy.rs | 706 ++++++++++++++++++ .../src/recipient_authorization.rs | 561 ++++++++++++++ 4 files changed, 1281 insertions(+), 1 deletion(-) create mode 100644 elastos/crates/elastos-protected-content-contracts/src/policy.rs create mode 100644 elastos/crates/elastos-protected-content-contracts/src/recipient_authorization.rs diff --git a/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs b/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs index af355ab31..deb710caa 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs @@ -533,7 +533,7 @@ fn has_duplicate_custody_keys(nodes: &[CustodyNodeIdentityV1]) -> bool { false } -fn validate_canonical_x25519_public_key( +pub(crate) fn validate_canonical_x25519_public_key( bytes: [u8; 32], field: &'static str, ) -> Result<(), ContractError> { diff --git a/elastos/crates/elastos-protected-content-contracts/src/lib.rs b/elastos/crates/elastos-protected-content-contracts/src/lib.rs index e20c7d206..1c031b2c3 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/lib.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/lib.rs @@ -10,6 +10,8 @@ mod identity; mod node_contribution; mod node_decision; mod node_set; +mod policy; +mod recipient_authorization; mod release_request; mod replay; mod rights; @@ -45,6 +47,17 @@ pub use node_decision::{ MAX_NODE_DECISION_LIFETIME_SECS, }; pub use node_set::{NodePublicKey, NodeSetV1}; +pub use policy::{ + EvmContractAddressV1, EvmFunctionSelectorV1, EvmRightsMethodAbiV1, + RightsEvaluationEvidenceRequestV1, RightsEvaluationEvidenceV1, RightsObservationFinalityV1, + RightsPolicyBodyV1, RightsSubjectSourceV1, +}; +pub use recipient_authorization::{ + RecipientAuthorizationError, RecipientKeyAuthorizationContextV1, + RecipientKeyAuthorizationStatementV1, RecipientPublicKeyBytesV1, + RuntimeOperationIssuerKeyV1, SignedRecipientKeyAuthorizationV1, + VerifiedRecipientKeyAuthorizationV1, MAX_RECIPIENT_KEY_AUTHORIZATION_LIFETIME_SECS, +}; pub use release_request::{ KeyReleaseError, KeyReleaseRequestV1, VerifiedKeyReleaseRequestV1, MAX_RELEASE_REQUEST_LIFETIME_SECS, diff --git a/elastos/crates/elastos-protected-content-contracts/src/policy.rs b/elastos/crates/elastos-protected-content-contracts/src/policy.rs new file mode 100644 index 000000000..d95d0ec00 --- /dev/null +++ b/elastos/crates/elastos-protected-content-contracts/src/policy.rs @@ -0,0 +1,706 @@ +use serde::Serialize; + +use crate::canonical::{validate_ascii_identifier, CanonicalBody, ContractError, Decoder, Encoder}; +use crate::{ + CanonicalContract, Digest32, ProtectedContentBindingV1, RightsActionV1, RightsPolicyIdentityV1, + WalletAddress, +}; + +const MAX_POLICY_IDENTIFIER_BYTES: usize = 256; +const MAX_EVM_RIGHT_ARGUMENT_BYTES: usize = "download".len(); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct EvmContractAddressV1([u8; 20]); + +impl EvmContractAddressV1 { + pub fn new(bytes: [u8; 20]) -> Result { + if bytes == [0; 20] { + return Err(ContractError::InvalidField("evm_contract_address")); + } + Ok(Self(bytes)) + } + + pub const fn as_bytes(&self) -> &[u8; 20] { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct EvmFunctionSelectorV1([u8; 4]); + +impl EvmFunctionSelectorV1 { + pub fn new(bytes: [u8; 4]) -> Result { + if bytes == [0; 4] { + return Err(ContractError::InvalidField("evm_function_selector")); + } + Ok(Self(bytes)) + } + + pub const fn as_bytes(&self) -> &[u8; 4] { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[repr(u8)] +pub enum EvmRightsMethodAbiV1 { + HasAccessByContentIdStringAddressString = 1, +} + +impl EvmRightsMethodAbiV1 { + fn decode(value: u8) -> Result { + match value { + 1 => Ok(Self::HasAccessByContentIdStringAddressString), + _ => Err(ContractError::InvalidField("evm_rights_method_abi")), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[repr(u8)] +pub enum RightsSubjectSourceV1 { + WalletAddress = 1, +} + +impl RightsSubjectSourceV1 { + fn decode(value: u8) -> Result { + match value { + 1 => Ok(Self::WalletAddress), + _ => Err(ContractError::InvalidField("rights_subject_source")), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +pub struct RightsObservationFinalityV1 { + min_confirmations: u16, +} + +impl RightsObservationFinalityV1 { + pub const fn new(min_confirmations: u16) -> Self { + Self { min_confirmations } + } + + pub const fn min_confirmations(&self) -> u16 { + self.min_confirmations + } +} + +impl CanonicalBody for RightsObservationFinalityV1 { + const DOMAIN: &'static str = "elastos.protected-content.rights-observation-finality/v1"; + + fn validate(&self) -> Result<(), ContractError> { + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.u16(self.min_confirmations); + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Ok(Self::new(decoder.u16()?)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RightsPolicyBodyV1 { + content_id: String, + required_action: RightsActionV1, + evm_right_argument: String, + subject_source: RightsSubjectSourceV1, + chain_id: u64, + contract_address: EvmContractAddressV1, + function_selector: EvmFunctionSelectorV1, + method_abi: EvmRightsMethodAbiV1, + observation_finality: RightsObservationFinalityV1, +} + +impl RightsPolicyBodyV1 { + #[allow(clippy::too_many_arguments)] + pub fn new( + content_id: impl Into, + required_action: RightsActionV1, + evm_right_argument: impl Into, + subject_source: RightsSubjectSourceV1, + chain_id: u64, + contract_address: EvmContractAddressV1, + function_selector: EvmFunctionSelectorV1, + method_abi: EvmRightsMethodAbiV1, + observation_finality: RightsObservationFinalityV1, + ) -> Result { + let value = Self { + content_id: content_id.into(), + required_action, + evm_right_argument: evm_right_argument.into(), + subject_source, + chain_id, + contract_address, + function_selector, + method_abi, + observation_finality, + }; + value.validate()?; + Ok(value) + } + + pub fn content_id(&self) -> &str { + &self.content_id + } + + pub const fn required_action(&self) -> RightsActionV1 { + self.required_action + } + + pub fn evm_right_argument(&self) -> &str { + &self.evm_right_argument + } + + pub const fn subject_source(&self) -> RightsSubjectSourceV1 { + self.subject_source + } + + pub const fn chain_id(&self) -> u64 { + self.chain_id + } + + pub const fn contract_address(&self) -> EvmContractAddressV1 { + self.contract_address + } + + pub const fn function_selector(&self) -> EvmFunctionSelectorV1 { + self.function_selector + } + + pub const fn method_abi(&self) -> EvmRightsMethodAbiV1 { + self.method_abi + } + + pub const fn observation_finality(&self) -> RightsObservationFinalityV1 { + self.observation_finality + } + + pub fn policy_identity(&self) -> Result { + RightsPolicyIdentityV1::new( + self.canonical_hash()?, + u32::try_from(self.canonical_bytes()?.len()) + .map_err(|_| ContractError::InvalidField("policy_bytes"))?, + ) + } +} + +impl CanonicalBody for RightsPolicyBodyV1 { + const DOMAIN: &'static str = "elastos.protected-content.rights-policy-body/v1"; + + fn validate(&self) -> Result<(), ContractError> { + validate_policy_identifier(&self.content_id, "content_id")?; + validate_evm_right_argument(&self.evm_right_argument)?; + if self.chain_id == 0 { + return Err(ContractError::InvalidField("chain_id")); + } + EvmContractAddressV1::new(*self.contract_address.as_bytes())?; + EvmFunctionSelectorV1::new(*self.function_selector.as_bytes())?; + self.observation_finality.canonical_bytes()?; + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.string(&self.content_id)?; + encoder.u8(self.required_action as u8); + encoder.string(&self.evm_right_argument)?; + encoder.u8(self.subject_source as u8); + encoder.u64(self.chain_id); + encoder.fixed(self.contract_address.as_bytes()); + encoder.fixed(self.function_selector.as_bytes()); + encoder.u8(self.method_abi as u8); + encoder.nested(&self.observation_finality)?; + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Self::new( + decoder.string("content_id", MAX_POLICY_IDENTIFIER_BYTES)?, + RightsActionV1::decode(decoder.u8()?)?, + decoder.string("evm_right_argument", MAX_EVM_RIGHT_ARGUMENT_BYTES)?, + RightsSubjectSourceV1::decode(decoder.u8()?)?, + decoder.u64()?, + EvmContractAddressV1::new(decoder.fixed()?)?, + EvmFunctionSelectorV1::new(decoder.fixed()?)?, + EvmRightsMethodAbiV1::decode(decoder.u8()?)?, + decoder.nested("observation_finality")?, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RightsEvaluationEvidenceRequestV1 { + binding: ProtectedContentBindingV1, + policy_identity: RightsPolicyIdentityV1, +} + +impl RightsEvaluationEvidenceRequestV1 { + pub fn new( + binding: ProtectedContentBindingV1, + policy_identity: RightsPolicyIdentityV1, + ) -> Result { + let value = Self { + binding, + policy_identity, + }; + value.validate()?; + Ok(value) + } + + pub fn binding(&self) -> &ProtectedContentBindingV1 { + &self.binding + } + + pub const fn policy_identity(&self) -> &RightsPolicyIdentityV1 { + &self.policy_identity + } + + pub fn validate_against_policy( + &self, + policy: &RightsPolicyBodyV1, + ) -> Result<(), ContractError> { + let policy_identity = policy.policy_identity()?; + if self.policy_identity != policy_identity { + return Err(ContractError::InvalidField( + "evidence_request.policy_identity", + )); + } + if self.binding.rights_policy() != &self.policy_identity { + return Err(ContractError::InvalidField("evidence_request.binding")); + } + Ok(()) + } +} + +impl CanonicalBody for RightsEvaluationEvidenceRequestV1 { + const DOMAIN: &'static str = "elastos.protected-content.rights-evaluation-evidence-request/v1"; + + fn validate(&self) -> Result<(), ContractError> { + self.binding.canonical_bytes()?; + self.policy_identity.canonical_bytes()?; + if self.binding.rights_policy() != &self.policy_identity { + return Err(ContractError::InvalidField("evidence_request.binding")); + } + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.nested(&self.binding)?; + encoder.nested(&self.policy_identity)?; + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Self::new( + decoder.nested("binding")?, + decoder.nested("policy_identity")?, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RightsEvaluationEvidenceV1 { + binding: ProtectedContentBindingV1, + policy_identity: RightsPolicyIdentityV1, + subject_wallet: WalletAddress, + observed_chain_id: u64, + observed_block_number: u64, + observed_block_hash: Digest32, + head_block_number: u64, + has_access: bool, +} + +impl RightsEvaluationEvidenceV1 { + #[allow(clippy::too_many_arguments)] + pub fn new( + binding: ProtectedContentBindingV1, + policy_identity: RightsPolicyIdentityV1, + subject_wallet: WalletAddress, + observed_chain_id: u64, + observed_block_number: u64, + observed_block_hash: Digest32, + head_block_number: u64, + has_access: bool, + ) -> Result { + let value = Self { + binding, + policy_identity, + subject_wallet, + observed_chain_id, + observed_block_number, + observed_block_hash, + head_block_number, + has_access, + }; + value.validate()?; + Ok(value) + } + + pub fn binding(&self) -> &ProtectedContentBindingV1 { + &self.binding + } + + pub const fn policy_identity(&self) -> &RightsPolicyIdentityV1 { + &self.policy_identity + } + + pub const fn subject_wallet(&self) -> WalletAddress { + self.subject_wallet + } + + pub const fn observed_chain_id(&self) -> u64 { + self.observed_chain_id + } + + pub const fn observed_block_number(&self) -> u64 { + self.observed_block_number + } + + pub const fn observed_block_hash(&self) -> Digest32 { + self.observed_block_hash + } + + pub const fn head_block_number(&self) -> u64 { + self.head_block_number + } + + pub const fn has_access(&self) -> bool { + self.has_access + } + + pub fn validate_against_request( + &self, + request: &RightsEvaluationEvidenceRequestV1, + policy: &RightsPolicyBodyV1, + ) -> Result<(), ContractError> { + request.validate_against_policy(policy)?; + if self.binding != *request.binding() { + return Err(ContractError::InvalidField("evidence.binding")); + } + if self.policy_identity != *request.policy_identity() { + return Err(ContractError::InvalidField("evidence.policy_identity")); + } + if self.subject_wallet != self.binding.wallet() { + return Err(ContractError::InvalidField("evidence.subject_wallet")); + } + if self.observed_chain_id != policy.chain_id() { + return Err(ContractError::InvalidField("evidence.observed_chain_id")); + } + let required_confirmations = u64::from(policy.observation_finality().min_confirmations()); + if self + .head_block_number + .saturating_sub(self.observed_block_number) + < required_confirmations + { + return Err(ContractError::InvalidField("evidence.head_block_number")); + } + Ok(()) + } +} + +impl CanonicalBody for RightsEvaluationEvidenceV1 { + const DOMAIN: &'static str = "elastos.protected-content.rights-evaluation-evidence/v1"; + + fn validate(&self) -> Result<(), ContractError> { + self.binding.canonical_bytes()?; + self.policy_identity.canonical_bytes()?; + if self.binding.rights_policy() != &self.policy_identity { + return Err(ContractError::InvalidField("evidence.binding")); + } + if self.subject_wallet != self.binding.wallet() { + return Err(ContractError::InvalidField("evidence.subject_wallet")); + } + if self.observed_chain_id == 0 { + return Err(ContractError::InvalidField("evidence.observed_chain_id")); + } + if self.observed_block_hash == Digest32::new([0; 32]) { + return Err(ContractError::InvalidField("evidence.observed_block_hash")); + } + if self.head_block_number < self.observed_block_number { + return Err(ContractError::InvalidField("evidence.head_block_number")); + } + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.nested(&self.binding)?; + encoder.nested(&self.policy_identity)?; + encoder.fixed(self.subject_wallet.as_bytes()); + encoder.u64(self.observed_chain_id); + encoder.u64(self.observed_block_number); + encoder.fixed(self.observed_block_hash.as_bytes()); + encoder.u64(self.head_block_number); + encoder.u8(u8::from(self.has_access)); + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Self::new( + decoder.nested("binding")?, + decoder.nested("policy_identity")?, + WalletAddress::new(decoder.fixed()?), + decoder.u64()?, + decoder.u64()?, + Digest32::new(decoder.fixed()?), + decoder.u64()?, + decode_bool(decoder, "has_access")?, + ) + } +} + +fn validate_policy_identifier(value: &str, field: &'static str) -> Result<(), ContractError> { + validate_ascii_identifier(value, field, MAX_POLICY_IDENTIFIER_BYTES)?; + if value.bytes().any(|byte| matches!(byte, b'/' | b'\\')) { + return Err(ContractError::InvalidField(field)); + } + Ok(()) +} + +fn validate_evm_right_argument(value: &str) -> Result<(), ContractError> { + match value { + "view" | "stream" | "download" | "execute" => Ok(()), + _ => Err(ContractError::InvalidField("evm_right_argument")), + } +} + +fn decode_bool(decoder: &mut Decoder<'_>, field: &'static str) -> Result { + match decoder.u8()? { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(ContractError::InvalidField(field)), + } +} + +#[cfg(test)] +mod tests { + use hex::encode; + + use super::*; + use crate::test_support::{binding_for_wallet, digest, wallet}; + + fn policy() -> RightsPolicyBodyV1 { + RightsPolicyBodyV1::new( + "content:alpha", + RightsActionV1::Stream, + "stream", + RightsSubjectSourceV1::WalletAddress, + 11155111, + EvmContractAddressV1::new([0x11; 20]).unwrap(), + EvmFunctionSelectorV1::new([0x12, 0x34, 0x56, 0x78]).unwrap(), + EvmRightsMethodAbiV1::HasAccessByContentIdStringAddressString, + RightsObservationFinalityV1::new(12), + ) + .unwrap() + } + + fn policy_binding(policy: &RightsPolicyBodyV1) -> ProtectedContentBindingV1 { + let base = binding_for_wallet(wallet(7)); + ProtectedContentBindingV1::new( + base.encrypted_content().clone(), + base.key_envelope().clone(), + policy.policy_identity().unwrap(), + base.profile(), + base.wallet(), + base.runtime_session_binding(), + ) + .unwrap() + } + + #[test] + fn policy_identity_matches_exact_policy_bytes() { + let policy = policy(); + let identity = policy.policy_identity().unwrap(); + + assert_eq!( + identity, + RightsPolicyIdentityV1::new(policy.canonical_hash().unwrap(), 167).unwrap() + ); + assert_eq!( + encode(policy.canonical_hash().unwrap().as_bytes()), + "f9e0147f85ca56d34c2325d5c559d1e464dd4b62e6dc32e46c7531f14438cb67" + ); + } + + #[test] + fn policy_rejects_invalid_contract_chain_and_selector() { + assert_eq!( + EvmContractAddressV1::new([0; 20]), + Err(ContractError::InvalidField("evm_contract_address")) + ); + assert_eq!( + EvmFunctionSelectorV1::new([0; 4]), + Err(ContractError::InvalidField("evm_function_selector")) + ); + assert_eq!( + RightsPolicyBodyV1::new( + "content:alpha", + RightsActionV1::Stream, + "raw_call", + RightsSubjectSourceV1::WalletAddress, + 11155111, + EvmContractAddressV1::new([0x11; 20]).unwrap(), + EvmFunctionSelectorV1::new([0x12, 0x34, 0x56, 0x78]).unwrap(), + EvmRightsMethodAbiV1::HasAccessByContentIdStringAddressString, + RightsObservationFinalityV1::new(0), + ), + Err(ContractError::InvalidField("evm_right_argument")) + ); + assert_eq!( + RightsPolicyBodyV1::new( + "content:alpha", + RightsActionV1::Stream, + "stream", + RightsSubjectSourceV1::WalletAddress, + 0, + EvmContractAddressV1::new([0x11; 20]).unwrap(), + EvmFunctionSelectorV1::new([0x12, 0x34, 0x56, 0x78]).unwrap(), + EvmRightsMethodAbiV1::HasAccessByContentIdStringAddressString, + RightsObservationFinalityV1::new(0), + ), + Err(ContractError::InvalidField("chain_id")) + ); + } + + #[test] + fn evidence_request_and_result_bind_exact_policy_wallet_and_observation() { + let policy = policy(); + let binding = policy_binding(&policy); + let request = RightsEvaluationEvidenceRequestV1::new( + binding.clone(), + policy.policy_identity().unwrap(), + ) + .unwrap(); + request.validate_against_policy(&policy).unwrap(); + + let evidence = RightsEvaluationEvidenceV1::new( + binding.clone(), + policy.policy_identity().unwrap(), + binding.wallet(), + policy.chain_id(), + 100, + digest(0x55), + 112, + true, + ) + .unwrap(); + evidence + .validate_against_request(&request, &policy) + .unwrap(); + + let wrong_policy = RightsEvaluationEvidenceRequestV1::new( + binding.clone(), + RightsPolicyIdentityV1::new(digest(0xee), 111).unwrap(), + ) + .unwrap_err(); + assert_eq!( + wrong_policy, + ContractError::InvalidField("evidence_request.binding") + ); + } + + #[test] + fn evidence_rejects_wrong_wallet_chain_and_finality() { + let policy = policy(); + let binding = policy_binding(&policy); + let request = RightsEvaluationEvidenceRequestV1::new( + binding.clone(), + policy.policy_identity().unwrap(), + ) + .unwrap(); + let wrong_wallet = RightsEvaluationEvidenceV1::new( + binding.clone(), + policy.policy_identity().unwrap(), + wallet(8), + policy.chain_id(), + 100, + digest(0x55), + 112, + true, + ) + .unwrap_err(); + assert_eq!( + wrong_wallet, + ContractError::InvalidField("evidence.subject_wallet") + ); + + let wrong_chain = RightsEvaluationEvidenceV1::new( + binding.clone(), + policy.policy_identity().unwrap(), + binding.wallet(), + 1, + 100, + digest(0x55), + 112, + true, + ) + .unwrap(); + assert_eq!( + wrong_chain.validate_against_request(&request, &policy), + Err(ContractError::InvalidField("evidence.observed_chain_id")) + ); + + let low_finality = RightsEvaluationEvidenceV1::new( + binding, + policy.policy_identity().unwrap(), + wallet(7), + policy.chain_id(), + 100, + digest(0x55), + 111, + true, + ) + .unwrap(); + assert_eq!( + low_finality.validate_against_request(&request, &policy), + Err(ContractError::InvalidField("evidence.head_block_number")) + ); + } + + #[test] + fn policy_decode_rejects_trailing_and_noncanonical_flags() { + let policy = policy(); + let mut bytes = policy.canonical_bytes().unwrap(); + bytes.push(0); + assert_eq!( + RightsPolicyBodyV1::from_canonical_bytes(&bytes), + Err(ContractError::TrailingBytes) + ); + + let mut noncanonical = policy.canonical_bytes().unwrap(); + let finality_bytes = policy.observation_finality().canonical_bytes().unwrap(); + let method_abi_index = noncanonical.len() - finality_bytes.len() - 2 - 1; + noncanonical[method_abi_index] = 2; + assert_eq!( + RightsPolicyBodyV1::from_canonical_bytes(&noncanonical), + Err(ContractError::InvalidField("evm_rights_method_abi")) + ); + } + + #[test] + fn policy_identity_changes_when_exact_contract_right_argument_changes() { + let stream = policy(); + let view = RightsPolicyBodyV1::new( + "content:alpha", + RightsActionV1::Stream, + "view", + RightsSubjectSourceV1::WalletAddress, + 11155111, + EvmContractAddressV1::new([0x11; 20]).unwrap(), + EvmFunctionSelectorV1::new([0x12, 0x34, 0x56, 0x78]).unwrap(), + EvmRightsMethodAbiV1::HasAccessByContentIdStringAddressString, + RightsObservationFinalityV1::new(12), + ) + .unwrap(); + + assert_ne!( + stream.policy_identity().unwrap(), + view.policy_identity().unwrap() + ); + } +} diff --git a/elastos/crates/elastos-protected-content-contracts/src/recipient_authorization.rs b/elastos/crates/elastos-protected-content-contracts/src/recipient_authorization.rs new file mode 100644 index 000000000..adaa5821f --- /dev/null +++ b/elastos/crates/elastos-protected-content-contracts/src/recipient_authorization.rs @@ -0,0 +1,561 @@ +use ed25519_dalek::{Signature, Verifier as _}; +use serde::Serialize; +use sha2::Digest as _; +use thiserror::Error; + +use crate::canonical::{CanonicalBody, ContractError, Decoder, Encoder}; +use crate::custody_envelope::validate_canonical_x25519_public_key; +use crate::rights::{validate_active, validate_time_window, RightsActionV1, RightsError}; +use crate::{CanonicalContract, Digest32, ProtectedContentBindingV1, RecipientKeyIdentityV1}; + +pub const MAX_RECIPIENT_KEY_AUTHORIZATION_LIFETIME_SECS: u64 = 5 * 60; +const ED25519_SIGNATURE_BYTES: usize = 64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct RecipientPublicKeyBytesV1([u8; 32]); + +impl RecipientPublicKeyBytesV1 { + pub fn new(bytes: [u8; 32]) -> Result { + validate_canonical_x25519_public_key(bytes, "recipient_public_key")?; + Ok(Self(bytes)) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn key_identity( + &self, + encryption_suite_id: &str, + ) -> Result { + RecipientKeyIdentityV1::new( + encryption_suite_id, + Digest32::new(sha2::Sha256::digest(self.0).into()), + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct RuntimeOperationIssuerKeyV1([u8; 32]); + +impl RuntimeOperationIssuerKeyV1 { + pub fn new(bytes: [u8; 32]) -> Result { + crate::identity::validate_ed25519_public_key(bytes, "runtime_operation_issuer")?; + Ok(Self(bytes)) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum RecipientAuthorizationError { + #[error(transparent)] + Contract(#[from] ContractError), + #[error("recipient-key authorization mismatch: {0}")] + BindingMismatch(&'static str), + #[error("recipient-key authorization is not yet valid")] + NotYetValid, + #[error("recipient-key authorization expired")] + Expired, + #[error("recipient-key authorization signature is invalid")] + InvalidProfileSignature, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RecipientKeyAuthorizationStatementV1 { + binding: ProtectedContentBindingV1, + action: RightsActionV1, + recipient_public_key: RecipientPublicKeyBytesV1, + recipient_identity: RecipientKeyIdentityV1, + runtime_operation_issuer: RuntimeOperationIssuerKeyV1, + issued_at: u64, + expires_at: u64, +} + +impl RecipientKeyAuthorizationStatementV1 { + #[allow(clippy::too_many_arguments)] + pub fn new( + binding: ProtectedContentBindingV1, + action: RightsActionV1, + recipient_public_key: RecipientPublicKeyBytesV1, + recipient_identity: RecipientKeyIdentityV1, + runtime_operation_issuer: RuntimeOperationIssuerKeyV1, + issued_at: u64, + expires_at: u64, + ) -> Result { + let value = Self { + binding, + action, + recipient_public_key, + recipient_identity, + runtime_operation_issuer, + issued_at, + expires_at, + }; + value.validate()?; + Ok(value) + } + + pub fn binding(&self) -> &ProtectedContentBindingV1 { + &self.binding + } + + pub const fn action(&self) -> RightsActionV1 { + self.action + } + + pub const fn recipient_public_key(&self) -> RecipientPublicKeyBytesV1 { + self.recipient_public_key + } + + pub fn recipient_identity(&self) -> &RecipientKeyIdentityV1 { + &self.recipient_identity + } + + pub const fn runtime_operation_issuer(&self) -> RuntimeOperationIssuerKeyV1 { + self.runtime_operation_issuer + } + + pub const fn issued_at(&self) -> u64 { + self.issued_at + } + + pub const fn expires_at(&self) -> u64 { + self.expires_at + } +} + +impl CanonicalBody for RecipientKeyAuthorizationStatementV1 { + const DOMAIN: &'static str = + "elastos.protected-content.recipient-key-authorization-statement/v1"; + + fn validate(&self) -> Result<(), ContractError> { + self.binding.canonical_bytes()?; + self.recipient_identity.canonical_bytes()?; + RecipientPublicKeyBytesV1::new(*self.recipient_public_key.as_bytes())?; + RuntimeOperationIssuerKeyV1::new(*self.runtime_operation_issuer.as_bytes())?; + validate_time_window( + self.issued_at, + self.expires_at, + MAX_RECIPIENT_KEY_AUTHORIZATION_LIFETIME_SECS, + "recipient_key_authorization_lifetime", + )?; + if !self + .recipient_identity + .matches_public_key(self.recipient_public_key.as_bytes()) + { + return Err(ContractError::InvalidField("recipient_key_identity")); + } + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.nested(&self.binding)?; + encoder.u8(self.action as u8); + encoder.fixed(self.recipient_public_key.as_bytes()); + encoder.nested(&self.recipient_identity)?; + encoder.fixed(self.runtime_operation_issuer.as_bytes()); + encoder.u64(self.issued_at); + encoder.u64(self.expires_at); + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Self::new( + decoder.nested("binding")?, + RightsActionV1::decode(decoder.u8()?)?, + RecipientPublicKeyBytesV1::new(decoder.fixed()?)?, + decoder.nested("recipient_key_identity")?, + RuntimeOperationIssuerKeyV1::new(decoder.fixed()?)?, + decoder.u64()?, + decoder.u64()?, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SignedRecipientKeyAuthorizationV1 { + statement: RecipientKeyAuthorizationStatementV1, + profile_signature: Vec, +} + +impl SignedRecipientKeyAuthorizationV1 { + pub fn new( + statement: RecipientKeyAuthorizationStatementV1, + profile_signature: Vec, + ) -> Result { + let value = Self { + statement, + profile_signature, + }; + value.validate()?; + Ok(value) + } + + pub fn statement(&self) -> &RecipientKeyAuthorizationStatementV1 { + &self.statement + } + + pub fn verify( + &self, + context: &RecipientKeyAuthorizationContextV1, + ) -> Result { + self.canonical_bytes()?; + if self.statement.binding != context.expected_binding { + return Err(RecipientAuthorizationError::BindingMismatch( + "protected_content_binding", + )); + } + if self.statement.action != context.expected_action { + return Err(RecipientAuthorizationError::BindingMismatch( + "rights_action", + )); + } + if self.statement.recipient_public_key != context.expected_recipient_public_key { + return Err(RecipientAuthorizationError::BindingMismatch( + "recipient_public_key", + )); + } + if self.statement.runtime_operation_issuer != context.expected_runtime_operation_issuer { + return Err(RecipientAuthorizationError::BindingMismatch( + "runtime_operation_issuer", + )); + } + if !self + .statement + .recipient_identity + .matches_public_key(self.statement.recipient_public_key.as_bytes()) + { + return Err(RecipientAuthorizationError::BindingMismatch( + "recipient_key_identity", + )); + } + map_active( + self.statement.issued_at, + self.statement.expires_at, + context.now, + )?; + let signature = Signature::from_bytes( + &self + .profile_signature + .clone() + .try_into() + .map_err(|_| RecipientAuthorizationError::InvalidProfileSignature)?, + ); + let profile_key = crate::identity::validate_ed25519_public_key( + *self.statement.binding.profile().public_key_bytes(), + "profile_public_key", + ) + .map_err(|_| RecipientAuthorizationError::InvalidProfileSignature)?; + profile_key + .verify(&self.statement.canonical_bytes()?, &signature) + .map_err(|_| RecipientAuthorizationError::InvalidProfileSignature)?; + Ok(VerifiedRecipientKeyAuthorizationV1 { + statement_hash: self.statement.canonical_hash()?, + binding: self.statement.binding.clone(), + action: self.statement.action, + recipient_public_key: self.statement.recipient_public_key, + recipient_identity: self.statement.recipient_identity.clone(), + runtime_operation_issuer: self.statement.runtime_operation_issuer, + issued_at: self.statement.issued_at, + expires_at: self.statement.expires_at, + }) + } +} + +impl CanonicalBody for SignedRecipientKeyAuthorizationV1 { + const DOMAIN: &'static str = "elastos.protected-content.recipient-key-authorization/v1"; + + fn validate(&self) -> Result<(), ContractError> { + self.statement.canonical_bytes()?; + if self.profile_signature.len() != ED25519_SIGNATURE_BYTES { + return Err(ContractError::InvalidField("profile_signature")); + } + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.nested(&self.statement)?; + encoder.bytes(&self.profile_signature) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Self::new( + decoder.nested("statement")?, + decoder.bytes("profile_signature", ED25519_SIGNATURE_BYTES)?, + ) + } +} + +#[derive(Debug, Clone)] +pub struct RecipientKeyAuthorizationContextV1 { + expected_binding: ProtectedContentBindingV1, + expected_action: RightsActionV1, + expected_recipient_public_key: RecipientPublicKeyBytesV1, + expected_runtime_operation_issuer: RuntimeOperationIssuerKeyV1, + now: u64, +} + +impl RecipientKeyAuthorizationContextV1 { + pub fn new( + expected_binding: ProtectedContentBindingV1, + expected_action: RightsActionV1, + expected_recipient_public_key: RecipientPublicKeyBytesV1, + expected_runtime_operation_issuer: RuntimeOperationIssuerKeyV1, + now: u64, + ) -> Self { + Self { + expected_binding, + expected_action, + expected_recipient_public_key, + expected_runtime_operation_issuer, + now, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedRecipientKeyAuthorizationV1 { + statement_hash: Digest32, + binding: ProtectedContentBindingV1, + action: RightsActionV1, + recipient_public_key: RecipientPublicKeyBytesV1, + recipient_identity: RecipientKeyIdentityV1, + runtime_operation_issuer: RuntimeOperationIssuerKeyV1, + issued_at: u64, + expires_at: u64, +} + +impl VerifiedRecipientKeyAuthorizationV1 { + pub const fn statement_hash(&self) -> Digest32 { + self.statement_hash + } + + pub fn binding(&self) -> &ProtectedContentBindingV1 { + &self.binding + } + + pub const fn action(&self) -> RightsActionV1 { + self.action + } + + pub const fn recipient_public_key(&self) -> RecipientPublicKeyBytesV1 { + self.recipient_public_key + } + + pub fn recipient_identity(&self) -> &RecipientKeyIdentityV1 { + &self.recipient_identity + } + + pub const fn runtime_operation_issuer(&self) -> RuntimeOperationIssuerKeyV1 { + self.runtime_operation_issuer + } + + pub const fn issued_at(&self) -> u64 { + self.issued_at + } + + pub const fn expires_at(&self) -> u64 { + self.expires_at + } +} + +fn map_active( + issued_at: u64, + expires_at: u64, + now: u64, +) -> Result<(), RecipientAuthorizationError> { + match validate_active(issued_at, expires_at, now) { + Ok(()) => Ok(()), + Err(RightsError::NotYetValid) => Err(RecipientAuthorizationError::NotYetValid), + Err(RightsError::Expired) => Err(RecipientAuthorizationError::Expired), + Err(RightsError::Contract(error)) => Err(RecipientAuthorizationError::Contract(error)), + Err(_) => Err(RecipientAuthorizationError::InvalidProfileSignature), + } +} + +#[cfg(test)] +mod tests { + use ed25519_dalek::{Signer as _, SigningKey}; + use hex::encode; + + use super::*; + use crate::test_support::{binding_for_wallet, digest, wallet, NOW}; + + fn runtime_issuer(seed: u8) -> RuntimeOperationIssuerKeyV1 { + let key = SigningKey::from_bytes(&[seed; 32]); + RuntimeOperationIssuerKeyV1::new(key.verifying_key().to_bytes()).unwrap() + } + + fn recipient_public_key(seed: u8) -> RecipientPublicKeyBytesV1 { + let mut bytes = [0u8; 32]; + bytes[0] = seed.max(9); + RecipientPublicKeyBytesV1::new(bytes).unwrap() + } + + fn signed_authorization(seed: u8) -> SignedRecipientKeyAuthorizationV1 { + let binding = binding_for_wallet(wallet(7)); + let profile = SigningKey::from_bytes(&[0x26; 32]); + let recipient_public_key = recipient_public_key(seed); + let statement = RecipientKeyAuthorizationStatementV1::new( + binding.clone(), + RightsActionV1::View, + recipient_public_key, + recipient_public_key + .key_identity("hpke-rfc9180-base-x25519-hkdf-sha256-aes256gcm/v1") + .unwrap(), + runtime_issuer(0x42), + NOW, + NOW + 120, + ) + .unwrap(); + let signature = profile + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(); + SignedRecipientKeyAuthorizationV1::new(statement, signature).unwrap() + } + + #[test] + fn recipient_authorization_binds_profile_recipient_and_runtime_issuer() { + let signed = signed_authorization(0x11); + let context = RecipientKeyAuthorizationContextV1::new( + signed.statement().binding().clone(), + signed.statement().action(), + signed.statement().recipient_public_key(), + signed.statement().runtime_operation_issuer(), + NOW + 1, + ); + let verified = signed.verify(&context).unwrap(); + assert_eq!( + encode(verified.statement_hash().as_bytes()), + "aa85c21dbed95cf45707306ee109108a6ce314e641859da2b1a4289d8fff94ca" + ); + } + + #[test] + fn recipient_authorization_rejects_wrong_identity_issuer_and_window() { + let signed = signed_authorization(0x11); + let wrong_recipient = RecipientKeyAuthorizationContextV1::new( + signed.statement().binding().clone(), + signed.statement().action(), + recipient_public_key(0x22), + signed.statement().runtime_operation_issuer(), + NOW + 1, + ); + assert_eq!( + signed.verify(&wrong_recipient), + Err(RecipientAuthorizationError::BindingMismatch( + "recipient_public_key" + )) + ); + + let wrong_issuer = RecipientKeyAuthorizationContextV1::new( + signed.statement().binding().clone(), + signed.statement().action(), + signed.statement().recipient_public_key(), + runtime_issuer(0x77), + NOW + 1, + ); + assert_eq!( + signed.verify(&wrong_issuer), + Err(RecipientAuthorizationError::BindingMismatch( + "runtime_operation_issuer" + )) + ); + + let future = RecipientKeyAuthorizationContextV1::new( + signed.statement().binding().clone(), + signed.statement().action(), + signed.statement().recipient_public_key(), + signed.statement().runtime_operation_issuer(), + NOW - 10, + ); + assert_eq!( + signed.verify(&future), + Err(RecipientAuthorizationError::NotYetValid) + ); + + let expired = RecipientKeyAuthorizationContextV1::new( + signed.statement().binding().clone(), + signed.statement().action(), + signed.statement().recipient_public_key(), + signed.statement().runtime_operation_issuer(), + signed.statement().expires_at(), + ); + assert_eq!( + signed.verify(&expired), + Err(RecipientAuthorizationError::Expired) + ); + } + + #[test] + fn recipient_authorization_rejects_wrong_profile_action_and_identity_bytes() { + let signed = signed_authorization(0x11); + let mut wrong_profile_binding = signed.statement().binding().clone(); + wrong_profile_binding = ProtectedContentBindingV1::new( + wrong_profile_binding.encrypted_content().clone(), + wrong_profile_binding.key_envelope().clone(), + wrong_profile_binding.rights_policy().clone(), + crate::ProfileIdentityV1::from_public_key_bytes( + SigningKey::from_bytes(&[0x61; 32]) + .verifying_key() + .to_bytes(), + ) + .unwrap(), + wrong_profile_binding.wallet(), + wrong_profile_binding.runtime_session_binding(), + ) + .unwrap(); + let wrong_profile = RecipientKeyAuthorizationContextV1::new( + wrong_profile_binding, + signed.statement().action(), + signed.statement().recipient_public_key(), + signed.statement().runtime_operation_issuer(), + NOW + 1, + ); + assert_eq!( + signed.verify(&wrong_profile), + Err(RecipientAuthorizationError::BindingMismatch( + "protected_content_binding" + )) + ); + + let wrong_action = RecipientKeyAuthorizationContextV1::new( + signed.statement().binding().clone(), + RightsActionV1::Download, + signed.statement().recipient_public_key(), + signed.statement().runtime_operation_issuer(), + NOW + 1, + ); + assert_eq!( + signed.verify(&wrong_action), + Err(RecipientAuthorizationError::BindingMismatch( + "rights_action" + )) + ); + + let statement = RecipientKeyAuthorizationStatementV1::new( + signed.statement().binding().clone(), + signed.statement().action(), + signed.statement().recipient_public_key(), + RecipientKeyIdentityV1::new( + "hpke-rfc9180-base-x25519-hkdf-sha256-aes256gcm/v1", + digest(0xee), + ) + .unwrap(), + signed.statement().runtime_operation_issuer(), + NOW, + NOW + 120, + ) + .unwrap_err(); + assert_eq!( + statement, + ContractError::InvalidField("recipient_key_identity") + ); + } +} From 58c00c767ac010ab630074ab16864e26e0935e93 Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Fri, 14 Aug 2026 13:45:52 -0500 Subject: [PATCH 07/15] feat(protected-content): bind custody epochs and release operations --- .../src/authority_tests.rs | 21 +- .../src/custody_envelope.rs | 77 +- .../src/custody_epoch.rs | 549 ++++++++++ .../src/identity.rs | 11 + .../src/lib.rs | 17 +- .../src/recipient_authorization.rs | 2 +- .../src/release_request.rs | 11 +- .../src/rights.rs | 14 +- .../src/runtime_release_operation.rs | 975 ++++++++++++++++++ .../src/test_support.rs | 12 +- .../src/provision.rs | 11 +- .../src/reconstruct.rs | 1 + .../src/release.rs | 30 + .../src/test_support.rs | 7 +- 14 files changed, 1676 insertions(+), 62 deletions(-) create mode 100644 elastos/crates/elastos-protected-content-contracts/src/custody_epoch.rs create mode 100644 elastos/crates/elastos-protected-content-contracts/src/runtime_release_operation.rs diff --git a/elastos/crates/elastos-protected-content-contracts/src/authority_tests.rs b/elastos/crates/elastos-protected-content-contracts/src/authority_tests.rs index 325c11ffd..759914815 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/authority_tests.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/authority_tests.rs @@ -4,7 +4,8 @@ use k256::ecdsa::SigningKey as WalletSigningKey; use elastos_auth::ethereum_signed_message_hash; use crate::test_support::{ - binding_for_wallet, digest, node_key, node_public_key, node_set, wallet, TestReplayClaims, NOW, + binding_for_wallet, custody_epoch_identity, digest, node_key, node_public_key, node_set, + wallet, TestReplayClaims, NOW, }; use crate::*; @@ -277,6 +278,7 @@ fn wrong_content_policy_threshold_and_session_are_authority_mismatches() { original.key_envelope().envelope_bytes(), original.key_envelope().node_set_id(), original.key_envelope().threshold(), + original.key_envelope().custody_epoch(), ) .unwrap(), original.rights_policy().clone(), @@ -302,6 +304,7 @@ fn wrong_content_policy_threshold_and_session_are_authority_mismatches() { original.key_envelope().envelope_bytes(), digest(0xee), ThresholdV1::new(3, 3).unwrap(), + custody_epoch_identity(), ) .unwrap(), original.rights_policy().clone(), @@ -754,19 +757,19 @@ fn canonical_signature_golden_vectors() { ); assert_eq!( hex::encode(rights.wallet_signature()), - "8192a42519dbdb47240a91ecbe13f8c8d326fb72c9cb694e21a6dc8bdd2f11650f023fd39f60b5a5fa53e6d57e646e13f805fe72fc0a98ddac33b607c34f0f5000" + "cda3a5d61aca1bb33e1d4df4bcca941800dab7f7739018a0c190a6d1f1faaa2e53f098e4bd6b1bc53ebc4205162cd7a283763602c48d287582adb944a86e50bb01" ); assert_eq!( hex::encode(decision.node_signature()), - "0c0fc8fd7e1ab1cf4e8e5589cde8b8180c7e11ba2a4384d3967e213f843d3dd9e509c7c117d705500b2ab15a81d38aeff430b2d17e350b10bd3d2e17669ed404" + "40a27f3b03e1f72754b9a26cc8fc62d980922982d82edfb127c6f45123b7d9d1aeb8b68ce2dac78ff12328eef24b2dd807039c176ad1b442558767b353518105" ); assert_eq!( hex::encode(contribution.node_signature()), - "0faed8e4cfd2d507133cc6ddc06b8f170212e40c7aa79ec58a21757be0b54a9c5862cf378a1e9f2d53b003785e90d90bdbf24eabcaf93dab5e109a9caeecd50a" + "23c25cfe287f73dbebb1fd7ee44c790f9966419041ed38fe9a4c3858cdc0a304f85c4e3054ae586e6bfa7c0d77105c4507209e578df802231716460ad7254109" ); assert_eq!( hex::encode(terminal.issuer_signature()), - "d3ca9cb0396b266511ad0aaac0ec19a09833b3fb84d9bcf60a6c05a3bab8bbfd7763f1e127caa5c56f68a939d95ba6d448c62f08a719b7914b272f4498004e0d" + "a3ac53c9c1f7f949a959371b31351b089ba6f84ecbd827c6550a306a5d4226991246fe52a060ff866a82a565623d523287833bbf1c3ae9dd49184440986a4306" ); assert_eq!( @@ -777,10 +780,10 @@ fn canonical_signature_golden_vectors() { hex::encode(terminal.canonical_hash().unwrap().as_bytes()), ], [ - "0791264458234aaba89f3d024ba5d127da503a72544b5cac10e455a845ebfa75", - "059628dcbe86397748e5775fd8211d3608c99291cc538911c94911072a73f2ad", - "2212266f5c6d1860a17d6b86995d99dfe8b19ad9e6c9d9dbe7a3c1809929a4a0", - "30e01ca0836e1913f5d72e6781b49bf3d3d90a0ed536365fc7bea013be4d5b31", + "61660286259d645be3551a50320dab0317901b61623e72b84ecb22830df2ef9d", + "6d55a220fb555f50f96500f0a6cf28ec933b15fefc9ac8925220a3520e3958c8", + "1a2900de201ab561469ea632ac6e06938b2ee4a2e7c007922a1543fca9c321f0", + "6be094d54f410df6423d8e2872ba8f54f1d1ad6d68d05277e5acc52a421766c8", ] .map(str::to_string) ); diff --git a/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs b/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs index deb710caa..41e7f4f67 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/custody_envelope.rs @@ -1,9 +1,11 @@ use curve25519_dalek::{constants::X25519_LOW_ORDER_POINTS, montgomery::MontgomeryPoint}; use crate::canonical::{CanonicalBody, ContractError, Decoder, Encoder}; +use crate::custody_epoch::validate_custody_node_set; use crate::{ - CanonicalContract, Digest32, EncryptedContentIdentityV1, KeyEnvelopeIdentityV1, NodePublicKey, - NodeSetV1, ProtectedContentBindingV1, RecipientKeyIdentityV1, ThresholdV1, MAX_THRESHOLD_NODES, + CanonicalContract, CustodyEpochIdentityV1, Digest32, EncryptedContentIdentityV1, + KeyEnvelopeIdentityV1, NodePublicKey, NodeSetV1, ProtectedContentBindingV1, + RecipientKeyIdentityV1, ThresholdV1, }; pub const CUSTODY_HPKE_SUITE_ID_V1: &str = "hpke-rfc9180-base-x25519-hkdf-sha256-aes256gcm/v1"; @@ -57,7 +59,7 @@ impl ShareCoordinateV1 { pub struct CustodyNodeIdentityV1 { node_public_key: NodePublicKey, custody_public_key: NodeCustodyPublicKeyV1, - share_coordinate: ShareCoordinateV1, + pub(crate) share_coordinate: ShareCoordinateV1, } impl CustodyNodeIdentityV1 { @@ -141,6 +143,7 @@ impl CanonicalBody for CustodyNodeIdentityV1 { #[derive(Debug, Clone, PartialEq, Eq)] pub struct CustodyEnvelopeManifestV1 { encrypted_content: EncryptedContentIdentityV1, + custody_epoch: CustodyEpochIdentityV1, threshold: ThresholdV1, content_key_commitment: Digest32, nodes: Vec, @@ -149,6 +152,7 @@ pub struct CustodyEnvelopeManifestV1 { impl CustodyEnvelopeManifestV1 { pub fn new( encrypted_content: EncryptedContentIdentityV1, + custody_epoch: CustodyEpochIdentityV1, threshold: ThresholdV1, content_key_commitment: Digest32, mut nodes: Vec, @@ -162,6 +166,7 @@ impl CustodyEnvelopeManifestV1 { } let value = Self { encrypted_content, + custody_epoch, threshold, content_key_commitment, nodes, @@ -178,6 +183,10 @@ impl CustodyEnvelopeManifestV1 { self.threshold } + pub const fn custody_epoch(&self) -> CustodyEpochIdentityV1 { + self.custody_epoch + } + pub const fn content_key_commitment(&self) -> Digest32 { self.content_key_commitment } @@ -227,34 +236,13 @@ impl CanonicalBody for CustodyEnvelopeManifestV1 { fn validate(&self) -> Result<(), ContractError> { self.encrypted_content.validate()?; - self.threshold.validate()?; - if self.nodes.len() != usize::from(self.threshold.total()) - || self.nodes.len() > usize::from(MAX_THRESHOLD_NODES) - || self - .nodes - .windows(2) - .any(|window| window[0].node_public_key() >= window[1].node_public_key()) - { - return Err(ContractError::InvalidField("custody_manifest.nodes")); - } - for (index, node) in self.nodes.iter().enumerate() { - node.canonical_bytes()?; - let expected_coordinate = ShareCoordinateV1::new( - u8::try_from(index + 1) - .map_err(|_| ContractError::InvalidField("share_coordinate"))?, - )?; - if node.share_coordinate() != expected_coordinate { - return Err(ContractError::InvalidField("share_coordinate")); - } - } - if has_duplicate_custody_keys(&self.nodes) { - return Err(ContractError::InvalidField("node_custody_public_key")); - } - Ok(()) + self.custody_epoch.validate()?; + validate_custody_node_set(&self.nodes, self.threshold, "custody_manifest.nodes") } fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { encoder.nested(&self.encrypted_content)?; + encoder.nested(&self.custody_epoch)?; self.threshold.encode(encoder); encoder.fixed(self.content_key_commitment.as_bytes()); encoder.u8(self.nodes.len() as u8); @@ -266,6 +254,7 @@ impl CanonicalBody for CustodyEnvelopeManifestV1 { fn decode_fields(decoder: &mut Decoder<'_>) -> Result { let encrypted_content = decoder.nested("encrypted_content")?; + let custody_epoch = decoder.nested("custody_epoch")?; let threshold = ThresholdV1::decode(decoder)?; let content_key_commitment = Digest32::new(decoder.fixed()?); let count = usize::from(decoder.u8()?); @@ -273,7 +262,13 @@ impl CanonicalBody for CustodyEnvelopeManifestV1 { for _ in 0..count { nodes.push(decoder.nested("custody_node")?); } - Self::new(encrypted_content, threshold, content_key_commitment, nodes) + Self::new( + encrypted_content, + custody_epoch, + threshold, + content_key_commitment, + nodes, + ) } } @@ -378,6 +373,7 @@ impl CustodyEnvelopeV1 { .map_err(|_| ContractError::FieldTooLong("custody_envelope"))?, self.manifest.node_set()?.node_set_id()?, self.manifest.threshold, + self.manifest.custody_epoch, ) } @@ -521,18 +517,6 @@ impl CanonicalBody for ReleasedShareAadV1 { } } -fn has_duplicate_custody_keys(nodes: &[CustodyNodeIdentityV1]) -> bool { - for (index, node) in nodes.iter().enumerate() { - if nodes[index + 1..] - .iter() - .any(|other| node.custody_public_key() == other.custody_public_key()) - { - return true; - } - } - false -} - pub(crate) fn validate_canonical_x25519_public_key( bytes: [u8; 32], field: &'static str, @@ -598,9 +582,14 @@ mod tests { digest(seed) } + fn custody_epoch_identity() -> CustodyEpochIdentityV1 { + CustodyEpochIdentityV1::new(digest(0x33), 512).unwrap() + } + fn manifest() -> CustodyEnvelopeManifestV1 { CustodyEnvelopeManifestV1::new( encrypted_content(0x11), + custody_epoch_identity(), ThresholdV1::new(2, 3).unwrap(), content_key_commitment(0x19), vec![node(3, 3), node(1, 1), node(2, 2)], @@ -659,7 +648,7 @@ mod tests { assert_eq!( encode(envelope.canonical_hash().unwrap().as_bytes()), - "158bbfdde78acc999faffc5c668f3dc674f08fe07e6f04c414aa9722f6b447ea" + "746aa446eb641e3ab818dccc536a30053d64711233a116b17929709f543a5cd3" ); let key_envelope = envelope.key_envelope_identity().unwrap(); @@ -689,6 +678,7 @@ mod tests { assert_eq!( CustodyEnvelopeManifestV1::new( encrypted_content(0x11), + custody_epoch_identity(), threshold, content_key_commitment(0x19), vec![ @@ -708,6 +698,7 @@ mod tests { assert_eq!( CustodyEnvelopeManifestV1::new( encrypted_content(0x11), + custody_epoch_identity(), threshold, content_key_commitment(0x19), vec![node(1, 1), node(2, 2)], @@ -801,6 +792,7 @@ mod tests { let changed_content = CustodyEnvelopeV1::new( CustodyEnvelopeManifestV1::new( encrypted_content(0x12), + custody_epoch_identity(), ThresholdV1::new(2, 3).unwrap(), content_key_commitment(0x19), vec![node(1, 1), node(2, 2), node(3, 3)], @@ -812,6 +804,7 @@ mod tests { let changed_threshold = CustodyEnvelopeV1::new( CustodyEnvelopeManifestV1::new( encrypted_content(0x11), + custody_epoch_identity(), ThresholdV1::new(3, 3).unwrap(), content_key_commitment(0x19), vec![node(1, 1), node(2, 2), node(3, 3)], @@ -828,6 +821,7 @@ mod tests { let changed_commitment = CustodyEnvelopeV1::new( CustodyEnvelopeManifestV1::new( encrypted_content(0x11), + custody_epoch_identity(), ThresholdV1::new(2, 3).unwrap(), content_key_commitment(0x1a), vec![node(1, 1), node(2, 2), node(3, 3)], @@ -839,6 +833,7 @@ mod tests { let changed_custody_key = CustodyEnvelopeV1::new( CustodyEnvelopeManifestV1::new( encrypted_content(0x11), + custody_epoch_identity(), ThresholdV1::new(2, 3).unwrap(), content_key_commitment(0x19), vec![ diff --git a/elastos/crates/elastos-protected-content-contracts/src/custody_epoch.rs b/elastos/crates/elastos-protected-content-contracts/src/custody_epoch.rs new file mode 100644 index 000000000..aed818c92 --- /dev/null +++ b/elastos/crates/elastos-protected-content-contracts/src/custody_epoch.rs @@ -0,0 +1,549 @@ +use ed25519_dalek::{Signature, Verifier as _}; +use serde::Serialize; +use thiserror::Error; + +use crate::canonical::{validate_ascii_identifier, CanonicalBody, ContractError, Decoder, Encoder}; +use crate::{ + CanonicalContract, CustodyNodeIdentityV1, Digest32, KeyEnvelopeIdentityV1, NodePublicKey, + NodeSetV1, ShareCoordinateV1, ThresholdV1, CUSTODY_HPKE_SUITE_ID_V1, + MAX_RECIPIENT_ENCRYPTION_SUITE_ID_BYTES, MAX_THRESHOLD_NODES, +}; + +const ED25519_SIGNATURE_BYTES: usize = 64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct CustodyEpochIssuerKeyV1([u8; 32]); + +impl CustodyEpochIssuerKeyV1 { + pub fn new(bytes: [u8; 32]) -> Result { + crate::identity::validate_ed25519_public_key(bytes, "custody_epoch_issuer")?; + Ok(Self(bytes)) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +pub struct CustodyApprovedSuitesV1 { + recipient_encryption_suite_id: String, + stored_share_hpke_suite_id: String, + released_share_hpke_suite_id: String, +} + +impl CustodyApprovedSuitesV1 { + pub fn new( + recipient_encryption_suite_id: impl Into, + stored_share_hpke_suite_id: impl Into, + released_share_hpke_suite_id: impl Into, + ) -> Result { + let value = Self { + recipient_encryption_suite_id: recipient_encryption_suite_id.into(), + stored_share_hpke_suite_id: stored_share_hpke_suite_id.into(), + released_share_hpke_suite_id: released_share_hpke_suite_id.into(), + }; + value.validate()?; + Ok(value) + } + + pub fn recipient_encryption_suite_id(&self) -> &str { + &self.recipient_encryption_suite_id + } + + pub fn stored_share_hpke_suite_id(&self) -> &str { + &self.stored_share_hpke_suite_id + } + + pub fn released_share_hpke_suite_id(&self) -> &str { + &self.released_share_hpke_suite_id + } +} + +impl CanonicalBody for CustodyApprovedSuitesV1 { + const DOMAIN: &'static str = "elastos.protected-content.custody-approved-suites/v1"; + + fn validate(&self) -> Result<(), ContractError> { + validate_ascii_identifier( + &self.recipient_encryption_suite_id, + "recipient_encryption_suite_id", + MAX_RECIPIENT_ENCRYPTION_SUITE_ID_BYTES, + )?; + validate_ascii_identifier( + &self.stored_share_hpke_suite_id, + "stored_share_hpke_suite_id", + MAX_RECIPIENT_ENCRYPTION_SUITE_ID_BYTES, + )?; + validate_ascii_identifier( + &self.released_share_hpke_suite_id, + "released_share_hpke_suite_id", + MAX_RECIPIENT_ENCRYPTION_SUITE_ID_BYTES, + )?; + if self.recipient_encryption_suite_id != CUSTODY_HPKE_SUITE_ID_V1 { + return Err(ContractError::InvalidField("recipient_encryption_suite_id")); + } + if self.stored_share_hpke_suite_id != CUSTODY_HPKE_SUITE_ID_V1 { + return Err(ContractError::InvalidField("stored_share_hpke_suite_id")); + } + if self.released_share_hpke_suite_id != CUSTODY_HPKE_SUITE_ID_V1 { + return Err(ContractError::InvalidField("released_share_hpke_suite_id")); + } + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.string(&self.recipient_encryption_suite_id)?; + encoder.string(&self.stored_share_hpke_suite_id)?; + encoder.string(&self.released_share_hpke_suite_id)?; + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Self::new( + decoder.string( + "recipient_encryption_suite_id", + MAX_RECIPIENT_ENCRYPTION_SUITE_ID_BYTES, + )?, + decoder.string( + "stored_share_hpke_suite_id", + MAX_RECIPIENT_ENCRYPTION_SUITE_ID_BYTES, + )?, + decoder.string( + "released_share_hpke_suite_id", + MAX_RECIPIENT_ENCRYPTION_SUITE_ID_BYTES, + )?, + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +pub struct CustodyEpochIdentityV1 { + epoch_sha256: Digest32, + epoch_bytes: u32, +} + +impl CustodyEpochIdentityV1 { + pub fn new(epoch_sha256: Digest32, epoch_bytes: u32) -> Result { + let value = Self { + epoch_sha256, + epoch_bytes, + }; + value.validate()?; + Ok(value) + } + + pub const fn epoch_sha256(&self) -> Digest32 { + self.epoch_sha256 + } + + pub const fn epoch_bytes(&self) -> u32 { + self.epoch_bytes + } +} + +impl CanonicalBody for CustodyEpochIdentityV1 { + const DOMAIN: &'static str = "elastos.protected-content.custody-epoch-identity/v1"; + + fn validate(&self) -> Result<(), ContractError> { + if self.epoch_bytes == 0 || self.epoch_bytes > (u16::MAX as u32) { + return Err(ContractError::InvalidField("epoch_bytes")); + } + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.fixed(self.epoch_sha256.as_bytes()); + encoder.u32(self.epoch_bytes); + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Self::new(Digest32::new(decoder.fixed()?), decoder.u32()?) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustodyEpochStatementV1 { + issuer: CustodyEpochIssuerKeyV1, + approved_suites: CustodyApprovedSuitesV1, + threshold: ThresholdV1, + nodes: Vec, +} + +impl CustodyEpochStatementV1 { + pub fn new( + issuer: CustodyEpochIssuerKeyV1, + approved_suites: CustodyApprovedSuitesV1, + threshold: ThresholdV1, + mut nodes: Vec, + ) -> Result { + nodes.sort_unstable_by_key(|node| node.node_public_key()); + for (index, node) in nodes.iter_mut().enumerate() { + node.share_coordinate = ShareCoordinateV1::new( + u8::try_from(index + 1) + .map_err(|_| ContractError::InvalidField("share_coordinate"))?, + )?; + } + let value = Self { + issuer, + approved_suites, + threshold, + nodes, + }; + value.validate()?; + Ok(value) + } + + pub const fn issuer(&self) -> CustodyEpochIssuerKeyV1 { + self.issuer + } + + pub fn approved_suites(&self) -> &CustodyApprovedSuitesV1 { + &self.approved_suites + } + + pub const fn threshold(&self) -> ThresholdV1 { + self.threshold + } + + pub fn nodes(&self) -> &[CustodyNodeIdentityV1] { + &self.nodes + } + + pub fn node_set(&self) -> Result { + NodeSetV1::new( + self.threshold, + self.nodes + .iter() + .map(|node| node.node_public_key()) + .collect(), + ) + } +} + +impl CanonicalBody for CustodyEpochStatementV1 { + const DOMAIN: &'static str = "elastos.protected-content.custody-epoch-statement/v1"; + + fn validate(&self) -> Result<(), ContractError> { + CustodyEpochIssuerKeyV1::new(*self.issuer.as_bytes())?; + self.approved_suites.canonical_bytes()?; + validate_custody_node_set(&self.nodes, self.threshold, "custody_epoch.nodes") + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.fixed(self.issuer.as_bytes()); + encoder.nested(&self.approved_suites)?; + self.threshold.encode(encoder); + encoder.u8(self.nodes.len() as u8); + for node in &self.nodes { + encoder.nested(node)?; + } + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + let issuer = CustodyEpochIssuerKeyV1::new(decoder.fixed()?)?; + let approved_suites = decoder.nested("approved_suites")?; + let threshold = ThresholdV1::decode(decoder)?; + let count = usize::from(decoder.u8()?); + let mut nodes = Vec::with_capacity(count); + for _ in 0..count { + nodes.push(decoder.nested("custody_node")?); + } + Self::new(issuer, approved_suites, threshold, nodes) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedCustodyEpochV1 { + statement: CustodyEpochStatementV1, + issuer_signature: Vec, +} + +impl SignedCustodyEpochV1 { + pub fn new( + statement: CustodyEpochStatementV1, + issuer_signature: Vec, + ) -> Result { + let value = Self { + statement, + issuer_signature, + }; + value.validate()?; + Ok(value) + } + + pub fn statement(&self) -> &CustodyEpochStatementV1 { + &self.statement + } + + pub fn epoch_identity(&self) -> Result { + CustodyEpochIdentityV1::new( + self.canonical_hash()?, + u32::try_from(self.canonical_bytes()?.len()) + .map_err(|_| ContractError::InvalidField("epoch_bytes"))?, + ) + } + + pub fn verify(&self) -> Result { + self.canonical_bytes()?; + let signature = Signature::from_bytes( + &self + .issuer_signature + .clone() + .try_into() + .map_err(|_| CustodyEpochError::InvalidIssuerSignature)?, + ); + let issuer_key = crate::identity::validate_ed25519_public_key( + *self.statement.issuer.as_bytes(), + "custody_epoch_issuer", + ) + .map_err(|_| CustodyEpochError::InvalidIssuerSignature)?; + issuer_key + .verify(&self.statement.canonical_bytes()?, &signature) + .map_err(|_| CustodyEpochError::InvalidIssuerSignature)?; + Ok(VerifiedCustodyEpochV1 { + epoch_identity: self.epoch_identity()?, + issuer: self.statement.issuer, + approved_suites: self.statement.approved_suites.clone(), + threshold: self.statement.threshold, + nodes: self.statement.nodes.clone(), + }) + } + + pub fn verify_against_key_envelope( + &self, + key_envelope: &KeyEnvelopeIdentityV1, + ) -> Result { + let verified = self.verify()?; + if verified.epoch_identity != key_envelope.custody_epoch() { + return Err(CustodyEpochError::BindingMismatch("custody_epoch_identity")); + } + if verified.node_set_id()? != key_envelope.node_set_id() { + return Err(CustodyEpochError::BindingMismatch("node_set_id")); + } + if verified.threshold != key_envelope.threshold() { + return Err(CustodyEpochError::BindingMismatch("threshold")); + } + Ok(verified) + } +} + +impl CanonicalBody for SignedCustodyEpochV1 { + const DOMAIN: &'static str = "elastos.protected-content.signed-custody-epoch/v1"; + + fn validate(&self) -> Result<(), ContractError> { + self.statement.canonical_bytes()?; + if self.issuer_signature.len() != ED25519_SIGNATURE_BYTES { + return Err(ContractError::InvalidField("issuer_signature")); + } + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.nested(&self.statement)?; + encoder.bytes(&self.issuer_signature) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Self::new( + decoder.nested("statement")?, + decoder.bytes("issuer_signature", ED25519_SIGNATURE_BYTES)?, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum CustodyEpochError { + #[error(transparent)] + Contract(#[from] ContractError), + #[error("custody epoch mismatch: {0}")] + BindingMismatch(&'static str), + #[error("custody epoch issuer signature is invalid")] + InvalidIssuerSignature, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedCustodyEpochV1 { + epoch_identity: CustodyEpochIdentityV1, + issuer: CustodyEpochIssuerKeyV1, + approved_suites: CustodyApprovedSuitesV1, + threshold: ThresholdV1, + nodes: Vec, +} + +impl VerifiedCustodyEpochV1 { + pub const fn epoch_identity(&self) -> CustodyEpochIdentityV1 { + self.epoch_identity + } + + pub const fn issuer(&self) -> CustodyEpochIssuerKeyV1 { + self.issuer + } + + pub fn approved_suites(&self) -> &CustodyApprovedSuitesV1 { + &self.approved_suites + } + + pub const fn threshold(&self) -> ThresholdV1 { + self.threshold + } + + pub fn nodes(&self) -> &[CustodyNodeIdentityV1] { + &self.nodes + } + + pub fn node_set_id(&self) -> Result { + NodeSetV1::new( + self.threshold, + self.nodes + .iter() + .map(|node| node.node_public_key()) + .collect(), + )? + .node_set_id() + } + + pub fn node(&self, node_public_key: NodePublicKey) -> Option<&CustodyNodeIdentityV1> { + self.nodes + .binary_search_by_key(&node_public_key, |node| node.node_public_key()) + .ok() + .map(|index| &self.nodes[index]) + } +} + +pub(crate) fn validate_custody_node_set( + nodes: &[CustodyNodeIdentityV1], + threshold: ThresholdV1, + nodes_field: &'static str, +) -> Result<(), ContractError> { + threshold.validate()?; + if nodes.len() != usize::from(threshold.total()) + || nodes.len() > usize::from(MAX_THRESHOLD_NODES) + { + return Err(ContractError::InvalidField(nodes_field)); + } + for (index, node) in nodes.iter().enumerate() { + node.canonical_bytes()?; + let expected_coordinate = ShareCoordinateV1::new( + u8::try_from(index + 1).map_err(|_| ContractError::InvalidField("share_coordinate"))?, + )?; + if node.share_coordinate() != expected_coordinate { + return Err(ContractError::InvalidField("share_coordinate")); + } + if index > 0 && nodes[index - 1].node_public_key() >= node.node_public_key() { + return Err(ContractError::InvalidField(nodes_field)); + } + } + for (index, node) in nodes.iter().enumerate() { + if nodes[index + 1..] + .iter() + .any(|other| node.custody_public_key() == other.custody_public_key()) + { + return Err(ContractError::InvalidField("node_custody_public_key")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use curve25519_dalek::montgomery::MontgomeryPoint; + use ed25519_dalek::{Signer as _, SigningKey}; + use hex::encode; + + use super::*; + use crate::NodeCustodyPublicKeyV1; + + fn node_public_key(seed: u8) -> NodePublicKey { + let key = SigningKey::from_bytes(&[seed; 32]); + NodePublicKey::new(key.verifying_key().to_bytes()).unwrap() + } + + fn custody_public_key(seed: u8) -> NodeCustodyPublicKeyV1 { + NodeCustodyPublicKeyV1::new(MontgomeryPoint::mul_base_clamped([seed; 32]).to_bytes()) + .unwrap() + } + + fn node(seed: u8, coordinate: u8) -> CustodyNodeIdentityV1 { + CustodyNodeIdentityV1::new( + node_public_key(seed), + custody_public_key(seed), + ShareCoordinateV1::new(coordinate).unwrap(), + ) + .unwrap() + } + + fn signed_epoch() -> SignedCustodyEpochV1 { + let issuer_key = SigningKey::from_bytes(&[0x71; 32]); + let statement = CustodyEpochStatementV1::new( + CustodyEpochIssuerKeyV1::new(issuer_key.verifying_key().to_bytes()).unwrap(), + CustodyApprovedSuitesV1::new( + CUSTODY_HPKE_SUITE_ID_V1, + CUSTODY_HPKE_SUITE_ID_V1, + CUSTODY_HPKE_SUITE_ID_V1, + ) + .unwrap(), + ThresholdV1::new(2, 3).unwrap(), + vec![node(1, 1), node(2, 2), node(3, 3)], + ) + .unwrap(); + SignedCustodyEpochV1::new( + statement.clone(), + issuer_key + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap() + } + + #[test] + fn custody_epoch_identity_is_signed_and_stable() { + let epoch = signed_epoch(); + let verified = epoch.verify().unwrap(); + assert_eq!(verified.threshold(), ThresholdV1::new(2, 3).unwrap()); + assert_eq!( + encode(epoch.epoch_identity().unwrap().epoch_sha256().as_bytes()), + "47d878494bd64d3b73ea851fe3319e0c6dc6971feba0f3243079c259c51f9deb" + ); + } + + #[test] + fn custody_epoch_rejects_bad_signature_suites_and_duplicate_nodes() { + let mut epoch = signed_epoch().canonical_bytes().unwrap(); + *epoch.last_mut().unwrap() ^= 1; + assert_eq!( + SignedCustodyEpochV1::from_canonical_bytes(&epoch) + .unwrap() + .verify(), + Err(CustodyEpochError::InvalidIssuerSignature) + ); + + assert_eq!( + CustodyApprovedSuitesV1::new( + "wrong", + CUSTODY_HPKE_SUITE_ID_V1, + CUSTODY_HPKE_SUITE_ID_V1 + ), + Err(ContractError::InvalidField("recipient_encryption_suite_id")) + ); + + let issuer_key = SigningKey::from_bytes(&[0x71; 32]); + assert_eq!( + CustodyEpochStatementV1::new( + CustodyEpochIssuerKeyV1::new(issuer_key.verifying_key().to_bytes()).unwrap(), + CustodyApprovedSuitesV1::new( + CUSTODY_HPKE_SUITE_ID_V1, + CUSTODY_HPKE_SUITE_ID_V1, + CUSTODY_HPKE_SUITE_ID_V1, + ) + .unwrap(), + ThresholdV1::new(2, 3).unwrap(), + vec![node(2, 2), node(1, 1), node(1, 3)], + ), + Err(ContractError::InvalidField("custody_epoch.nodes")) + ); + } +} diff --git a/elastos/crates/elastos-protected-content-contracts/src/identity.rs b/elastos/crates/elastos-protected-content-contracts/src/identity.rs index 3fbc84cdf..6b87ae232 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/identity.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/identity.rs @@ -3,6 +3,7 @@ use serde::Serialize; use sha2::{Digest as _, Sha256}; use crate::canonical::{validate_ascii_identifier, CanonicalBody, ContractError, Decoder, Encoder}; +use crate::CustodyEpochIdentityV1; pub const MAX_ENCRYPTED_CONTENT_BYTES: u64 = 1 << 50; pub const MAX_KEY_ENVELOPE_BYTES: u32 = 1 << 20; @@ -325,6 +326,7 @@ pub struct KeyEnvelopeIdentityV1 { envelope_bytes: u32, node_set_id: Digest32, threshold: ThresholdV1, + custody_epoch: CustodyEpochIdentityV1, } impl KeyEnvelopeIdentityV1 { @@ -334,6 +336,7 @@ impl KeyEnvelopeIdentityV1 { envelope_bytes: u32, node_set_id: Digest32, threshold: ThresholdV1, + custody_epoch: CustodyEpochIdentityV1, ) -> Result { let value = Self { encrypted_content, @@ -341,6 +344,7 @@ impl KeyEnvelopeIdentityV1 { envelope_bytes, node_set_id, threshold, + custody_epoch, }; value.validate()?; Ok(value) @@ -365,6 +369,10 @@ impl KeyEnvelopeIdentityV1 { pub const fn threshold(&self) -> ThresholdV1 { self.threshold } + + pub const fn custody_epoch(&self) -> CustodyEpochIdentityV1 { + self.custody_epoch + } } impl CanonicalBody for KeyEnvelopeIdentityV1 { @@ -373,6 +381,7 @@ impl CanonicalBody for KeyEnvelopeIdentityV1 { fn validate(&self) -> Result<(), ContractError> { self.encrypted_content.validate()?; self.threshold.validate()?; + self.custody_epoch.validate()?; if self.envelope_bytes == 0 || self.envelope_bytes > MAX_KEY_ENVELOPE_BYTES { return Err(ContractError::InvalidField("envelope_bytes")); } @@ -385,6 +394,7 @@ impl CanonicalBody for KeyEnvelopeIdentityV1 { encoder.u32(self.envelope_bytes); encoder.fixed(self.node_set_id.as_bytes()); self.threshold.encode(encoder); + encoder.nested(&self.custody_epoch)?; Ok(()) } @@ -395,6 +405,7 @@ impl CanonicalBody for KeyEnvelopeIdentityV1 { decoder.u32()?, Digest32::new(decoder.fixed()?), ThresholdV1::decode(decoder)?, + decoder.nested("custody_epoch")?, ) } } diff --git a/elastos/crates/elastos-protected-content-contracts/src/lib.rs b/elastos/crates/elastos-protected-content-contracts/src/lib.rs index 1c031b2c3..ef209489c 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/lib.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/lib.rs @@ -6,6 +6,7 @@ mod canonical; mod custody_envelope; +mod custody_epoch; mod identity; mod node_contribution; mod node_decision; @@ -16,6 +17,7 @@ mod release_request; mod replay; mod rights; mod rights_receipt; +mod runtime_release_operation; mod terminal_receipt; #[cfg(test)] @@ -30,6 +32,10 @@ pub use custody_envelope::{ CUSTODY_HPKE_SUITE_ID_V1, HPKE_ENCAPPED_KEY_BYTES, HPKE_SEALED_SHARE_BYTES, RELEASED_SHARE_HPKE_INFO_V1, STORED_SHARE_HPKE_INFO_V1, }; +pub use custody_epoch::{ + CustodyApprovedSuitesV1, CustodyEpochError, CustodyEpochIdentityV1, CustodyEpochIssuerKeyV1, + CustodyEpochStatementV1, SignedCustodyEpochV1, VerifiedCustodyEpochV1, +}; pub use identity::{ Digest32, EncryptedContentIdentityV1, KeyEnvelopeIdentityV1, ProfileIdentityV1, ProtectedContentBindingV1, RecipientKeyIdentityV1, RightsPolicyIdentityV1, @@ -54,9 +60,9 @@ pub use policy::{ }; pub use recipient_authorization::{ RecipientAuthorizationError, RecipientKeyAuthorizationContextV1, - RecipientKeyAuthorizationStatementV1, RecipientPublicKeyBytesV1, - RuntimeOperationIssuerKeyV1, SignedRecipientKeyAuthorizationV1, - VerifiedRecipientKeyAuthorizationV1, MAX_RECIPIENT_KEY_AUTHORIZATION_LIFETIME_SECS, + RecipientKeyAuthorizationStatementV1, RecipientPublicKeyBytesV1, RuntimeOperationIssuerKeyV1, + SignedRecipientKeyAuthorizationV1, VerifiedRecipientKeyAuthorizationV1, + MAX_RECIPIENT_KEY_AUTHORIZATION_LIFETIME_SECS, }; pub use release_request::{ KeyReleaseError, KeyReleaseRequestV1, VerifiedKeyReleaseRequestV1, @@ -72,6 +78,11 @@ pub use rights_receipt::{ RightsReceiptIssuerKey, RightsReceiptStatementV1, SignedRightsReceiptV1, MAX_RIGHTS_RECEIPT_LIFETIME_SECS, }; +pub use runtime_release_operation::{ + AuthenticatedRuntimeReleaseOperationV1, RuntimeReleaseAuditIdV1, RuntimeReleaseOperationError, + RuntimeReleaseOperationStatementV1, SignedRuntimeReleaseOperationV1, + MAX_RUNTIME_RELEASE_OPERATION_LIFETIME_SECS, +}; pub use terminal_receipt::{ KeyReleaseOutcomeV1, NodeContributionRefV1, SignedTerminalReceiptV1, TerminalReceiptIssuerKey, TerminalReceiptStatementV1, MAX_TERMINAL_RECEIPT_LIFETIME_SECS, diff --git a/elastos/crates/elastos-protected-content-contracts/src/recipient_authorization.rs b/elastos/crates/elastos-protected-content-contracts/src/recipient_authorization.rs index adaa5821f..138241bd2 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/recipient_authorization.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/recipient_authorization.rs @@ -433,7 +433,7 @@ mod tests { let verified = signed.verify(&context).unwrap(); assert_eq!( encode(verified.statement_hash().as_bytes()), - "aa85c21dbed95cf45707306ee109108a6ce314e641859da2b1a4289d8fff94ca" + "0fa97000353d7968ffacf1908a9325aba58888d0a40f8f497bce2949237abe9e" ); } diff --git a/elastos/crates/elastos-protected-content-contracts/src/release_request.rs b/elastos/crates/elastos-protected-content-contracts/src/release_request.rs index f515fd7bd..4fff8d93e 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/release_request.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/release_request.rs @@ -155,6 +155,16 @@ impl KeyReleaseRequestV1 { rights: &VerifiedRightsRequestV1, now: u64, replay: &mut impl AtomicReplayClaimer, + ) -> Result { + let verified = self.verify_unclaimed(rights, now)?; + replay.claim(self.replay_claim_key()?, self.expires_at, now)?; + Ok(verified) + } + + pub(crate) fn verify_unclaimed( + &self, + rights: &VerifiedRightsRequestV1, + now: u64, ) -> Result { self.canonical_bytes()?; if self.binding != *rights.binding() { @@ -175,7 +185,6 @@ impl KeyReleaseRequestV1 { return Err(KeyReleaseError::BindingMismatch("rights_request_window")); } validate_active(self.issued_at, self.expires_at, now)?; - replay.claim(self.replay_claim_key()?, self.expires_at, now)?; Ok(VerifiedKeyReleaseRequestV1 { request_hash: self.request_hash()?, binding: self.binding.clone(), diff --git a/elastos/crates/elastos-protected-content-contracts/src/rights.rs b/elastos/crates/elastos-protected-content-contracts/src/rights.rs index 4708a0416..172292f39 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/rights.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/rights.rs @@ -258,6 +258,19 @@ impl WalletSignedRightsRequestV1 { &self, context: &RightsVerificationContextV1, replay: &mut impl AtomicReplayClaimer, + ) -> Result { + let verified = self.verify_unclaimed(context)?; + replay.claim( + self.request.replay_claim_key()?, + self.request.expires_at, + context.now, + )?; + Ok(verified) + } + + pub(crate) fn verify_unclaimed( + &self, + context: &RightsVerificationContextV1, ) -> Result { self.canonical_bytes()?; let request = &self.request; @@ -277,7 +290,6 @@ impl WalletSignedRightsRequestV1 { return Err(RightsError::WalletMismatch); } - replay.claim(request.replay_claim_key()?, request.expires_at, context.now)?; Ok(VerifiedRightsRequestV1 { request_hash: request.request_hash()?, binding: request.binding.clone(), diff --git a/elastos/crates/elastos-protected-content-contracts/src/runtime_release_operation.rs b/elastos/crates/elastos-protected-content-contracts/src/runtime_release_operation.rs new file mode 100644 index 000000000..87a4d3f7e --- /dev/null +++ b/elastos/crates/elastos-protected-content-contracts/src/runtime_release_operation.rs @@ -0,0 +1,975 @@ +use ed25519_dalek::{Signature, Verifier as _}; +use serde::Serialize; +use thiserror::Error; + +use crate::canonical::{CanonicalBody, ContractError, Decoder, Encoder}; +use crate::rights::{validate_active, validate_time_window, RightsError}; +use crate::{ + CanonicalContract, CustodyEpochError, CustodyEpochIdentityV1, Digest32, KeyReleaseError, + KeyReleaseRequestV1, RecipientAuthorizationError, RecipientKeyAuthorizationContextV1, + RecipientPublicKeyBytesV1, ReplayClaimKeyV1, RightsEvaluationEvidenceRequestV1, + RightsPolicyBodyV1, RuntimeOperationIssuerKeyV1, SignedCustodyEpochV1, + SignedRecipientKeyAuthorizationV1, WalletSignedRightsRequestV1, +}; + +pub const MAX_RUNTIME_RELEASE_OPERATION_LIFETIME_SECS: u64 = 60; +const ED25519_SIGNATURE_BYTES: usize = 64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct RuntimeReleaseAuditIdV1(Digest32); + +impl RuntimeReleaseAuditIdV1 { + pub fn new(value: Digest32) -> Result { + if value == Digest32::new([0; 32]) { + return Err(ContractError::InvalidField("runtime_release_audit_id")); + } + Ok(Self(value)) + } + + pub const fn digest(&self) -> Digest32 { + self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum RuntimeReleaseOperationError { + #[error(transparent)] + Contract(#[from] ContractError), + #[error(transparent)] + Rights(#[from] RightsError), + #[error(transparent)] + KeyRelease(#[from] KeyReleaseError), + #[error(transparent)] + RecipientAuthorization(#[from] RecipientAuthorizationError), + #[error(transparent)] + CustodyEpoch(#[from] CustodyEpochError), + #[error("runtime release operation mismatch: {0}")] + BindingMismatch(&'static str), + #[error("runtime release operation signature is invalid")] + InvalidRuntimeSignature, + #[error("runtime release operation is not yet valid")] + NotYetValid, + #[error("runtime release operation expired")] + Expired, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeReleaseOperationStatementV1 { + runtime_operation_issuer: RuntimeOperationIssuerKeyV1, + rights_request: WalletSignedRightsRequestV1, + release_request: KeyReleaseRequestV1, + recipient_public_key: RecipientPublicKeyBytesV1, + recipient_authorization: SignedRecipientKeyAuthorizationV1, + policy_body: RightsPolicyBodyV1, + evidence_request: RightsEvaluationEvidenceRequestV1, + custody_epoch: SignedCustodyEpochV1, + audit_request_id: RuntimeReleaseAuditIdV1, + issued_at: u64, + expires_at: u64, +} + +impl RuntimeReleaseOperationStatementV1 { + #[allow(clippy::too_many_arguments)] + pub fn new( + runtime_operation_issuer: RuntimeOperationIssuerKeyV1, + rights_request: WalletSignedRightsRequestV1, + release_request: KeyReleaseRequestV1, + recipient_public_key: RecipientPublicKeyBytesV1, + recipient_authorization: SignedRecipientKeyAuthorizationV1, + policy_body: RightsPolicyBodyV1, + evidence_request: RightsEvaluationEvidenceRequestV1, + custody_epoch: SignedCustodyEpochV1, + audit_request_id: RuntimeReleaseAuditIdV1, + issued_at: u64, + expires_at: u64, + ) -> Result { + let value = Self { + runtime_operation_issuer, + rights_request, + release_request, + recipient_public_key, + recipient_authorization, + policy_body, + evidence_request, + custody_epoch, + audit_request_id, + issued_at, + expires_at, + }; + value.validate()?; + Ok(value) + } + + pub const fn runtime_operation_issuer(&self) -> RuntimeOperationIssuerKeyV1 { + self.runtime_operation_issuer + } + + pub fn rights_request(&self) -> &WalletSignedRightsRequestV1 { + &self.rights_request + } + + pub fn release_request(&self) -> &KeyReleaseRequestV1 { + &self.release_request + } + + pub const fn recipient_public_key(&self) -> RecipientPublicKeyBytesV1 { + self.recipient_public_key + } + + pub fn recipient_authorization(&self) -> &SignedRecipientKeyAuthorizationV1 { + &self.recipient_authorization + } + + pub fn policy_body(&self) -> &RightsPolicyBodyV1 { + &self.policy_body + } + + pub fn evidence_request(&self) -> &RightsEvaluationEvidenceRequestV1 { + &self.evidence_request + } + + pub fn custody_epoch(&self) -> &SignedCustodyEpochV1 { + &self.custody_epoch + } + + pub const fn audit_request_id(&self) -> RuntimeReleaseAuditIdV1 { + self.audit_request_id + } + + pub const fn issued_at(&self) -> u64 { + self.issued_at + } + + pub const fn expires_at(&self) -> u64 { + self.expires_at + } +} + +impl CanonicalBody for RuntimeReleaseOperationStatementV1 { + const DOMAIN: &'static str = "elastos.protected-content.runtime-release-operation-statement/v1"; + + fn validate(&self) -> Result<(), ContractError> { + RuntimeOperationIssuerKeyV1::new(*self.runtime_operation_issuer.as_bytes())?; + self.rights_request.canonical_bytes()?; + self.release_request.canonical_bytes()?; + RecipientPublicKeyBytesV1::new(*self.recipient_public_key.as_bytes())?; + self.recipient_authorization.canonical_bytes()?; + self.policy_body.canonical_bytes()?; + self.evidence_request.canonical_bytes()?; + self.evidence_request + .validate_against_policy(&self.policy_body)?; + self.custody_epoch.canonical_bytes()?; + RuntimeReleaseAuditIdV1::new(self.audit_request_id.digest())?; + validate_time_window( + self.issued_at, + self.expires_at, + MAX_RUNTIME_RELEASE_OPERATION_LIFETIME_SECS, + "runtime_release_operation_lifetime", + )?; + + if self.rights_request.request().binding() != self.release_request.binding() { + return Err(ContractError::InvalidField("release_request.binding")); + } + if self.rights_request.request().request_hash()? + != self.release_request.rights_request_hash() + { + return Err(ContractError::InvalidField( + "release_request.rights_request_hash", + )); + } + if self.rights_request.request().action() != self.release_request.action() { + return Err(ContractError::InvalidField("release_request.action")); + } + if self.rights_request.request().recipient() != self.release_request.recipient() { + return Err(ContractError::InvalidField("release_request.recipient")); + } + if self.rights_request.request().binding() != self.evidence_request.binding() { + return Err(ContractError::InvalidField("evidence_request.binding")); + } + if self.rights_request.request().binding() + != self.recipient_authorization.statement().binding() + { + return Err(ContractError::InvalidField( + "recipient_authorization.binding", + )); + } + if self.rights_request.request().action() + != self.recipient_authorization.statement().action() + { + return Err(ContractError::InvalidField( + "recipient_authorization.action", + )); + } + if self.release_request.recipient() + != self + .recipient_authorization + .statement() + .recipient_identity() + { + return Err(ContractError::InvalidField( + "recipient_authorization.recipient_key_identity", + )); + } + if self.recipient_public_key + != self + .recipient_authorization + .statement() + .recipient_public_key() + { + return Err(ContractError::InvalidField( + "recipient_authorization.recipient_public_key", + )); + } + if self.runtime_operation_issuer + != self + .recipient_authorization + .statement() + .runtime_operation_issuer() + { + return Err(ContractError::InvalidField( + "recipient_authorization.runtime_operation_issuer", + )); + } + if !self + .release_request + .recipient() + .matches_public_key(self.recipient_public_key.as_bytes()) + { + return Err(ContractError::InvalidField("recipient_public_key")); + } + if self.policy_body.required_action() != self.release_request.action() { + return Err(ContractError::InvalidField("policy_body.required_action")); + } + if self.policy_body.policy_identity()? != *self.release_request.binding().rights_policy() { + return Err(ContractError::InvalidField("policy_body")); + } + if self.issued_at < self.release_request.issued_at() + || self.expires_at > self.release_request.expires_at() + { + return Err(ContractError::InvalidField( + "runtime_release_operation_lifetime", + )); + } + if self.issued_at < self.recipient_authorization.statement().issued_at() + || self.expires_at > self.recipient_authorization.statement().expires_at() + { + return Err(ContractError::InvalidField( + "runtime_release_operation_lifetime", + )); + } + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.fixed(self.runtime_operation_issuer.as_bytes()); + encoder.nested(&self.rights_request)?; + encoder.nested(&self.release_request)?; + encoder.fixed(self.recipient_public_key.as_bytes()); + encoder.nested(&self.recipient_authorization)?; + encoder.nested(&self.policy_body)?; + encoder.nested(&self.evidence_request)?; + encoder.nested(&self.custody_epoch)?; + encoder.fixed(self.audit_request_id.digest().as_bytes()); + encoder.u64(self.issued_at); + encoder.u64(self.expires_at); + Ok(()) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Self::new( + RuntimeOperationIssuerKeyV1::new(decoder.fixed()?)?, + decoder.nested("rights_request")?, + decoder.nested("release_request")?, + RecipientPublicKeyBytesV1::new(decoder.fixed()?)?, + decoder.nested("recipient_authorization")?, + decoder.nested("policy_body")?, + decoder.nested("evidence_request")?, + decoder.nested("custody_epoch")?, + RuntimeReleaseAuditIdV1::new(Digest32::new(decoder.fixed()?))?, + decoder.u64()?, + decoder.u64()?, + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedRuntimeReleaseOperationV1 { + statement: RuntimeReleaseOperationStatementV1, + runtime_signature: Vec, +} + +impl SignedRuntimeReleaseOperationV1 { + pub fn new( + statement: RuntimeReleaseOperationStatementV1, + runtime_signature: Vec, + ) -> Result { + let value = Self { + statement, + runtime_signature, + }; + value.validate()?; + Ok(value) + } + + pub fn statement(&self) -> &RuntimeReleaseOperationStatementV1 { + &self.statement + } + + pub fn verify( + &self, + now: u64, + ) -> Result { + self.canonical_bytes()?; + map_active(self.statement.issued_at, self.statement.expires_at, now)?; + let signature = Signature::from_bytes( + &self + .runtime_signature + .clone() + .try_into() + .map_err(|_| RuntimeReleaseOperationError::InvalidRuntimeSignature)?, + ); + let runtime_key = crate::identity::validate_ed25519_public_key( + *self.statement.runtime_operation_issuer.as_bytes(), + "runtime_operation_issuer", + ) + .map_err(|_| RuntimeReleaseOperationError::InvalidRuntimeSignature)?; + runtime_key + .verify(&self.statement.canonical_bytes()?, &signature) + .map_err(|_| RuntimeReleaseOperationError::InvalidRuntimeSignature)?; + + let rights_context = crate::RightsVerificationContextV1::new( + self.statement.rights_request.request().binding().clone(), + self.statement.rights_request.request().action(), + self.statement.rights_request.request().recipient().clone(), + now, + ); + let verified_rights = self + .statement + .rights_request + .verify_unclaimed(&rights_context)?; + let verified_release = self + .statement + .release_request + .verify_unclaimed(&verified_rights, now)?; + let recipient_context = RecipientKeyAuthorizationContextV1::new( + verified_release.binding().clone(), + verified_release.action(), + self.statement.recipient_public_key, + self.statement.runtime_operation_issuer, + now, + ); + let verified_authorization = self + .statement + .recipient_authorization + .verify(&recipient_context)?; + let verified_epoch = self + .statement + .custody_epoch + .verify_against_key_envelope(verified_release.binding().key_envelope())?; + if verified_epoch + .approved_suites() + .recipient_encryption_suite_id() + != verified_authorization + .recipient_identity() + .encryption_suite_id() + { + return Err(RuntimeReleaseOperationError::BindingMismatch( + "recipient_encryption_suite_id", + )); + } + if self.statement.recipient_public_key.key_identity( + verified_epoch + .approved_suites() + .recipient_encryption_suite_id(), + )? != *verified_authorization.recipient_identity() + { + return Err(RuntimeReleaseOperationError::BindingMismatch( + "recipient_public_key", + )); + } + Ok(AuthenticatedRuntimeReleaseOperationV1 { + statement: self.statement.clone(), + operation_hash: self.statement.canonical_hash()?, + rights_request_hash: verified_rights.request_hash(), + rights_request_replay_claim_key: self + .statement + .rights_request + .request() + .replay_claim_key()?, + release_request_hash: verified_release.request_hash(), + release_request_replay_claim_key: self.statement.release_request.replay_claim_key()?, + recipient_authorization_hash: verified_authorization.statement_hash(), + custody_epoch_identity: verified_epoch.epoch_identity(), + }) + } +} + +impl CanonicalBody for SignedRuntimeReleaseOperationV1 { + const DOMAIN: &'static str = "elastos.protected-content.runtime-release-operation/v1"; + + fn validate(&self) -> Result<(), ContractError> { + self.statement.canonical_bytes()?; + if self.runtime_signature.len() != ED25519_SIGNATURE_BYTES { + return Err(ContractError::InvalidField("runtime_signature")); + } + Ok(()) + } + + fn encode_fields(&self, encoder: &mut Encoder) -> Result<(), ContractError> { + encoder.nested(&self.statement)?; + encoder.bytes(&self.runtime_signature) + } + + fn decode_fields(decoder: &mut Decoder<'_>) -> Result { + Self::new( + decoder.nested("statement")?, + decoder.bytes("runtime_signature", ED25519_SIGNATURE_BYTES)?, + ) + } +} + +/// Signature-checked Runtime release operation with exact replay claim keys. +/// +/// This type is deliberately replay-pending. It proves the Runtime signature, +/// nested request signatures, recipient authorization, and custody epoch +/// bindings, but it does not claim replay and it does not expose actionable +/// `VerifiedRightsRequestV1` or `VerifiedKeyReleaseRequestV1` values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticatedRuntimeReleaseOperationV1 { + statement: RuntimeReleaseOperationStatementV1, + operation_hash: Digest32, + rights_request_hash: Digest32, + rights_request_replay_claim_key: ReplayClaimKeyV1, + release_request_hash: Digest32, + release_request_replay_claim_key: ReplayClaimKeyV1, + recipient_authorization_hash: Digest32, + custody_epoch_identity: CustodyEpochIdentityV1, +} + +impl AuthenticatedRuntimeReleaseOperationV1 { + pub fn statement(&self) -> &RuntimeReleaseOperationStatementV1 { + &self.statement + } + + pub const fn operation_hash(&self) -> Digest32 { + self.operation_hash + } + + pub const fn rights_request_hash(&self) -> Digest32 { + self.rights_request_hash + } + + pub const fn rights_request_replay_claim_key(&self) -> ReplayClaimKeyV1 { + self.rights_request_replay_claim_key + } + + pub const fn release_request_hash(&self) -> Digest32 { + self.release_request_hash + } + + pub const fn release_request_replay_claim_key(&self) -> ReplayClaimKeyV1 { + self.release_request_replay_claim_key + } + + pub const fn recipient_authorization_hash(&self) -> Digest32 { + self.recipient_authorization_hash + } + + pub const fn custody_epoch_identity(&self) -> CustodyEpochIdentityV1 { + self.custody_epoch_identity + } +} + +fn map_active( + issued_at: u64, + expires_at: u64, + now: u64, +) -> Result<(), RuntimeReleaseOperationError> { + match validate_active(issued_at, expires_at, now) { + Ok(()) => Ok(()), + Err(RightsError::NotYetValid) => Err(RuntimeReleaseOperationError::NotYetValid), + Err(RightsError::Expired) => Err(RuntimeReleaseOperationError::Expired), + Err(RightsError::Contract(error)) => Err(RuntimeReleaseOperationError::Contract(error)), + Err(other) => Err(RuntimeReleaseOperationError::Rights(other)), + } +} + +#[cfg(test)] +mod tests { + use curve25519_dalek::montgomery::MontgomeryPoint; + use ed25519_dalek::{Signer as _, SigningKey}; + use hex::encode; + use k256::ecdsa::SigningKey as WalletSigningKey; + use sha3::Digest as _; + + use elastos_auth::ethereum_signed_message_hash; + + use super::*; + use crate::test_support::{digest, NOW}; + use crate::{ + CustodyApprovedSuitesV1, CustodyEpochIssuerKeyV1, CustodyEpochStatementV1, + CustodyNodeIdentityV1, EvmContractAddressV1, EvmFunctionSelectorV1, EvmRightsMethodAbiV1, + KeyEnvelopeIdentityV1, NodeCustodyPublicKeyV1, NodePublicKey, ProfileIdentityV1, + ProtectedContentBindingV1, ReplayNonce16, RightsActionV1, RightsObservationFinalityV1, + RightsSubjectSourceV1, RuntimeSessionBindingV1, ShareCoordinateV1, ThresholdV1, + WalletAddress, + }; + + fn wallet(seed: u8) -> WalletAddress { + let key = WalletSigningKey::from_slice(&[seed; 32]).unwrap(); + let encoded = key.verifying_key().to_encoded_point(false); + let digest = sha3::Keccak256::digest(&encoded.as_bytes()[1..]); + WalletAddress::new(digest[12..].try_into().unwrap()) + } + + fn node_public_key(seed: u8) -> NodePublicKey { + let key = SigningKey::from_bytes(&[seed; 32]); + NodePublicKey::new(key.verifying_key().to_bytes()).unwrap() + } + + fn custody_public_key(seed: u8) -> NodeCustodyPublicKeyV1 { + NodeCustodyPublicKeyV1::new(MontgomeryPoint::mul_base_clamped([seed; 32]).to_bytes()) + .unwrap() + } + + fn signed_epoch() -> SignedCustodyEpochV1 { + let issuer_key = SigningKey::from_bytes(&[0x71; 32]); + let statement = CustodyEpochStatementV1::new( + CustodyEpochIssuerKeyV1::new(issuer_key.verifying_key().to_bytes()).unwrap(), + CustodyApprovedSuitesV1::new( + crate::CUSTODY_HPKE_SUITE_ID_V1, + crate::CUSTODY_HPKE_SUITE_ID_V1, + crate::CUSTODY_HPKE_SUITE_ID_V1, + ) + .unwrap(), + ThresholdV1::new(2, 3).unwrap(), + vec![ + CustodyNodeIdentityV1::new( + node_public_key(1), + custody_public_key(1), + ShareCoordinateV1::new(1).unwrap(), + ) + .unwrap(), + CustodyNodeIdentityV1::new( + node_public_key(2), + custody_public_key(2), + ShareCoordinateV1::new(2).unwrap(), + ) + .unwrap(), + CustodyNodeIdentityV1::new( + node_public_key(3), + custody_public_key(3), + ShareCoordinateV1::new(3).unwrap(), + ) + .unwrap(), + ], + ) + .unwrap(); + SignedCustodyEpochV1::new( + statement.clone(), + issuer_key + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap() + } + + fn policy_body() -> RightsPolicyBodyV1 { + RightsPolicyBodyV1::new( + "content:alpha", + RightsActionV1::View, + "view", + RightsSubjectSourceV1::WalletAddress, + 11155111, + EvmContractAddressV1::new([0x11; 20]).unwrap(), + EvmFunctionSelectorV1::new([0x12, 0x34, 0x56, 0x78]).unwrap(), + EvmRightsMethodAbiV1::HasAccessByContentIdStringAddressString, + RightsObservationFinalityV1::new(12), + ) + .unwrap() + } + + fn binding() -> ProtectedContentBindingV1 { + let encrypted_content = crate::EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(); + let epoch = signed_epoch(); + let envelope = KeyEnvelopeIdentityV1::new( + encrypted_content.clone(), + digest(0x22), + 512, + epoch.statement().node_set().unwrap().node_set_id().unwrap(), + epoch.statement().threshold(), + epoch.epoch_identity().unwrap(), + ) + .unwrap(); + let profile = SigningKey::from_bytes(&[0x26; 32]); + let policy_body = policy_body(); + ProtectedContentBindingV1::new( + encrypted_content, + envelope, + policy_body.policy_identity().unwrap(), + ProfileIdentityV1::from_public_key_bytes(profile.verifying_key().to_bytes()).unwrap(), + wallet(7), + RuntimeSessionBindingV1::new(digest(0x66)).unwrap(), + ) + .unwrap() + } + + fn signed_operation() -> SignedRuntimeReleaseOperationV1 { + let runtime_key = SigningKey::from_bytes(&[0x42; 32]); + let recipient_public_key = RecipientPublicKeyBytesV1::new([9; 32]).unwrap(); + let binding = binding(); + let policy_body = policy_body(); + let rights_request = crate::RightsRequestV1::new( + binding.clone(), + RightsActionV1::View, + recipient_public_key + .key_identity(crate::CUSTODY_HPKE_SUITE_ID_V1) + .unwrap(), + NOW, + NOW + 180, + ReplayNonce16::new([0x55; 16]), + ) + .unwrap(); + let wallet_key = WalletSigningKey::from_slice(&[7; 32]).unwrap(); + let (wallet_signature, recovery_id) = wallet_key + .sign_prehash_recoverable(ðereum_signed_message_hash( + &rights_request.canonical_bytes().unwrap(), + )) + .unwrap(); + let mut wallet_signature_bytes = wallet_signature.to_bytes().to_vec(); + wallet_signature_bytes.push(recovery_id.to_byte()); + let rights_request = + WalletSignedRightsRequestV1::new(rights_request, wallet_signature_bytes).unwrap(); + let release_request = KeyReleaseRequestV1::new( + binding.clone(), + rights_request.request().request_hash().unwrap(), + RightsActionV1::View, + rights_request.request().recipient().clone(), + NOW + 1, + NOW + 50, + ReplayNonce16::new([0x66; 16]), + ) + .unwrap(); + let profile = SigningKey::from_bytes(&[0x26; 32]); + let authorization_statement = crate::RecipientKeyAuthorizationStatementV1::new( + binding.clone(), + RightsActionV1::View, + recipient_public_key, + rights_request.request().recipient().clone(), + RuntimeOperationIssuerKeyV1::new(runtime_key.verifying_key().to_bytes()).unwrap(), + NOW, + NOW + 90, + ) + .unwrap(); + let authorization = SignedRecipientKeyAuthorizationV1::new( + authorization_statement.clone(), + profile + .sign(&authorization_statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap(); + let statement = RuntimeReleaseOperationStatementV1::new( + RuntimeOperationIssuerKeyV1::new(runtime_key.verifying_key().to_bytes()).unwrap(), + rights_request, + release_request, + recipient_public_key, + authorization, + policy_body.clone(), + RightsEvaluationEvidenceRequestV1::new(binding, policy_body.policy_identity().unwrap()) + .unwrap(), + signed_epoch(), + RuntimeReleaseAuditIdV1::new(digest(0x91)).unwrap(), + NOW + 2, + NOW + 40, + ) + .unwrap(); + SignedRuntimeReleaseOperationV1::new( + statement.clone(), + runtime_key + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap() + } + + #[test] + fn runtime_release_operation_binds_exact_authority_hashes_and_replay_keys() { + let operation = signed_operation(); + let authenticated = operation.verify(NOW + 3).unwrap(); + assert_eq!( + encode(authenticated.operation_hash().as_bytes()), + "787f4f626efb5a95122fc9c6cdc3205a1715a094037817d4ce78308cb8318e44" + ); + assert_eq!( + authenticated.rights_request_hash(), + operation + .statement() + .rights_request() + .request() + .request_hash() + .unwrap() + ); + assert_eq!( + authenticated.rights_request_replay_claim_key(), + operation + .statement() + .rights_request() + .request() + .replay_claim_key() + .unwrap() + ); + assert_eq!( + authenticated.release_request_replay_claim_key(), + operation + .statement() + .release_request() + .replay_claim_key() + .unwrap() + ); + } + + #[test] + fn runtime_release_operation_rejects_invalid_wallet_signature_and_release_hash() { + let operation = signed_operation(); + let mut wrong_rights = operation.statement().rights_request().clone(); + wrong_rights = + WalletSignedRightsRequestV1::new(wrong_rights.request().clone(), vec![0; 65]).unwrap(); + let statement = RuntimeReleaseOperationStatementV1::new( + operation.statement().runtime_operation_issuer(), + wrong_rights, + operation.statement().release_request().clone(), + operation.statement().recipient_public_key(), + operation.statement().recipient_authorization().clone(), + operation.statement().policy_body().clone(), + operation.statement().evidence_request().clone(), + operation.statement().custody_epoch().clone(), + operation.statement().audit_request_id(), + operation.statement().issued_at(), + operation.statement().expires_at(), + ) + .unwrap(); + let runtime_key = SigningKey::from_bytes(&[0x42; 32]); + let wrong = SignedRuntimeReleaseOperationV1::new( + statement.clone(), + runtime_key + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap(); + assert_eq!( + wrong.verify(NOW + 3), + Err(RuntimeReleaseOperationError::Rights( + RightsError::InvalidWalletSignature + )) + ); + + let mut wrong_release = operation.statement().release_request().clone(); + wrong_release = KeyReleaseRequestV1::new( + wrong_release.binding().clone(), + digest(0xee), + wrong_release.action(), + wrong_release.recipient().clone(), + wrong_release.issued_at(), + wrong_release.expires_at(), + wrong_release.replay_nonce(), + ) + .unwrap(); + assert_eq!( + RuntimeReleaseOperationStatementV1::new( + operation.statement().runtime_operation_issuer(), + operation.statement().rights_request().clone(), + wrong_release, + operation.statement().recipient_public_key(), + operation.statement().recipient_authorization().clone(), + operation.statement().policy_body().clone(), + operation.statement().evidence_request().clone(), + operation.statement().custody_epoch().clone(), + operation.statement().audit_request_id(), + operation.statement().issued_at(), + operation.statement().expires_at(), + ), + Err(ContractError::InvalidField( + "release_request.rights_request_hash" + )) + ); + } + + #[test] + fn runtime_release_operation_rejects_wrong_policy_runtime_issuer_and_binding_mutations() { + let operation = signed_operation(); + let wrong_policy = RightsPolicyBodyV1::new( + "content:beta", + RightsActionV1::View, + "view", + RightsSubjectSourceV1::WalletAddress, + 11155111, + EvmContractAddressV1::new([0x11; 20]).unwrap(), + EvmFunctionSelectorV1::new([0x12, 0x34, 0x56, 0x78]).unwrap(), + EvmRightsMethodAbiV1::HasAccessByContentIdStringAddressString, + RightsObservationFinalityV1::new(12), + ) + .unwrap(); + assert_eq!( + RuntimeReleaseOperationStatementV1::new( + operation.statement().runtime_operation_issuer(), + operation.statement().rights_request().clone(), + operation.statement().release_request().clone(), + operation.statement().recipient_public_key(), + operation.statement().recipient_authorization().clone(), + wrong_policy, + operation.statement().evidence_request().clone(), + operation.statement().custody_epoch().clone(), + operation.statement().audit_request_id(), + operation.statement().issued_at(), + operation.statement().expires_at(), + ), + Err(ContractError::InvalidField( + "evidence_request.policy_identity" + )) + ); + + let wrong_runtime_issuer = RuntimeOperationIssuerKeyV1::new( + SigningKey::from_bytes(&[0x77; 32]) + .verifying_key() + .to_bytes(), + ) + .unwrap(); + assert_eq!( + RuntimeReleaseOperationStatementV1::new( + wrong_runtime_issuer, + operation.statement().rights_request().clone(), + operation.statement().release_request().clone(), + operation.statement().recipient_public_key(), + operation.statement().recipient_authorization().clone(), + operation.statement().policy_body().clone(), + operation.statement().evidence_request().clone(), + operation.statement().custody_epoch().clone(), + operation.statement().audit_request_id(), + operation.statement().issued_at(), + operation.statement().expires_at(), + ), + Err(ContractError::InvalidField( + "recipient_authorization.runtime_operation_issuer" + )) + ); + + let mut wrong_binding = operation + .statement() + .rights_request() + .request() + .binding() + .clone(); + wrong_binding = ProtectedContentBindingV1::new( + wrong_binding.encrypted_content().clone(), + wrong_binding.key_envelope().clone(), + wrong_binding.rights_policy().clone(), + wrong_binding.profile(), + wrong_binding.wallet(), + RuntimeSessionBindingV1::new(digest(0xaa)).unwrap(), + ) + .unwrap(); + let wrong_release = KeyReleaseRequestV1::new( + wrong_binding.clone(), + operation + .statement() + .release_request() + .rights_request_hash(), + operation.statement().release_request().action(), + operation.statement().release_request().recipient().clone(), + operation.statement().release_request().issued_at(), + operation.statement().release_request().expires_at(), + operation.statement().release_request().replay_nonce(), + ) + .unwrap(); + assert_eq!( + RuntimeReleaseOperationStatementV1::new( + operation.statement().runtime_operation_issuer(), + operation.statement().rights_request().clone(), + wrong_release, + operation.statement().recipient_public_key(), + operation.statement().recipient_authorization().clone(), + operation.statement().policy_body().clone(), + operation.statement().evidence_request().clone(), + operation.statement().custody_epoch().clone(), + operation.statement().audit_request_id(), + operation.statement().issued_at(), + operation.statement().expires_at(), + ), + Err(ContractError::InvalidField("release_request.binding")) + ); + } + + #[test] + fn runtime_release_operation_rejects_wrong_recipient_authorization_and_epoch_suite() { + let operation = signed_operation(); + let wrong_recipient_public_key = RecipientPublicKeyBytesV1::new([0x21; 32]).unwrap(); + assert_eq!( + RuntimeReleaseOperationStatementV1::new( + operation.statement().runtime_operation_issuer(), + operation.statement().rights_request().clone(), + operation.statement().release_request().clone(), + wrong_recipient_public_key, + operation.statement().recipient_authorization().clone(), + operation.statement().policy_body().clone(), + operation.statement().evidence_request().clone(), + operation.statement().custody_epoch().clone(), + operation.statement().audit_request_id(), + operation.statement().issued_at(), + operation.statement().expires_at(), + ), + Err(ContractError::InvalidField( + "recipient_authorization.recipient_public_key" + )) + ); + + let mut wrong_epoch = operation.statement().custody_epoch().clone(); + let mut canonical = wrong_epoch.canonical_bytes().unwrap(); + *canonical.last_mut().unwrap() ^= 1; + wrong_epoch = SignedCustodyEpochV1::from_canonical_bytes(&canonical).unwrap(); + let statement = RuntimeReleaseOperationStatementV1::new( + operation.statement().runtime_operation_issuer(), + operation.statement().rights_request().clone(), + operation.statement().release_request().clone(), + operation.statement().recipient_public_key(), + operation.statement().recipient_authorization().clone(), + operation.statement().policy_body().clone(), + operation.statement().evidence_request().clone(), + wrong_epoch, + operation.statement().audit_request_id(), + operation.statement().issued_at(), + operation.statement().expires_at(), + ) + .unwrap(); + let runtime_key = SigningKey::from_bytes(&[0x42; 32]); + let wrong = SignedRuntimeReleaseOperationV1::new( + statement.clone(), + runtime_key + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap(); + assert_eq!( + wrong.verify(NOW + 3), + Err(RuntimeReleaseOperationError::CustodyEpoch( + CustodyEpochError::InvalidIssuerSignature + )) + ); + } + + #[test] + fn runtime_release_operation_decode_rejects_trailing_bytes() { + let operation = signed_operation(); + let mut trailing = operation.canonical_bytes().unwrap(); + trailing.push(0); + assert_eq!( + SignedRuntimeReleaseOperationV1::from_canonical_bytes(&trailing), + Err(ContractError::TrailingBytes) + ); + } +} diff --git a/elastos/crates/elastos-protected-content-contracts/src/test_support.rs b/elastos/crates/elastos-protected-content-contracts/src/test_support.rs index 71a36de47..59490239a 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/test_support.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/test_support.rs @@ -5,9 +5,10 @@ use k256::ecdsa::SigningKey as WalletSigningKey; use sha3::{Digest as _, Keccak256}; use crate::{ - AtomicReplayClaimer, Digest32, EncryptedContentIdentityV1, KeyEnvelopeIdentityV1, - NodePublicKey, NodeSetV1, ProfileIdentityV1, ProtectedContentBindingV1, ReplayClaimError, - ReplayClaimKeyV1, RightsPolicyIdentityV1, RuntimeSessionBindingV1, ThresholdV1, WalletAddress, + AtomicReplayClaimer, CustodyEpochIdentityV1, Digest32, EncryptedContentIdentityV1, + KeyEnvelopeIdentityV1, NodePublicKey, NodeSetV1, ProfileIdentityV1, ProtectedContentBindingV1, + ReplayClaimError, ReplayClaimKeyV1, RightsPolicyIdentityV1, RuntimeSessionBindingV1, + ThresholdV1, WalletAddress, }; pub(crate) const NOW: u64 = 2_000_000_000; @@ -39,6 +40,10 @@ pub(crate) fn wallet(seed: u8) -> WalletAddress { WalletAddress::new(digest[12..].try_into().unwrap()) } +pub(crate) fn custody_epoch_identity() -> CustodyEpochIdentityV1 { + CustodyEpochIdentityV1::new(digest(0x33), 512).unwrap() +} + pub(crate) fn binding_for_wallet(wallet: WalletAddress) -> ProtectedContentBindingV1 { let content = EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(); let node_set = node_set(); @@ -49,6 +54,7 @@ pub(crate) fn binding_for_wallet(wallet: WalletAddress) -> ProtectedContentBindi 512, node_set.node_set_id().unwrap(), threshold, + custody_epoch_identity(), ) .unwrap(); let profile_key = SigningKey::from_bytes(&[0x26; 32]); diff --git a/elastos/crates/elastos-protected-content-custody/src/provision.rs b/elastos/crates/elastos-protected-content-custody/src/provision.rs index b79a2b84c..d50ce91bc 100644 --- a/elastos/crates/elastos-protected-content-custody/src/provision.rs +++ b/elastos/crates/elastos-protected-content-custody/src/provision.rs @@ -1,5 +1,5 @@ use elastos_protected_content_contracts::{ - CustodyEnvelopeManifestV1, CustodyEnvelopeV1, CustodyNodeIdentityV1, + CustodyEnvelopeManifestV1, CustodyEnvelopeV1, CustodyEpochIdentityV1, CustodyNodeIdentityV1, EncryptedContentIdentityV1, NodeCustodyPublicKeyV1, NodePublicKey, ShareCoordinateV1, ThresholdV1, }; @@ -18,6 +18,7 @@ use crate::{ pub fn provision_custody_envelope( encrypted_content: EncryptedContentIdentityV1, content_key: &ContentEncryptionKeyV1, + custody_epoch: CustodyEpochIdentityV1, threshold: ThresholdV1, node_keys: Vec<(NodePublicKey, NodeCustodyPublicKeyV1)>, ) -> Result { @@ -28,6 +29,7 @@ pub fn provision_custody_envelope( provision_custody_envelope_with_rng( encrypted_content, content_key, + custody_epoch, threshold, node_keys, &mut hpke_rng, @@ -38,6 +40,7 @@ pub fn provision_custody_envelope( pub(crate) fn provision_custody_envelope_with_rng( encrypted_content: EncryptedContentIdentityV1, content_key: &ContentEncryptionKeyV1, + custody_epoch: CustodyEpochIdentityV1, threshold: ThresholdV1, node_keys: Vec<(NodePublicKey, NodeCustodyPublicKeyV1)>, hpke_rng: &mut RHpke, @@ -49,6 +52,7 @@ where { let manifest = CustodyEnvelopeManifestV1::new( encrypted_content, + custody_epoch, threshold, content_key.commitment(), node_keys @@ -134,7 +138,7 @@ mod tests { use rand10::{rngs::StdRng as ShamirStdRng, SeedableRng as _}; use super::*; - use crate::test_support::{content_key, custody_nodes, digest}; + use crate::test_support::{content_key, custody_epoch_identity, custody_nodes, digest}; #[test] fn provision_is_deterministic_under_test_rng_and_binds_identity() { @@ -145,6 +149,7 @@ mod tests { let envelope_a = provision_custody_envelope_with_rng( EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(), &content_key(), + custody_epoch_identity(), ThresholdV1::new(2, 3).unwrap(), custody_nodes(), &mut hpke_rng_a, @@ -154,6 +159,7 @@ mod tests { let envelope_b = provision_custody_envelope_with_rng( EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(), &content_key(), + custody_epoch_identity(), ThresholdV1::new(2, 3).unwrap(), custody_nodes(), &mut hpke_rng_b, @@ -176,6 +182,7 @@ mod tests { let err = provision_custody_envelope( EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(), &content_key(), + custody_epoch_identity(), ThresholdV1::new(2, 3).unwrap(), nodes, ) diff --git a/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs b/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs index f5ae2d3dd..6d9cd8dba 100644 --- a/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs +++ b/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs @@ -349,6 +349,7 @@ mod tests { provision_custody_envelope_with_rng( EncryptedContentIdentityV1::new(digest(0x99), 4096).unwrap(), &content_key(), + crate::test_support::custody_epoch_identity(), ThresholdV1::new(2, 3).unwrap(), custody_nodes(), &mut StdRng::from_seed([0x51; 32]), diff --git a/elastos/crates/elastos-protected-content-custody/src/release.rs b/elastos/crates/elastos-protected-content-custody/src/release.rs index 926fd7210..4b5837439 100644 --- a/elastos/crates/elastos-protected-content-custody/src/release.rs +++ b/elastos/crates/elastos-protected-content-custody/src/release.rs @@ -15,6 +15,36 @@ use crate::{ }; #[allow(clippy::too_many_arguments)] +/// ```compile_fail +/// use ed25519_dalek::SigningKey; +/// use elastos_protected_content_contracts::{ +/// AuthenticatedRuntimeReleaseOperationV1, CustodyEnvelopeV1, SignedNodeRightsDecisionV1, +/// }; +/// use elastos_protected_content_custody::{ +/// produce_node_contribution, NodeCustodySecretKeyV1, RecipientPublicKeyV1, +/// }; +/// +/// fn replay_pending_runtime_operations_are_not_actionable( +/// operation: AuthenticatedRuntimeReleaseOperationV1, +/// signed_rights_decision: SignedNodeRightsDecisionV1, +/// envelope: CustodyEnvelopeV1, +/// node_signing_key: SigningKey, +/// node_custody_secret: NodeCustodySecretKeyV1, +/// recipient_public_key: RecipientPublicKeyV1, +/// ) { +/// let _ = produce_node_contribution( +/// operation.statement().release_request(), +/// &signed_rights_decision, +/// &envelope, +/// &node_signing_key, +/// &node_custody_secret, +/// &recipient_public_key, +/// 1, +/// 2, +/// 1, +/// ); +/// } +/// ``` pub fn produce_node_contribution( request: &VerifiedKeyReleaseRequestV1, signed_rights_decision: &SignedNodeRightsDecisionV1, diff --git a/elastos/crates/elastos-protected-content-custody/src/test_support.rs b/elastos/crates/elastos-protected-content-custody/src/test_support.rs index 4e8edbfc4..c0403dbd6 100644 --- a/elastos/crates/elastos-protected-content-custody/src/test_support.rs +++ b/elastos/crates/elastos-protected-content-custody/src/test_support.rs @@ -7,7 +7,7 @@ use sha3::Keccak256; use elastos_auth::ethereum_signed_message_hash; use elastos_protected_content_contracts::{ - AtomicReplayClaimer, CanonicalContract, CustodyEnvelopeV1, Digest32, + AtomicReplayClaimer, CanonicalContract, CustodyEnvelopeV1, CustodyEpochIdentityV1, Digest32, EncryptedContentIdentityV1, KeyReleaseRequestV1, NodeCustodyPublicKeyV1, NodePublicKey, ProtectedContentBindingV1, RecipientKeyIdentityV1, ReplayClaimError, ReplayClaimKeyV1, ReplayNonce16, RightsActionV1, RightsDecisionV1, RightsRequestV1, RightsVerificationContextV1, @@ -95,6 +95,10 @@ pub(crate) fn custody_nodes() -> Vec<(NodePublicKey, NodeCustodyPublicKeyV1)> { ] } +pub(crate) fn custody_epoch_identity() -> CustodyEpochIdentityV1 { + CustodyEpochIdentityV1::new(digest(0x33), 512).unwrap() +} + fn wallet(seed: u8) -> WalletAddress { let key = WalletSigningKey::from_slice(&[seed; 32]).unwrap(); let encoded = key.verifying_key().to_encoded_point(false); @@ -246,6 +250,7 @@ pub(crate) fn provisioned_envelope() -> CustodyEnvelopeV1 { provision_custody_envelope_with_rng( EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(), &content_key(), + custody_epoch_identity(), ThresholdV1::new(2, 3).unwrap(), custody_nodes(), &mut HpkeStdRng::from_seed([0x41; 32]), From 18899940ab002e56fb6e29fb61cbee89272e0286 Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Fri, 14 Aug 2026 13:51:50 -0500 Subject: [PATCH 08/15] docs(protected-content): record operational contract boundaries --- ROADMAP.md | 16 ++- TASKS.md | 189 +++++++++++++++---------- docs/PROTECTED_CONTENT.md | 86 ++++++++--- docs/PROTECTED_CONTENT_CONTRACTS_V1.md | 149 ++++++++++++++++--- state.md | 11 ++ 5 files changed, 330 insertions(+), 121 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 274de21fe..8f59641b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,11 +147,17 @@ authority, storage APIs, or provider credentials. The dependency order for this work is strict: -1. review the canonical v1 contract as source only; -2. review custody, recipient-encryption, replay, and policy design; -3. replace the provisional DTO/provider surface atomically and wire - Runtime/Wallet/provider integration to the reviewed contract; and -4. prove the installed end-to-end open/decrypt/render path. +A. review the canonical v1 source-only contract and operational contract + surface, including the exact EVM `has_access_by_content_id` + policy/evidence contract, recipient-key authorization, immutable custody + epochs, and the authenticated replay-pending Runtime-to-release-node + envelope; +B. build source-only custody-node operations and durable node state on top of + that contract, including replay, issuer lifecycle, and operational review; +C. replace the provisional DTO/provider surface atomically and wire + Runtime/Wallet/provider integration to the reviewed contract, with one + source allow flow and one source deny flow; and +D. prove the installed end-to-end open/decrypt/render path. Carrier remains transport only throughout that sequence. It carries Runtime-selected traffic, but it does not define rights authority, custody diff --git a/TASKS.md b/TASKS.md index 51580929e..e7f715341 100644 --- a/TASKS.md +++ b/TASKS.md @@ -708,6 +708,21 @@ and review items pass. - [ ] Extract the shared capsule token block into a versioned support asset once capsule packaging can import shared CSS without coupling style to runtime authority. ### Trusted content and access rights + +The source-only protected-content stack now has three review layers in order: +published canonical contracts, published custody helpers, and the local +operational-contract child line. Remaining work must stay in this order. Carrier +remains transport only throughout every stage below. + +The local operational-contract line now defines the reviewed source-only EVM +`has_access_by_content_id` policy/evidence contract, including the exact +contract right string mapped from one product action, Profile-signed +recipient-key authorization, signed immutable custody epoch, and authenticated +replay-pending Runtime release-operation envelope. Remaining tasks start after +that boundary; they do not reopen it as ambient provider configuration. + +#### A. Source-only review gate + - [ ] Obtain external cryptographic, custody, and contract review of `docs/PROTECTED_CONTENT_CONTRACTS_V1.md`, the source-only `elastos-protected-content-contracts` crate, and the source-only @@ -720,93 +735,117 @@ and review items pass. threshold settlement, and post-reconstruction CEK commitment checking; that review is also not a professional external audit. Review the canonical codec, domain tags, field bounds, EIP-191 recovery (`0/1` only), canonical - Profile `did:key`, policy identity, owner-Wallet recovery, Runtime-selected - and Wallet-signed recipient binding, atomic replay keys, node decisions, - terminal issuer signatures, nested authority windows including - contribution-to-terminal settlement, shared canonical Ed25519 validation - (including noncanonical-encoding rejection), the stricter local canonical - X25519 contract-key rule, custody-envelope AAD domains, pinned HPKE share - sealing, GF256 Shamir share handling, exact-threshold release settlement, - threshold reconstruction zeroization, manifest-bound reconstructed-key - commitment checking, unique contribution commitments at threshold settlement, - and golden vectors. + Profile `did:key`, policy identity and body, recipient-key authorization, + owner-Wallet recovery, Runtime operation issuer binding, atomic replay keys, + signed custody epochs, node decisions, terminal issuer signatures, nested + authority windows including contribution-to-terminal settlement, shared + canonical Ed25519 validation (including noncanonical-encoding rejection), + the stricter local canonical X25519 contract-key rule, custody-envelope AAD + domains, pinned HPKE share sealing, GF256 Shamir share handling, + exact-threshold release settlement, threshold reconstruction zeroization, + manifest-bound reconstructed-key commitment checking, unique contribution + commitments at threshold settlement, and golden vectors. +- [ ] Add professionally reviewed wrapper-level known-answer coverage for the + pinned HPKE share framing if a grounded upstream vector path becomes + available. The current source keeps tamper and binding tests, but the pinned + upstream `hpke` 0.13 vector set does not cover our exact fixed 32-byte share + wrapper framing. + +#### B. Source-only custody node operations and durable node state + +- [ ] Complete the remaining custody operational design around the reviewed + epoch and release-operation contracts: node admission and rotation, review or + replacement of the pinned threshold and recipient-encryption suites, + revocation, audit retention, issuer-key lifecycle, and recovery operations. + The source-only `elastos-protected-content-custody` crate now implements new + content share provisioning, recipient-sealed node release, and recipient-side + threshold reconstruction, but it does not prove operational custody safety, + durable storage, or product integration. Its manifest-bound CEK commitment + detects a wrong reconstructed key; it does not identify the malicious node or + add verifiable secret sharing. +- [ ] Implement durable per-node replay claim stores and operational node state + around the reviewed contracts. Claims must use the contract's exact + authority-scope hash plus nonce, survive restarts until expiry, and prove + insert-if-absent behavior under concurrency and storage failure. +- [ ] Add the atomic custody-node transition from the authenticated + replay-pending Runtime release operation to claimed actionable release + authority. The node must claim the exact Wallet and release replay keys + durably before it opens any stored share. Stage A's replay-pending contract + value must remain non-actionable. + +#### C. Atomic Runtime/provider cutover plus source allow/deny proof + - [ ] Add a typed protected-content Wallet operation that verifies an externally completed signature, canonicalizes a `27/28` recovery byte to `0/1` after signer verification and before `WalletSignedRightsRequestV1` construction, and covers managed, MetaMask, and WalletConnect paths with exact tests. The v1 contract itself continues to accept only canonical `0/1`. -- [ ] Complete the remaining custody review and operational design: node - admission and rotation, review or replacement of the pinned threshold and - recipient-encryption suites, durable per-node replay claims, revocation, - audit retention, issuer-key lifecycle, and recovery operations. The - source-only `elastos-protected-content-custody` crate now implements new - content share provisioning, recipient-sealed node release, and recipient-side - threshold reconstruction, but it does not prove operational custody safety, - durable storage/replay, provider wiring, or product integration. Its - manifest-bound CEK commitment detects a wrong reconstructed key; it does not - identify the malicious node or add verifiable secret sharing. -- [ ] Add professionally reviewed wrapper-level known-answer coverage for the - pinned HPKE share framing if a grounded upstream vector path becomes - available. The current source keeps tamper and binding tests, but the pinned - upstream `hpke` 0.13 vector set does not cover our exact fixed 32-byte share - wrapper framing. -- [ ] Define how Runtime derives the recipient public key from the authenticated - Profile and session, proves key possession, and selects an approved encryption - suite. The caller must not provide endpoint, device, route, or Carrier - identity as recipient authority. -- [ ] Define the typed, application-authenticated Runtime-to-release-node - operation. It must carry the exact canonical Wallet request and release - request plus the exact recipient public key, bind the invoking Runtime and - provider operation, and require every node to verify both requests, match the - public key to `RecipientKeyIdentityV1`, and claim replay before acting. - Carrier endpoint authentication is transport evidence, not application - authorship; never serialize or trust a caller-asserted `Verified*` marker. -- [ ] Implement durable atomic replay claim stores for Runtime and every release - node. Claims must use the contract's exact authority-scope hash plus nonce, - survive restarts until expiry, and prove insert-if-absent behavior under - concurrency and storage failure. Runtime must generate a fresh opaque - 32-byte session binding, fresh rights and release nonces, and bounded time - windows; capsules must not select replay or session authority. -- [ ] Define the immutable rights-policy body format and provider evaluation - evidence addressed by `RightsPolicyIdentityV1`. Pin approved chain ids, - contract addresses, selectors, and ABI fixtures inside provider policy; keep - chain URLs and transport selection outside capsule/provider contracts. -- [ ] Wire Runtime-owned protected-content orchestration only after that review: - content status/fetch, Wallet-approved rights request, independent node rights - checks, key release, scoped decrypt/render session, terminal receipt, audit, - cancellation, and cleanup. Runtime must select providers and Carrier endpoints; - capsules must not select routes or receive contributions or raw keys. Bind - the fresh opaque contract value to the verified session in Runtime state; - never place a session ID, session cookie, launch token, capability token, or - bearer credential in the contract. -- [ ] Replace the provisional `elastos_common::protected_content` DTOs and their - current provider consumers atomically with the reviewed v1 crate during the - integration slice. Delete the superseded shapes; do not keep parallel +- [ ] Implement how Runtime derives the recipient public key from the + authenticated Profile and session, proves holder-only key possession, and + selects an approved encryption suite. The current Profile signature + authorizes one exact recipient public key and one Runtime operation issuer; + it does not prove X25519 secret-key possession. The caller must not provide + endpoint, device, route, or Carrier identity as recipient authority. +- [ ] Implement the Runtime-owned replay claim store and orchestration around + the reviewed contract values. Runtime must generate a fresh opaque 32-byte + session binding, fresh rights and release nonces, and bounded time windows; + capsules must not select replay or session authority. +- [ ] Pin approved chain ids, contract addresses, selectors, and ABI fixtures + inside provider policy and evaluation evidence for `RightsPolicyBodyV1`. + The source-only contract already requires those typed inputs; this stage + chooses the approved production values and wires them into provider policy. + Keep chain URLs and transport selection outside capsule/provider contracts. +- [ ] Wire Runtime-owned protected-content orchestration only through the + reviewed v1 contracts: content status/fetch, Wallet-approved rights request, + recipient-key authorization, application-authenticated release operation, + independent node rights checks, key release, scoped decrypt/render session, + terminal receipt, audit, cancellation, and cleanup. Runtime must select + providers and Carrier endpoints; capsules must not select routes or receive + contributions or raw keys. Bind the fresh opaque contract value to the + verified session in Runtime state; never place a session ID, session cookie, + launch token, capability token, or bearer credential in the contract. +- [ ] Replace the provisional `elastos_common::protected_content` DTOs and + their current provider consumers atomically with the reviewed v1 crate during + the integration slice. Delete the superseded shapes; do not keep parallel authorities, compatibility adapters, or fallback decoding. -- [ ] Add reviewed production dDRM rights-method configuration for the approved ESC/Elacity contracts only after contract addresses, selectors, and ABI fixtures are pinned in tests. -- [ ] Wire `rights-provider` to approved dDRM/chain policy backends through typed - `chain-provider` reads, with every release node verifying the Wallet's exact - right independently. Do not put license logic in app UI or gateway-only checks. +- [ ] Add reviewed production dDRM rights-method configuration for the approved + ESC/Elacity contracts only after contract addresses, selectors, and ABI + fixtures are pinned in tests. +- [ ] Wire `rights-provider` to approved dDRM/chain policy backends through + typed `chain-provider` reads, with every release node verifying the Wallet's + exact right independently. Do not put license logic in app UI or + gateway-only checks. - [ ] Wire `key-provider` to the reviewed threshold-custody backend only after node contribution authentication, same-node rights evidence, - recipient-sealing encryption, unique-node threshold settlement, durable - replay rejection, expiry, revocation, and failure cleanup are proven. Verify - the installed capsule-visible response is the authenticated terminal receipt, - not provider-private contribution bytes or key-backend authority. -- [ ] Wire `decrypt-provider` to a real decrypt/render backend that returns scoped rendered output or decrypt sessions to the viewer instead of broad raw key access. + recipient-sealing encryption, exact-threshold settlement, durable replay + rejection, expiry, revocation, and failure cleanup are proven. Verify the + capsule-visible response is the authenticated terminal receipt, not + provider-private contribution bytes or key-backend authority. +- [ ] Wire `decrypt-provider` to a real decrypt/render backend that returns + scoped rendered output or decrypt sessions to the viewer instead of broad raw + key access. - [ ] Evaluate decrypt/render/media helper crates as provider-internal implementation candidates only after the fail-closed protected-content - sequence is wired and installed-path tests prove capsules receive neither - key material nor IPFS, chain, Wallet, or route authority. Source contract + sequence is wired and source-path tests prove capsules receive neither key + material nor IPFS, chain, Wallet, or route authority. Source contract field-name tests are not confidentiality proof. -- [ ] Wire real protected-content producers to the existing `sealed` content object contract after payload encryption, rights policy, availability receipt, provenance, key-envelope, and viewer-interface generation exist. -- [ ] Add permissioned ElastOS PQ-hybrid dKMS custody for new protected content - only after the v1 contracts and custody design pass independent review. Do not - use FROST as the long-term dKMS root; FROST is only a classical helper for - receipts or cohort decisions. -- [ ] Keep Carrier responsible for encrypted blob transport and remote update delivery, not for replacing capability and policy enforcement. -- [ ] Prove one honest protected-content flow end to end: resolve object by stable identity, verify trust material, authorize access, decrypt for the rightful user, open in the correct viewer/app, and fail closed for everyone else. +- [ ] Prove one source allow flow and one source deny flow through the reviewed + Runtime/provider contract path with no fallback or parallel DTO surface. + +#### D. Isolated installed proof + +- [ ] Wire real protected-content producers to the existing `sealed` content + object contract after payload encryption, rights policy, availability + receipt, provenance, key-envelope, and viewer-interface generation exist. +- [ ] Prove one honest protected-content flow end to end on an installed + target: resolve object by stable identity, verify trust material, authorize + access, decrypt for the rightful user, open in the correct viewer/app, and + fail closed for everyone else. +- [ ] Only after stages A-D, decide whether to add permissioned ElastOS + PQ-hybrid dKMS custody for new protected content. Do not use FROST as the + long-term dKMS root; FROST is only a classical helper for receipts or cohort + decisions. ### Operator and audit hardening - [ ] Keep the existing SHA-256 audit chain canonical for 0.6 unless an explicit diff --git a/docs/PROTECTED_CONTENT.md b/docs/PROTECTED_CONTENT.md index 22f7b1920..b762f4c5b 100644 --- a/docs/PROTECTED_CONTENT.md +++ b/docs/PROTECTED_CONTENT.md @@ -10,12 +10,14 @@ The source tree currently has two protected-content layers: `elastos-protected-content-contracts` crate plus the companion `elastos-protected-content-custody` crate, documented in [Protected-content v1 contracts](PROTECTED_CONTENT_CONTRACTS_V1.md). That - stack defines canonical authority bindings plus source-only custody helpers - for custody-envelope provisioning, recipient-sealed node release, and - recipient-side threshold reconstruction for new content. It is not yet wired - into Runtime orchestration, provider integration, durable replay storage, - recipient key-possession proof, decryption, playback, installation, or - deployment. + stack now defines canonical authority bindings, typed rights-policy and + evidence contracts, Profile-signed recipient-key authorization, signed + immutable custody epochs, a typed Runtime-to-release-node operation + envelope, and source-only custody helpers for custody-envelope + provisioning, recipient-sealed node release, and recipient-side threshold + reconstruction for new content. It is not yet wired into Runtime + orchestration, provider integration, durable replay storage, recipient + key-possession proof, decryption, playback, installation, or deployment. - The current installed/provider surface is the older provisional `elastos_common::protected_content` DTO set plus the fail-closed `drm-provider`, `rights-provider`, `key-provider`, and `decrypt-provider` @@ -95,6 +97,12 @@ crate already defines canonical custody envelopes plus stored-share and released-share sealing for new content, but the Runtime/provider handoff to a decrypt boundary is still future integration work. +The new Profile-signed recipient-key authorization in the v1 contract is an +authorization object only. It binds one exact recipient public key and one +exact Runtime operation issuer for one binding/action/session/time window. It +does not prove X25519 secret-key possession. Actual holder-only possession +remains a later Runtime-owned invariant. + Other options were considered and rejected as the normal Runtime path: | Option | Shape | Assessment | @@ -145,6 +153,40 @@ branch therefore makes no external cryptographic audit claim. The manifest commitment only detects that threshold reconstruction produced the wrong key; it does not identify the malicious node and it is not verifiable secret sharing. +## Current source-only operational contracts + +The reviewed v1 contract stack now also defines the source-only operational +contract layer that later Runtime and custody-node integrations must consume: + +- `RightsPolicyBodyV1`, `RightsEvaluationEvidenceRequestV1`, and + `RightsEvaluationEvidenceV1` provide one narrow typed EVM policy/evidence + shape grounded in the current `chain-provider` + `has_access_by_content_id` method only: exact Wallet-derived subject, + `chain_id`, contract bytes, selector, ABI identity, `content_id`, one exact + contract right string, the product action that policy maps to that right, + one bounded confirmation rule, and exact observed block/result evidence. + They do not carry RPC URLs, provider labels, or routes. +- `SignedRecipientKeyAuthorizationV1` lets the authenticated Profile authorize + one exact recipient public key and one exact Runtime application-operation + issuer for one protected-content binding, action, session, and time window. +- `SignedCustodyEpochV1` signs one immutable custody epoch over the exact node + signing keys, node custody keys, deterministic coordinates, threshold, + approved suites, and issuer key. Existing envelopes cannot silently inherit a + new epoch. +- `SignedRuntimeReleaseOperationV1` is the typed application-authenticated + Runtime-to-release-node envelope above Carrier transport evidence. It binds + the exact Wallet request, exact release request, exact recipient bytes and + authorization, exact policy and evidence request, exact custody epoch, exact + Runtime issuer, one audit id, and one bounded window. Verification returns an + authenticated replay-pending value only. It exposes exact request hashes and + replay claim keys for a later atomic durable claim step, but it does not + expose actionable verified requests. + +This operational layer is still source-only. There is no provider registry +cutover, durable replay store, recipient key-possession proof, node lifecycle +service, installed product flow, or production confidentiality claim in this +branch. + ## Provider Boundary Normal capsules must not see: @@ -169,22 +211,24 @@ The provider plane should expose typed questions instead: ## Remaining sequence -1. Finish independent review of the source-only canonical v1 contract and - source-only custody helper, then close the remaining custody, replay, - recipient-key, and policy design decisions. -2. Replace the provisional `elastos_common::protected_content` DTO/provider +A. Finish the source-only review gate: professional external cryptographic and + contract review, plus any additional grounded HPKE wrapper known-answer + coverage that a reviewed upstream vector path can support. +B. Build source-only custody-node operations and durable node state around the + reviewed contracts: node admission/rotation/revocation/recovery, durable + replay claims, issuer-key lifecycle, and operational audit retention. +C. Replace the provisional `elastos_common::protected_content` DTO/provider surface atomically with Runtime-owned orchestration over the reviewed v1 - contract: content status/fetch, typed rights checks, rights-bound key - release, release-receipt-bound decrypt/render sessions, sealed decrypt - material, and signed terminal receipts. -3. Wire `rights-provider`, `key-provider`, and `decrypt-provider` to reviewed - policy, custody, and decrypt/render backends behind that one contract. -4. Wire real protected-content producers to the existing sealed-object publish - contract after payload encryption, rights policy, availability receipt, - provenance, key-envelope, and viewer-interface generation exist. -5. Add a reviewed custody-node lifecycle and, only after that, decide whether - a permissioned ElastOS PQ-hybrid dKMS layer should replace the pinned - source-only helper for new content. + contract: Wallet integration, recipient key generation and possession proof, + typed rights checks, rights-bound key release, release-receipt-bound + decrypt/render sessions, sealed decrypt material, and one source allow/deny + proof with no fallback path. +D. Prove the installed end-to-end path: real protected-content producers, + installed provider/runtime flow, decrypt/render handoff, and final product + evidence. + +Only after A-D should ElastOS decide whether a permissioned PQ-hybrid dKMS +layer should replace the pinned source-only helper for new content. Visible protected-content UI may ship only as a disabled/read-only readiness rail until fail-closed provider tests and capability-resource checks cover the diff --git a/docs/PROTECTED_CONTENT_CONTRACTS_V1.md b/docs/PROTECTED_CONTENT_CONTRACTS_V1.md index 8c5cb8d95..4efa1141c 100644 --- a/docs/PROTECTED_CONTENT_CONTRACTS_V1.md +++ b/docs/PROTECTED_CONTENT_CONTRACTS_V1.md @@ -1,11 +1,12 @@ # Protected-content v1 contracts -Status: source-only authority and canonical-wire foundation. The normative -authority types are in the `elastos-protected-content-contracts` crate, and the -companion source-only custody helper lives in -`elastos-protected-content-custody`. This document describes the reviewed -contract layer; it does not claim running rights providers, custody nodes, -content availability, decryption, playback, or product integration. +Status: source-only authority, operational-contract, and canonical-wire +foundation. The normative authority types are in the +`elastos-protected-content-contracts` crate, and the companion source-only +custody helper lives in `elastos-protected-content-custody`. This document +describes the reviewed contract layer; it does not claim running rights +providers, custody nodes, content availability, decryption, playback, or +product integration. ## Authority boundaries @@ -37,7 +38,7 @@ It binds all of the following: - canonical Profile Ed25519 public key; - recovered Wallet address; - non-secret, opaque 32-byte Runtime session binding; -- node-set identity and threshold. +- node-set identity, threshold, and custody-epoch identity. `RightsPolicyIdentityV1` identifies the exact immutable policy each node must evaluate. The policy body belongs to the rights provider. Its identity contains @@ -131,6 +132,114 @@ authentic `Allowed` receipt is not key-release authority. The release verifier does not accept this receipt type. A forged or unsigned allow therefore has no path into release verification. +## Typed policy and recipient-key authorization + +`RightsPolicyBodyV1` is the v1 immutable policy body. It is intentionally +narrow and grounded in the current reviewed rights-provider surface: + +- one exact `content_id`; +- one exact required `RightsActionV1`; +- one exact EVM right argument string sent as the third + `has_access_by_content_id(content_id, subject, right)` ABI argument; +- one exact subject source: the Wallet address from + `ProtectedContentBindingV1`; +- one exact EVM `chain_id`; +- one exact 20-byte EVM contract address; +- one exact 4-byte function selector; +- one exact ABI identity: + `HasAccessByContentIdStringAddressString`; +- one exact bounded observation/finality rule: + `RightsObservationFinalityV1::min_confirmations`. + +This is not a generic policy language, opaque byte blob, or arbitrary map. +It is the smallest reviewed source-only policy shape that matches the current +typed `chain-provider` `has_access_by_content_id` path. The signed policy now +explicitly maps one product `RightsActionV1` to one exact contract right string, +so two nodes cannot claim the same policy identity while sending different ABI +right arguments. Production-approved chain ids, contract addresses, selectors, +ABI fixtures, and right strings remain open review inputs, but they are +required typed policy fields now, not ambient provider configuration. + +`RightsEvaluationEvidenceRequestV1` is the matching typed evidence request. It +pins the exact `ProtectedContentBindingV1` and `RightsPolicyIdentityV1`. A node +cannot claim to evaluate a different policy or a different Wallet/content/ +session binding than the one named in the request. + +`RightsEvaluationEvidenceV1` is the matching typed evaluation result. It binds +the same exact binding and policy identity plus: + +- the exact Wallet subject address derived from the binding; +- the exact observed chain id; +- the exact observed block number and block hash; +- the exact head block number used for the finality rule; and +- the exact `has_access` result. + +Verification rejects a different Wallet subject, different chain, or +insufficient confirmation depth before any provider integration exists. RPC +URLs, provider labels, transport routes, and endpoint identity remain outside +the contract. Because the evidence binds the exact `RightsPolicyIdentityV1`, it +indirectly pins the full reviewed policy body: contract, selector, ABI, exact +right argument, content id, and finality rule. + +`SignedRecipientKeyAuthorizationV1` is a Profile-signed recipient-key +authorization. It binds all of the following: + +- the exact `ProtectedContentBindingV1`; +- the exact requested action; +- the exact recipient X25519 public-key bytes; +- the exact `RecipientKeyIdentityV1`; +- the exact Runtime application-operation issuer key; +- the shared opaque Runtime session binding; +- one bounded issue/expiry window. + +Verification compares the recipient bytes to `RecipientKeyIdentityV1`, the +Profile signer to the binding Profile, the Runtime issuer to the signed issuer, +and the window to the parent binding/action/session context. This object proves +authorization only. It does not prove that the recipient holds the X25519 +secret key. Actual recipient-key possession remains a later Runtime-owned +invariant. + +## Custody epoch and Runtime release operation + +`SignedCustodyEpochV1` is the immutable signed custody epoch. Its statement +binds: + +- the exact epoch issuer key; +- the exact approved recipient/stored-share/released-share suite ids; +- the exact threshold; +- the exact ordered node list of: + - Ed25519 node signing key, + - X25519 node custody public key, + - deterministic share coordinate. + +The node list is canonicalized by node signing key, and the coordinates are +reassigned deterministically from that order. `KeyEnvelopeIdentityV1` now pins +the resulting `CustodyEpochIdentityV1`, so the epoch signature is not +self-authorizing and an existing envelope cannot silently inherit a different +issuer, node set, threshold, or suite. + +`SignedRuntimeReleaseOperationV1` is the typed application-authenticated +Runtime-to-release-node envelope above Carrier transport authentication. It +binds: + +- the exact canonical Wallet-signed rights request bytes and derived hash; +- the exact canonical key-release request bytes and derived hash; +- the exact recipient public-key bytes; +- the exact signed recipient-key authorization; +- the exact typed policy body, policy identity, and evidence request; +- the exact signed custody epoch and bound key-envelope identity; +- the exact Runtime application-operation issuer; +- one bounded audit request id and issue/expiry window. + +Verification proves those bindings and requires the Profile-signed +recipient-key authorization to authorize the Runtime issuer. Carrier endpoint +authentication remains transport evidence only. This source-only contract does +not implement durable replay state or provider routing. Verification returns an +authenticated replay-pending surface only: it proves signatures and exact +bindings, exposes the nested request hashes and replay-claim keys, and does not +expose actionable `VerifiedRightsRequestV1` or `VerifiedKeyReleaseRequestV1` +values. Runtime and custody-node durable replay claims remain later work. + ## Node decision and contribution `KeyReleaseRequestV1` binds the same protected-content identity, original @@ -139,12 +248,11 @@ separately claimed release nonce. It stays inside the verified Wallet request window. `KeyReleaseRequestV1` is not a Runtime signature or a bearer grant. A remote -node must receive the canonical Wallet request and release request through a -typed, application-authenticated Runtime-to-provider operation. The node must -verify both requests and claim replay in its own durable store before it acts. +node must receive the canonical Wallet request and release request through the +typed `SignedRuntimeReleaseOperationV1` envelope. The node must still verify +the nested requests and claim replay in its own durable store before it acts. Carrier endpoint authentication alone does not prove who authored the -application request. That operation envelope is integration work and is not -defined by this source-only crate. +application request. Each node creates `SignedNodeRightsDecisionV1` after independently evaluating the exact rights-policy identity. The signed statement binds the release and @@ -189,14 +297,15 @@ release. ## Threat model and fail-closed rules Tests reject wrong Wallet, attacker-signed victim address, wrong content, -policy, node set, threshold, Profile, session, action, request hash, issuer, -recipient, expiry, replay, and post-signature mutation. They also reject a -forged allow receipt, nonce reuse after changing fields, child lifetime escape, -a denied or cross-node decision, cross-request contribution reuse, duplicate -node/decision/contribution/commitment evidence, insufficient threshold, -required-plus-one released evidence, malformed canonical input, noncanonical -Wallet/Profile encodings, and noncanonical/low-order X25519 contract key -encodings. +policy, evidence request, node set, threshold, Profile, session, action, +request hash, issuer, recipient, recipient-key identity, Runtime issuer, +custody epoch, approved suite, expiry, replay, and post-signature mutation. +They also reject a forged allow receipt, nonce reuse after changing fields, +child lifetime escape, a denied or cross-node decision, cross-request +contribution reuse, duplicate node/decision/contribution/commitment evidence, +insufficient threshold, required-plus-one released evidence, malformed +canonical input, noncanonical Wallet/Profile encodings, and noncanonical or +low-order X25519 contract key encodings. These contracts plus the source-only `elastos-protected-content-custody` helper do not solve malicious custody nodes, rights-policy correctness, durable replay diff --git a/state.md b/state.md index b75019fde..ec0f98108 100644 --- a/state.md +++ b/state.md @@ -38,6 +38,17 @@ the public repository. Runtime/provider/Carrier orchestration, durable replay storage, recipient key-possession proof, decrypt/render product flows, installation, or deployment. +- `feat/protected-content-operational-contracts` is the current local + dependent source-only child line stacked on + `origin/feat/protected-content-custody`. It adds the narrow typed v1 EVM + rights-policy body plus exact evidence request/result contracts, including + the exact EVM right string mapped from one product action, the + Profile-signed recipient-key authorization, the signed immutable custody + epoch, and the authenticated replay-pending Runtime-to-release-node + operation envelope. It does not prove X25519 secret-key possession, add + durable replay or custody-node state, expose actionable claimed requests, + wire Runtime/provider/product flows, or make an installed confidentiality + claim. - Released 0.6 and the published collaboration review stack retain the older provisional `elastos_common::protected_content` DTOs plus fail-closed `drm-provider`, `rights-provider`, `key-provider`, and `decrypt-provider` From 2c2e0dc3ddeb0d98a18263a99402d99bda2d4b3f Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Fri, 14 Aug 2026 18:43:08 -0500 Subject: [PATCH 09/15] feat(protected-content): gate node release on durable replay claims --- elastos/Cargo.lock | 2 + .../src/lib.rs | 2 +- .../src/replay.rs | 21 + .../src/runtime_release_operation.rs | 351 ++++-- .../Cargo.toml | 2 + .../src/lib.rs | 10 +- .../src/reconstruct.rs | 13 +- .../src/release.rs | 103 +- .../src/replay_store.rs | 1075 +++++++++++++++++ .../src/test_support.rs | 185 ++- 10 files changed, 1619 insertions(+), 145 deletions(-) create mode 100644 elastos/crates/elastos-protected-content-custody/src/replay_store.rs diff --git a/elastos/Cargo.lock b/elastos/Cargo.lock index 470d30737..2c9bbad5a 100644 --- a/elastos/Cargo.lock +++ b/elastos/Cargo.lock @@ -1667,11 +1667,13 @@ dependencies = [ "elastos-protected-content-contracts", "hpke", "k256", + "nix", "rand 0.10.2", "rand 0.9.5", "sha2 0.10.9", "sha3", "subtle", + "tempfile", "thiserror 1.0.69", "vsss-rs", "zeroize", diff --git a/elastos/crates/elastos-protected-content-contracts/src/lib.rs b/elastos/crates/elastos-protected-content-contracts/src/lib.rs index ef209489c..11fc055b1 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/lib.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/lib.rs @@ -68,7 +68,7 @@ pub use release_request::{ KeyReleaseError, KeyReleaseRequestV1, VerifiedKeyReleaseRequestV1, MAX_RELEASE_REQUEST_LIFETIME_SECS, }; -pub use replay::{AtomicReplayClaimer, ReplayClaimError, ReplayClaimKeyV1}; +pub use replay::{AtomicReplayClaimer, ReplayClaimEntryV1, ReplayClaimError, ReplayClaimKeyV1}; pub use rights::{ ReplayNonce16, RightsActionV1, RightsDecisionV1, RightsError, RightsRequestV1, RightsVerificationContextV1, VerifiedRightsRequestV1, WalletSignedRightsRequestV1, diff --git a/elastos/crates/elastos-protected-content-contracts/src/replay.rs b/elastos/crates/elastos-protected-content-contracts/src/replay.rs index 9d47ac735..f936dbbf2 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/replay.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/replay.rs @@ -30,6 +30,27 @@ impl ReplayClaimKeyV1 { } } +/// A replay claim and the authority lifetime it must hold through. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReplayClaimEntryV1 { + key: ReplayClaimKeyV1, + expires_at: u64, +} + +impl ReplayClaimEntryV1 { + pub const fn new(key: ReplayClaimKeyV1, expires_at: u64) -> Self { + Self { key, expires_at } + } + + pub const fn key(&self) -> ReplayClaimKeyV1 { + self.key + } + + pub const fn expires_at(&self) -> u64 { + self.expires_at + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] pub enum ReplayClaimError { #[error("authority nonce was already claimed")] diff --git a/elastos/crates/elastos-protected-content-contracts/src/runtime_release_operation.rs b/elastos/crates/elastos-protected-content-contracts/src/runtime_release_operation.rs index 87a4d3f7e..80ea64ff0 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/runtime_release_operation.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/runtime_release_operation.rs @@ -5,11 +5,13 @@ use thiserror::Error; use crate::canonical::{CanonicalBody, ContractError, Decoder, Encoder}; use crate::rights::{validate_active, validate_time_window, RightsError}; use crate::{ - CanonicalContract, CustodyEpochError, CustodyEpochIdentityV1, Digest32, KeyReleaseError, - KeyReleaseRequestV1, RecipientAuthorizationError, RecipientKeyAuthorizationContextV1, - RecipientPublicKeyBytesV1, ReplayClaimKeyV1, RightsEvaluationEvidenceRequestV1, - RightsPolicyBodyV1, RuntimeOperationIssuerKeyV1, SignedCustodyEpochV1, - SignedRecipientKeyAuthorizationV1, WalletSignedRightsRequestV1, + CanonicalContract, CustodyEnvelopeV1, CustodyEpochError, CustodyEpochIdentityV1, Digest32, + KeyReleaseError, KeyReleaseRequestV1, NodePublicKey, NodeSetV1, RecipientAuthorizationError, + RecipientKeyAuthorizationContextV1, RecipientPublicKeyBytesV1, ReplayClaimKeyV1, + RightsEvaluationEvidenceRequestV1, RightsPolicyBodyV1, RuntimeOperationIssuerKeyV1, + SignedCustodyEpochV1, SignedNodeContributionV1, SignedNodeRightsDecisionV1, + SignedRecipientKeyAuthorizationV1, VerifiedKeyReleaseRequestV1, VerifiedNodeContributionV1, + VerifiedNodeRightsDecisionV1, WalletSignedRightsRequestV1, }; pub const MAX_RUNTIME_RELEASE_OPERATION_LIFETIME_SECS: u64 = 60; @@ -337,70 +339,24 @@ impl SignedRuntimeReleaseOperationV1 { runtime_key .verify(&self.statement.canonical_bytes()?, &signature) .map_err(|_| RuntimeReleaseOperationError::InvalidRuntimeSignature)?; - - let rights_context = crate::RightsVerificationContextV1::new( - self.statement.rights_request.request().binding().clone(), - self.statement.rights_request.request().action(), - self.statement.rights_request.request().recipient().clone(), - now, - ); - let verified_rights = self - .statement - .rights_request - .verify_unclaimed(&rights_context)?; - let verified_release = self - .statement - .release_request - .verify_unclaimed(&verified_rights, now)?; - let recipient_context = RecipientKeyAuthorizationContextV1::new( - verified_release.binding().clone(), - verified_release.action(), - self.statement.recipient_public_key, - self.statement.runtime_operation_issuer, - now, - ); - let verified_authorization = self - .statement - .recipient_authorization - .verify(&recipient_context)?; - let verified_epoch = self - .statement - .custody_epoch - .verify_against_key_envelope(verified_release.binding().key_envelope())?; - if verified_epoch - .approved_suites() - .recipient_encryption_suite_id() - != verified_authorization - .recipient_identity() - .encryption_suite_id() - { - return Err(RuntimeReleaseOperationError::BindingMismatch( - "recipient_encryption_suite_id", - )); - } - if self.statement.recipient_public_key.key_identity( - verified_epoch - .approved_suites() - .recipient_encryption_suite_id(), - )? != *verified_authorization.recipient_identity() - { - return Err(RuntimeReleaseOperationError::BindingMismatch( - "recipient_public_key", - )); - } + let verified = verify_release_operation_statement(&self.statement, now)?; Ok(AuthenticatedRuntimeReleaseOperationV1 { statement: self.statement.clone(), operation_hash: self.statement.canonical_hash()?, - rights_request_hash: verified_rights.request_hash(), + rights_request_hash: self.statement.rights_request.request().request_hash()?, rights_request_replay_claim_key: self .statement .rights_request .request() .replay_claim_key()?, - release_request_hash: verified_release.request_hash(), + release_request_hash: verified.release.request_hash(), release_request_replay_claim_key: self.statement.release_request.replay_claim_key()?, - recipient_authorization_hash: verified_authorization.statement_hash(), - custody_epoch_identity: verified_epoch.epoch_identity(), + recipient_authorization_hash: self + .statement + .recipient_authorization + .statement() + .canonical_hash()?, + custody_epoch_identity: verified.epoch.epoch_identity(), }) } } @@ -479,6 +435,84 @@ impl AuthenticatedRuntimeReleaseOperationV1 { pub const fn custody_epoch_identity(&self) -> CustodyEpochIdentityV1 { self.custody_epoch_identity } + + pub fn binding(&self) -> &crate::ProtectedContentBindingV1 { + self.statement.release_request().binding() + } + + pub fn action(&self) -> crate::RightsActionV1 { + self.statement.release_request().action() + } + + pub fn recipient(&self) -> &crate::RecipientKeyIdentityV1 { + self.statement.release_request().recipient() + } + + pub fn validate_node_release_claim_context( + &self, + envelope: &CustodyEnvelopeV1, + node_public_key: NodePublicKey, + now: u64, + ) -> Result<(), RuntimeReleaseOperationError> { + map_active(self.statement.issued_at, self.statement.expires_at, now)?; + let verified = verify_release_operation_statement(&self.statement, now)?; + if !envelope.matches_key_envelope_identity(verified.release.binding().key_envelope())? { + return Err(RuntimeReleaseOperationError::BindingMismatch( + "key_envelope", + )); + } + if envelope.manifest().custody_epoch() != verified.epoch.epoch_identity() { + return Err(RuntimeReleaseOperationError::BindingMismatch( + "custody_epoch", + )); + } + if envelope.manifest().node(node_public_key).is_none() { + return Err(RuntimeReleaseOperationError::BindingMismatch( + "custody_node", + )); + } + Ok(()) + } + + pub fn verify_node_rights_decision( + &self, + decision: &SignedNodeRightsDecisionV1, + node_set: &NodeSetV1, + now: u64, + ) -> Result { + let verified = verify_release_operation_statement(&self.statement, now) + .map_err(map_runtime_release_key_release_error)?; + decision.verify(&verified.release, node_set, now) + } + + pub fn validate_node_contribution_active_window( + &self, + issued_at: u64, + expires_at: u64, + decision: &VerifiedNodeRightsDecisionV1, + now: u64, + ) -> Result<(), KeyReleaseError> { + let verified = verify_release_operation_statement(&self.statement, now) + .map_err(map_runtime_release_key_release_error)?; + crate::validate_node_contribution_active_window( + issued_at, + expires_at, + &verified.release, + decision, + now, + ) + } + + pub fn verify_node_contribution( + &self, + contribution: &SignedNodeContributionV1, + node_set: &NodeSetV1, + now: u64, + ) -> Result { + let verified = verify_release_operation_statement(&self.statement, now) + .map_err(map_runtime_release_key_release_error)?; + contribution.verify(&verified.release, node_set, now) + } } fn map_active( @@ -495,6 +529,89 @@ fn map_active( } } +struct VerifiedReleaseOperationStatementV1 { + release: VerifiedKeyReleaseRequestV1, + epoch: crate::VerifiedCustodyEpochV1, +} + +fn verify_release_operation_statement( + statement: &RuntimeReleaseOperationStatementV1, + now: u64, +) -> Result { + let rights_context = crate::RightsVerificationContextV1::new( + statement.rights_request.request().binding().clone(), + statement.rights_request.request().action(), + statement.rights_request.request().recipient().clone(), + now, + ); + let verified_rights = statement.rights_request.verify_unclaimed(&rights_context)?; + let verified_release = statement + .release_request + .verify_unclaimed(&verified_rights, now)?; + let recipient_context = RecipientKeyAuthorizationContextV1::new( + verified_release.binding().clone(), + verified_release.action(), + statement.recipient_public_key, + statement.runtime_operation_issuer, + now, + ); + let verified_authorization = statement + .recipient_authorization + .verify(&recipient_context)?; + let verified_epoch = statement + .custody_epoch + .verify_against_key_envelope(verified_release.binding().key_envelope())?; + if verified_epoch + .approved_suites() + .recipient_encryption_suite_id() + != verified_authorization + .recipient_identity() + .encryption_suite_id() + { + return Err(RuntimeReleaseOperationError::BindingMismatch( + "recipient_encryption_suite_id", + )); + } + if statement.recipient_public_key.key_identity( + verified_epoch + .approved_suites() + .recipient_encryption_suite_id(), + )? != *verified_authorization.recipient_identity() + { + return Err(RuntimeReleaseOperationError::BindingMismatch( + "recipient_public_key", + )); + } + Ok(VerifiedReleaseOperationStatementV1 { + release: verified_release, + epoch: verified_epoch, + }) +} + +fn map_runtime_release_key_release_error(error: RuntimeReleaseOperationError) -> KeyReleaseError { + match error { + RuntimeReleaseOperationError::Contract(error) => KeyReleaseError::Contract(error), + RuntimeReleaseOperationError::Rights(error) => KeyReleaseError::Rights(error), + RuntimeReleaseOperationError::KeyRelease(error) => error, + RuntimeReleaseOperationError::RecipientAuthorization(_) => { + KeyReleaseError::BindingMismatch("recipient_authorization") + } + RuntimeReleaseOperationError::CustodyEpoch(_) => { + KeyReleaseError::BindingMismatch("custody_epoch") + } + RuntimeReleaseOperationError::BindingMismatch(field) => { + KeyReleaseError::BindingMismatch(field) + } + RuntimeReleaseOperationError::InvalidRuntimeSignature => { + KeyReleaseError::BindingMismatch("runtime_signature") + } + RuntimeReleaseOperationError::NotYetValid => { + KeyReleaseError::Rights(RightsError::NotYetValid) + } + RuntimeReleaseOperationError::Expired => KeyReleaseError::Rights(RightsError::Expired), + } +} + #[cfg(test)] mod tests { use curve25519_dalek::montgomery::MontgomeryPoint; @@ -510,10 +627,9 @@ mod tests { use crate::{ CustodyApprovedSuitesV1, CustodyEpochIssuerKeyV1, CustodyEpochStatementV1, CustodyNodeIdentityV1, EvmContractAddressV1, EvmFunctionSelectorV1, EvmRightsMethodAbiV1, - KeyEnvelopeIdentityV1, NodeCustodyPublicKeyV1, NodePublicKey, ProfileIdentityV1, - ProtectedContentBindingV1, ReplayNonce16, RightsActionV1, RightsObservationFinalityV1, - RightsSubjectSourceV1, RuntimeSessionBindingV1, ShareCoordinateV1, ThresholdV1, - WalletAddress, + NodeCustodyPublicKeyV1, NodePublicKey, ProfileIdentityV1, ProtectedContentBindingV1, + ReplayNonce16, RightsActionV1, RightsObservationFinalityV1, RightsSubjectSourceV1, + RuntimeSessionBindingV1, ShareCoordinateV1, ThresholdV1, WalletAddress, }; fn wallet(seed: u8) -> WalletAddress { @@ -576,6 +692,46 @@ mod tests { .unwrap() } + fn envelope_for_epoch(custody_epoch: CustodyEpochIdentityV1) -> CustodyEnvelopeV1 { + let manifest = crate::CustodyEnvelopeManifestV1::new( + crate::EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(), + custody_epoch, + ThresholdV1::new(2, 3).unwrap(), + digest(0x19), + vec![ + CustodyNodeIdentityV1::new( + node_public_key(1), + custody_public_key(1), + ShareCoordinateV1::new(1).unwrap(), + ) + .unwrap(), + CustodyNodeIdentityV1::new( + node_public_key(2), + custody_public_key(2), + ShareCoordinateV1::new(2).unwrap(), + ) + .unwrap(), + CustodyNodeIdentityV1::new( + node_public_key(3), + custody_public_key(3), + ShareCoordinateV1::new(3).unwrap(), + ) + .unwrap(), + ], + ) + .unwrap(); + let stored_share = crate::HpkeCiphertextV1::new([0x31; 32], [0x41; 48]).unwrap(); + CustodyEnvelopeV1::new( + manifest, + vec![ + stored_share.clone(), + stored_share.clone(), + stored_share.clone(), + ], + ) + .unwrap() + } + fn policy_body() -> RightsPolicyBodyV1 { RightsPolicyBodyV1::new( "content:alpha", @@ -593,16 +749,9 @@ mod tests { fn binding() -> ProtectedContentBindingV1 { let encrypted_content = crate::EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(); - let epoch = signed_epoch(); - let envelope = KeyEnvelopeIdentityV1::new( - encrypted_content.clone(), - digest(0x22), - 512, - epoch.statement().node_set().unwrap().node_set_id().unwrap(), - epoch.statement().threshold(), - epoch.epoch_identity().unwrap(), - ) - .unwrap(); + let envelope = envelope_for_epoch(signed_epoch().epoch_identity().unwrap()) + .key_envelope_identity() + .unwrap(); let profile = SigningKey::from_bytes(&[0x26; 32]); let policy_body = policy_body(); ProtectedContentBindingV1::new( @@ -702,7 +851,7 @@ mod tests { let authenticated = operation.verify(NOW + 3).unwrap(); assert_eq!( encode(authenticated.operation_hash().as_bytes()), - "787f4f626efb5a95122fc9c6cdc3205a1715a094037817d4ce78308cb8318e44" + "f37a1060048335ddaf05fc2c1b767b9983bc6a16af936113b1742af679089058" ); assert_eq!( authenticated.rights_request_hash(), @@ -732,6 +881,58 @@ mod tests { ); } + #[test] + fn runtime_release_operation_exposes_exact_replay_keys_but_stays_non_actionable() { + let operation = signed_operation(); + let authenticated = operation.verify(NOW + 3).unwrap(); + assert_eq!( + authenticated.release_request_hash(), + operation + .statement() + .release_request() + .request_hash() + .unwrap() + ); + } + + #[test] + fn runtime_release_operation_claim_context_rejects_wrong_epoch_and_unknown_node() { + let wrong_epoch = signed_operation() + .verify(NOW + 3) + .unwrap() + .validate_node_release_claim_context( + &envelope_for_epoch(CustodyEpochIdentityV1::new(digest(0xee), 512).unwrap()), + node_public_key(1), + NOW + 3, + ); + assert!(matches!( + wrong_epoch, + Err(RuntimeReleaseOperationError::BindingMismatch( + "key_envelope" + )) + )); + + let unknown_node = signed_operation() + .verify(NOW + 3) + .unwrap() + .validate_node_release_claim_context( + &envelope_for_epoch(signed_epoch().epoch_identity().unwrap()), + NodePublicKey::new( + SigningKey::from_bytes(&[0x66; 32]) + .verifying_key() + .to_bytes(), + ) + .unwrap(), + NOW + 3, + ); + assert!(matches!( + unknown_node, + Err(RuntimeReleaseOperationError::BindingMismatch( + "custody_node" + )) + )); + } + #[test] fn runtime_release_operation_rejects_invalid_wallet_signature_and_release_hash() { let operation = signed_operation(); diff --git a/elastos/crates/elastos-protected-content-custody/Cargo.toml b/elastos/crates/elastos-protected-content-custody/Cargo.toml index db15483db..559a0c6f2 100644 --- a/elastos/crates/elastos-protected-content-custody/Cargo.toml +++ b/elastos/crates/elastos-protected-content-custody/Cargo.toml @@ -9,6 +9,7 @@ description = "Source-only protected-content custody crypto helpers" ed25519-dalek = "2.1" elastos-protected-content-contracts = { path = "../elastos-protected-content-contracts" } hpke = { version = "0.13.0", default-features = false, features = ["std", "x25519"] } +nix = { version = "0.29", features = ["fs", "user"] } # hpke 0.13 and vsss-rs 6.0.1 depend on different rand_core generations. rand09 = { package = "rand", version = "0.9.5" } rand10 = { package = "rand", version = "0.10" } @@ -22,3 +23,4 @@ zeroize = "1.9" elastos-auth = { path = "../elastos-auth" } k256 = { version = "0.13", features = ["ecdsa"] } sha3 = "0.10" +tempfile = "3.12" diff --git a/elastos/crates/elastos-protected-content-custody/src/lib.rs b/elastos/crates/elastos-protected-content-custody/src/lib.rs index 054d9f69f..98d8a0e90 100644 --- a/elastos/crates/elastos-protected-content-custody/src/lib.rs +++ b/elastos/crates/elastos-protected-content-custody/src/lib.rs @@ -4,6 +4,7 @@ mod hpke_helpers; mod provision; mod reconstruct; mod release; +mod replay_store; mod secrets; #[cfg(test)] @@ -14,11 +15,14 @@ use thiserror::Error; pub use provision::provision_custody_envelope; pub use reconstruct::reconstruct_content_key; pub use release::produce_node_contribution; +pub use replay_store::{ClaimedNodeReleaseOperationV1, DurableReplayClaimStoreV1}; pub use secrets::{ ContentEncryptionKeyV1, NodeCustodySecretKeyV1, RecipientPublicKeyV1, RecipientSecretKeyV1, }; -use elastos_protected_content_contracts::{ContractError, KeyReleaseError}; +use elastos_protected_content_contracts::{ + ContractError, KeyReleaseError, ReplayClaimError, RuntimeReleaseOperationError, +}; pub(crate) const CONTENT_KEY_BYTES: usize = 32; pub(crate) const RELEASED_SHARE_TAG_BYTES: usize = 16; @@ -29,6 +33,10 @@ pub enum CustodyError { Contract(#[from] ContractError), #[error(transparent)] Release(#[from] KeyReleaseError), + #[error(transparent)] + RuntimeReleaseOperation(#[from] RuntimeReleaseOperationError), + #[error(transparent)] + Replay(#[from] ReplayClaimError), #[error("hpke operation failed")] Hpke(#[from] hpke::HpkeError), #[error("shamir operation failed: {0}")] diff --git a/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs b/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs index 6d9cd8dba..d3f005c19 100644 --- a/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs +++ b/elastos/crates/elastos-protected-content-custody/src/reconstruct.rs @@ -155,9 +155,10 @@ mod tests { provision::provision_custody_envelope_with_rng, release::produce_node_contribution_with_rng, test_support::{ - content_key, custody_nodes, digest, node_custody_secret, node_signing_key, - provisioned_envelope, recipient_public_key, recipient_secret, signed_node_decision, - verified_release_request, verified_release_request_for_envelope, + claimed_runtime_release_operation_for_envelope_and_node_seed, content_key, + custody_nodes, digest, node_custody_secret, node_signing_key, provisioned_envelope, + recipient_public_key, recipient_secret, signed_node_decision, verified_release_request, + verified_release_request_for_envelope, verified_release_request_for_envelope_and_recipient_seed, NOW, }, }; @@ -172,7 +173,11 @@ mod tests { hpke_seed: u8, ) -> SignedNodeContributionV1 { produce_node_contribution_with_rng( - request, + &claimed_runtime_release_operation_for_envelope_and_node_seed( + envelope, + node_seed, + recipient_seed, + ), &signed_node_decision( request, node_seed, diff --git a/elastos/crates/elastos-protected-content-custody/src/release.rs b/elastos/crates/elastos-protected-content-custody/src/release.rs index 4b5837439..435b07338 100644 --- a/elastos/crates/elastos-protected-content-custody/src/release.rs +++ b/elastos/crates/elastos-protected-content-custody/src/release.rs @@ -3,15 +3,15 @@ use hpke::rand_core::{CryptoRng as HpkeCryptoRng, RngCore as HpkeRngCore}; use rand09::{rngs::StdRng, SeedableRng as _}; use elastos_protected_content_contracts::{ - validate_node_contribution_active_window, CanonicalContract, CustodyEnvelopeV1, - NodeContributionStatementV1, NodePublicKey, RecipientSealedContributionV1, RightsDecisionV1, - SignedNodeContributionV1, SignedNodeRightsDecisionV1, VerifiedKeyReleaseRequestV1, + CanonicalContract, CustodyEnvelopeV1, NodeContributionStatementV1, NodePublicKey, + RecipientSealedContributionV1, RightsDecisionV1, SignedNodeContributionV1, + SignedNodeRightsDecisionV1, }; use crate::{ hpke_helpers::seal_share, secrets::{NodeCustodySecretKeyV1, RecipientPublicKeyV1}, - CustodyError, + ClaimedNodeReleaseOperationV1, CustodyError, }; #[allow(clippy::too_many_arguments)] @@ -33,7 +33,7 @@ use crate::{ /// recipient_public_key: RecipientPublicKeyV1, /// ) { /// let _ = produce_node_contribution( -/// operation.statement().release_request(), +/// operation, /// &signed_rights_decision, /// &envelope, /// &node_signing_key, @@ -46,7 +46,7 @@ use crate::{ /// } /// ``` pub fn produce_node_contribution( - request: &VerifiedKeyReleaseRequestV1, + operation: ClaimedNodeReleaseOperationV1, signed_rights_decision: &SignedNodeRightsDecisionV1, envelope: &CustodyEnvelopeV1, node_signing_key: &SigningKey, @@ -59,7 +59,7 @@ pub fn produce_node_contribution( let mut hpke_rng = StdRng::try_from_os_rng().map_err(|_| CustodyError::RandomnessUnavailable)?; produce_node_contribution_with_rng( - request, + &operation, signed_rights_decision, envelope, node_signing_key, @@ -74,7 +74,7 @@ pub fn produce_node_contribution( #[allow(clippy::too_many_arguments)] pub(crate) fn produce_node_contribution_with_rng( - request: &VerifiedKeyReleaseRequestV1, + operation: &ClaimedNodeReleaseOperationV1, signed_rights_decision: &SignedNodeRightsDecisionV1, envelope: &CustodyEnvelopeV1, node_signing_key: &SigningKey, @@ -85,32 +85,35 @@ pub(crate) fn produce_node_contribution_with_rng now: u64, hpke_rng: &mut R, ) -> Result { - if !envelope.matches_key_envelope_identity(request.binding().key_envelope())? { + if !envelope.matches_key_envelope_identity(operation.binding().key_envelope())? { return Err(CustodyError::BindingMismatch("key_envelope")); } - if request.recipient().encryption_suite_id() + if operation.recipient().encryption_suite_id() != elastos_protected_content_contracts::CUSTODY_HPKE_SUITE_ID_V1 { return Err(CustodyError::BindingMismatch( "recipient_encryption_suite_id", )); } - if recipient_public_key.identity()? != *request.recipient() { + if recipient_public_key.identity()? != *operation.recipient() { return Err(CustodyError::BindingMismatch("recipient_key_identity")); } let node_set = envelope.manifest().node_set()?; - let decision = signed_rights_decision.verify(request, &node_set, now)?; + let decision = operation.verify_node_rights_decision(signed_rights_decision, &node_set, now)?; if decision.decision() != RightsDecisionV1::Allowed { return Err(CustodyError::Release( elastos_protected_content_contracts::KeyReleaseError::RightsDenied, )); } - validate_node_contribution_active_window(issued_at, expires_at, request, &decision, now)?; + operation.validate_node_contribution_active_window(issued_at, expires_at, &decision, now)?; let node_public_key = NodePublicKey::new(node_signing_key.verifying_key().to_bytes())?; if node_public_key != decision.node_public_key() { return Err(CustodyError::BindingMismatch("node_public_key")); } + if node_public_key != operation.selected_node_public_key() { + return Err(CustodyError::BindingMismatch("claimed_node_public_key")); + } let node_entry = envelope .manifest() @@ -135,10 +138,10 @@ pub(crate) fn produce_node_contribution_with_rng &stored_aad, )?; let released_aad = node_entry.released_share_aad_bytes( - request.request_hash(), - request.binding(), + operation.release_request_hash(), + operation.binding(), decision.decision_hash(), - request.recipient(), + operation.recipient(), )?; let released_ciphertext = seal_share( recipient_public_key.as_bytes(), @@ -148,12 +151,12 @@ pub(crate) fn produce_node_contribution_with_rng hpke_rng, )?; let recipient_sealed_contribution = RecipientSealedContributionV1::new( - request.recipient().clone(), + operation.recipient().clone(), released_ciphertext.canonical_bytes()?, )?; let statement = NodeContributionStatementV1::new( - request.request_hash(), - request.binding().clone(), + operation.release_request_hash(), + operation.binding().clone(), signed_rights_decision.clone(), recipient_sealed_contribution, issued_at, @@ -164,7 +167,7 @@ pub(crate) fn produce_node_contribution_with_rng .to_bytes() .to_vec(); let signed = SignedNodeContributionV1::new(statement, signature)?; - signed.verify(request, &node_set, now)?; + operation.verify_node_contribution(&signed, &node_set, now)?; Ok(signed) } @@ -175,9 +178,9 @@ mod tests { use super::*; use crate::hpke_helpers::{open_share, seal_share}; use crate::test_support::{ - node_custody_secret, node_signing_key, provisioned_envelope, recipient_public_key, - signed_node_decision, verified_release_request, verified_release_request_for_envelope, - verified_release_request_with_suite, + claimed_runtime_release_operation_for_envelope_and_node_seed, node_custody_secret, + node_signing_key, provisioned_envelope, recipient_public_key, signed_node_decision, + verified_release_request, verified_release_request_for_envelope, }; use elastos_protected_content_contracts::{ ContractError, KeyReleaseError, RightsError, RIGHTS_CLOCK_SKEW_SECS, @@ -191,12 +194,19 @@ mod tests { envelope.manifest().node_index(node_public_key).unwrap() } + fn claimed_operation( + envelope: &CustodyEnvelopeV1, + node_seed: u8, + ) -> ClaimedNodeReleaseOperationV1 { + claimed_runtime_release_operation_for_envelope_and_node_seed(envelope, node_seed, 0x30) + } + #[test] fn release_produces_a_verified_node_contribution() { let request = verified_release_request(); let envelope = provisioned_envelope(); let signed = produce_node_contribution( - &request, + claimed_operation(&envelope, 1), &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), &envelope, &node_signing_key(1), @@ -228,7 +238,7 @@ mod tests { let request = verified_release_request(); let envelope = provisioned_envelope(); let err = produce_node_contribution( - &request, + claimed_operation(&envelope, 1), &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), &envelope, &node_signing_key(2), @@ -246,14 +256,14 @@ mod tests { } #[test] - fn release_rejects_wrong_custody_secret() { + fn release_rejects_claimed_operation_for_a_different_node() { let request = verified_release_request(); let envelope = provisioned_envelope(); let err = produce_node_contribution( - &request, - &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), + claimed_operation(&envelope, 1), + &signed_node_decision(&request, 2, RightsDecisionV1::Allowed), &envelope, - &node_signing_key(1), + &node_signing_key(2), &node_custody_secret(2), &recipient_public_key(0x30), crate::test_support::NOW + 5, @@ -263,21 +273,20 @@ mod tests { .unwrap_err(); assert!(matches!( err, - CustodyError::BindingMismatch("node_custody_public_key") + CustodyError::BindingMismatch("claimed_node_public_key") )); } #[test] - fn release_rejects_wrong_recipient_suite_or_public_key() { - let wrong_suite_request = - verified_release_request_with_suite("x25519-hkdf-sha256-aes256gcm/v1"); + fn release_rejects_wrong_custody_secret() { + let request = verified_release_request(); let envelope = provisioned_envelope(); let err = produce_node_contribution( - &wrong_suite_request, - &signed_node_decision(&wrong_suite_request, 1, RightsDecisionV1::Allowed), + claimed_operation(&envelope, 1), + &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), &envelope, &node_signing_key(1), - &node_custody_secret(1), + &node_custody_secret(2), &recipient_public_key(0x30), crate::test_support::NOW + 5, crate::test_support::NOW + 45, @@ -286,12 +295,16 @@ mod tests { .unwrap_err(); assert!(matches!( err, - CustodyError::BindingMismatch("recipient_encryption_suite_id") + CustodyError::BindingMismatch("node_custody_public_key") )); + } + #[test] + fn release_rejects_wrong_recipient_public_key() { + let envelope = provisioned_envelope(); let request = verified_release_request(); let err = produce_node_contribution( - &request, + claimed_operation(&envelope, 1), &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), &envelope, &node_signing_key(1), @@ -313,7 +326,7 @@ mod tests { let request = verified_release_request(); let envelope = provisioned_envelope(); let err = produce_node_contribution( - &request, + claimed_operation(&envelope, 1), &signed_node_decision(&request, 1, RightsDecisionV1::Denied), &envelope, &node_signing_key(1), @@ -347,7 +360,7 @@ mod tests { .unwrap(); let tampered = CustodyEnvelopeV1::new(envelope.manifest().clone(), stored_shares).unwrap(); let err = produce_node_contribution_with_rng( - &request, + &claimed_operation(&envelope, 1), &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), &tampered, &node_signing_key(1), @@ -377,7 +390,7 @@ mod tests { let tampered = CustodyEnvelopeV1::new(envelope.manifest().clone(), stored_shares).unwrap(); let request = verified_release_request_for_envelope(&tampered); let err = produce_node_contribution_with_rng( - &request, + &claimed_operation(&tampered, 1), &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), &tampered, &node_signing_key(1), @@ -437,7 +450,7 @@ mod tests { let request = verified_release_request_for_envelope(&tampered); let err = produce_node_contribution_with_rng( - &request, + &claimed_operation(&tampered, 1), &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), &tampered, &node_signing_key(1), @@ -467,7 +480,7 @@ mod tests { let tampered = CustodyEnvelopeV1::new(envelope.manifest().clone(), stored_shares).unwrap(); let request = verified_release_request_for_envelope(&tampered); let err = produce_node_contribution_with_rng( - &request, + &claimed_operation(&tampered, 1), &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), &tampered, &node_signing_key(1), @@ -507,7 +520,7 @@ mod tests { let request = verified_release_request(); let envelope = provisioned_envelope(); produce_node_contribution( - &request, + claimed_operation(&envelope, 1), &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), &envelope, &node_signing_key(1), @@ -525,7 +538,7 @@ mod tests { let request = verified_release_request(); let envelope = provisioned_envelope(); let err = produce_node_contribution( - &request, + claimed_operation(&envelope, 1), &signed_node_decision(&request, 1, RightsDecisionV1::Allowed), &envelope, &node_signing_key(1), diff --git a/elastos/crates/elastos-protected-content-custody/src/replay_store.rs b/elastos/crates/elastos-protected-content-custody/src/replay_store.rs new file mode 100644 index 000000000..fcbe5280d --- /dev/null +++ b/elastos/crates/elastos-protected-content-custody/src/replay_store.rs @@ -0,0 +1,1075 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{Read as _, Write as _}; +use std::path::{Path, PathBuf}; + +use nix::fcntl::{Flock, FlockArg}; +use nix::unistd::geteuid; +use sha2::{Digest as _, Sha256}; + +use elastos_protected_content_contracts::{ + AuthenticatedRuntimeReleaseOperationV1, CustodyEnvelopeV1, Digest32, NodePublicKey, NodeSetV1, + ReplayClaimEntryV1, ReplayClaimError, ReplayClaimKeyV1, ReplayNonce16, + SignedNodeContributionV1, SignedNodeRightsDecisionV1, VerifiedNodeContributionV1, + VerifiedNodeRightsDecisionV1, +}; + +use crate::CustodyError; + +const STATE_MAGIC: &[u8; 8] = b"epc-rcl1"; +const STATE_DIGEST_DOMAIN: &[u8] = b"elastos/protected-content/node-replay-claims/state/v1"; +const STATE_HEADER_BYTES: usize = 44; +const STATE_ENTRY_BYTES: usize = 56; +const STATE_DIGEST_BYTES: usize = 32; +const MAX_REPLAY_CLAIMS: usize = 4096; +const MAX_STATE_PAYLOAD_BYTES: usize = STATE_HEADER_BYTES + (MAX_REPLAY_CLAIMS * STATE_ENTRY_BYTES); +const MAX_STATE_BYTES: usize = MAX_STATE_PAYLOAD_BYTES + STATE_DIGEST_BYTES; + +/// Atomically claimed node-release authority for one custody node. +/// +/// This token is non-serializable and has no public constructor. Only the +/// concrete owner-only durable replay store can return it after the exact +/// dual-key claim succeeds. +/// +/// ```compile_fail +/// use elastos_protected_content_contracts::{AuthenticatedRuntimeReleaseOperationV1, NodePublicKey}; +/// use elastos_protected_content_custody::ClaimedNodeReleaseOperationV1; +/// +/// fn cannot_fabricate( +/// operation: AuthenticatedRuntimeReleaseOperationV1, +/// node: NodePublicKey, +/// ) { +/// let _ = ClaimedNodeReleaseOperationV1 { +/// authenticated_operation: operation, +/// selected_node_public_key: node, +/// }; +/// } +/// ``` +#[derive(Debug)] +pub struct ClaimedNodeReleaseOperationV1 { + authenticated_operation: AuthenticatedRuntimeReleaseOperationV1, + selected_node_public_key: NodePublicKey, +} + +impl ClaimedNodeReleaseOperationV1 { + pub const fn operation_hash(&self) -> Digest32 { + self.authenticated_operation.operation_hash() + } + + pub const fn rights_request_hash(&self) -> Digest32 { + self.authenticated_operation.rights_request_hash() + } + + pub const fn release_request_hash(&self) -> Digest32 { + self.authenticated_operation.release_request_hash() + } + + pub const fn recipient_authorization_hash(&self) -> Digest32 { + self.authenticated_operation.recipient_authorization_hash() + } + + pub const fn selected_node_public_key(&self) -> NodePublicKey { + self.selected_node_public_key + } + + pub fn binding(&self) -> &elastos_protected_content_contracts::ProtectedContentBindingV1 { + self.authenticated_operation.binding() + } + + pub fn action(&self) -> elastos_protected_content_contracts::RightsActionV1 { + self.authenticated_operation.action() + } + + pub fn recipient(&self) -> &elastos_protected_content_contracts::RecipientKeyIdentityV1 { + self.authenticated_operation.recipient() + } + + pub fn verify_node_rights_decision( + &self, + decision: &SignedNodeRightsDecisionV1, + node_set: &NodeSetV1, + now: u64, + ) -> Result { + Ok(self + .authenticated_operation + .verify_node_rights_decision(decision, node_set, now)?) + } + + pub fn validate_node_contribution_active_window( + &self, + issued_at: u64, + expires_at: u64, + decision: &VerifiedNodeRightsDecisionV1, + now: u64, + ) -> Result<(), CustodyError> { + Ok(self + .authenticated_operation + .validate_node_contribution_active_window(issued_at, expires_at, decision, now)?) + } + + pub fn verify_node_contribution( + &self, + contribution: &SignedNodeContributionV1, + node_set: &NodeSetV1, + now: u64, + ) -> Result { + Ok(self + .authenticated_operation + .verify_node_contribution(contribution, node_set, now)?) + } +} + +#[derive(Debug, Clone)] +pub struct DurableReplayClaimStoreV1 { + node_public_key: NodePublicKey, + state_dir: PathBuf, + lock_path: PathBuf, + state_path: PathBuf, + temp_path: PathBuf, +} + +impl DurableReplayClaimStoreV1 { + pub fn new(node_public_key: NodePublicKey, state_dir: impl Into) -> Self { + let state_dir = state_dir.into(); + Self { + node_public_key, + lock_path: state_dir.join("node-replay-claims.lock"), + state_path: state_dir.join("node-replay-claims.v1"), + temp_path: state_dir.join("node-replay-claims.tmp"), + state_dir, + } + } + + pub const fn node_public_key(&self) -> NodePublicKey { + self.node_public_key + } + + pub fn claim_node_release_operation( + &mut self, + operation: AuthenticatedRuntimeReleaseOperationV1, + envelope: &CustodyEnvelopeV1, + selected_node_public_key: NodePublicKey, + now: u64, + ) -> Result { + if selected_node_public_key != self.node_public_key { + return Err(CustodyError::BindingMismatch("store_node_public_key")); + } + operation.validate_node_release_claim_context(envelope, selected_node_public_key, now)?; + self.claim_pair_inner( + [ + ReplayClaimEntryV1::new( + operation.rights_request_replay_claim_key(), + operation + .statement() + .rights_request() + .request() + .expires_at(), + ), + ReplayClaimEntryV1::new( + operation.release_request_replay_claim_key(), + operation.statement().release_request().expires_at(), + ), + ], + now, + )?; + Ok(ClaimedNodeReleaseOperationV1 { + authenticated_operation: operation, + selected_node_public_key, + }) + } + + fn claim_pair_inner( + &self, + mut claims: [ReplayClaimEntryV1; 2], + now: u64, + ) -> Result<(), ReplayClaimError> { + self.ensure_state_dir()?; + let _lock = ExclusiveFileLock::acquire(&self.lock_path)?; + self.cleanup_stale_temp()?; + + let mut entries = self.read_state()?; + entries.retain(|entry| entry.expires_at() > now); + + sort_claims(&mut claims); + if claims[0].key() == claims[1].key() { + return Err(ReplayClaimError::Unavailable); + } + if claims + .iter() + .any(|claim| entries.iter().any(|entry| entry.key() == claim.key())) + { + return Err(ReplayClaimError::AlreadyClaimed); + } + + entries.extend(claims); + entries.sort_unstable_by_key(|entry| replay_claim_sort_key(entry.key())); + if entries.len() > MAX_REPLAY_CLAIMS { + return Err(ReplayClaimError::Unavailable); + } + + self.write_state(&entries) + } + + fn ensure_state_dir(&self) -> Result<(), ReplayClaimError> { + let parent = self + .state_dir + .parent() + .ok_or(ReplayClaimError::Unavailable)?; + validate_owner_only_directory(parent)?; + match fs::symlink_metadata(&self.state_dir) { + Ok(metadata) => validate_owner_only_directory_metadata(&self.state_dir, &metadata), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + create_owner_only_directory(&self.state_dir)?; + sync_directory(parent)?; + validate_owner_only_directory(&self.state_dir) + } + Err(_) => Err(ReplayClaimError::Unavailable), + } + } + + fn cleanup_stale_temp(&self) -> Result<(), ReplayClaimError> { + match fs::symlink_metadata(&self.temp_path) { + Ok(metadata) => { + validate_owner_only_regular_file_metadata(&self.temp_path, &metadata)?; + fs::remove_file(&self.temp_path).map_err(|_| ReplayClaimError::Unavailable)?; + sync_directory(&self.state_dir) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(ReplayClaimError::Unavailable), + } + } + + fn read_state(&self) -> Result, ReplayClaimError> { + let metadata = match fs::symlink_metadata(&self.state_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(_) => return Err(ReplayClaimError::Unavailable), + }; + validate_owner_only_regular_file_metadata(&self.state_path, &metadata)?; + let metadata_len = + usize::try_from(metadata.len()).map_err(|_| ReplayClaimError::Unavailable)?; + if metadata_len > MAX_STATE_BYTES { + return Err(ReplayClaimError::Unavailable); + } + + let file = open_owner_only_file(&self.state_path, false)?; + let mut bytes = Vec::with_capacity(metadata_len); + file.take((MAX_STATE_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| ReplayClaimError::Unavailable)?; + if bytes.len() > MAX_STATE_BYTES { + return Err(ReplayClaimError::Unavailable); + } + decode_state(&bytes, self.node_public_key) + } + + fn write_state(&self, entries: &[ReplayClaimEntryV1]) -> Result<(), ReplayClaimError> { + match fs::symlink_metadata(&self.state_path) { + Ok(metadata) => validate_owner_only_regular_file_metadata(&self.state_path, &metadata)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(ReplayClaimError::Unavailable), + } + let mut file = open_owner_only_temp_file_for_write(&self.temp_path)?; + let bytes = encode_state(self.node_public_key, entries)?; + file.write_all(&bytes) + .map_err(|_| ReplayClaimError::Unavailable)?; + file.sync_all().map_err(|_| ReplayClaimError::Unavailable)?; + validate_owner_only_regular_file_metadata( + &self.temp_path, + &file.metadata().map_err(|_| ReplayClaimError::Unavailable)?, + )?; + maybe_inject_test_fault(&self.state_dir)?; + fs::rename(&self.temp_path, &self.state_path).map_err(|_| ReplayClaimError::Unavailable)?; + sync_directory(&self.state_dir) + } +} + +#[derive(Debug)] +struct ExclusiveFileLock { + file: Option>, +} + +impl ExclusiveFileLock { + fn acquire(path: &Path) -> Result { + let file = open_or_create_owner_only_lock_file(path)?; + let file = Flock::lock(file, FlockArg::LockExclusive) + .map_err(|_| ReplayClaimError::Unavailable)?; + validate_owner_only_regular_file_metadata( + path, + &file.metadata().map_err(|_| ReplayClaimError::Unavailable)?, + )?; + Ok(Self { file: Some(file) }) + } +} + +impl Drop for ExclusiveFileLock { + fn drop(&mut self) { + if let Some(file) = self.file.take() { + let _ = file.unlock(); + } + } +} + +fn create_owner_only_directory(path: &Path) -> Result<(), ReplayClaimError> { + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + match builder.create(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(_) => Err(ReplayClaimError::Unavailable), + } +} + +fn open_owner_only_file(path: &Path, write: bool) -> Result { + let mut options = OpenOptions::new(); + options.read(true).write(write); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(nix::libc::O_NOFOLLOW); + } + let file = options + .open(path) + .map_err(|_| ReplayClaimError::Unavailable)?; + validate_owner_only_regular_file_metadata( + path, + &file.metadata().map_err(|_| ReplayClaimError::Unavailable)?, + )?; + Ok(file) +} + +fn open_or_create_owner_only_lock_file(path: &Path) -> Result { + let parent = path.parent().ok_or(ReplayClaimError::Unavailable)?; + validate_owner_only_directory(parent)?; + + let mut create_new = OpenOptions::new(); + create_new.read(true).write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + create_new.mode(0o600).custom_flags(nix::libc::O_NOFOLLOW); + } + match create_new.open(path) { + Ok(file) => { + validate_owner_only_regular_file_metadata( + path, + &file.metadata().map_err(|_| ReplayClaimError::Unavailable)?, + )?; + sync_directory(parent)?; + Ok(file) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + open_owner_only_file(path, true) + } + Err(_) => Err(ReplayClaimError::Unavailable), + } +} + +fn open_owner_only_temp_file_for_write(path: &Path) -> Result { + let parent = path.parent().ok_or(ReplayClaimError::Unavailable)?; + validate_owner_only_directory(parent)?; + + let mut options = OpenOptions::new(); + options.create_new(true).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(nix::libc::O_NOFOLLOW); + } + let file = options + .open(path) + .map_err(|_| ReplayClaimError::Unavailable)?; + validate_owner_only_regular_file_metadata( + path, + &file.metadata().map_err(|_| ReplayClaimError::Unavailable)?, + )?; + sync_directory(parent)?; + Ok(file) +} + +fn sync_directory(path: &Path) -> Result<(), ReplayClaimError> { + File::open(path) + .and_then(|file| file.sync_all()) + .map_err(|_| ReplayClaimError::Unavailable) +} + +fn validate_owner_only_directory(path: &Path) -> Result<(), ReplayClaimError> { + let metadata = fs::symlink_metadata(path).map_err(|_| ReplayClaimError::Unavailable)?; + validate_owner_only_directory_metadata(path, &metadata) +} + +fn validate_owner_only_directory_metadata( + _path: &Path, + metadata: &fs::Metadata, +) -> Result<(), ReplayClaimError> { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(ReplayClaimError::Unavailable); + } + validate_owner_and_mode(metadata, 0o700) +} + +fn validate_owner_only_regular_file_metadata( + _path: &Path, + metadata: &fs::Metadata, +) -> Result<(), ReplayClaimError> { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(ReplayClaimError::Unavailable); + } + validate_owner_and_mode(metadata, 0o600)?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + + if metadata.nlink() != 1 { + return Err(ReplayClaimError::Unavailable); + } + } + Ok(()) +} + +fn validate_owner_and_mode( + metadata: &fs::Metadata, + exact_mode: u32, +) -> Result<(), ReplayClaimError> { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + + if metadata.uid() != geteuid().as_raw() || metadata.mode() & 0o777 != exact_mode { + return Err(ReplayClaimError::Unavailable); + } + } + Ok(()) +} + +fn sort_claims(claims: &mut [ReplayClaimEntryV1; 2]) { + if replay_claim_sort_key(claims[1].key()) < replay_claim_sort_key(claims[0].key()) { + claims.swap(0, 1); + } +} + +fn replay_claim_sort_key(key: ReplayClaimKeyV1) -> [u8; 48] { + let mut bytes = [0u8; 48]; + bytes[..32].copy_from_slice(key.authority_scope_hash().as_bytes()); + bytes[32..].copy_from_slice(key.nonce().as_bytes()); + bytes +} + +fn encode_state( + node_public_key: NodePublicKey, + entries: &[ReplayClaimEntryV1], +) -> Result, ReplayClaimError> { + if entries.len() > MAX_REPLAY_CLAIMS { + return Err(ReplayClaimError::Unavailable); + } + let mut payload = Vec::with_capacity(STATE_HEADER_BYTES + entries.len() * STATE_ENTRY_BYTES); + payload.extend_from_slice(STATE_MAGIC); + payload.extend_from_slice(node_public_key.as_bytes()); + payload.extend_from_slice(&(entries.len() as u32).to_be_bytes()); + for entry in entries { + payload.extend_from_slice(entry.key().authority_scope_hash().as_bytes()); + payload.extend_from_slice(entry.key().nonce().as_bytes()); + payload.extend_from_slice(&entry.expires_at().to_be_bytes()); + } + let digest = state_integrity_digest(&payload); + let mut bytes = payload; + bytes.extend_from_slice(&digest); + Ok(bytes) +} + +fn decode_state( + bytes: &[u8], + expected_node_public_key: NodePublicKey, +) -> Result, ReplayClaimError> { + if bytes.len() < STATE_HEADER_BYTES + STATE_DIGEST_BYTES { + return Err(ReplayClaimError::Unavailable); + } + let (payload, digest) = bytes.split_at(bytes.len() - STATE_DIGEST_BYTES); + if payload[..8] != *STATE_MAGIC { + return Err(ReplayClaimError::Unavailable); + } + if state_integrity_digest(payload) != digest { + return Err(ReplayClaimError::Unavailable); + } + if payload[8..40] != *expected_node_public_key.as_bytes() { + return Err(ReplayClaimError::Unavailable); + } + let count = u32::from_be_bytes( + payload[40..44] + .try_into() + .map_err(|_| ReplayClaimError::Unavailable)?, + ) as usize; + if count > MAX_REPLAY_CLAIMS { + return Err(ReplayClaimError::Unavailable); + } + if payload.len() != STATE_HEADER_BYTES + (count * STATE_ENTRY_BYTES) { + return Err(ReplayClaimError::Unavailable); + } + + let mut entries = Vec::with_capacity(count); + let mut previous = None; + let mut offset = STATE_HEADER_BYTES; + for _ in 0..count { + let mut authority_scope_hash = [0u8; 32]; + authority_scope_hash.copy_from_slice(&payload[offset..offset + 32]); + offset += 32; + let mut nonce = [0u8; 16]; + nonce.copy_from_slice(&payload[offset..offset + 16]); + offset += 16; + let expires_at = u64::from_be_bytes( + payload[offset..offset + 8] + .try_into() + .map_err(|_| ReplayClaimError::Unavailable)?, + ); + offset += 8; + let entry = ReplayClaimEntryV1::new( + ReplayClaimKeyV1::new( + Digest32::new(authority_scope_hash), + ReplayNonce16::new(nonce), + ), + expires_at, + ); + let sort_key = replay_claim_sort_key(entry.key()); + if previous.is_some_and(|prior| prior >= sort_key) { + return Err(ReplayClaimError::Unavailable); + } + previous = Some(sort_key); + entries.push(entry); + } + Ok(entries) +} + +fn state_integrity_digest(payload: &[u8]) -> [u8; STATE_DIGEST_BYTES] { + let mut hasher = Sha256::new(); + hasher.update(STATE_DIGEST_DOMAIN); + hasher.update(payload); + hasher.finalize().into() +} + +#[cfg(not(test))] +fn maybe_inject_test_fault(_state_dir: &Path) -> Result<(), ReplayClaimError> { + Ok(()) +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TestFault { + RenameStateDirBeforeRename, +} + +#[cfg(test)] +static TEST_FAULT: std::sync::Mutex> = std::sync::Mutex::new(None); + +#[cfg(test)] +fn maybe_inject_test_fault(state_dir: &Path) -> Result<(), ReplayClaimError> { + let fault = { + let mut guard = TEST_FAULT + .lock() + .map_err(|_| ReplayClaimError::Unavailable)?; + match guard.as_ref() { + Some((fault, path)) if path == state_dir => { + let fault = *fault; + guard.take(); + Some(fault) + } + _ => None, + } + }; + match fault { + Some(TestFault::RenameStateDirBeforeRename) => { + let moved = state_dir.with_extension("fault"); + if moved.exists() { + if moved.is_dir() { + fs::remove_dir_all(&moved).map_err(|_| ReplayClaimError::Unavailable)?; + } else { + fs::remove_file(&moved).map_err(|_| ReplayClaimError::Unavailable)?; + } + } + fs::rename(state_dir, moved).map_err(|_| ReplayClaimError::Unavailable) + } + None => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use std::io::{BufRead as _, BufReader, Write as _}; + use std::process::{Command, Stdio}; + use std::sync::{Arc, Barrier}; + + use ed25519_dalek::SigningKey; + + use super::*; + use crate::test_support::{ + authenticated_runtime_release_operation_for_envelope_and_recipient_seed, node_public_key, + provisioned_envelope, NOW, + }; + + fn claim(seed: u8, expiry: u64) -> ReplayClaimEntryV1 { + ReplayClaimEntryV1::new( + ReplayClaimKeyV1::new(Digest32::new([seed; 32]), ReplayNonce16::new([seed; 16])), + expiry, + ) + } + + fn store_node(seed: u8) -> NodePublicKey { + NodePublicKey::new( + SigningKey::from_bytes(&[seed; 32]) + .verifying_key() + .to_bytes(), + ) + .unwrap() + } + + fn owner_only_tempdir() -> tempfile::TempDir { + let temp = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)).unwrap(); + } + temp + } + + fn store_dir(temp: &tempfile::TempDir) -> PathBuf { + temp.path().join("replay") + } + + fn durable_store(seed: u8, temp: &tempfile::TempDir) -> DurableReplayClaimStoreV1 { + DurableReplayClaimStoreV1::new(store_node(seed), store_dir(temp)) + } + + #[test] + fn durable_store_claims_both_keys_atomically_and_persists_across_restart() { + let temp = owner_only_tempdir(); + let store = durable_store(1, &temp); + store + .claim_pair_inner([claim(1, 30), claim(2, 40)], 10) + .unwrap(); + + let reopened = durable_store(1, &temp); + assert_eq!( + reopened.claim_pair_inner([claim(1, 30), claim(2, 40)], 10), + Err(ReplayClaimError::AlreadyClaimed) + ); + } + + #[test] + fn durable_store_rejects_partial_overlap_without_claiming_new_key() { + let temp = owner_only_tempdir(); + let store = durable_store(1, &temp); + store + .claim_pair_inner([claim(1, 30), claim(2, 40)], 10) + .unwrap(); + assert_eq!( + store.claim_pair_inner([claim(2, 40), claim(3, 50)], 11), + Err(ReplayClaimError::AlreadyClaimed) + ); + store + .claim_pair_inner([claim(3, 50), claim(4, 60)], 11) + .unwrap(); + } + + #[test] + fn durable_store_prunes_expired_claims_inside_the_same_transaction() { + let temp = owner_only_tempdir(); + let store = durable_store(1, &temp); + store + .claim_pair_inner([claim(1, 20), claim(2, 20)], 10) + .unwrap(); + store + .claim_pair_inner([claim(1, 40), claim(2, 40)], 21) + .unwrap(); + } + + #[test] + fn durable_store_serializes_concurrent_claims() { + let temp = owner_only_tempdir(); + let store_dir = store_dir(&temp); + let barrier = Arc::new(Barrier::new(3)); + let results = std::thread::scope(|scope| { + let mut handles = Vec::new(); + for _ in 0..2 { + let barrier = Arc::clone(&barrier); + let store_dir = store_dir.clone(); + handles.push(scope.spawn(move || { + let store = DurableReplayClaimStoreV1::new(store_node(1), store_dir); + barrier.wait(); + store.claim_pair_inner([claim(1, 30), claim(2, 40)], 10) + })); + } + barrier.wait(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>() + }); + let success = results.iter().filter(|result| result.is_ok()).count(); + let conflicts = results + .iter() + .filter(|result| matches!(result, Err(ReplayClaimError::AlreadyClaimed))) + .count(); + assert_eq!(success, 1); + assert_eq!(conflicts, 1); + } + + #[test] + fn durable_store_serializes_simultaneous_process_claims() { + if std::env::var_os("ELASTOS_CUSTODY_REPLAY_RACE_CHILD").is_some() { + let dir = PathBuf::from(std::env::var_os("ELASTOS_CUSTODY_REPLAY_DIR").unwrap()); + println!("ready"); + std::io::stdout().flush().unwrap(); + let mut line = String::new(); + std::io::stdin().read_line(&mut line).unwrap(); + let store = DurableReplayClaimStoreV1::new(store_node(1), dir); + let code = match store.claim_pair_inner([claim(1, 30), claim(2, 40)], 10) { + Ok(()) => 10, + Err(ReplayClaimError::AlreadyClaimed) => 11, + Err(ReplayClaimError::Unavailable) => 12, + }; + std::process::exit(code); + } + + let temp = owner_only_tempdir(); + let exact_test = + "replay_store::tests::durable_store_serializes_simultaneous_process_claims"; + let mut children = Vec::new(); + for _ in 0..2 { + let mut child = Command::new(std::env::current_exe().unwrap()) + .env("ELASTOS_CUSTODY_REPLAY_RACE_CHILD", "1") + .env("ELASTOS_CUSTODY_REPLAY_DIR", store_dir(&temp)) + .arg("--exact") + .arg(exact_test) + .arg("--nocapture") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + + let mut ready = String::new(); + let mut reader = BufReader::new(child.stdout.take().unwrap()); + loop { + reader.read_line(&mut ready).unwrap(); + if ready.is_empty() { + panic!("child exited before ready barrier"); + } + if ready.trim() == "ready" { + break; + } + ready.clear(); + } + drop(reader); + children.push(child); + } + + for child in &mut children { + child.stdin.as_mut().unwrap().write_all(b"go\n").unwrap(); + } + + let mut statuses = Vec::new(); + for mut child in children { + statuses.push(child.wait().unwrap().code().unwrap()); + } + + let success = statuses.iter().filter(|code| **code == 10).count(); + let conflicts = statuses.iter().filter(|code| **code == 11).count(); + assert_eq!(success, 1); + assert_eq!(conflicts, 1); + } + + #[test] + fn durable_store_rejects_corrupt_truncated_and_oversized_state() { + let temp = owner_only_tempdir(); + let state_dir = store_dir(&temp); + fs::create_dir(&state_dir).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&state_dir, fs::Permissions::from_mode(0o700)).unwrap(); + } + + fs::write(state_dir.join("node-replay-claims.v1"), b"broken").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + state_dir.join("node-replay-claims.v1"), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } + let corrupt = durable_store(1, &temp); + assert_eq!( + corrupt.claim_pair_inner([claim(1, 30), claim(2, 40)], 10), + Err(ReplayClaimError::Unavailable) + ); + + fs::write( + state_dir.join("node-replay-claims.v1"), + vec![0u8; MAX_STATE_BYTES + 1], + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + state_dir.join("node-replay-claims.v1"), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } + let oversized = durable_store(1, &temp); + assert_eq!( + oversized.claim_pair_inner([claim(1, 30), claim(2, 40)], 10), + Err(ReplayClaimError::Unavailable) + ); + } + + #[test] + fn durable_store_rejects_same_length_state_corruption_and_forged_digest() { + let temp = owner_only_tempdir(); + let store = durable_store(1, &temp); + store + .claim_pair_inner([claim(1, 30), claim(2, 40)], 10) + .unwrap(); + + let state_path = store_dir(&temp).join("node-replay-claims.v1"); + let original = fs::read(&state_path).unwrap(); + let cases = [ + ("key-byte", STATE_HEADER_BYTES + 5), + ("expiry-byte", STATE_HEADER_BYTES + 32 + 16 + 7), + ("digest-byte", original.len() - 1), + ]; + + for (label, index) in cases { + let mut corrupted = original.clone(); + corrupted[index] ^= 0x5a; + fs::write(&state_path, &corrupted).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&state_path, fs::Permissions::from_mode(0o600)).unwrap(); + } + + let corrupted_store = durable_store(1, &temp); + assert_eq!( + corrupted_store.claim_pair_inner([claim(3, 50), claim(4, 60)], 11), + Err(ReplayClaimError::Unavailable), + "corruption case {label} should fail closed" + ); + + fs::write(&state_path, &original).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&state_path, fs::Permissions::from_mode(0o600)).unwrap(); + } + } + } + + #[test] + fn durable_store_rejects_symlinked_loose_mode_and_hard_linked_paths() { + let temp = owner_only_tempdir(); + #[cfg(unix)] + { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let state_dir = store_dir(&temp); + fs::create_dir(&state_dir).unwrap(); + fs::set_permissions(&state_dir, fs::Permissions::from_mode(0o700)).unwrap(); + + let target = temp.path().join("outside"); + fs::write(&target, b"state").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + symlink(&target, state_dir.join("node-replay-claims.v1")).unwrap(); + + let symlink_store = durable_store(1, &temp); + assert_eq!( + symlink_store.claim_pair_inner([claim(1, 30), claim(2, 40)], 10), + Err(ReplayClaimError::Unavailable) + ); + + fs::remove_file(state_dir.join("node-replay-claims.v1")).unwrap(); + fs::write( + state_dir.join("node-replay-claims.v1"), + encode_state(store_node(1), &[]).unwrap(), + ) + .unwrap(); + fs::set_permissions( + state_dir.join("node-replay-claims.v1"), + fs::Permissions::from_mode(0o644), + ) + .unwrap(); + let mode_store = durable_store(1, &temp); + assert_eq!( + mode_store.claim_pair_inner([claim(1, 30), claim(2, 40)], 10), + Err(ReplayClaimError::Unavailable) + ); + + fs::remove_file(state_dir.join("node-replay-claims.v1")).unwrap(); + let hard_link_target = state_dir.join("lock-target"); + fs::write(&hard_link_target, b"keepme").unwrap(); + fs::set_permissions(&hard_link_target, fs::Permissions::from_mode(0o600)).unwrap(); + let _ = fs::remove_file(state_dir.join("node-replay-claims.lock")); + fs::hard_link(&hard_link_target, state_dir.join("node-replay-claims.lock")).unwrap(); + let hard_link_store = durable_store(1, &temp); + assert_eq!( + hard_link_store.claim_pair_inner([claim(1, 30), claim(2, 40)], 10), + Err(ReplayClaimError::Unavailable) + ); + assert_eq!(fs::read(&hard_link_target).unwrap(), b"keepme"); + } + } + + #[test] + fn durable_store_rejects_non_not_found_state_metadata_errors_before_temp_write() { + let temp = owner_only_tempdir(); + let blocker = temp.path().join("not-a-dir"); + fs::write(&blocker, b"blocker").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&blocker, fs::Permissions::from_mode(0o600)).unwrap(); + } + + let store = + DurableReplayClaimStoreV1::new(store_node(1), blocker.join("replay-store-state")); + assert_eq!(store.write_state(&[]), Err(ReplayClaimError::Unavailable)); + assert_eq!(fs::read(&blocker).unwrap(), b"blocker"); + assert!(!blocker.join("replay-store-state").exists()); + } + + #[test] + fn durable_store_recovers_valid_stale_temp_and_rejects_wrong_node_store() { + let temp = owner_only_tempdir(); + let state_dir = store_dir(&temp); + fs::create_dir(&state_dir).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&state_dir, fs::Permissions::from_mode(0o700)).unwrap(); + } + + fs::write(state_dir.join("node-replay-claims.tmp"), b"stale-temp").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + state_dir.join("node-replay-claims.tmp"), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } + let recovered = durable_store(1, &temp); + recovered + .claim_pair_inner([claim(1, 30), claim(2, 40)], 10) + .unwrap(); + assert!(!state_dir.join("node-replay-claims.tmp").exists()); + + let wrong_node = durable_store(2, &temp); + assert_eq!( + wrong_node.claim_pair_inner([claim(3, 30), claim(4, 40)], 10), + Err(ReplayClaimError::Unavailable) + ); + } + + #[test] + fn durable_store_rejects_wrong_selected_node_before_state_write_and_persists_node_identity() { + let temp = owner_only_tempdir(); + let envelope = provisioned_envelope(); + let operation = authenticated_runtime_release_operation_for_envelope_and_recipient_seed( + &envelope, 0x30, + ); + let mut wrong_store = durable_store(2, &temp); + assert!(matches!( + wrong_store.claim_node_release_operation( + operation, + &envelope, + node_public_key(1), + NOW + 3 + ), + Err(CustodyError::BindingMismatch("store_node_public_key")) + )); + assert!(!store_dir(&temp).exists()); + + let mut correct_store = durable_store(1, &temp); + let operation = authenticated_runtime_release_operation_for_envelope_and_recipient_seed( + &envelope, 0x30, + ); + correct_store + .claim_node_release_operation(operation, &envelope, node_public_key(1), NOW + 3) + .unwrap(); + + let mut reopened_wrong_store = durable_store(2, &temp); + let operation = authenticated_runtime_release_operation_for_envelope_and_recipient_seed( + &envelope, 0x30, + ); + assert!(matches!( + reopened_wrong_store.claim_node_release_operation( + operation, + &envelope, + node_public_key(2), + NOW + 3 + ), + Err(CustodyError::Replay(ReplayClaimError::Unavailable)) + )); + } + + #[test] + fn durable_store_rename_failure_leaves_no_partial_claim_state() { + let temp = owner_only_tempdir(); + let store = durable_store(1, &temp); + store + .claim_pair_inner([claim(1, 30), claim(2, 40)], 10) + .unwrap(); + + *TEST_FAULT.lock().unwrap() = + Some((TestFault::RenameStateDirBeforeRename, store_dir(&temp))); + let failure = store.claim_pair_inner([claim(3, 50), claim(4, 60)], 11); + assert_eq!(failure, Err(ReplayClaimError::Unavailable)); + + let moved = store_dir(&temp).with_extension("fault"); + fs::rename(&moved, store_dir(&temp)).unwrap(); + + let reopened = durable_store(1, &temp); + assert_eq!( + reopened.claim_pair_inner([claim(1, 30), claim(2, 40)], 11), + Err(ReplayClaimError::AlreadyClaimed) + ); + reopened + .claim_pair_inner([claim(3, 50), claim(4, 60)], 11) + .unwrap(); + } + + #[test] + fn durable_store_persists_across_process_boundary() { + if std::env::var_os("ELASTOS_CUSTODY_REPLAY_CHILD").is_some() { + let dir = PathBuf::from(std::env::var_os("ELASTOS_CUSTODY_REPLAY_DIR").unwrap()); + let store = DurableReplayClaimStoreV1::new(store_node(1), dir); + store + .claim_pair_inner([claim(1, 30), claim(2, 40)], 10) + .unwrap(); + return; + } + + let temp = owner_only_tempdir(); + let status = Command::new(std::env::current_exe().unwrap()) + .env("ELASTOS_CUSTODY_REPLAY_CHILD", "1") + .env("ELASTOS_CUSTODY_REPLAY_DIR", store_dir(&temp)) + .arg("--exact") + .arg("replay_store::tests::durable_store_persists_across_process_boundary") + .arg("--nocapture") + .status() + .unwrap(); + assert!(status.success()); + + let store = durable_store(1, &temp); + assert_eq!( + store.claim_pair_inner([claim(1, 30), claim(2, 40)], 10), + Err(ReplayClaimError::AlreadyClaimed) + ); + } +} diff --git a/elastos/crates/elastos-protected-content-custody/src/test_support.rs b/elastos/crates/elastos-protected-content-custody/src/test_support.rs index c0403dbd6..3fcbec799 100644 --- a/elastos/crates/elastos-protected-content-custody/src/test_support.rs +++ b/elastos/crates/elastos-protected-content-custody/src/test_support.rs @@ -7,16 +7,24 @@ use sha3::Keccak256; use elastos_auth::ethereum_signed_message_hash; use elastos_protected_content_contracts::{ - AtomicReplayClaimer, CanonicalContract, CustodyEnvelopeV1, CustodyEpochIdentityV1, Digest32, - EncryptedContentIdentityV1, KeyReleaseRequestV1, NodeCustodyPublicKeyV1, NodePublicKey, - ProtectedContentBindingV1, RecipientKeyIdentityV1, ReplayClaimError, ReplayClaimKeyV1, - ReplayNonce16, RightsActionV1, RightsDecisionV1, RightsRequestV1, RightsVerificationContextV1, - RuntimeSessionBindingV1, SignedNodeRightsDecisionV1, ThresholdV1, VerifiedKeyReleaseRequestV1, - WalletAddress, WalletSignedRightsRequestV1, CUSTODY_HPKE_SUITE_ID_V1, + AtomicReplayClaimer, AuthenticatedRuntimeReleaseOperationV1, CanonicalContract, + CustodyApprovedSuitesV1, CustodyEnvelopeV1, CustodyEpochIdentityV1, CustodyEpochIssuerKeyV1, + CustodyEpochStatementV1, Digest32, EncryptedContentIdentityV1, EvmContractAddressV1, + EvmFunctionSelectorV1, EvmRightsMethodAbiV1, KeyReleaseRequestV1, NodeCustodyPublicKeyV1, + NodePublicKey, ProtectedContentBindingV1, RecipientKeyIdentityV1, RecipientPublicKeyBytesV1, + ReplayClaimError, ReplayClaimKeyV1, ReplayNonce16, RightsActionV1, RightsDecisionV1, + RightsEvaluationEvidenceRequestV1, RightsObservationFinalityV1, RightsPolicyBodyV1, + RightsRequestV1, RightsSubjectSourceV1, RightsVerificationContextV1, + RuntimeOperationIssuerKeyV1, RuntimeReleaseAuditIdV1, RuntimeReleaseOperationStatementV1, + RuntimeSessionBindingV1, SignedCustodyEpochV1, SignedNodeRightsDecisionV1, + SignedRecipientKeyAuthorizationV1, SignedRuntimeReleaseOperationV1, ThresholdV1, + VerifiedKeyReleaseRequestV1, WalletAddress, WalletSignedRightsRequestV1, + CUSTODY_HPKE_SUITE_ID_V1, }; use crate::{ - provision::provision_custody_envelope_with_rng, ContentEncryptionKeyV1, NodeCustodySecretKeyV1, + provision::provision_custody_envelope_with_rng, ClaimedNodeReleaseOperationV1, + ContentEncryptionKeyV1, DurableReplayClaimStoreV1, NodeCustodySecretKeyV1, RecipientPublicKeyV1, RecipientSecretKeyV1, }; @@ -95,8 +103,46 @@ pub(crate) fn custody_nodes() -> Vec<(NodePublicKey, NodeCustodyPublicKeyV1)> { ] } +pub(crate) fn signed_custody_epoch() -> SignedCustodyEpochV1 { + let issuer_key = SigningKey::from_bytes(&[0x71; 32]); + let statement = CustodyEpochStatementV1::new( + CustodyEpochIssuerKeyV1::new(issuer_key.verifying_key().to_bytes()).unwrap(), + CustodyApprovedSuitesV1::new( + CUSTODY_HPKE_SUITE_ID_V1, + CUSTODY_HPKE_SUITE_ID_V1, + CUSTODY_HPKE_SUITE_ID_V1, + ) + .unwrap(), + ThresholdV1::new(2, 3).unwrap(), + custody_nodes() + .into_iter() + .enumerate() + .map(|(index, (node_public_key, custody_public_key))| { + elastos_protected_content_contracts::CustodyNodeIdentityV1::new( + node_public_key, + custody_public_key, + elastos_protected_content_contracts::ShareCoordinateV1::new( + u8::try_from(index + 1).unwrap(), + ) + .unwrap(), + ) + .unwrap() + }) + .collect(), + ) + .unwrap(); + SignedCustodyEpochV1::new( + statement.clone(), + issuer_key + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap() +} + pub(crate) fn custody_epoch_identity() -> CustodyEpochIdentityV1 { - CustodyEpochIdentityV1::new(digest(0x33), 512).unwrap() + signed_custody_epoch().epoch_identity().unwrap() } fn wallet(seed: u8) -> WalletAddress { @@ -111,11 +157,11 @@ fn binding_for_wallet_with_envelope( envelope: &CustodyEnvelopeV1, ) -> ProtectedContentBindingV1 { let content = EncryptedContentIdentityV1::new(digest(0x11), 4096).unwrap(); + let policy_body = policy_body(); ProtectedContentBindingV1::new( content.clone(), envelope.key_envelope_identity().unwrap(), - elastos_protected_content_contracts::RightsPolicyIdentityV1::new(digest(0x44), 384) - .unwrap(), + policy_body.policy_identity().unwrap(), elastos_protected_content_contracts::ProfileIdentityV1::from_public_key_bytes( SigningKey::from_bytes(&[0x26; 32]) .verifying_key() @@ -128,18 +174,25 @@ fn binding_for_wallet_with_envelope( .unwrap() } -pub(crate) fn verified_release_request() -> VerifiedKeyReleaseRequestV1 { - verified_release_request_for_envelope_with_suite_and_recipient_seed( - &provisioned_envelope(), - CUSTODY_HPKE_SUITE_ID_V1, - 0x30, +fn policy_body() -> RightsPolicyBodyV1 { + RightsPolicyBodyV1::new( + "content:alpha", + RightsActionV1::View, + "view", + RightsSubjectSourceV1::WalletAddress, + 11155111, + EvmContractAddressV1::new([0x11; 20]).unwrap(), + EvmFunctionSelectorV1::new([0x12, 0x34, 0x56, 0x78]).unwrap(), + EvmRightsMethodAbiV1::HasAccessByContentIdStringAddressString, + RightsObservationFinalityV1::new(12), ) + .unwrap() } -pub(crate) fn verified_release_request_with_suite(suite: &str) -> VerifiedKeyReleaseRequestV1 { +pub(crate) fn verified_release_request() -> VerifiedKeyReleaseRequestV1 { verified_release_request_for_envelope_with_suite_and_recipient_seed( &provisioned_envelope(), - suite, + CUSTODY_HPKE_SUITE_ID_V1, 0x30, ) } @@ -187,8 +240,8 @@ fn verified_release_request_for_envelope_with_suite_and_recipient_seed( rights.request_hash(), rights.action(), rights.recipient().clone(), - NOW + 2, - NOW + 60, + NOW + 1, + NOW + 50, ReplayNonce16::new([0x66; 16]), ) .unwrap() @@ -222,6 +275,100 @@ fn signed_rights_request_with_suite_for_envelope( WalletSignedRightsRequestV1::new(request, signature_bytes).unwrap() } +pub(crate) fn authenticated_runtime_release_operation_for_envelope_and_recipient_seed( + envelope: &CustodyEnvelopeV1, + recipient_seed: u8, +) -> AuthenticatedRuntimeReleaseOperationV1 { + let runtime_key = SigningKey::from_bytes(&[0x42; 32]); + let recipient_public_key = recipient_public_key(recipient_seed); + let recipient_public_key_bytes = + RecipientPublicKeyBytesV1::new(*recipient_public_key.as_bytes()).unwrap(); + let rights_request = signed_rights_request_with_suite_for_envelope( + CUSTODY_HPKE_SUITE_ID_V1, + envelope, + recipient_seed, + ); + let release_request = KeyReleaseRequestV1::new( + rights_request.request().binding().clone(), + rights_request.request().request_hash().unwrap(), + RightsActionV1::View, + rights_request.request().recipient().clone(), + NOW + 1, + NOW + 50, + ReplayNonce16::new([0x66; 16]), + ) + .unwrap(); + let profile = SigningKey::from_bytes(&[0x26; 32]); + let authorization_statement = + elastos_protected_content_contracts::RecipientKeyAuthorizationStatementV1::new( + rights_request.request().binding().clone(), + RightsActionV1::View, + recipient_public_key_bytes, + rights_request.request().recipient().clone(), + RuntimeOperationIssuerKeyV1::new(runtime_key.verifying_key().to_bytes()).unwrap(), + NOW, + NOW + 90, + ) + .unwrap(); + let authorization = SignedRecipientKeyAuthorizationV1::new( + authorization_statement.clone(), + profile + .sign(&authorization_statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap(); + let policy_body = policy_body(); + let binding = rights_request.request().binding().clone(); + let statement = RuntimeReleaseOperationStatementV1::new( + RuntimeOperationIssuerKeyV1::new(runtime_key.verifying_key().to_bytes()).unwrap(), + rights_request, + release_request, + recipient_public_key_bytes, + authorization, + policy_body.clone(), + RightsEvaluationEvidenceRequestV1::new(binding, policy_body.policy_identity().unwrap()) + .unwrap(), + signed_custody_epoch(), + RuntimeReleaseAuditIdV1::new(digest(0x91)).unwrap(), + NOW + 2, + NOW + 40, + ) + .unwrap(); + SignedRuntimeReleaseOperationV1::new( + statement.clone(), + runtime_key + .sign(&statement.canonical_bytes().unwrap()) + .to_bytes() + .to_vec(), + ) + .unwrap() + .verify(NOW + 3) + .unwrap() +} + +pub(crate) fn claimed_runtime_release_operation_for_envelope_and_node_seed( + envelope: &CustodyEnvelopeV1, + node_seed: u8, + recipient_seed: u8, +) -> ClaimedNodeReleaseOperationV1 { + let authenticated = authenticated_runtime_release_operation_for_envelope_and_recipient_seed( + envelope, + recipient_seed, + ); + let temp = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let mut store = + DurableReplayClaimStoreV1::new(node_public_key(node_seed), temp.path().join("replay")); + store + .claim_node_release_operation(authenticated, envelope, node_public_key(node_seed), NOW + 3) + .unwrap() +} + pub(crate) fn signed_node_decision( request: &VerifiedKeyReleaseRequestV1, node_seed: u8, From 854842b739ae1995c48c749e2c2bea00c7be7e5f Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Fri, 14 Aug 2026 18:43:21 -0500 Subject: [PATCH 10/15] docs(protected-content): record custody operation boundary --- ROADMAP.md | 5 ++- TASKS.md | 51 ++++++++++++++++---------- docs/PROTECTED_CONTENT.md | 43 ++++++++++++++-------- docs/PROTECTED_CONTENT_CONTRACTS_V1.md | 33 +++++++++++------ state.md | 25 +++++++++---- 5 files changed, 101 insertions(+), 56 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8f59641b6..e471a8b82 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -153,7 +153,10 @@ A. review the canonical v1 source-only contract and operational contract epochs, and the authenticated replay-pending Runtime-to-release-node envelope; B. build source-only custody-node operations and durable node state on top of - that contract, including replay, issuer lifecycle, and operational review; + that contract. The current local child line covers the node-local durable + replay-claim boundary and claim-gated release transition; remaining work is + issuer lifecycle, node admission or rotation or recovery, operational + review, and durable post-claim operation recovery; C. replace the provisional DTO/provider surface atomically and wire Runtime/Wallet/provider integration to the reviewed contract, with one source allow flow and one source deny flow; and diff --git a/TASKS.md b/TASKS.md index e7f715341..ad0151721 100644 --- a/TASKS.md +++ b/TASKS.md @@ -709,17 +709,26 @@ and review items pass. ### Trusted content and access rights -The source-only protected-content stack now has three review layers in order: -published canonical contracts, published custody helpers, and the local -operational-contract child line. Remaining work must stay in this order. Carrier -remains transport only throughout every stage below. - -The local operational-contract line now defines the reviewed source-only EVM -`has_access_by_content_id` policy/evidence contract, including the exact +The source-only protected-content stack now has four review layers in order: +published canonical contracts, published custody helpers, the accepted local +operational-contract child line, and the current local custody-operations child +line. Remaining work must stay in this order. Carrier remains transport only +throughout every stage below. + +The accepted local operational-contract line defines the reviewed source-only +EVM `has_access_by_content_id` policy/evidence contract, including the exact contract right string mapped from one product action, Profile-signed recipient-key authorization, signed immutable custody epoch, and authenticated -replay-pending Runtime release-operation envelope. Remaining tasks start after -that boundary; they do not reopen it as ambient provider configuration. +replay-pending Runtime release-operation envelope. The current local +custody-operations line adds the node-local durable dual-key replay-claim store +and the private claim-gated transition from authenticated replay-pending +Runtime evidence to node-actionable release authority. Remaining tasks start +after those boundaries; they do not reopen them as ambient provider +configuration. Runtime owns its own orchestration replay. Each custody node +owns only its exact local dual-key claim. A crash after a successful local +claim but before contribution settlement is fail closed and currently requires +a fresh Runtime release operation; there is no durable operation-resume +journal yet. #### A. Source-only review gate @@ -759,19 +768,21 @@ that boundary; they do not reopen it as ambient provider configuration. revocation, audit retention, issuer-key lifecycle, and recovery operations. The source-only `elastos-protected-content-custody` crate now implements new content share provisioning, recipient-sealed node release, and recipient-side - threshold reconstruction, but it does not prove operational custody safety, - durable storage, or product integration. Its manifest-bound CEK commitment + threshold reconstruction, but it does not yet prove full operational custody + safety, complete node-state durability, or product integration. Its + manifest-bound CEK commitment detects a wrong reconstructed key; it does not identify the malicious node or add verifiable secret sharing. -- [ ] Implement durable per-node replay claim stores and operational node state - around the reviewed contracts. Claims must use the contract's exact - authority-scope hash plus nonce, survive restarts until expiry, and prove - insert-if-absent behavior under concurrency and storage failure. -- [ ] Add the atomic custody-node transition from the authenticated - replay-pending Runtime release operation to claimed actionable release - authority. The node must claim the exact Wallet and release replay keys - durably before it opens any stored share. Stage A's replay-pending contract - value must remain non-actionable. +- [ ] Extend the reviewed node-local replay-claim and claim-gated release + transition into full operational node state: retained claim pruning policy, + multi-process operational audit retention, node admission and issuer-key + lifecycle, recovery tooling, and storage review for production hosts. The + current local child line already proves one owner-only durable dual-key + claim-many store, atomic no-partial-claim updates, restart survival, and the + private claim-before-open transition from authenticated replay-pending + Runtime evidence to node-actionable release authority. It does not yet add a + durable operation-resume journal after claim success, so a post-claim crash + still requires a fresh Runtime release operation. #### C. Atomic Runtime/provider cutover plus source allow/deny proof diff --git a/docs/PROTECTED_CONTENT.md b/docs/PROTECTED_CONTENT.md index b762f4c5b..c85b8331b 100644 --- a/docs/PROTECTED_CONTENT.md +++ b/docs/PROTECTED_CONTENT.md @@ -13,11 +13,13 @@ The source tree currently has two protected-content layers: stack now defines canonical authority bindings, typed rights-policy and evidence contracts, Profile-signed recipient-key authorization, signed immutable custody epochs, a typed Runtime-to-release-node operation - envelope, and source-only custody helpers for custody-envelope - provisioning, recipient-sealed node release, and recipient-side threshold - reconstruction for new content. It is not yet wired into Runtime - orchestration, provider integration, durable replay storage, recipient - key-possession proof, decryption, playback, installation, or deployment. + envelope, a local durable dual-key replay-claim store for custody nodes, + a claim-gated node release path, and source-only custody helpers for + custody-envelope provisioning, recipient-sealed node release, and + recipient-side threshold reconstruction for new content. It is not yet wired + into Runtime orchestration, provider integration, Runtime-owned replay + storage, recipient key-possession proof, decryption, playback, installation, + or deployment. - The current installed/provider surface is the older provisional `elastos_common::protected_content` DTO set plus the fail-closed `drm-provider`, `rights-provider`, `key-provider`, and `decrypt-provider` @@ -143,8 +145,13 @@ protected content: - It returns only opaque, redacted secret wrappers; it does not expose a capsule-visible raw-key API. - It is source-only. There are no running custody nodes, Runtime/provider - routes, durable replay stores, recipient key-possession proof, decrypt/render - product flow, installation, or deployment in this branch. + routes, Runtime-owned replay orchestration, recipient key-possession proof, + decrypt/render product flow, installation, or deployment in this branch. The + current local child line adds one node-local durable dual-key replay-claim + store and the private claim-gated release transition only. That store adds a + domain-separated integrity digest to detect same-length corruption or torn + local state, but it does not defend against a malicious same-UID rewrite + that can recompute the digest. This helper does not make a PQ claim. PQ-hybrid custody, dKMS node lifecycle, and product wiring remain future work. The current HPKE dependency is `hpke` @@ -179,13 +186,16 @@ contract layer that later Runtime and custody-node integrations must consume: authorization, exact policy and evidence request, exact custody epoch, exact Runtime issuer, one audit id, and one bounded window. Verification returns an authenticated replay-pending value only. It exposes exact request hashes and - replay claim keys for a later atomic durable claim step, but it does not - expose actionable verified requests. + replay claim keys for the node-local atomic durable claim step, but it does + not expose actionable verified requests before that claim succeeds. A crash + or storage failure after the node-local claim succeeds but before + contribution settlement is fail closed and currently requires a fresh + Runtime release operation; there is no durable operation-resume journal yet. This operational layer is still source-only. There is no provider registry -cutover, durable replay store, recipient key-possession proof, node lifecycle -service, installed product flow, or production confidentiality claim in this -branch. +cutover, Runtime-owned replay store, recipient key-possession proof, node +lifecycle service, installed product flow, or production confidentiality claim +in this branch. ## Provider Boundary @@ -214,9 +224,12 @@ The provider plane should expose typed questions instead: A. Finish the source-only review gate: professional external cryptographic and contract review, plus any additional grounded HPKE wrapper known-answer coverage that a reviewed upstream vector path can support. -B. Build source-only custody-node operations and durable node state around the - reviewed contracts: node admission/rotation/revocation/recovery, durable - replay claims, issuer-key lifecycle, and operational audit retention. +B. Continue the source-only custody-node operations and durable node-state + layer around the reviewed contracts. The current local child line adds the + durable dual-key replay-claim boundary and the claim-gated node-release + transition. Remaining work is node admission/rotation/revocation/recovery, + issuer-key lifecycle, operational audit retention, and durable + post-claim operation recovery. C. Replace the provisional `elastos_common::protected_content` DTO/provider surface atomically with Runtime-owned orchestration over the reviewed v1 contract: Wallet integration, recipient key generation and possession proof, diff --git a/docs/PROTECTED_CONTENT_CONTRACTS_V1.md b/docs/PROTECTED_CONTENT_CONTRACTS_V1.md index 4efa1141c..d2868238e 100644 --- a/docs/PROTECTED_CONTENT_CONTRACTS_V1.md +++ b/docs/PROTECTED_CONTENT_CONTRACTS_V1.md @@ -16,8 +16,10 @@ product integration. the protected-content binding. It does not prove that the right exists; each release node evaluates the bound policy. Wallet does not replace Profile identity. -3. Runtime derives authority, selects providers, owns lifecycle, performs the - mandatory atomic replay claim, chooses terminal-receipt issuers, and audits. +3. Runtime derives authority, selects providers, owns lifecycle, owns its + orchestration replay, chooses terminal-receipt issuers, and audits. Each + custody node owns the exact local atomic dual-key replay claim before it + can act. 4. Provider contracts define local and remote rights and custody operations. 5. Carrier transports traffic only to endpoints selected by Runtime. It grants no authority and is absent from these types. @@ -234,11 +236,16 @@ binds: Verification proves those bindings and requires the Profile-signed recipient-key authorization to authorize the Runtime issuer. Carrier endpoint authentication remains transport evidence only. This source-only contract does -not implement durable replay state or provider routing. Verification returns an -authenticated replay-pending surface only: it proves signatures and exact -bindings, exposes the nested request hashes and replay-claim keys, and does not -expose actionable `VerifiedRightsRequestV1` or `VerifiedKeyReleaseRequestV1` -values. Runtime and custody-node durable replay claims remain later work. +not implement provider routing or Runtime durable replay state. Verification +returns an authenticated replay-pending surface only: it proves signatures and +exact bindings, exposes the nested request hashes and replay-claim keys, and +does not expose actionable `VerifiedRightsRequestV1` or +`VerifiedKeyReleaseRequestV1` values. The companion source-only custody helper +now uses those exact claim keys for one local dual-key atomic claim transition, +but Runtime durable replay and orchestration remain later work. A crash or +storage failure after that node-local claim succeeds but before contribution +settlement is fail closed and currently requires a fresh Runtime release +operation; there is no durable operation-resume journal yet. ## Node decision and contribution @@ -250,9 +257,9 @@ window. `KeyReleaseRequestV1` is not a Runtime signature or a bearer grant. A remote node must receive the canonical Wallet request and release request through the typed `SignedRuntimeReleaseOperationV1` envelope. The node must still verify -the nested requests and claim replay in its own durable store before it acts. -Carrier endpoint authentication alone does not prove who authored the -application request. +the nested requests and claim both replay keys atomically in its own durable +store before it acts. Carrier endpoint authentication alone does not prove who +authored the application request. Each node creates `SignedNodeRightsDecisionV1` after independently evaluating the exact rights-policy identity. The signed statement binds the release and @@ -308,8 +315,10 @@ canonical input, noncanonical Wallet/Profile encodings, and noncanonical or low-order X25519 contract key encodings. These contracts plus the source-only `elastos-protected-content-custody` helper -do not solve malicious custody nodes, rights-policy correctness, durable replay -storage, issuer-key lifecycle, recipient key-possession proof, node +provide one bounded node-local owner-only dual-key replay-claim store, but +they do not solve malicious custody nodes, rights-policy correctness, Runtime +durable replay storage, full operational custody state, durable post-claim +resume/settlement, issuer-key lifecycle, recipient key-possession proof, node admission/rotation/recovery, availability, decryption, rendering, or product workflow safety. The custody helper uses `hpke` 0.13, whose upstream documentation says it has not been formally audited. These source-only crates diff --git a/state.md b/state.md index ec0f98108..187fce64a 100644 --- a/state.md +++ b/state.md @@ -35,20 +35,29 @@ the public repository. invalid X25519 contract key bytes before HPKE use, requires exactly the bound released threshold, and checks a manifest-bound CEK commitment after reconstruction to detect a wrong reconstructed key. It does not integrate - Runtime/provider/Carrier orchestration, durable replay storage, recipient - key-possession proof, decrypt/render product flows, installation, or - deployment. -- `feat/protected-content-operational-contracts` is the current local + Runtime/provider/Carrier orchestration, Runtime-owned durable replay + storage, full operational custody state, recipient key-possession proof, + decrypt/render product flows, installation, or deployment. +- `feat/protected-content-operational-contracts` is the accepted local dependent source-only child line stacked on `origin/feat/protected-content-custody`. It adds the narrow typed v1 EVM rights-policy body plus exact evidence request/result contracts, including the exact EVM right string mapped from one product action, the Profile-signed recipient-key authorization, the signed immutable custody epoch, and the authenticated replay-pending Runtime-to-release-node - operation envelope. It does not prove X25519 secret-key possession, add - durable replay or custody-node state, expose actionable claimed requests, - wire Runtime/provider/product flows, or make an installed confidentiality - claim. + operation envelope. It does not prove X25519 secret-key possession, wire + Runtime/provider/product flows, or make an installed confidentiality claim. +- `feat/protected-content-custody-operations` is the current local dependent + source-only child line stacked on + `feat/protected-content-operational-contracts`. It adds one owner-only + durable dual-key replay-claim store for custody nodes plus the private + claim-gated transition from authenticated replay-pending Runtime evidence to + node-actionable release authority. It still does not add Runtime-owned + replay orchestration, recipient key-possession proof, node admission or + issuer lifecycle services, provider wiring, product flows, installation, or + deployment. A crash after a successful local claim but before contribution + settlement is fail closed and currently requires a fresh Runtime release + operation; there is no durable operation-resume journal yet. - Released 0.6 and the published collaboration review stack retain the older provisional `elastos_common::protected_content` DTOs plus fail-closed `drm-provider`, `rights-provider`, `key-provider`, and `decrypt-provider` From 3b07fd4d0fa06288d197185a9c804fdccb1ed3df Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Sat, 15 Aug 2026 11:45:02 -0500 Subject: [PATCH 11/15] feat(protected-content): replay exact custody results --- .../src/lib.rs | 3 +- .../src/release.rs | 37 +- .../src/replay_store.rs | 1018 +++++++++++++++-- .../src/test_support.rs | 2 +- 4 files changed, 920 insertions(+), 140 deletions(-) diff --git a/elastos/crates/elastos-protected-content-custody/src/lib.rs b/elastos/crates/elastos-protected-content-custody/src/lib.rs index 98d8a0e90..8753a3d84 100644 --- a/elastos/crates/elastos-protected-content-custody/src/lib.rs +++ b/elastos/crates/elastos-protected-content-custody/src/lib.rs @@ -14,8 +14,7 @@ use thiserror::Error; pub use provision::provision_custody_envelope; pub use reconstruct::reconstruct_content_key; -pub use release::produce_node_contribution; -pub use replay_store::{ClaimedNodeReleaseOperationV1, DurableReplayClaimStoreV1}; +pub use replay_store::DurableReplayClaimStoreV1; pub use secrets::{ ContentEncryptionKeyV1, NodeCustodySecretKeyV1, RecipientPublicKeyV1, RecipientSecretKeyV1, }; diff --git a/elastos/crates/elastos-protected-content-custody/src/release.rs b/elastos/crates/elastos-protected-content-custody/src/release.rs index 435b07338..9567f1ce4 100644 --- a/elastos/crates/elastos-protected-content-custody/src/release.rs +++ b/elastos/crates/elastos-protected-content-custody/src/release.rs @@ -1,5 +1,6 @@ use ed25519_dalek::{Signer as _, SigningKey}; use hpke::rand_core::{CryptoRng as HpkeCryptoRng, RngCore as HpkeRngCore}; +#[cfg(test)] use rand09::{rngs::StdRng, SeedableRng as _}; use elastos_protected_content_contracts::{ @@ -10,42 +11,14 @@ use elastos_protected_content_contracts::{ use crate::{ hpke_helpers::seal_share, + replay_store::ClaimedNodeReleaseOperationV1, secrets::{NodeCustodySecretKeyV1, RecipientPublicKeyV1}, - ClaimedNodeReleaseOperationV1, CustodyError, + CustodyError, }; #[allow(clippy::too_many_arguments)] -/// ```compile_fail -/// use ed25519_dalek::SigningKey; -/// use elastos_protected_content_contracts::{ -/// AuthenticatedRuntimeReleaseOperationV1, CustodyEnvelopeV1, SignedNodeRightsDecisionV1, -/// }; -/// use elastos_protected_content_custody::{ -/// produce_node_contribution, NodeCustodySecretKeyV1, RecipientPublicKeyV1, -/// }; -/// -/// fn replay_pending_runtime_operations_are_not_actionable( -/// operation: AuthenticatedRuntimeReleaseOperationV1, -/// signed_rights_decision: SignedNodeRightsDecisionV1, -/// envelope: CustodyEnvelopeV1, -/// node_signing_key: SigningKey, -/// node_custody_secret: NodeCustodySecretKeyV1, -/// recipient_public_key: RecipientPublicKeyV1, -/// ) { -/// let _ = produce_node_contribution( -/// operation, -/// &signed_rights_decision, -/// &envelope, -/// &node_signing_key, -/// &node_custody_secret, -/// &recipient_public_key, -/// 1, -/// 2, -/// 1, -/// ); -/// } -/// ``` -pub fn produce_node_contribution( +#[cfg(test)] +pub(crate) fn produce_node_contribution( operation: ClaimedNodeReleaseOperationV1, signed_rights_decision: &SignedNodeRightsDecisionV1, envelope: &CustodyEnvelopeV1, diff --git a/elastos/crates/elastos-protected-content-custody/src/replay_store.rs b/elastos/crates/elastos-protected-content-custody/src/replay_store.rs index fcbe5280d..1e28593f0 100644 --- a/elastos/crates/elastos-protected-content-custody/src/replay_store.rs +++ b/elastos/crates/elastos-protected-content-custody/src/replay_store.rs @@ -2,71 +2,56 @@ use std::fs::{self, File, OpenOptions}; use std::io::{Read as _, Write as _}; use std::path::{Path, PathBuf}; +use ed25519_dalek::SigningKey; +use hpke::rand_core::{CryptoRng as HpkeCryptoRng, RngCore as HpkeRngCore}; use nix::fcntl::{Flock, FlockArg}; use nix::unistd::geteuid; +use rand09::SeedableRng as _; use sha2::{Digest as _, Sha256}; use elastos_protected_content_contracts::{ - AuthenticatedRuntimeReleaseOperationV1, CustodyEnvelopeV1, Digest32, NodePublicKey, NodeSetV1, - ReplayClaimEntryV1, ReplayClaimError, ReplayClaimKeyV1, ReplayNonce16, - SignedNodeContributionV1, SignedNodeRightsDecisionV1, VerifiedNodeContributionV1, - VerifiedNodeRightsDecisionV1, + AuthenticatedRuntimeReleaseOperationV1, CanonicalContract, CustodyEnvelopeV1, Digest32, + NodePublicKey, NodeSetV1, ReplayClaimEntryV1, ReplayClaimError, ReplayClaimKeyV1, + ReplayNonce16, RightsDecisionV1, RuntimeReleaseAuditIdV1, SignedNodeContributionV1, + SignedNodeRightsDecisionV1, VerifiedNodeContributionV1, VerifiedNodeRightsDecisionV1, + CUSTODY_HPKE_SUITE_ID_V1, MAX_RECIPIENT_SEALED_CONTRIBUTION_BYTES, }; -use crate::CustodyError; +use crate::{ + release::produce_node_contribution_with_rng, secrets::NodeCustodySecretKeyV1, CustodyError, + RecipientPublicKeyV1, +}; const STATE_MAGIC: &[u8; 8] = b"epc-rcl1"; const STATE_DIGEST_DOMAIN: &[u8] = b"elastos/protected-content/node-replay-claims/state/v1"; const STATE_HEADER_BYTES: usize = 44; -const STATE_ENTRY_BYTES: usize = 56; +const REPLAY_CLAIM_KEY_BYTES: usize = 48; +const STORED_RESULT_STATE_CLAIM_ONLY: u8 = 0; +const STORED_RESULT_STATE_PRESENT: u8 = 1; +const MAX_STORED_CONTRIBUTION_BYTES: usize = MAX_RECIPIENT_SEALED_CONTRIBUTION_BYTES + (4 * 1024); +const STATE_RECORD_FIXED_BYTES: usize = (REPLAY_CLAIM_KEY_BYTES + 8) * 2 + 32 + 32 + 1 + 2; const STATE_DIGEST_BYTES: usize = 32; const MAX_REPLAY_CLAIMS: usize = 4096; -const MAX_STATE_PAYLOAD_BYTES: usize = STATE_HEADER_BYTES + (MAX_REPLAY_CLAIMS * STATE_ENTRY_BYTES); -const MAX_STATE_BYTES: usize = MAX_STATE_PAYLOAD_BYTES + STATE_DIGEST_BYTES; +const MAX_NODE_RELEASE_RECORDS: usize = MAX_REPLAY_CLAIMS / 2; +const MAX_STATE_BYTES: usize = 8 * 1024 * 1024; +const MAX_STATE_PAYLOAD_BYTES: usize = MAX_STATE_BYTES - STATE_DIGEST_BYTES; /// Atomically claimed node-release authority for one custody node. /// /// This token is non-serializable and has no public constructor. Only the /// concrete owner-only durable replay store can return it after the exact /// dual-key claim succeeds. -/// -/// ```compile_fail -/// use elastos_protected_content_contracts::{AuthenticatedRuntimeReleaseOperationV1, NodePublicKey}; -/// use elastos_protected_content_custody::ClaimedNodeReleaseOperationV1; -/// -/// fn cannot_fabricate( -/// operation: AuthenticatedRuntimeReleaseOperationV1, -/// node: NodePublicKey, -/// ) { -/// let _ = ClaimedNodeReleaseOperationV1 { -/// authenticated_operation: operation, -/// selected_node_public_key: node, -/// }; -/// } -/// ``` #[derive(Debug)] -pub struct ClaimedNodeReleaseOperationV1 { +pub(crate) struct ClaimedNodeReleaseOperationV1 { authenticated_operation: AuthenticatedRuntimeReleaseOperationV1, selected_node_public_key: NodePublicKey, } impl ClaimedNodeReleaseOperationV1 { - pub const fn operation_hash(&self) -> Digest32 { - self.authenticated_operation.operation_hash() - } - - pub const fn rights_request_hash(&self) -> Digest32 { - self.authenticated_operation.rights_request_hash() - } - pub const fn release_request_hash(&self) -> Digest32 { self.authenticated_operation.release_request_hash() } - pub const fn recipient_authorization_hash(&self) -> Digest32 { - self.authenticated_operation.recipient_authorization_hash() - } - pub const fn selected_node_public_key(&self) -> NodePublicKey { self.selected_node_public_key } @@ -75,10 +60,6 @@ impl ClaimedNodeReleaseOperationV1 { self.authenticated_operation.binding() } - pub fn action(&self) -> elastos_protected_content_contracts::RightsActionV1 { - self.authenticated_operation.action() - } - pub fn recipient(&self) -> &elastos_protected_content_contracts::RecipientKeyIdentityV1 { self.authenticated_operation.recipient() } @@ -118,6 +99,83 @@ impl ClaimedNodeReleaseOperationV1 { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct StoredNodeReleaseRecordV1 { + rights_request_replay_claim_key: ReplayClaimKeyV1, + rights_request_expires_at: u64, + release_request_replay_claim_key: ReplayClaimKeyV1, + release_request_expires_at: u64, + audit_request_id: RuntimeReleaseAuditIdV1, + operation_hash: Digest32, + contribution_bytes: Option>, +} + +impl StoredNodeReleaseRecordV1 { + fn claim_only( + rights_claim: ReplayClaimEntryV1, + release_claim: ReplayClaimEntryV1, + audit_request_id: RuntimeReleaseAuditIdV1, + operation_hash: Digest32, + ) -> Self { + Self { + rights_request_replay_claim_key: rights_claim.key(), + rights_request_expires_at: rights_claim.expires_at(), + release_request_replay_claim_key: release_claim.key(), + release_request_expires_at: release_claim.expires_at(), + audit_request_id, + operation_hash, + contribution_bytes: None, + } + } + + const fn rights_request_replay_claim_key(&self) -> ReplayClaimKeyV1 { + self.rights_request_replay_claim_key + } + + const fn release_request_replay_claim_key(&self) -> ReplayClaimKeyV1 { + self.release_request_replay_claim_key + } + + const fn audit_request_id(&self) -> RuntimeReleaseAuditIdV1 { + self.audit_request_id + } + + const fn operation_hash(&self) -> Digest32 { + self.operation_hash + } + + const fn rights_active(&self, now: u64) -> bool { + self.rights_request_expires_at > now + } + + const fn release_active(&self, now: u64) -> bool { + self.release_request_expires_at > now + } + + const fn any_active(&self, now: u64) -> bool { + self.rights_active(now) || self.release_active(now) + } + + fn contribution_bytes(&self) -> Option<&[u8]> { + self.contribution_bytes.as_deref() + } + + fn set_contribution_bytes(&mut self, contribution_bytes: Vec) { + self.contribution_bytes = Some(contribution_bytes); + } + + fn sort_key(&self) -> [u8; REPLAY_CLAIM_KEY_BYTES * 2] { + let mut bytes = [0u8; REPLAY_CLAIM_KEY_BYTES * 2]; + bytes[..REPLAY_CLAIM_KEY_BYTES].copy_from_slice(&replay_claim_sort_key( + self.release_request_replay_claim_key(), + )); + bytes[REPLAY_CLAIM_KEY_BYTES..].copy_from_slice(&replay_claim_sort_key( + self.rights_request_replay_claim_key(), + )); + bytes + } +} + #[derive(Debug, Clone)] pub struct DurableReplayClaimStoreV1 { node_public_key: NodePublicKey, @@ -143,7 +201,8 @@ impl DurableReplayClaimStoreV1 { self.node_public_key } - pub fn claim_node_release_operation( + #[cfg(test)] + pub(crate) fn claim_node_release_operation( &mut self, operation: AuthenticatedRuntimeReleaseOperationV1, envelope: &CustodyEnvelopeV1, @@ -154,21 +213,25 @@ impl DurableReplayClaimStoreV1 { return Err(CustodyError::BindingMismatch("store_node_public_key")); } operation.validate_node_release_claim_context(envelope, selected_node_public_key, now)?; - self.claim_pair_inner( - [ - ReplayClaimEntryV1::new( - operation.rights_request_replay_claim_key(), - operation - .statement() - .rights_request() - .request() - .expires_at(), - ), - ReplayClaimEntryV1::new( - operation.release_request_replay_claim_key(), - operation.statement().release_request().expires_at(), - ), - ], + let rights_claim = ReplayClaimEntryV1::new( + operation.rights_request_replay_claim_key(), + operation + .statement() + .rights_request() + .request() + .expires_at(), + ); + let release_claim = ReplayClaimEntryV1::new( + operation.release_request_replay_claim_key(), + operation.statement().release_request().expires_at(), + ); + self.claim_claim_only_record( + StoredNodeReleaseRecordV1::claim_only( + rights_claim, + release_claim, + operation.statement().audit_request_id(), + operation.operation_hash(), + ), now, )?; Ok(ClaimedNodeReleaseOperationV1 { @@ -177,36 +240,228 @@ impl DurableReplayClaimStoreV1 { }) } - fn claim_pair_inner( - &self, - mut claims: [ReplayClaimEntryV1; 2], + #[allow(clippy::too_many_arguments)] + pub fn claim_or_replay_node_contribution( + &mut self, + operation: AuthenticatedRuntimeReleaseOperationV1, + signed_rights_decision: &SignedNodeRightsDecisionV1, + envelope: &CustodyEnvelopeV1, + node_signing_key: &SigningKey, + node_custody_secret: &NodeCustodySecretKeyV1, + recipient_public_key: &RecipientPublicKeyV1, + issued_at: u64, + expires_at: u64, now: u64, - ) -> Result<(), ReplayClaimError> { + ) -> Result { + let mut hpke_rng = rand09::rngs::StdRng::try_from_os_rng() + .map_err(|_| CustodyError::RandomnessUnavailable)?; + self.claim_or_replay_node_contribution_with_rng( + operation, + signed_rights_decision, + envelope, + node_signing_key, + node_custody_secret, + recipient_public_key, + issued_at, + expires_at, + now, + &mut hpke_rng, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn claim_or_replay_node_contribution_with_rng( + &mut self, + operation: AuthenticatedRuntimeReleaseOperationV1, + signed_rights_decision: &SignedNodeRightsDecisionV1, + envelope: &CustodyEnvelopeV1, + node_signing_key: &SigningKey, + node_custody_secret: &NodeCustodySecretKeyV1, + recipient_public_key: &RecipientPublicKeyV1, + issued_at: u64, + expires_at: u64, + now: u64, + hpke_rng: &mut R, + ) -> Result { + let selected_node_public_key = + NodePublicKey::new(node_signing_key.verifying_key().to_bytes())?; + if selected_node_public_key != self.node_public_key { + return Err(CustodyError::BindingMismatch("store_node_public_key")); + } + operation.validate_node_release_claim_context(envelope, selected_node_public_key, now)?; + if operation.recipient().encryption_suite_id() != CUSTODY_HPKE_SUITE_ID_V1 { + return Err(CustodyError::BindingMismatch( + "recipient_encryption_suite_id", + )); + } + if recipient_public_key.identity()? != *operation.recipient() { + return Err(CustodyError::BindingMismatch("recipient_key_identity")); + } + let node_set = envelope.manifest().node_set()?; + let decision = + operation.verify_node_rights_decision(signed_rights_decision, &node_set, now)?; + if decision.decision() != RightsDecisionV1::Allowed { + return Err(CustodyError::Release( + elastos_protected_content_contracts::KeyReleaseError::RightsDenied, + )); + } + operation + .validate_node_contribution_active_window(issued_at, expires_at, &decision, now)?; + let node_entry = envelope + .manifest() + .node(selected_node_public_key) + .ok_or(CustodyError::BindingMismatch("custody_node"))?; + node_custody_secret.matches_node_entry( + selected_node_public_key, + node_entry.custody_public_key(), + node_signing_key, + )?; + + let rights_claim = ReplayClaimEntryV1::new( + operation.rights_request_replay_claim_key(), + operation + .statement() + .rights_request() + .request() + .expires_at(), + ); + let release_claim = ReplayClaimEntryV1::new( + operation.release_request_replay_claim_key(), + operation.statement().release_request().expires_at(), + ); + let audit_request_id = operation.statement().audit_request_id(); + let operation_hash = operation.operation_hash(); + let decision_bytes = signed_rights_decision.canonical_bytes()?; + self.ensure_state_dir()?; let _lock = ExclusiveFileLock::acquire(&self.lock_path)?; self.cleanup_stale_temp()?; - let mut entries = self.read_state()?; - entries.retain(|entry| entry.expires_at() > now); + let mut records = self.read_state()?; + records.retain(|record| record.any_active(now)); + + match find_exact_record_index( + &records, + rights_claim.key(), + release_claim.key(), + audit_request_id, + operation_hash, + now, + )? { + Some(index) => { + return replay_exact_contribution( + &records[index], + &decision_bytes, + &operation, + &node_set, + now, + ); + } + None => { + if has_conflicting_active_record( + &records, + rights_claim.key(), + release_claim.key(), + audit_request_id, + now, + ) { + return Err(CustodyError::Replay(ReplayClaimError::Unavailable)); + } + } + } + let claim_only = StoredNodeReleaseRecordV1::claim_only( + rights_claim, + release_claim, + audit_request_id, + operation_hash, + ); + records.push(claim_only); + records.sort_unstable_by_key(StoredNodeReleaseRecordV1::sort_key); + if records.len() > MAX_NODE_RELEASE_RECORDS { + return Err(CustodyError::Replay(ReplayClaimError::Unavailable)); + } + self.write_state(&records)?; + + let claimed = ClaimedNodeReleaseOperationV1 { + authenticated_operation: operation, + selected_node_public_key, + }; + let contribution = produce_node_contribution_with_rng( + &claimed, + signed_rights_decision, + envelope, + node_signing_key, + node_custody_secret, + recipient_public_key, + issued_at, + expires_at, + now, + hpke_rng, + )?; + let contribution_bytes = contribution.canonical_bytes()?; + + let Some(record) = records.iter_mut().find(|record| { + record.rights_request_replay_claim_key() == rights_claim.key() + && record.release_request_replay_claim_key() == release_claim.key() + && record.audit_request_id() == audit_request_id + && record.operation_hash() == operation_hash + }) else { + return Err(CustodyError::Replay(ReplayClaimError::Unavailable)); + }; + record.set_contribution_bytes(contribution_bytes); + self.write_state(&records)?; + Ok(contribution) + } + + #[cfg(test)] + fn claim_pair_inner( + &self, + mut claims: [ReplayClaimEntryV1; 2], + now: u64, + ) -> Result<(), ReplayClaimError> { sort_claims(&mut claims); if claims[0].key() == claims[1].key() { return Err(ReplayClaimError::Unavailable); } - if claims - .iter() - .any(|claim| entries.iter().any(|entry| entry.key() == claim.key())) - { + self.claim_claim_only_record( + StoredNodeReleaseRecordV1::claim_only( + claims[0], + claims[1], + synthetic_runtime_release_audit_id(&claims), + synthetic_operation_hash(&claims), + ), + now, + ) + } + + #[cfg(test)] + fn claim_claim_only_record( + &self, + record: StoredNodeReleaseRecordV1, + now: u64, + ) -> Result<(), ReplayClaimError> { + self.ensure_state_dir()?; + let _lock = ExclusiveFileLock::acquire(&self.lock_path)?; + self.cleanup_stale_temp()?; + + let mut records = self.read_state()?; + records.retain(|existing| existing.any_active(now)); + if has_conflicting_active_record( + &records, + record.rights_request_replay_claim_key(), + record.release_request_replay_claim_key(), + record.audit_request_id(), + now, + ) { return Err(ReplayClaimError::AlreadyClaimed); } - - entries.extend(claims); - entries.sort_unstable_by_key(|entry| replay_claim_sort_key(entry.key())); - if entries.len() > MAX_REPLAY_CLAIMS { + records.push(record); + records.sort_unstable_by_key(StoredNodeReleaseRecordV1::sort_key); + if records.len() > MAX_NODE_RELEASE_RECORDS { return Err(ReplayClaimError::Unavailable); } - - self.write_state(&entries) + self.write_state(&records) } fn ensure_state_dir(&self) -> Result<(), ReplayClaimError> { @@ -238,7 +493,7 @@ impl DurableReplayClaimStoreV1 { } } - fn read_state(&self) -> Result, ReplayClaimError> { + fn read_state(&self) -> Result, ReplayClaimError> { let metadata = match fs::symlink_metadata(&self.state_path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), @@ -262,14 +517,14 @@ impl DurableReplayClaimStoreV1 { decode_state(&bytes, self.node_public_key) } - fn write_state(&self, entries: &[ReplayClaimEntryV1]) -> Result<(), ReplayClaimError> { + fn write_state(&self, records: &[StoredNodeReleaseRecordV1]) -> Result<(), ReplayClaimError> { match fs::symlink_metadata(&self.state_path) { Ok(metadata) => validate_owner_only_regular_file_metadata(&self.state_path, &metadata)?, Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(_) => return Err(ReplayClaimError::Unavailable), } let mut file = open_owner_only_temp_file_for_write(&self.temp_path)?; - let bytes = encode_state(self.node_public_key, entries)?; + let bytes = encode_state(self.node_public_key, records)?; file.write_all(&bytes) .map_err(|_| ReplayClaimError::Unavailable)?; file.sync_all().map_err(|_| ReplayClaimError::Unavailable)?; @@ -445,6 +700,7 @@ fn validate_owner_and_mode( Ok(()) } +#[cfg(test)] fn sort_claims(claims: &mut [ReplayClaimEntryV1; 2]) { if replay_claim_sort_key(claims[1].key()) < replay_claim_sort_key(claims[0].key()) { claims.swap(0, 1); @@ -460,30 +716,87 @@ fn replay_claim_sort_key(key: ReplayClaimKeyV1) -> [u8; 48] { fn encode_state( node_public_key: NodePublicKey, - entries: &[ReplayClaimEntryV1], + records: &[StoredNodeReleaseRecordV1], ) -> Result, ReplayClaimError> { - if entries.len() > MAX_REPLAY_CLAIMS { + if records.len() > MAX_NODE_RELEASE_RECORDS { return Err(ReplayClaimError::Unavailable); } - let mut payload = Vec::with_capacity(STATE_HEADER_BYTES + entries.len() * STATE_ENTRY_BYTES); + let total_contribution_bytes = records + .iter() + .map(|record| record.contribution_bytes().map_or(0, <[u8]>::len)) + .sum::(); + let mut payload = Vec::with_capacity( + STATE_HEADER_BYTES + (records.len() * STATE_RECORD_FIXED_BYTES) + total_contribution_bytes, + ); payload.extend_from_slice(STATE_MAGIC); payload.extend_from_slice(node_public_key.as_bytes()); - payload.extend_from_slice(&(entries.len() as u32).to_be_bytes()); - for entry in entries { - payload.extend_from_slice(entry.key().authority_scope_hash().as_bytes()); - payload.extend_from_slice(entry.key().nonce().as_bytes()); - payload.extend_from_slice(&entry.expires_at().to_be_bytes()); + payload.extend_from_slice(&(records.len() as u32).to_be_bytes()); + let mut previous = None; + for record in records { + let sort_key = record.sort_key(); + if previous.is_some_and(|prior| prior >= sort_key) { + return Err(ReplayClaimError::Unavailable); + } + previous = Some(sort_key); + + payload.extend_from_slice( + record + .rights_request_replay_claim_key() + .authority_scope_hash() + .as_bytes(), + ); + payload.extend_from_slice(record.rights_request_replay_claim_key().nonce().as_bytes()); + payload.extend_from_slice(&record.rights_request_expires_at.to_be_bytes()); + + payload.extend_from_slice( + record + .release_request_replay_claim_key() + .authority_scope_hash() + .as_bytes(), + ); + payload.extend_from_slice(record.release_request_replay_claim_key().nonce().as_bytes()); + payload.extend_from_slice(&record.release_request_expires_at.to_be_bytes()); + + payload.extend_from_slice(record.audit_request_id().digest().as_bytes()); + payload.extend_from_slice(record.operation_hash().as_bytes()); + + match record.contribution_bytes() { + Some(contribution_bytes) => { + if contribution_bytes.is_empty() + || contribution_bytes.len() > MAX_STORED_CONTRIBUTION_BYTES + { + return Err(ReplayClaimError::Unavailable); + } + payload.push(STORED_RESULT_STATE_PRESENT); + payload.extend_from_slice( + &u16::try_from(contribution_bytes.len()) + .map_err(|_| ReplayClaimError::Unavailable)? + .to_be_bytes(), + ); + payload.extend_from_slice(contribution_bytes); + } + None => { + payload.push(STORED_RESULT_STATE_CLAIM_ONLY); + payload.extend_from_slice(&0u16.to_be_bytes()); + } + } + } + if payload.len() > MAX_STATE_PAYLOAD_BYTES { + return Err(ReplayClaimError::Unavailable); } let digest = state_integrity_digest(&payload); let mut bytes = payload; bytes.extend_from_slice(&digest); + if bytes.len() > MAX_STATE_BYTES { + return Err(ReplayClaimError::Unavailable); + } Ok(bytes) } fn decode_state( bytes: &[u8], expected_node_public_key: NodePublicKey, -) -> Result, ReplayClaimError> { +) -> Result, ReplayClaimError> { if bytes.len() < STATE_HEADER_BYTES + STATE_DIGEST_BYTES { return Err(ReplayClaimError::Unavailable); } @@ -502,44 +815,204 @@ fn decode_state( .try_into() .map_err(|_| ReplayClaimError::Unavailable)?, ) as usize; - if count > MAX_REPLAY_CLAIMS { - return Err(ReplayClaimError::Unavailable); - } - if payload.len() != STATE_HEADER_BYTES + (count * STATE_ENTRY_BYTES) { + if count > MAX_NODE_RELEASE_RECORDS { return Err(ReplayClaimError::Unavailable); } - let mut entries = Vec::with_capacity(count); + let mut records = Vec::with_capacity(count); let mut previous = None; let mut offset = STATE_HEADER_BYTES; for _ in 0..count { - let mut authority_scope_hash = [0u8; 32]; - authority_scope_hash.copy_from_slice(&payload[offset..offset + 32]); + if payload.len() < offset + STATE_RECORD_FIXED_BYTES { + return Err(ReplayClaimError::Unavailable); + } + + let mut rights_authority_scope_hash = [0u8; 32]; + rights_authority_scope_hash.copy_from_slice(&payload[offset..offset + 32]); offset += 32; - let mut nonce = [0u8; 16]; - nonce.copy_from_slice(&payload[offset..offset + 16]); + let mut rights_nonce = [0u8; 16]; + rights_nonce.copy_from_slice(&payload[offset..offset + 16]); offset += 16; - let expires_at = u64::from_be_bytes( + let rights_request_expires_at = u64::from_be_bytes( payload[offset..offset + 8] .try_into() .map_err(|_| ReplayClaimError::Unavailable)?, ); offset += 8; - let entry = ReplayClaimEntryV1::new( - ReplayClaimKeyV1::new( - Digest32::new(authority_scope_hash), - ReplayNonce16::new(nonce), - ), - expires_at, + + let mut release_authority_scope_hash = [0u8; 32]; + release_authority_scope_hash.copy_from_slice(&payload[offset..offset + 32]); + offset += 32; + let mut release_nonce = [0u8; 16]; + release_nonce.copy_from_slice(&payload[offset..offset + 16]); + offset += 16; + let release_request_expires_at = u64::from_be_bytes( + payload[offset..offset + 8] + .try_into() + .map_err(|_| ReplayClaimError::Unavailable)?, ); - let sort_key = replay_claim_sort_key(entry.key()); + offset += 8; + + let mut audit_request_id = [0u8; 32]; + audit_request_id.copy_from_slice(&payload[offset..offset + 32]); + offset += 32; + + let mut operation_hash = [0u8; 32]; + operation_hash.copy_from_slice(&payload[offset..offset + 32]); + offset += 32; + + let contribution_state = payload[offset]; + offset += 1; + let contribution_len = u16::from_be_bytes( + payload[offset..offset + 2] + .try_into() + .map_err(|_| ReplayClaimError::Unavailable)?, + ) as usize; + offset += 2; + if payload.len() < offset + contribution_len { + return Err(ReplayClaimError::Unavailable); + } + + let contribution_bytes = match contribution_state { + STORED_RESULT_STATE_CLAIM_ONLY => { + if contribution_len != 0 { + return Err(ReplayClaimError::Unavailable); + } + None + } + STORED_RESULT_STATE_PRESENT => { + if contribution_len == 0 || contribution_len > MAX_STORED_CONTRIBUTION_BYTES { + return Err(ReplayClaimError::Unavailable); + } + let contribution = payload[offset..offset + contribution_len].to_vec(); + offset += contribution_len; + Some(contribution) + } + _ => return Err(ReplayClaimError::Unavailable), + }; + + let record = StoredNodeReleaseRecordV1 { + rights_request_replay_claim_key: ReplayClaimKeyV1::new( + Digest32::new(rights_authority_scope_hash), + ReplayNonce16::new(rights_nonce), + ), + rights_request_expires_at, + release_request_replay_claim_key: ReplayClaimKeyV1::new( + Digest32::new(release_authority_scope_hash), + ReplayNonce16::new(release_nonce), + ), + release_request_expires_at, + audit_request_id: RuntimeReleaseAuditIdV1::new(Digest32::new(audit_request_id)) + .map_err(|_| ReplayClaimError::Unavailable)?, + operation_hash: Digest32::new(operation_hash), + contribution_bytes, + }; + + let sort_key = record.sort_key(); if previous.is_some_and(|prior| prior >= sort_key) { return Err(ReplayClaimError::Unavailable); } previous = Some(sort_key); - entries.push(entry); + records.push(record); + } + if offset != payload.len() { + return Err(ReplayClaimError::Unavailable); + } + Ok(records) +} + +fn has_conflicting_active_record( + records: &[StoredNodeReleaseRecordV1], + rights_request_replay_claim_key: ReplayClaimKeyV1, + release_request_replay_claim_key: ReplayClaimKeyV1, + audit_request_id: RuntimeReleaseAuditIdV1, + now: u64, +) -> bool { + records.iter().any(|record| { + let matches_rights_key = record.rights_request_replay_claim_key() + == rights_request_replay_claim_key + || record.release_request_replay_claim_key() == rights_request_replay_claim_key; + let matches_release_key = record.rights_request_replay_claim_key() + == release_request_replay_claim_key + || record.release_request_replay_claim_key() == release_request_replay_claim_key; + record.any_active(now) + && (matches_rights_key + || matches_release_key + || record.audit_request_id() == audit_request_id) + }) +} + +fn find_exact_record_index( + records: &[StoredNodeReleaseRecordV1], + rights_request_replay_claim_key: ReplayClaimKeyV1, + release_request_replay_claim_key: ReplayClaimKeyV1, + audit_request_id: RuntimeReleaseAuditIdV1, + operation_hash: Digest32, + now: u64, +) -> Result, ReplayClaimError> { + let mut exact = None; + for (index, record) in records.iter().enumerate() { + if !record.any_active(now) { + continue; + } + let same_claims = record.rights_request_replay_claim_key() + == rights_request_replay_claim_key + && record.release_request_replay_claim_key() == release_request_replay_claim_key; + if same_claims && record.audit_request_id() == audit_request_id { + if record.operation_hash() != operation_hash { + return Err(ReplayClaimError::Unavailable); + } + exact = Some(index); + } + } + Ok(exact) +} + +fn replay_exact_contribution( + record: &StoredNodeReleaseRecordV1, + decision_bytes: &[u8], + operation: &AuthenticatedRuntimeReleaseOperationV1, + node_set: &NodeSetV1, + now: u64, +) -> Result { + let contribution_bytes = record + .contribution_bytes() + .ok_or(CustodyError::Replay(ReplayClaimError::Unavailable))?; + let contribution = SignedNodeContributionV1::from_canonical_bytes(contribution_bytes)?; + if contribution + .statement() + .signed_rights_decision() + .canonical_bytes()? + != decision_bytes + { + return Err(CustodyError::BindingMismatch("node_rights_decision")); } - Ok(entries) + operation.verify_node_contribution(&contribution, node_set, now)?; + Ok(contribution) +} + +#[cfg(test)] +fn synthetic_runtime_release_audit_id(claims: &[ReplayClaimEntryV1; 2]) -> RuntimeReleaseAuditIdV1 { + let mut hasher = Sha256::new(); + hasher.update(b"elastos/protected-content/node-replay-claims/test-audit-id/v1"); + for claim in claims { + hasher.update(claim.key().authority_scope_hash().as_bytes()); + hasher.update(claim.key().nonce().as_bytes()); + hasher.update(claim.expires_at().to_be_bytes()); + } + RuntimeReleaseAuditIdV1::new(Digest32::new(hasher.finalize().into())).unwrap() +} + +#[cfg(test)] +fn synthetic_operation_hash(claims: &[ReplayClaimEntryV1; 2]) -> Digest32 { + let mut hasher = Sha256::new(); + hasher.update(b"elastos/protected-content/node-replay-claims/test-operation-hash/v1"); + for claim in claims { + hasher.update(claim.key().authority_scope_hash().as_bytes()); + hasher.update(claim.key().nonce().as_bytes()); + hasher.update(claim.expires_at().to_be_bytes()); + } + Digest32::new(hasher.finalize().into()) } fn state_integrity_digest(payload: &[u8]) -> [u8; STATE_DIGEST_BYTES] { @@ -601,13 +1074,36 @@ mod tests { use std::sync::{Arc, Barrier}; use ed25519_dalek::SigningKey; + use hpke::rand_core::{CryptoRng, RngCore}; + use rand09::{rngs::StdRng, SeedableRng as _}; use super::*; use crate::test_support::{ - authenticated_runtime_release_operation_for_envelope_and_recipient_seed, node_public_key, - provisioned_envelope, NOW, + authenticated_runtime_release_operation_for_envelope_and_recipient_seed, + node_custody_secret, node_public_key, node_signing_key, provisioned_envelope, + recipient_public_key, signed_node_decision, + verified_release_request_for_envelope_and_recipient_seed, NOW, }; + #[derive(Debug)] + struct PanicRng; + + impl CryptoRng for PanicRng {} + + impl RngCore for PanicRng { + fn next_u32(&mut self) -> u32 { + panic!("replay path must not consume randomness") + } + + fn next_u64(&mut self) -> u64 { + panic!("replay path must not consume randomness") + } + + fn fill_bytes(&mut self, _dest: &mut [u8]) { + panic!("replay path must not consume randomness") + } + } + fn claim(seed: u8, expiry: u64) -> ReplayClaimEntryV1 { ReplayClaimEntryV1::new( ReplayClaimKeyV1::new(Digest32::new([seed; 32]), ReplayNonce16::new([seed; 16])), @@ -642,6 +1138,116 @@ mod tests { DurableReplayClaimStoreV1::new(store_node(seed), store_dir(temp)) } + fn derived_max_signed_node_contribution_bytes() -> usize { + const DIGEST_BYTES: usize = 32; + const U64_BYTES: usize = 8; + const U32_BYTES: usize = 4; + const U16_BYTES: usize = 2; + const U8_BYTES: usize = 1; + const WALLET_ADDRESS_BYTES: usize = 20; + const ED25519_SIGNATURE_BYTES: usize = 64; + + let max_encrypted_content_identity_bytes = + "elastos.protected-content.encrypted-content/v1".len() + 1 + DIGEST_BYTES + U64_BYTES; + let max_rights_policy_identity_bytes = "elastos.protected-content.rights-policy-identity/v1" + .len() + + 1 + + DIGEST_BYTES + U32_BYTES; + let max_profile_identity_bytes = + "elastos.protected-content.profile-identity/v1".len() + 1 + DIGEST_BYTES; + let max_custody_epoch_identity_bytes = "elastos.protected-content.custody-epoch-identity/v1" + .len() + + 1 + + DIGEST_BYTES + + DIGEST_BYTES + + U8_BYTES + + U8_BYTES + U32_BYTES; + let max_key_envelope_identity_bytes = "elastos.protected-content.key-envelope/v1".len() + + 1 + + U16_BYTES + + max_encrypted_content_identity_bytes + + DIGEST_BYTES + + U32_BYTES + + DIGEST_BYTES + + U8_BYTES + + U8_BYTES + + U16_BYTES + + max_custody_epoch_identity_bytes; + let max_protected_content_binding_bytes = "elastos.protected-content.binding/v1".len() + + 1 + + U16_BYTES + + max_encrypted_content_identity_bytes + + U16_BYTES + + max_key_envelope_identity_bytes + + U16_BYTES + + max_rights_policy_identity_bytes + + U16_BYTES + + max_profile_identity_bytes + + WALLET_ADDRESS_BYTES + + DIGEST_BYTES; + let max_recipient_key_identity_bytes = "elastos.protected-content.recipient-key-identity/v1" + .len() + + 1 + + U16_BYTES + + elastos_protected_content_contracts::MAX_RECIPIENT_ENCRYPTION_SUITE_ID_BYTES + + DIGEST_BYTES; + let max_recipient_sealed_contribution_bytes = + "elastos.protected-content.recipient-sealed-contribution/v1".len() + + 1 + + U16_BYTES + + max_recipient_key_identity_bytes + + U16_BYTES + + elastos_protected_content_contracts::MAX_RECIPIENT_SEALED_CONTRIBUTION_BYTES; + let max_signed_node_rights_decision_bytes = + "elastos.protected-content.signed-node-rights-decision/v1".len() + + 1 + + U16_BYTES + + ("elastos.protected-content.node-rights-decision/v1".len() + + 1 + + DIGEST_BYTES + + DIGEST_BYTES + + U16_BYTES + + max_protected_content_binding_bytes + + U8_BYTES + + DIGEST_BYTES + + U8_BYTES + + DIGEST_BYTES + + U64_BYTES + + U64_BYTES) + + U16_BYTES + + ED25519_SIGNATURE_BYTES; + "elastos.protected-content.signed-node-contribution/v1".len() + + 1 + + U16_BYTES + + ("elastos.protected-content.node-contribution/v1".len() + + 1 + + DIGEST_BYTES + + U16_BYTES + + max_protected_content_binding_bytes + + U16_BYTES + + max_signed_node_rights_decision_bytes + + U16_BYTES + + max_recipient_sealed_contribution_bytes + + U64_BYTES + + U64_BYTES) + + U16_BYTES + + ED25519_SIGNATURE_BYTES + } + + fn allowed_decision_for( + envelope: &CustodyEnvelopeV1, + recipient_seed: u8, + ) -> SignedNodeRightsDecisionV1 { + let request = + verified_release_request_for_envelope_and_recipient_seed(envelope, recipient_seed); + signed_node_decision(&request, 1, RightsDecisionV1::Allowed) + } + + #[test] + fn durable_store_cap_admits_every_current_valid_signed_contribution_shape() { + assert!(derived_max_signed_node_contribution_bytes() <= MAX_STORED_CONTRIBUTION_BYTES); + } + #[test] fn durable_store_claims_both_keys_atomically_and_persists_across_restart() { let temp = owner_only_tempdir(); @@ -685,6 +1291,208 @@ mod tests { .unwrap(); } + #[test] + fn durable_store_replays_exact_contribution_after_restart_without_new_effects() { + let temp = owner_only_tempdir(); + let envelope = provisioned_envelope(); + let decision = allowed_decision_for(&envelope, 0x30); + let mut store = durable_store(1, &temp); + let first_operation = + authenticated_runtime_release_operation_for_envelope_and_recipient_seed( + &envelope, 0x30, + ); + let mut first_rng = StdRng::from_seed([0x51; 32]); + let first = store + .claim_or_replay_node_contribution_with_rng( + first_operation, + &decision, + &envelope, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + NOW + 5, + NOW + 45, + NOW + 6, + &mut first_rng, + ) + .unwrap(); + let first_bytes = first.canonical_bytes().unwrap(); + + let mut reopened = durable_store(1, &temp); + let replayed = reopened + .claim_or_replay_node_contribution_with_rng( + authenticated_runtime_release_operation_for_envelope_and_recipient_seed( + &envelope, 0x30, + ), + &decision, + &envelope, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + NOW + 5, + NOW + 45, + NOW + 6, + &mut PanicRng, + ) + .unwrap(); + assert_eq!(replayed.canonical_bytes().unwrap(), first_bytes); + } + + #[test] + fn durable_store_claim_without_result_fails_closed() { + let temp = owner_only_tempdir(); + let envelope = provisioned_envelope(); + let mut store = durable_store(1, &temp); + let operation = authenticated_runtime_release_operation_for_envelope_and_recipient_seed( + &envelope, 0x30, + ); + store + .claim_node_release_operation(operation, &envelope, node_public_key(1), NOW + 3) + .unwrap(); + + let decision = allowed_decision_for(&envelope, 0x30); + let err = store + .claim_or_replay_node_contribution_with_rng( + authenticated_runtime_release_operation_for_envelope_and_recipient_seed( + &envelope, 0x30, + ), + &decision, + &envelope, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + NOW + 5, + NOW + 45, + NOW + 6, + &mut PanicRng, + ) + .unwrap_err(); + assert!(matches!( + err, + CustodyError::Replay(ReplayClaimError::Unavailable) + )); + } + + #[test] + fn durable_store_rejects_mismatched_retry_without_mutating_stored_result() { + let temp = owner_only_tempdir(); + let envelope = provisioned_envelope(); + let mut store = durable_store(1, &temp); + let decision = allowed_decision_for(&envelope, 0x30); + let mut first_rng = StdRng::from_seed([0x52; 32]); + let first = store + .claim_or_replay_node_contribution_with_rng( + authenticated_runtime_release_operation_for_envelope_and_recipient_seed( + &envelope, 0x30, + ), + &decision, + &envelope, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + NOW + 5, + NOW + 45, + NOW + 6, + &mut first_rng, + ) + .unwrap(); + let first_bytes = first.canonical_bytes().unwrap(); + + let mismatched_decision = allowed_decision_for(&envelope, 0x31); + let err = store + .claim_or_replay_node_contribution_with_rng( + authenticated_runtime_release_operation_for_envelope_and_recipient_seed( + &envelope, 0x31, + ), + &mismatched_decision, + &envelope, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x31), + NOW + 5, + NOW + 45, + NOW + 6, + &mut PanicRng, + ) + .unwrap_err(); + assert!(matches!( + err, + CustodyError::Replay(ReplayClaimError::Unavailable) + )); + + let replayed = store + .claim_or_replay_node_contribution_with_rng( + authenticated_runtime_release_operation_for_envelope_and_recipient_seed( + &envelope, 0x30, + ), + &decision, + &envelope, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + NOW + 5, + NOW + 45, + NOW + 6, + &mut PanicRng, + ) + .unwrap(); + assert_eq!(replayed.canonical_bytes().unwrap(), first_bytes); + } + + #[test] + fn durable_store_rejects_corrupt_stored_result_without_mutation() { + let temp = owner_only_tempdir(); + let envelope = provisioned_envelope(); + let decision = allowed_decision_for(&envelope, 0x30); + let mut store = durable_store(1, &temp); + let mut first_rng = StdRng::from_seed([0x53; 32]); + store + .claim_or_replay_node_contribution_with_rng( + authenticated_runtime_release_operation_for_envelope_and_recipient_seed( + &envelope, 0x30, + ), + &decision, + &envelope, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + NOW + 5, + NOW + 45, + NOW + 6, + &mut first_rng, + ) + .unwrap(); + + let mut records = store.read_state().unwrap(); + let contribution = records[0].contribution_bytes.as_mut().unwrap(); + let last = contribution.len() - 1; + contribution[last] ^= 0x5a; + let expected = records.clone(); + store.write_state(&records).unwrap(); + + let err = store + .claim_or_replay_node_contribution_with_rng( + authenticated_runtime_release_operation_for_envelope_and_recipient_seed( + &envelope, 0x30, + ), + &decision, + &envelope, + &node_signing_key(1), + &node_custody_secret(1), + &recipient_public_key(0x30), + NOW + 5, + NOW + 45, + NOW + 6, + &mut PanicRng, + ) + .unwrap_err(); + assert!(matches!( + err, + CustodyError::Contract(_) | CustodyError::Release(_) | CustodyError::BindingMismatch(_) + )); + assert_eq!(store.read_state().unwrap(), expected); + } + #[test] fn durable_store_serializes_concurrent_claims() { let temp = owner_only_tempdir(); diff --git a/elastos/crates/elastos-protected-content-custody/src/test_support.rs b/elastos/crates/elastos-protected-content-custody/src/test_support.rs index 3fcbec799..e53088f0e 100644 --- a/elastos/crates/elastos-protected-content-custody/src/test_support.rs +++ b/elastos/crates/elastos-protected-content-custody/src/test_support.rs @@ -23,7 +23,7 @@ use elastos_protected_content_contracts::{ }; use crate::{ - provision::provision_custody_envelope_with_rng, ClaimedNodeReleaseOperationV1, + provision::provision_custody_envelope_with_rng, replay_store::ClaimedNodeReleaseOperationV1, ContentEncryptionKeyV1, DurableReplayClaimStoreV1, NodeCustodySecretKeyV1, RecipientPublicKeyV1, RecipientSecretKeyV1, }; From 4bbadb16377638f70bfdf494cbc2d3682e5a73a7 Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Mon, 17 Aug 2026 07:55:38 -0500 Subject: [PATCH 12/15] docs(protected-content): define canonical custody architecture --- docs/CARRIER.md | 18 ++- docs/CONTENT_AVAILABILITY.md | 59 ++++---- docs/DECRYPT_PROVIDER.md | 111 ++++----------- docs/GLOSSARY.md | 26 +++- docs/KEY_PROVIDER.md | 94 +++--------- docs/PROTECTED_CONTENT.md | 189 +++++++++++-------------- docs/PROTECTED_CONTENT_CONTRACTS_V1.md | 21 +-- docs/README.md | 8 +- docs/RIGHTS_PROVIDER.md | 74 ++++------ 9 files changed, 235 insertions(+), 365 deletions(-) diff --git a/docs/CARRIER.md b/docs/CARRIER.md index 4cddc34ec..26b60c735 100644 --- a/docs/CARRIER.md +++ b/docs/CARRIER.md @@ -142,15 +142,19 @@ Carrier should make content exchange feel like one SmartWeb, but it should not own all storage policy. The content provider owns publish/fetch/status/repair semantics and availability receipts. Carrier owns secure peer discovery, messaging, relay, and peer/object transport. IPLD gives the traversable CID graph -shape. Rights/decryption remain in the access provider and dDRM layer. +shape. In the target protected-content cutover, rights, custody, and decryption +remain in Runtime-selected providers. Provider-to-provider Carrier invocation follows the same boundary. Runtime adds -an `elastos.provider.invocation/v1` envelope, selects `carrier-provider-plane`, -and sends a generic Carrier `provider_invoke` message only between service -providers such as `content`, `availability`, `rights`, `key`, `decrypt`, and -`drm`. Raw connect tickets stay inside Runtime transport state and are not -returned in app-visible receipts. Raw backend providers such as `ipfs` or -`localhost` remain local implementation details, not remote Carrier authorities. +an `elastos.provider.invocation/v1` envelope and selects +`carrier-provider-plane`. The current Carrier provider target allowlist includes +`content`, `availability`, and the provisional `rights`, `key`, `decrypt`, and +`drm` labels. It does not yet include a custody route. The target cutover keeps +the same transport envelope but replaces the provisional protected-content +surface with Runtime-selected `rights`, `custody`, and `decrypt` providers. Raw +connect tickets stay inside Runtime transport state and are not returned in +app-visible receipts. Raw backend providers such as `ipfs` or `localhost` remain +local implementation details, not remote Carrier authorities. If the provider transfer is `stream`, the Carrier side also validates the target-visible `elastos.provider.stream/v1` base64-chunk contract before dispatching the request. Carrier availability fetches use that path for remote diff --git a/docs/CONTENT_AVAILABILITY.md b/docs/CONTENT_AVAILABILITY.md index c68e74f7f..347535137 100644 --- a/docs/CONTENT_AVAILABILITY.md +++ b/docs/CONTENT_AVAILABILITY.md @@ -67,8 +67,8 @@ IPLD can identify and traverse a CID graph. It does not decide: - who pays volunteer or professional storage providers - who may decrypt sealed content -Those are Runtime, Carrier, availability, dDRM, key-provider, decrypt-provider, -and blockchain responsibilities. +Those are Runtime, Carrier, availability, rights, custody, decrypt, and Chain +provider responsibilities. ## Capsule-Facing Contract @@ -596,9 +596,13 @@ invocation envelope for IPFS and availability effects. The envelope validates source, target, operation, transfer class, byte range, and progress receipt metadata. The provider plane now has explicit local and Carrier transports: Carrier `provider_invoke` runs over the Carrier ALPN with the same -`elastos.provider.invocation/v1` envelope, hides raw connect tickets from -receipts, and only dispatches to service providers (`content`, `availability`, -`rights`, `key`, `decrypt`, `drm`) rather than raw backends. +`elastos.provider.invocation/v1` envelope and hides raw connect tickets from +receipts. Its current provider target allowlist includes `content`, +`availability`, and the provisional `rights`, `key`, `decrypt`, and `drm` +labels. It does not yet include a custody route. The target cutover will retain +the envelope while replacing the provisional protected-content labels with +Runtime-selected `rights`, `custody`, and `decrypt` providers rather than raw +backends. `ProviderTransfer::Stream` now carries validated `elastos.provider.stream/v1` base64 chunks with range/progress receipts, and `content-provider` fetch opens that stream envelope as a Runtime-owned session @@ -640,24 +644,28 @@ dashboard rows. Runtime-owned fields; source providers cannot predeclare or spoof them in the target request. -## dDRM and Protected Content +## Protected Content Availability stores bytes. Rights decide who may use them. Protected content should be encrypted before it is published. IPFS, Elacity, -supernodes, and volunteers can safely store encrypted blocks. The dDRM or -ElastOS-native rights provider verifies ownership, subscription, access token, -or license state. The `drm-provider` owns the protected-content open contract -and should delegate to rights, key, and decrypt providers. The key provider -releases a short-lived decryption capability only after Runtime authority and -rights checks pass. +supernodes, and volunteers can store encrypted blocks. In the intended open +path, Runtime will own the operation. It will verify the authenticated request, +ask `rights-provider` for typed policy evidence, select custody providers for +recipient-encrypted contributions, and create a scoped `decrypt-provider` +session. Carrier will transport only Runtime-selected endpoint traffic. + +The current Library path still uses the provisional +`drm-provider -> rights-provider -> key-provider -> decrypt-provider` sequence. +Those providers remain fail closed without configured backends. They do not +implement or verify the intended custody path. That keeps the core invariant simple: ```text public CID can expose encrypted bytes -valid rights holder gets decrypt capability -unauthorized opener gets no key + valid rights holder gets scoped output + unauthorized opener gets no decrypt session ``` ## Default SmartWeb Sync @@ -745,21 +753,22 @@ verifies/summarizes remote content availability receipts when present, and other `carrier_announced` or `repair_needed`; if Carrier is unavailable, publish remains honestly local or `repair_needed`. Public gateway installer publishing now also uses `elastos://content/publish` instead of -direct IPFS. The -protected-content -foundation now adds shared sealed-object schemas, a fail-closed `drm-provider` -for `elastos://drm/open`, a fail-closed `rights-provider` for typed access -questions, a fail-closed `key-provider` for key release, a fail-closed -`decrypt-provider` for decrypt/render sessions, a canonical -`drm-provider.status.required_sequence` for protected-content opens, a -machine-readable fail-closed `open` response that repeats that sequence, -Runtime-owned receipt/audit steps in that sequence, and typed `chain-provider` -rights reads. The content provider now validates +direct IPFS. The protected-content source tree now adds canonical v1 contracts +and custody helpers for typed rights evidence, signed recipient authorization, +immutable custody epochs, recipient-encrypted node contributions, +exact-threshold reconstruction, node-local durable claims, and exact encrypted +result replay. It is source-only and does not provide Runtime orchestration, +installed custody nodes, decryption, or playback. Released 0.6 also retains the +provisional fail-closed DRM, rights, key, and decrypt capsules as a retirement +surface; they do not prove the canonical v1 path. The content provider now validates `sealed` object publishes against the sealed descriptor, required graph links, and protected-content algorithm allowlists. The remaining work is: -- Wire sealed-object publish/open to rights, PQ-hybrid key release, and decrypt/render providers without exposing raw CEKs or backend SDKs to app capsules. +- Replace the provisional protected-content DTO/provider surface atomically with + Runtime-owned rights, custody, and decrypt orchestration. Do not expose raw + CEKs, custody shares, provider routes, network locations, or backend SDKs to + app capsules. - Move remaining release artifact uploads off explicit operator IPFS tooling when the release pipeline can use the same content-provider contract without losing install compatibility or build/release proof. - MicroVM materialization paths should consult availability state and repair/fetch through explicit operator/provider-plane tooling. - Promote the first Carrier remote proof path into production-grade storage policy: production independent provider-network quota-ledger federation beyond the configured bounded endpoint quorum, repair-fleet worker attestations/SLA/settlement beyond the configured external dispatch endpoint quorum, live market pricing/escrow/settlement beyond the configured storage-market endpoint-quorum admission gate and current storage-settlement policy-status receipt, actual federated network abuse throttles/banlists/abuse ledgers beyond the configured bounded abuse-control endpoint quorum, richer remote storage receipt policy, production peer reputation trust policy/third-party attestations/revocation beyond the configured Carrier peer-attestation endpoint quorum, and live federated dashboard/UI/peer-health subscriptions beyond the current provider-local dashboard plus configured alert-exchange endpoint. diff --git a/docs/DECRYPT_PROVIDER.md b/docs/DECRYPT_PROVIDER.md index cf9e6c4d4..65caf886a 100644 --- a/docs/DECRYPT_PROVIDER.md +++ b/docs/DECRYPT_PROVIDER.md @@ -1,101 +1,42 @@ # Decrypt Provider -`decrypt-provider` is the protected-content decrypt/render boundary. It -validates scoped decrypt-session requests and fails closed until a real -decrypt/render backend is configured. +`decrypt-provider` will be the canonical protected-content decrypt and render +boundary. Runtime will bind one scoped session to the exact authenticated +Profile, Wallet-approved action, object, rights evidence, recipient +authorization, custody epoch, expiry, and provider identity. -The contract is: +The intended path is: -`capsule -> runtime capability -> elastos://decrypt/* -> decrypt-provider -> decrypt/render backend` +`Runtime coordinator -> decrypt-provider -> decrypt/render backend` -This page describes the older provisional provider surface. It does not -describe or prove the canonical source-only v1 contract in -[`PROTECTED_CONTENT_CONTRACTS_V1.md`](PROTECTED_CONTENT_CONTRACTS_V1.md). Future -integration must replace the provisional surface atomically, with no parallel -decoder, fallback, or migration path. +Custody providers return recipient-encrypted contributions. Runtime may relay +those opaque contributions or other sealed material, but it cannot open them. +The decrypt boundary is the only component that may reconstruct and briefly +hold the live CEK. It returns scoped output or an opaque session handle and +zeroizes the CEK when the session ends. It must not make outbound calls to gain +rights, custody, Wallet, Chain, storage, or network authority. -Apps and viewers must not receive raw CEKs, broad plaintext authority, -filesystem authority, key-backend SDKs, KMS credentials, chain RPC, wallet RPC, -or provider credentials. They receive scoped rendered output, streams, or -working copies after Runtime, rights, and key-release checks succeed. +Capsules receive no raw CEK, raw plaintext authority, custody shares, provider +routes, endpoint DIDs, network locations, credentials, filesystem authority, +Wallet RPC, Chain RPC, or backend SDK. -## Operations +## Current source state -- `status` -> `elastos://decrypt/meta/status` -- `open_session` -> `elastos://decrypt/session/open` -- `render` -> `elastos://decrypt/render` +The canonical Runtime-to-decrypt handoff is future integration work. The +source-only contracts and custody crate do not claim decryption, rendering, +installation, or playback. -Unsupported operations fail closed and do not create broad provider wildcards. - -## Request Shape - -The current provisional `elastos_common::protected_content::DecryptSessionRequestV1` -binds: - -- request ID -- principal ID -- session ID -- object CID -- requested action -- viewer interface -- typed `elastos.release.receipt/v1` from `key-provider`, bound to the same - principal/session/object/action -- output kind -- reason -- expiry - -The current provider accepts only documented protected-content actions and -outputs: `rendered`, `stream`, and `working_copy`. - -## Key Material Rail - -The current provisional `elastos_common::protected_content::ReleaseReceiptV1` -is an authorization receipt, not key material. It proves that the key-provider -accepted the rights-bound release request for the same -principal/session/object/action, but it does not contain a CEK. - -When the real decrypt backend is wired, the recommended normal path is: - -- `decrypt-provider` creates a per-session one-time public key for the decrypt - sandbox. -- `key-provider` or the dKMS release backend seals decrypt material to that - public key. -- `DecryptSessionRequestV1` is extended with a sealed decrypt material envelope, - bound to the same principal/session/object/action and release receipt. -- The decrypt sandbox unwraps inside the boundary, decrypts/renders, zeroizes - the live CEK, and returns only scoped output. -- `decrypt-provider` must not be granted outbound key-fetch authority as the - normal path. - -This preserves the provider-to-provider chain without letting the component that -briefly sees the live CEK call out to other providers or raw backends. - -The alternatives are intentionally narrower: - -- outbound key-fetch from `decrypt-provider` is only acceptable for an explicit - adapter with a scoped, audited capability, not as the default rail. -- a combined key/decrypt provider is acceptable for tests but not for the target - trust boundary because it merges dKMS authority with decrypt/render authority. -- Lit/Chipotle-style CEK envelopes are compatibility inputs only. The decrypt - contract must validate a backend-neutral sealed material envelope so an - ElastOS-native dKMS backend can replace vendor key release. - -## Current State - -The provider is intentionally not configured for real decrypt/render work yet. -It only proves the Runtime/provider boundary: - -- request validation is typed -- object IDs are treated as opaque identifiers -- raw CEK and raw plaintext paths are absent -- `open_session` and `render` return `not_configured` -- the sealed decrypt material envelope is not implemented yet +Released 0.6 contains an older provisional `decrypt-provider` capsule. It uses +the old `elastos_common::protected_content` DTO, validates requests, and returns +`not_configured`. It remains only as a fail-closed retirement surface and must +be replaced atomically. It does not verify the canonical v1 path. ## Verification +Provisional retirement guard only: + ```bash cargo test --manifest-path capsules/decrypt-provider/Cargo.toml cargo clippy --manifest-path capsules/decrypt-provider/Cargo.toml -- -D warnings -cargo test -p elastos-server --manifest-path elastos/Cargo.toml provider_resource -scripts/check-wci-alignment.sh +bash scripts/protected-content-provider-contract-smoke.sh ``` diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index aa4d72676..59f85f7d1 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -108,7 +108,15 @@ authorizes every launch, approval, provider, and shell-switch effect. ## Provider -A capsule that implements a protocol contract for other capsules to consume. Examples: `localhost-provider` (file-backed localhost roots), `did-provider` (identity), `ai-provider` (LLM routing), `chain-provider` (typed chain reads/proofs), `wallet-provider` (wallet proof, account-link, and approval authority), `drm-provider` (protected-content open boundary), `rights-provider` (protected-content rights questions), `key-provider` (protected-content key release), `decrypt-provider` (protected-content decrypt/render sessions), `availability-provider` (configured replication adapter), and `ipfs-provider` (low-level IPFS via Kubo). P2P networking is provided by built-in Carrier, not a separate provider capsule. Application capsules use providers through `elastos://` or rooted `localhost://` resources rather than implementing protocols directly. +A capsule or Runtime-owned service that implements a typed contract. Examples +include `localhost-provider` for rooted local storage, `did-provider` for +identity, `chain-provider` for typed chain reads and proofs, `wallet-provider` +for Wallet proof and approval authority, `rights-provider` for protected-content +rights evidence, `decrypt-provider` for scoped decrypt/render sessions, +`availability-provider` for configured replication, and `ipfs-provider` for +low-level local IPFS through Kubo. Runtime selects providers. Application +capsules use typed Runtime resources instead of choosing providers or network +routes. P2P networking is provided by Carrier below Runtime routing. ## Content Availability Provider @@ -119,18 +127,22 @@ Elacity/supernodes, served by volunteer nodes, or later backed by paid storage markets. Normal app capsules should use `elastos://content/*`, not raw `elastos://ipfs/*`. -## Protected Content Provider +## Protected Content Coordination -The Runtime-mediated `elastos://drm/*` contract for sealed/protected content. -It validates open requests and delegates rights, key release, and decrypt/render -work to provider-owned authority. Apps do not receive raw CEKs, key-backend SDKs, wallet -RPC, chain RPC, Kubo/IPFS APIs, or Elacity credentials. +The intended Runtime-owned sequence for sealed content. Runtime will bind the +exact object, Profile, Wallet-approved action, session, rights evidence, custody +epoch, and decrypt session. It will then coordinate rights, custody, and decrypt +providers. +Apps receive scoped output or an opaque handle. They do not receive raw CEKs, +custody shares, provider routes, endpoint DIDs, network locations, credentials, +Wallet RPC, Chain RPC, Kubo/IPFS APIs, or Elacity authority. ## dKMS Distributed key-management system for protected content. The ElastOS direction is PQ-hybrid threshold release for new content: AES-256 CEKs, `t-of-n` shares, -hybrid X25519 + ML-KEM share wrapping, and provider-owned key/decrypt sessions. +hybrid X25519 + ML-KEM share wrapping, Runtime-selected custody providers, and +scoped decrypt sessions. FROST may help sign classical v0 receipts or cohort decisions, but it is not the long-term dKMS security root. diff --git a/docs/KEY_PROVIDER.md b/docs/KEY_PROVIDER.md index 61842b797..b8533171b 100644 --- a/docs/KEY_PROVIDER.md +++ b/docs/KEY_PROVIDER.md @@ -1,84 +1,36 @@ # Key Provider -`key-provider` is the protected-content key-release boundary. It validates -Runtime-mediated key requests and keeps dangerous backend authority out of app -capsules: +`key-provider` is a provisional 0.6 capsule. It validates the old +`elastos_common::protected_content` key-release request and remains fail closed +when no backend is configured. It is not part of the canonical v1 architecture. -`capsule -> runtime capability -> elastos://key/* -> key-provider -> dKMS backend` +The intended canonical path is: -This page describes the older provisional provider surface. It does not -describe or prove the canonical source-only v1 contract in -[`PROTECTED_CONTENT_CONTRACTS_V1.md`](PROTECTED_CONTENT_CONTRACTS_V1.md). Future -integration must replace the provisional surface atomically, with no parallel -decoder, fallback, or migration path. +`Runtime coordinator -> rights-provider -> custody providers -> decrypt-provider` -Capsules do not receive raw CEKs, KMS node credentials, chain RPC, wallet RPC, -or provider credentials. +Runtime will select custody providers after it verifies the exact authenticated +request, Wallet approval, rights evidence, recipient authorization, and custody +epoch. Each custody provider will return only a recipient-encrypted contribution +and an authenticated receipt. Runtime and capsules will receive neither raw CEKs +nor raw shares. `decrypt-provider` will be the only boundary that may reconstruct +and briefly hold the CEK for one scoped session. -## Operations +The provisional `key-provider` must not be connected to a dKMS or retained as a +second key-release route. Runtime integration must remove its DTO and provider +surface atomically after the canonical path is reviewed. There is no supported +compatibility or migration path between the two authority models. -- `status`: list supported schemes and blocked raw authority. -- `release`: validate a `KeyReleaseRequestV1` and request scoped key release. +## Retirement guard -Current implementation is intentionally fail-closed. It validates schema, -principal/session/object/action fields in the provisional -`elastos_common::protected_content::KeyReleaseRequestV1`, an allowed -`elastos.rights.decision.receipt/v1` bound to the same -principal/session/object/action, supported schemes, and PQ-hybrid algorithm -metadata, then refuses backend work until an ElastOS dKMS adapter exists. - -## Decrypt Handoff - -`key-provider` owns key-release validation and dKMS authority, but app and viewer -capsules must never receive raw CEKs. The next live decrypt integration should -seal decrypt material to a one-time public key generated for the decrypt -session: - -- `decrypt-provider` supplies a one-time decrypt-session public key. -- `key-provider` or the dKMS release backend seals the CEK/material to that key - using the approved PQ-hybrid envelope profile. -- the release receipt remains a receipt; it is not a key carrier. -- the sealed material is handed to the decrypt session and can only be unwrapped - inside that decrypt sandbox. - -Prefer direct dKMS sealing to the decrypt-session key. A key-provider re-seal is -acceptable only as a migration step if it remains provider-internal, signed, -auditable, and short-lived. Lit/Chipotle can be one compatibility backend behind -this provider, but it must not define the Runtime contract or become the only -key-release dependency. The independent target is an ElastOS-native PQ-hybrid -threshold dKMS that can produce the same backend-neutral sealed material -handoff. - -## Capability Schema - -| Scope | Resource | -|-------|----------| -| Status | `elastos://key/meta/status` | -| Release | `elastos://key/release` | - -## Supported Schemes - -- `elastos-pq-hybrid-threshold-v0` - -## Algorithm Policy - -The provider accepts only reviewed algorithm metadata: - -- Payload cipher: `aes-256-gcm` or `chacha20-poly1305` -- KEM/share wrapping: both `x25519` and `ml-kem-768` -- Signatures: approved classical + PQ set, currently `ed25519` plus `ml-dsa-65` - or `slh-dsa-sha2-256s` -- Share scheme: `shamir-t-of-n` - -FROST is not a dKMS root. It can be a classical helper for receipts or cohort -decisions, but new long-lived protected content must use PQ-hybrid key -envelopes. - -## Verification +The provisional capsule still rejects unsupported algorithms, raw key requests, +and unconfigured backend work. This behavior is checked only to keep the old +surface fail closed until removal: ```bash cargo test --manifest-path capsules/key-provider/Cargo.toml cargo clippy --manifest-path capsules/key-provider/Cargo.toml -- -D warnings -cargo test -p elastos-server --manifest-path elastos/Cargo.toml provider_resource -bash scripts/check-wci-alignment.sh +bash scripts/protected-content-provider-contract-smoke.sh ``` + +These commands do not verify canonical custody, Runtime orchestration, +decryption, or playback. diff --git a/docs/PROTECTED_CONTENT.md b/docs/PROTECTED_CONTENT.md index c85b8331b..d53a68876 100644 --- a/docs/PROTECTED_CONTENT.md +++ b/docs/PROTECTED_CONTENT.md @@ -4,7 +4,8 @@ Protected content is Runtime-mediated. App, viewer, and content capsules ask to open an object; they do not receive raw wallet, chain, IPFS, Elacity, or key authority. -The source tree currently has two protected-content layers: +The source tree currently has one canonical source-only review stack and one +provisional retirement surface: - The canonical v1 source-only review stack is the `elastos-protected-content-contracts` crate plus the companion @@ -20,84 +21,69 @@ The source tree currently has two protected-content layers: into Runtime orchestration, provider integration, Runtime-owned replay storage, recipient key-possession proof, decryption, playback, installation, or deployment. -- The current installed/provider surface is the older provisional +- The older installed/provider surface is the provisional `elastos_common::protected_content` DTO set plus the fail-closed `drm-provider`, `rights-provider`, `key-provider`, and `decrypt-provider` - capsules. That provisional surface does not consume or prove the new v1 - contract. Future integration must replace it atomically, with no parallel - decoder, fallback, or migration path. - -The current provider chain is: - -`capsule -> runtime capability -> elastos://drm/open -> drm-provider -> rights/key/decrypt providers` - -## Current provider slice - -The repo currently has the provisional fail-closed provider boundary, not -production DRM: - -- shared provisional protected-content schemas in `elastos-common` -- `drm-provider` registered as `elastos://drm/*` -- `rights-provider` registered as `elastos://rights/*` -- `key-provider` registered as `elastos://key/*` -- `decrypt-provider` registered as `elastos://decrypt/*` -- `status` advertises the blocked raw-authority list and canonical open - sequence, including Runtime-owned receipt and audit steps -- `open` validates sealed-object requests and fails closed with the same - machine-readable required sequence until rights, key, and decrypt providers - exist -- `open` rejects key envelopes without approved algorithm metadata -- `rights-provider` validates typed access/subscription questions and fails - closed until a dDRM/chain policy backend is configured -- `chain-provider` exposes typed `has_access_by_content_id` reads that validate - inputs and only call configured contract selectors -- `key-provider` validates key-release requests and algorithm-agile key - envelopes, then fails closed until a dKMS backend is configured -- `decrypt-provider` validates scoped decrypt/render session requests and fails - closed until decrypt/render backends are configured -- `content-provider` rejects incomplete `sealed` object publishes before IPFS: - `sealed.json`, payload, rights policy, availability receipt, provenance, and - approved key-envelope algorithms are required - -This is intentional. The first safe steps are to make the authority and -custody boundaries unambiguous in the source-only v1 crates and keep the -current provider chain fail closed until the reviewed integration slice exists. - -PC2's dDRM contracts and WASM decrypt/render/media crates are useful -implementation references. They should enter Runtime only as provider-internal -backends behind `rights-provider`, `key-provider`, and `decrypt-provider`; they -must not give app or viewer capsules raw CEK, wallet, chain, IPFS, or Elacity + capsules. It is not current architecture and does not consume or prove the + v1 contract. The Runtime integration stage must remove it atomically. It must + not remain as a parallel decoder or compatibility path. + +## Canonical architecture + +The intended protected-content path is: + +`capsule -> Runtime coordinator -> rights-provider -> custody providers -> decrypt-provider` + +- Capsules own user workflow and request an action. They do not select + providers, custody nodes, routes, or network locations. +- Runtime derives authority from the authenticated Profile, exact Wallet + approval, session, object, and action. It selects providers, owns durable + orchestration, and audits the result. +- `rights-provider` evaluates the exact approved policy through typed Chain + evidence. It does not release content keys. +- Each selected custody provider independently verifies the exact Runtime + operation and rights evidence, then returns only a recipient-encrypted + contribution and an authenticated receipt. +- `decrypt-provider` is the only boundary that may reconstruct and briefly hold + a live CEK. It returns only scoped output or an opaque session handle and + zeroizes the CEK when the session ends. +- Carrier transports endpoint traffic selected by Runtime. Carrier is not + Profile, rights, custody, or key authority. + +Runtime may relay recipient-encrypted contributions or sealed material that it +cannot open. Runtime and capsules must never receive raw CEKs or custody shares. +Capsules must also never receive provider routes, endpoint DIDs, IP addresses, +ports, credentials, Wallet RPC, Chain RPC, Kubo/IPFS APIs, or Elacity SDK authority. -## dDRM Decrypt Rail Options And Recommendation +This architecture is not yet installed or wired into Runtime. The current +branch is source-only contracts and custody behavior. -The v0.4.0 provider chain intentionally proves the fail-closed sockets: +## Provisional retirement surface -`drm-provider -> rights-provider -> key-provider -> decrypt-provider` +Released 0.6 still contains the provisional `elastos_common::protected_content` +DTOs and the fail-closed `drm-provider`, `rights-provider`, `key-provider`, and +`decrypt-provider` capsules. They remain only as a disabled retirement surface +until the canonical Runtime path replaces them. They are not a second product +architecture and are not evidence that the canonical v1 path works. -The remaining architecture decision is how the live CEK reaches the decrypt -boundary once real decryption is wired. The recommended default is a -sealed-material rail: +The provisional provider smoke verifies only that this old surface rejects raw +authority and remains unavailable without configured backends. It must not be +used to claim custody, Runtime orchestration, decryption, or playback readiness. -- Runtime orchestrates the normal provider chain through `drm`, `rights`, and - `key`. -- `decrypt-provider` creates a per-session one-time public key for the decrypt - sandbox. -- `key-provider` or the dKMS release backend seals the CEK to that one-time - decrypt-session public key, using a separately reviewed decrypt-handoff - suite. -- The decrypt step receives sealed material in the decrypt-session request, - unwraps it inside the sandbox, decrypts/renders, zeroizes the live CEK, and - returns only scoped output. -- `decrypt-provider` does not pull keys by making outbound capability calls. - The component that briefly sees the live CEK must have the smallest possible - authority surface. +PC2's dDRM contracts and WASM decrypt/render/media crates remain implementation +references. A later integration may use reviewed parts inside canonical +providers, but those parts must not create a second authority path. -This keeps the rights/key/decrypt separation while avoiding an outbound -authority grant to the highest-risk boundary. The current source-only custody -crate already defines canonical custody envelopes plus stored-share and -released-share sealing for new content, but the Runtime/provider handoff to a -decrypt boundary is still future integration work. +## Future sealed decrypt handoff + +The intended handoff keeps live key material out of Runtime. Runtime binds an +authorized decrypt session to the exact object, action, recipient, rights +evidence, custody epoch, expiry, and provider identity. Custody nodes return +recipient-encrypted contributions. Runtime relays those opaque contributions to +the authorized decrypt boundary. The decrypt boundary reconstructs and uses the +CEK only inside that scoped session, then zeroizes it. It does not make outbound +calls to obtain broader authority. The new Profile-signed recipient-key authorization in the v1 contract is an authorization object only. It binds one exact recipient public key and one @@ -105,17 +91,6 @@ exact Runtime operation issuer for one binding/action/session/time window. It does not prove X25519 secret-key possession. Actual holder-only possession remains a later Runtime-owned invariant. -Other options were considered and rejected as the normal Runtime path: - -| Option | Shape | Assessment | -|--------|-------|------------| -| Decrypt pulls keys | `decrypt-provider` calls `key-provider` or a key backend after authorization | Flexible, but grants outbound authority to the boundary that briefly holds live CEK. Keep only for controlled diagnostics or explicit capability-gated adapters. | -| One combined key/decrypt provider | Key release and decrypt/render run in one provider | Simpler CEK path, but collapses authority separation and increases blast radius. Useful for tests, not the target trust boundary. | -| Runtime relays raw CEK | Runtime receives CEK and passes it to decrypt | Not acceptable. Runtime would become a key-material holder instead of an orchestrator. | -| Runtime relays sealed material | Runtime passes a sealed envelope without being able to open it | Acceptable if the envelope is transcript-bound and Runtime never sees raw CEK. This is the practical form of the recommended rail. | -| dKMS direct-to-decrypt sealing | dKMS seals directly to the one-time decrypt-session key | Best target when available. `key-provider` brokers policy and receipts without seeing raw CEK. | -| Lit/Chipotle backend | Vendor-backed key release returns a CEK envelope, as PC2 does today | Useful compatibility backend only. The Runtime contract must remain backend-neutral and must also support an ElastOS-native dKMS path. | - Before live decrypt is enabled, the sealed material envelope must bind the full transcript: principal, session, object, action, viewer interface, output kind, expiry, release receipt hash, decrypt-session public key, envelope algorithm, @@ -147,11 +122,13 @@ protected content: - It is source-only. There are no running custody nodes, Runtime/provider routes, Runtime-owned replay orchestration, recipient key-possession proof, decrypt/render product flow, installation, or deployment in this branch. The - current local child line adds one node-local durable dual-key replay-claim - store and the private claim-gated release transition only. That store adds a - domain-separated integrity digest to detect same-length corruption or torn - local state, but it does not defend against a malicious same-UID rewrite - that can recompute the digest. + custody crate includes one node-local durable dual-key replay-claim store. It + privately gates release on the exact claim, persists the exact encrypted node + contribution, and replays only that result after restart. A durable claim + without a stored result fails closed. The store adds a domain-separated + integrity digest to detect same-length corruption or torn local state, but it + does not defend against a malicious same-UID rewrite that can recompute the + digest. This helper does not make a PQ claim. PQ-hybrid custody, dKMS node lifecycle, and product wiring remain future work. The current HPKE dependency is `hpke` @@ -197,7 +174,7 @@ cutover, Runtime-owned replay store, recipient key-possession proof, node lifecycle service, installed product flow, or production confidentiality claim in this branch. -## Provider Boundary +## Capsule boundary Normal capsules must not see: @@ -206,18 +183,12 @@ Normal capsules must not see: - arbitrary chain RPC - Kubo/IPFS APIs - Elacity SDK or pinning credentials +- custody shares or node contributions +- provider routes, endpoint DIDs, IP addresses, or ports -The provider plane should expose typed questions instead: - -- `elastos://drm/open` -- `elastos://rights/access/has_access_by_content_id` -- `elastos://rights/subscription/is_subscription_active` -- `elastos://rights/content/can_stream` -- `elastos://rights/content/can_download` -- `elastos://chain//rights/has_access_by_content_id` -- `elastos://key/release` -- `elastos://decrypt/session/open` -- `elastos://decrypt/render` +In the intended architecture, capsules request an object action through Runtime +and receive a typed terminal result or scoped output handle. Runtime will select +and coordinate the typed rights, custody, and decrypt operations. ## Remaining sequence @@ -225,11 +196,11 @@ A. Finish the source-only review gate: professional external cryptographic and contract review, plus any additional grounded HPKE wrapper known-answer coverage that a reviewed upstream vector path can support. B. Continue the source-only custody-node operations and durable node-state - layer around the reviewed contracts. The current local child line adds the - durable dual-key replay-claim boundary and the claim-gated node-release - transition. Remaining work is node admission/rotation/revocation/recovery, - issuer-key lifecycle, operational audit retention, and durable - post-claim operation recovery. + layer around the reviewed contracts. The published custody branch adds the + durable dual-key claim boundary, claim-gated node release, and exact encrypted + result replay. Remaining work is node admission, rotation, revocation, + recovery, issuer-key lifecycle, operational audit retention, and recovery + from a claim that completed before its result became durable. C. Replace the provisional `elastos_common::protected_content` DTO/provider surface atomically with Runtime-owned orchestration over the reviewed v1 contract: Wallet integration, recipient key generation and possession proof, @@ -250,14 +221,14 @@ receipts and a disabled `Encrypted recipients` option, but it must not claim production encrypted-recipient sharing, dDRM completion, or generic decrypt/ render readiness. -## Executable Proof +## Provisional retirement guard -Run `scripts/protected-content-provider-contract-smoke.sh` after changing -protected-content provider capsules. It exercises the real provider binaries -over their JSON line protocol and verifies the current provisional provider -journey, not the canonical v1 contract crate: +Run `scripts/protected-content-provider-contract-smoke.sh` only when changing +the provisional provider capsules. It exercises those old binaries over their +JSON line protocol. It is a fail-closed retirement guard, not verification of +the canonical Runtime, rights, custody, or decrypt architecture: - status exposes blocked raw authority - valid requests fail closed until backends are configured - invalid raw-authority requests are rejected -- `drm-provider.open` reports the declared provider/runtime sequence +- the provisional `drm-provider.open` reports its old declared sequence diff --git a/docs/PROTECTED_CONTENT_CONTRACTS_V1.md b/docs/PROTECTED_CONTENT_CONTRACTS_V1.md index d2868238e..eca8a0191 100644 --- a/docs/PROTECTED_CONTENT_CONTRACTS_V1.md +++ b/docs/PROTECTED_CONTENT_CONTRACTS_V1.md @@ -241,11 +241,12 @@ returns an authenticated replay-pending surface only: it proves signatures and exact bindings, exposes the nested request hashes and replay-claim keys, and does not expose actionable `VerifiedRightsRequestV1` or `VerifiedKeyReleaseRequestV1` values. The companion source-only custody helper -now uses those exact claim keys for one local dual-key atomic claim transition, -but Runtime durable replay and orchestration remain later work. A crash or -storage failure after that node-local claim succeeds but before contribution -settlement is fail closed and currently requires a fresh Runtime release -operation; there is no durable operation-resume journal yet. +uses those exact claim keys for one local dual-key atomic claim transition. It +can persist the exact recipient-encrypted node contribution with that claim and +replay only that result after restart. Runtime durable replay and orchestration +remain later work. A crash or storage failure after the claim becomes durable +but before its result becomes durable is fail closed and requires a fresh +Runtime release operation; there is no operation-resume journal for that state. ## Node decision and contribution @@ -315,10 +316,12 @@ canonical input, noncanonical Wallet/Profile encodings, and noncanonical or low-order X25519 contract key encodings. These contracts plus the source-only `elastos-protected-content-custody` helper -provide one bounded node-local owner-only dual-key replay-claim store, but -they do not solve malicious custody nodes, rights-policy correctness, Runtime -durable replay storage, full operational custody state, durable post-claim -resume/settlement, issuer-key lifecycle, recipient key-possession proof, node +provide one bounded node-local owner-only dual-key replay store. It binds the +store to one node, privately gates release on the exact claim, persists the +exact recipient-encrypted contribution, and replays only that result. They do +not solve malicious custody nodes, rights-policy correctness, Runtime durable +replay storage, full operational custody state, recovery from a durable claim +without a result, issuer-key lifecycle, recipient key-possession proof, node admission/rotation/recovery, availability, decryption, rendering, or product workflow safety. The custody helper uses `hpke` 0.13, whose upstream documentation says it has not been formally audited. These source-only crates diff --git a/docs/README.md b/docs/README.md index 65b2da069..4676b65b5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,9 +59,11 @@ Each top-level ledger has one job: - [Protected content](PROTECTED_CONTENT.md): sealed object access sequence - [Protected-content v1 contracts](PROTECTED_CONTENT_CONTRACTS_V1.md): canonical source-only review candidate -- [Rights provider](RIGHTS_PROVIDER.md): rights questions and failure policy -- [Key provider](KEY_PROVIDER.md): protected content key release -- [Decrypt provider](DECRYPT_PROVIDER.md): decrypt and render sessions +- [Rights provider](RIGHTS_PROVIDER.md): canonical role and provisional capsule + retirement state +- [Key provider](KEY_PROVIDER.md): provisional provider retirement notice +- [Decrypt provider](DECRYPT_PROVIDER.md): canonical role and provisional + capsule retirement state - [Archive policy](ARCHIVE_POLICY.md): archive dependencies and family enablement ## Browser contracts and decisions diff --git a/docs/RIGHTS_PROVIDER.md b/docs/RIGHTS_PROVIDER.md index b6330c3b9..03970e87f 100644 --- a/docs/RIGHTS_PROVIDER.md +++ b/docs/RIGHTS_PROVIDER.md @@ -1,67 +1,43 @@ # Rights Provider -`rights-provider` is the protected-content policy boundary. It answers typed -questions for the DRM open path: +`rights-provider` will be the canonical protected-content policy boundary. +Runtime will ask one typed policy question for the exact authenticated Profile, +Wallet, object, action, policy identity, chain, contract, selector, and evidence +window. The provider will use typed `chain-provider` reads and return signed +evidence. It will not release key material or select custody nodes. -`capsule -> runtime capability -> elastos://rights/* -> rights-provider -> policy backend` +The intended path is: -This page describes the older provisional provider surface. It does not -describe or prove the canonical source-only v1 contract in -[`PROTECTED_CONTENT_CONTRACTS_V1.md`](PROTECTED_CONTENT_CONTRACTS_V1.md). Future -integration must replace the provisional surface atomically, with no parallel -decoder, fallback, or migration path. +`Runtime coordinator -> rights-provider -> chain-provider` -Capsules do not receive contract SDK objects, chain RPC, wallet RPC, -key-backend SDKs, raw CEKs, or provider credentials. The provider validates the -question and fails closed until a reviewed dDRM/chain policy backend is -configured. +Capsules may request a protected-content action, but they do not call rights or +Chain providers directly. They do not supply provider routes, RPC URLs, contract +transports, endpoint DIDs, IP addresses, ports, or credentials. -## Operations +## Current source state -- `status`: list supported rights questions and blocked raw authority. -- `has_access_by_content_id`: ask whether a principal/session has a specific - right for a content ID. -- `is_subscription_active`: ask whether a principal/session has an active plan. -- `can_stream`: ask whether protected content can be streamed. -- `can_download`: ask whether protected content can be downloaded. +The canonical source-only contract is `RightsPolicyBodyV1` with matching typed +evidence request and result values in +`elastos-protected-content-contracts`. It is not yet wired into Runtime or an +installed rights provider. -Supported rights are shared with the protected-content schema: +Released 0.6 also contains an older provisional `rights-provider` capsule. It +uses the old `elastos_common::protected_content` DTO and supports a wider set of +unwired operations. It remains fail closed and must be replaced atomically +during Runtime integration. It is not a second supported contract. -```text -view, stream, download, execute -``` - -## Capability Schema - -| Scope | Resource | -|-------|----------| -| Status | `elastos://rights/meta/status` | -| Access | `elastos://rights/access/has_access_by_content_id` | -| Subscription | `elastos://rights/subscription/is_subscription_active` | -| Stream | `elastos://rights/content/can_stream` | -| Download | `elastos://rights/content/can_download` | - -## Current Status - -The current provider is intentionally fail-closed. It gives Runtime, DRM, and -future key/decrypt providers one stable contract without pretending production -dDRM reads are ready. +## Verification -The next slice is to configure a reviewed policy backend that can call approved -typed `chain-provider` rights reads such as: +Canonical source-only contract: -```text -hasAccessByContentId(string contentId, address subject, string right) -> bool +```bash +(cd elastos && cargo test -p elastos-protected-content-contracts) ``` -Do not add generic contract calls, raw RPC passthrough, wallet objects, or -frontend-only license checks to this provider. - -## Verification +Provisional retirement guard only: ```bash cargo test --manifest-path capsules/rights-provider/Cargo.toml cargo clippy --manifest-path capsules/rights-provider/Cargo.toml -- -D warnings -cargo test -p elastos-server --manifest-path elastos/Cargo.toml provider_resource -bash scripts/check-wci-alignment.sh +bash scripts/protected-content-provider-contract-smoke.sh ``` From bd2d799ceb5532da6794108b9df0c7dc34b8cc0a Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Mon, 17 Aug 2026 07:56:34 -0500 Subject: [PATCH 13/15] docs(protected-content): record published custody truth --- ROADMAP.md | 25 +++++++----- TASKS.md | 49 +++++++++++------------ docs/RUNTIME_REPO_USER_STORY_CHECKLIST.md | 4 ++ state.md | 44 ++++++++------------ 4 files changed, 59 insertions(+), 63 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e471a8b82..b02c237f6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,15 +135,17 @@ The contract and its limits live in ### 3. Build Runtime-mediated protected content -Protected content uses the same effect path as ordinary content: +The target protected-content design uses the same effect path as ordinary +content: -`viewer -> Runtime capability -> protected-content coordinator -> providers` +`viewer -> Runtime -> rights provider -> custody providers -> decrypt provider` -The coordinator resolves the content object, checks availability, verifies -rights, requests key release, creates a decrypt or render session, and records -the result. Each dependency has a typed contract and fails closed. Viewers -receive scoped output or a scoped session, not content keys, chain RPC, wallet -authority, storage APIs, or provider credentials. +Runtime will resolve the content object, check availability, verify rights, +request recipient-encrypted custody contributions, create a scoped decrypt or +render session, and record the result. Each dependency will have a typed +contract and fail closed. Viewers will receive scoped output or a scoped +session, not content keys, custody shares, chain RPC, Wallet authority, storage +APIs, provider routes, network locations, or credentials. The dependency order for this work is strict: @@ -153,10 +155,11 @@ A. review the canonical v1 source-only contract and operational contract epochs, and the authenticated replay-pending Runtime-to-release-node envelope; B. build source-only custody-node operations and durable node state on top of - that contract. The current local child line covers the node-local durable - replay-claim boundary and claim-gated release transition; remaining work is - issuer lifecycle, node admission or rotation or recovery, operational - review, and durable post-claim operation recovery; + that contract. The published custody branch covers the node-local durable + dual-key claim, claim-gated release, exact encrypted-result persistence, and + exact retry replay. Remaining work is issuer lifecycle, node admission, + rotation, recovery, operational review, and recovery from a durable claim + that has no durable result; C. replace the provisional DTO/provider surface atomically and wire Runtime/Wallet/provider integration to the reviewed contract, with one source allow flow and one source deny flow; and diff --git a/TASKS.md b/TASKS.md index ad0151721..a0af78673 100644 --- a/TASKS.md +++ b/TASKS.md @@ -415,7 +415,7 @@ and review items pass. Reopen a slice only for a newly proven release-candidate defect with a named owner and verification command. - [ ] Treat Remote Carrier Exit as part of the Carrier slice: two-runtime evidence must cite the exact source/exit runtime DIDs and endpoint evidence; the installed artifact readiness report and route-readiness report must be hash-bound; evidence for route readiness, installed artifact readiness, discovery, policy, accounting, stream transport, Browser proof, and cleanup must cite reviewed route nouns; the local Browser machine-proof artifact must cite the reviewed route target or target host; local artifacts must stay redacted, and remote paths need an explicit digest and review trail. Compose Inspector, typed Runtime authority, installed artifact readiness, route-readiness, operator evidence, Browser handoff, manual UX, performance/zoom, and clean-worktree proof before any full-goal claim. -- [ ] Keep the verification gate green after each slice: run Rust workspace commands from `elastos/` such as `cargo fmt --all -- --check`, the narrow Rust tests for touched crates, `cargo check` for changed capsules, `git diff --check`, `scripts/check-wci-alignment.sh`, `scripts/protected-content-provider-contract-smoke.sh` where protected-content providers are touched, `node scripts/home-entropy-check.mjs` where Home UI is touched, `scripts/auth-wallet-focus-smoke.sh` after auth/wallet/chain changes, `scripts/installed-provider-verify.sh ` after installed provider binary changes, and a live `/apps/home/` proof before handing browser-visible changes back for testing. +- [ ] Keep the verification gate green after each slice: run Rust workspace commands from `elastos/` such as `cargo fmt --all -- --check`, the narrow Rust tests for touched crates, `cargo check` for changed capsules, `git diff --check`, `scripts/check-wci-alignment.sh`, `scripts/protected-content-provider-contract-smoke.sh` only as the provisional provider retirement guard where those old capsules are touched, `node scripts/home-entropy-check.mjs` where Home UI is touched, `scripts/auth-wallet-focus-smoke.sh` after auth/wallet/chain changes, `scripts/installed-provider-verify.sh ` after installed provider binary changes, and a live `/apps/home/` proof before handing browser-visible changes back for testing. - [ ] Do not add visible UI, protocol surface, provider behavior, or blockchain hooks unless the runtime capability path, fail-closed behavior, and docs contract are already explicit. - [ ] Keep first-party capsule projection validation covered by `first_party_capsules_have_complete_projection_contract`; extend the same @@ -655,7 +655,7 @@ and review items pass. 2. Runtime: signed bundle identity, whole-package verification, interface/version contracts, install receipts, update policy 3. Carrier: package/update distribution and peer discovery for trusted sources 4. Blockchain: publisher identity, provenance receipts, and optional license/payment hooks without making token mechanics the core model -- [ ] Keep Marketplace remote install/update/uninstall gated until the signed install contract exists. Marketplace may browse installed and verified Runtime apps, show details, and open installed Home launch targets, but remote actions need signed app manifests, publisher identity, install/update/removal receipts, provider policy, payment receipts where required, dDRM rights/key/decrypt policy, and Home/capsule change events before one-click install is exposed. Loose repo/dev folders can remain Home/dev targets, but they must not be presented as remotely installable Marketplace apps. +- [ ] Keep Marketplace remote install/update/uninstall gated until the signed install contract exists. Marketplace may browse installed and verified Runtime apps, show details, and open installed Home launch targets, but remote actions need signed app manifests, publisher identity, install/update/removal receipts, provider policy, payment receipts where required, protected-content rights/custody/decrypt policy, and Home/capsule change events before one-click install is exposed. Loose repo/dev folders can remain Home/dev targets, but they must not be presented as remotely installable Marketplace apps. - [ ] Do not prioritize rich DRM economics, DeFi/BtcFi, Android box specifics, or literal Capsule-NFT mechanics before the package identity, principal, space, and provider contracts are real. ### Runtime primitives missing for the PC2 world-computer model @@ -709,26 +709,24 @@ and review items pass. ### Trusted content and access rights -The source-only protected-content stack now has four review layers in order: -published canonical contracts, published custody helpers, the accepted local -operational-contract child line, and the current local custody-operations child -line. Remaining work must stay in this order. Carrier remains transport only -throughout every stage below. +The source-only protected-content stack now has two published review branches +in order: canonical contracts, then custody and node operations. Remaining work +must stay in this order. Carrier remains transport only throughout every stage +below. -The accepted local operational-contract line defines the reviewed source-only -EVM `has_access_by_content_id` policy/evidence contract, including the exact +The published custody branch defines the source-only EVM +`has_access_by_content_id` policy/evidence contract, including the exact contract right string mapped from one product action, Profile-signed recipient-key authorization, signed immutable custody epoch, and authenticated -replay-pending Runtime release-operation envelope. The current local -custody-operations line adds the node-local durable dual-key replay-claim store -and the private claim-gated transition from authenticated replay-pending -Runtime evidence to node-actionable release authority. Remaining tasks start -after those boundaries; they do not reopen them as ambient provider +replay-pending Runtime release-operation envelope. It also adds the node-local +durable dual-key replay store and the private claim-gated transition from +authenticated replay-pending evidence to an exact persisted encrypted +contribution. Exact retries replay only that result after restart. Remaining +tasks start after those boundaries; they do not reopen them as ambient provider configuration. Runtime owns its own orchestration replay. Each custody node -owns only its exact local dual-key claim. A crash after a successful local -claim but before contribution settlement is fail closed and currently requires -a fresh Runtime release operation; there is no durable operation-resume -journal yet. +owns only its local claim and result. A crash after the claim becomes durable +but before the result becomes durable remains fail closed and requires a fresh +Runtime release operation; there is no operation-resume journal for that state. #### A. Source-only review gate @@ -777,12 +775,12 @@ journal yet. transition into full operational node state: retained claim pruning policy, multi-process operational audit retention, node admission and issuer-key lifecycle, recovery tooling, and storage review for production hosts. The - current local child line already proves one owner-only durable dual-key - claim-many store, atomic no-partial-claim updates, restart survival, and the - private claim-before-open transition from authenticated replay-pending - Runtime evidence to node-actionable release authority. It does not yet add a - durable operation-resume journal after claim success, so a post-claim crash - still requires a fresh Runtime release operation. + published custody branch already proves one owner-only durable dual-key + claim store, atomic no-partial-claim updates, restart survival, private + claim-before-release authority, exact encrypted-result persistence, and exact + retry replay. It does not yet add an operation-resume journal for a claim + that became durable before its result, so that state still requires a fresh + Runtime release operation. #### C. Atomic Runtime/provider cutover plus source allow/deny proof @@ -827,7 +825,8 @@ journal yet. typed `chain-provider` reads, with every release node verifying the Wallet's exact right independently. Do not put license logic in app UI or gateway-only checks. -- [ ] Wire `key-provider` to the reviewed threshold-custody backend only after +- [ ] Wire Runtime-selected custody providers to the reviewed threshold-custody + backend only after node contribution authentication, same-node rights evidence, recipient-sealing encryption, exact-threshold settlement, durable replay rejection, expiry, revocation, and failure cleanup are proven. Verify the diff --git a/docs/RUNTIME_REPO_USER_STORY_CHECKLIST.md b/docs/RUNTIME_REPO_USER_STORY_CHECKLIST.md index 1a63f7633..d862db9cb 100644 --- a/docs/RUNTIME_REPO_USER_STORY_CHECKLIST.md +++ b/docs/RUNTIME_REPO_USER_STORY_CHECKLIST.md @@ -60,6 +60,10 @@ cargo fmt --manifest-path capsules/chain-provider/Cargo.toml -- --check just verify ``` +`protected-content-provider-contract-smoke.sh` is a fail-closed retirement +guard for the provisional provider capsules. It does not verify the canonical +v1 Runtime, rights, custody, and decrypt architecture. + `just verify` is the complete source gate in this tree. There is no separate `terminology-lint` recipe. Any unavailable command or accepted target-specific exception must be recorded explicitly rather than reported as a pass. diff --git a/state.md b/state.md index 187fce64a..7f0d7eefd 100644 --- a/state.md +++ b/state.md @@ -1,6 +1,6 @@ # State -Last updated: 2026-08-14 UTC +Last updated: 2026-08-17 UTC This file records public-safe current truth for released 0.6.0 and active unpublished work. Historical @@ -27,37 +27,26 @@ the public repository. the shared strict DID/Carrier codec correction required by that contract surface. It does not integrate Runtime orchestration, provider replacement, decryption, playback, installation, or deployment. -- `feat/protected-content-custody` is the source-only child review line +- `origin/feat/protected-content-custody` is the published source-only child + review line stacked on `origin/feat/protected-content-contracts`. It adds the `elastos-protected-content-custody` crate plus custody-envelope provisioning, recipient-sealed node release, and recipient-side threshold - reconstruction for new content. Its current reviewed behavior rejects + reconstruction for new content. It also includes the typed EVM rights-policy + and evidence contracts, Profile-signed recipient-key authorization, signed + custody epochs, the Runtime-to-release-node operation envelope, and one + owner-only node-local durable dual-key replay store. That store privately + gates release, persists the exact encrypted node contribution, and replays + only that result after restart. Its current reviewed behavior rejects invalid X25519 contract key bytes before HPKE use, requires exactly the bound released threshold, and checks a manifest-bound CEK commitment after reconstruction to detect a wrong reconstructed key. It does not integrate Runtime/provider/Carrier orchestration, Runtime-owned durable replay - storage, full operational custody state, recipient key-possession proof, - decrypt/render product flows, installation, or deployment. -- `feat/protected-content-operational-contracts` is the accepted local - dependent source-only child line stacked on - `origin/feat/protected-content-custody`. It adds the narrow typed v1 EVM - rights-policy body plus exact evidence request/result contracts, including - the exact EVM right string mapped from one product action, the - Profile-signed recipient-key authorization, the signed immutable custody - epoch, and the authenticated replay-pending Runtime-to-release-node - operation envelope. It does not prove X25519 secret-key possession, wire - Runtime/provider/product flows, or make an installed confidentiality claim. -- `feat/protected-content-custody-operations` is the current local dependent - source-only child line stacked on - `feat/protected-content-operational-contracts`. It adds one owner-only - durable dual-key replay-claim store for custody nodes plus the private - claim-gated transition from authenticated replay-pending Runtime evidence to - node-actionable release authority. It still does not add Runtime-owned - replay orchestration, recipient key-possession proof, node admission or - issuer lifecycle services, provider wiring, product flows, installation, or - deployment. A crash after a successful local claim but before contribution - settlement is fail closed and currently requires a fresh Runtime release - operation; there is no durable operation-resume journal yet. + storage, full operational custody state, recipient key-possession proof, node + admission or issuer lifecycle services, decrypt/render product flows, + installation, or deployment. A durable local claim without a stored result + fails closed and currently requires a fresh Runtime release operation; there + is no operation-resume journal for that state. - Released 0.6 and the published collaboration review stack retain the older provisional `elastos_common::protected_content` DTOs plus fail-closed `drm-provider`, `rights-provider`, `key-provider`, and `decrypt-provider` @@ -176,8 +165,9 @@ the public repository. `local-identity-profile-smoke.sh`, depending on target role. - Public Linux runtime portability proof path: `audit-linux-runtime-portability.sh`. -- Protected-content provider journey proof path: - `protected-content-provider-contract-smoke.sh`. +- Provisional protected-content provider retirement guard: + `protected-content-provider-contract-smoke.sh`. It does not verify the + canonical v1 custody or Runtime path. ## Browser Truth From 29879b25d7c527aece3d3664222e6dd3b3957422 Mon Sep 17 00:00:00 2001 From: Anders Alm Date: Mon, 17 Aug 2026 07:57:53 -0500 Subject: [PATCH 14/15] test(protected-content): label provisional provider retirement guard --- scripts/README.md | 5 +++-- scripts/check-wci-alignment.sh | 6 +++--- scripts/protected-content-provider-contract-smoke.sh | 8 ++++++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 0178d5ea3..c15508bd8 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -71,8 +71,9 @@ Common branch gates include: - `wallet-product-safety-smoke.sh` for product Wallet release safety - `wallet-connector-transaction-smoke.mjs` for fake-DOM, fake-provider connector handoff source proof, not hosted Browser acceptance -- `protected-content-provider-contract-smoke.sh` for rights, key, decrypt, and - DRM provider boundaries +- `protected-content-provider-contract-smoke.sh` as the fail-closed retirement + guard for the provisional rights, key, decrypt, and DRM providers; it does not + verify the canonical v1 custody path - `people-conversations-local-smoke.sh` for profile, discovery, contacts, and Chat handoff - `capsule-inspector-act-check.sh` for Inspector scope and Inbox approval diff --git a/scripts/check-wci-alignment.sh b/scripts/check-wci-alignment.sh index 6a4edcfab..fb6bc8513 100755 --- a/scripts/check-wci-alignment.sh +++ b/scripts/check-wci-alignment.sh @@ -343,16 +343,16 @@ check_required 'site promote' docs/SITES.md 'site docs must teach site promotion check_required 'public-install-operator-smoke\.sh' docs/RUNTIME_REPO_USER_STORY_CHECKLIST.md 'runtime repo checklist must record the installed operator/update proof' check_required 'public-install-identity-smoke\.sh' docs/RUNTIME_REPO_USER_STORY_CHECKLIST.md 'runtime repo checklist must record the DID/profile proof contract' check_required 'audit-linux-runtime-portability\.sh' docs/RUNTIME_REPO_USER_STORY_CHECKLIST.md 'runtime repo checklist must record the public Linux runtime portability proof' -check_required 'protected-content-provider-contract-smoke\.sh' docs/RUNTIME_REPO_USER_STORY_CHECKLIST.md 'runtime repo checklist must record the protected-content provider journey proof' +check_required 'protected-content-provider-contract-smoke\.sh' docs/RUNTIME_REPO_USER_STORY_CHECKLIST.md 'runtime repo checklist must record the provisional protected-content provider retirement guard' check_required 'just verify-release' docs/RUNTIME_REPO_USER_STORY_CHECKLIST.md 'runtime repo checklist must record the canonical release-trust gate' check_required 'public-install-operator-smoke\.sh' state.md 'state ledger must record the explicit installed operator/update proof' check_required 'local-identity-profile-smoke\.sh|public-install-identity-smoke\.sh' state.md 'state ledger must record the DID/profile proof path' check_required 'audit-linux-runtime-portability\.sh' state.md 'state ledger must record the explicit public Linux runtime portability proof' -check_required 'protected-content-provider-contract-smoke\.sh' state.md 'state ledger must record the protected-content provider journey proof' +check_required 'protected-content-provider-contract-smoke\.sh' state.md 'state ledger must record the provisional protected-content provider retirement guard' check_required 'public-install-operator-smoke\.sh' TASKS.md 'tasks must keep the installed operator/update proof in scope' check_required 'public-install-identity-smoke\.sh|DID-backed People/profile contract' TASKS.md 'tasks must keep the DID/profile public proof in scope' check_required 'audit-linux-runtime-portability\.sh' TASKS.md 'tasks must keep the public Linux runtime portability proof in scope' -check_required 'protected-content-provider-contract-smoke\.sh' TASKS.md 'tasks must keep the protected-content provider journey proof in scope' +check_required 'protected-content-provider-contract-smoke\.sh' TASKS.md 'tasks must keep the provisional protected-content provider retirement guard in scope' check_required 'BindDomain' elastos/crates/elastos-server/src/main.rs 'site command surface must expose bind-domain' check_required 'Publish' elastos/crates/elastos-server/src/main.rs 'site command surface must expose publish' check_required 'Releases' elastos/crates/elastos-server/src/main.rs 'site command surface must expose releases' diff --git a/scripts/protected-content-provider-contract-smoke.sh b/scripts/protected-content-provider-contract-smoke.sh index 17eb955a1..cf621435b 100755 --- a/scripts/protected-content-provider-contract-smoke.sh +++ b/scripts/protected-content-provider-contract-smoke.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash set -euo pipefail +# Retirement guard only. This checks that the released provisional DRM, rights, +# key, and decrypt provider capsules remain fail closed. It does not verify the +# canonical Runtime -> rights -> custody -> decrypt architecture. + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TMP_ROOT="$(mktemp -d)" trap 'rm -rf "$TMP_ROOT"' EXIT @@ -47,7 +51,7 @@ def provider_roundtrip(name, requests): if result.returncode != 0: sys.stderr.write(result.stderr) raise SystemExit( - f"[protected-content-provider-contract] {name} exited with {result.returncode}" + f"[protected-content-provisional-retirement-guard] {name} exited with {result.returncode}" ) lines = [line for line in result.stdout.splitlines() if line.strip()] assert_eq(len(lines), len(requests), f"{name} response count") @@ -272,5 +276,5 @@ check_rights_provider() check_key_provider() check_decrypt_provider() -print("[protected-content-provider-contract] PASS") +print("[protected-content-provisional-retirement-guard] PASS") PY From 2f844cef432728b86db6e27fcb1e85bde613e595 Mon Sep 17 00:00:00 2001 From: Irzhy Ranaivoarivony Date: Thu, 27 Aug 2026 16:41:26 +0300 Subject: [PATCH 15/15] fix(protected-content): verify statement signatures strictly custody_epoch, recipient_authorization, and runtime_release_operation verified their statement signatures with ed25519-dalek verify while every other signed contract in the crate uses verify_strict. Align the three sites so the exact-canonical-authority claim holds uniformly; canonical-key validation already rejected non-canonical verifying keys, so this only tightens signature-side malleability. --- capsules/decrypt-provider/Cargo.lock | 2 +- capsules/drm-provider/Cargo.lock | 2 +- capsules/key-provider/Cargo.lock | 2 +- capsules/rights-provider/Cargo.lock | 2 +- .../elastos-protected-content-contracts/src/custody_epoch.rs | 4 ++-- .../src/recipient_authorization.rs | 4 ++-- .../src/runtime_release_operation.rs | 4 ++-- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/capsules/decrypt-provider/Cargo.lock b/capsules/decrypt-provider/Cargo.lock index 2b42971e3..15b11574c 100644 --- a/capsules/decrypt-provider/Cargo.lock +++ b/capsules/decrypt-provider/Cargo.lock @@ -57,7 +57,7 @@ dependencies = [ [[package]] name = "elastos-common" -version = "0.5.0" +version = "0.6.0" dependencies = [ "hex", "serde", diff --git a/capsules/drm-provider/Cargo.lock b/capsules/drm-provider/Cargo.lock index bdc27e56f..eae5e2ed8 100644 --- a/capsules/drm-provider/Cargo.lock +++ b/capsules/drm-provider/Cargo.lock @@ -57,7 +57,7 @@ dependencies = [ [[package]] name = "elastos-common" -version = "0.5.0" +version = "0.6.0" dependencies = [ "hex", "serde", diff --git a/capsules/key-provider/Cargo.lock b/capsules/key-provider/Cargo.lock index cf4bc255e..d83512759 100644 --- a/capsules/key-provider/Cargo.lock +++ b/capsules/key-provider/Cargo.lock @@ -48,7 +48,7 @@ dependencies = [ [[package]] name = "elastos-common" -version = "0.5.0" +version = "0.6.0" dependencies = [ "hex", "serde", diff --git a/capsules/rights-provider/Cargo.lock b/capsules/rights-provider/Cargo.lock index cd585744e..014aa6b32 100644 --- a/capsules/rights-provider/Cargo.lock +++ b/capsules/rights-provider/Cargo.lock @@ -48,7 +48,7 @@ dependencies = [ [[package]] name = "elastos-common" -version = "0.5.0" +version = "0.6.0" dependencies = [ "hex", "serde", diff --git a/elastos/crates/elastos-protected-content-contracts/src/custody_epoch.rs b/elastos/crates/elastos-protected-content-contracts/src/custody_epoch.rs index aed818c92..4f674bde3 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/custody_epoch.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/custody_epoch.rs @@ -1,4 +1,4 @@ -use ed25519_dalek::{Signature, Verifier as _}; +use ed25519_dalek::Signature; use serde::Serialize; use thiserror::Error; @@ -301,7 +301,7 @@ impl SignedCustodyEpochV1 { ) .map_err(|_| CustodyEpochError::InvalidIssuerSignature)?; issuer_key - .verify(&self.statement.canonical_bytes()?, &signature) + .verify_strict(&self.statement.canonical_bytes()?, &signature) .map_err(|_| CustodyEpochError::InvalidIssuerSignature)?; Ok(VerifiedCustodyEpochV1 { epoch_identity: self.epoch_identity()?, diff --git a/elastos/crates/elastos-protected-content-contracts/src/recipient_authorization.rs b/elastos/crates/elastos-protected-content-contracts/src/recipient_authorization.rs index 138241bd2..c5799a3fd 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/recipient_authorization.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/recipient_authorization.rs @@ -1,4 +1,4 @@ -use ed25519_dalek::{Signature, Verifier as _}; +use ed25519_dalek::Signature; use serde::Serialize; use sha2::Digest as _; use thiserror::Error; @@ -252,7 +252,7 @@ impl SignedRecipientKeyAuthorizationV1 { ) .map_err(|_| RecipientAuthorizationError::InvalidProfileSignature)?; profile_key - .verify(&self.statement.canonical_bytes()?, &signature) + .verify_strict(&self.statement.canonical_bytes()?, &signature) .map_err(|_| RecipientAuthorizationError::InvalidProfileSignature)?; Ok(VerifiedRecipientKeyAuthorizationV1 { statement_hash: self.statement.canonical_hash()?, diff --git a/elastos/crates/elastos-protected-content-contracts/src/runtime_release_operation.rs b/elastos/crates/elastos-protected-content-contracts/src/runtime_release_operation.rs index 80ea64ff0..bb0be2b50 100644 --- a/elastos/crates/elastos-protected-content-contracts/src/runtime_release_operation.rs +++ b/elastos/crates/elastos-protected-content-contracts/src/runtime_release_operation.rs @@ -1,4 +1,4 @@ -use ed25519_dalek::{Signature, Verifier as _}; +use ed25519_dalek::Signature; use serde::Serialize; use thiserror::Error; @@ -337,7 +337,7 @@ impl SignedRuntimeReleaseOperationV1 { ) .map_err(|_| RuntimeReleaseOperationError::InvalidRuntimeSignature)?; runtime_key - .verify(&self.statement.canonical_bytes()?, &signature) + .verify_strict(&self.statement.canonical_bytes()?, &signature) .map_err(|_| RuntimeReleaseOperationError::InvalidRuntimeSignature)?; let verified = verify_release_operation_statement(&self.statement, now)?; Ok(AuthenticatedRuntimeReleaseOperationV1 {