diff --git a/lib/did-prism/src/protocol/error.rs b/lib/did-prism/src/protocol/error.rs index 4afa15cb..84d7d30a 100644 --- a/lib/did-prism/src/protocol/error.rs +++ b/lib/did-prism/src/protocol/error.rs @@ -27,6 +27,8 @@ pub enum ProcessError { #[from] #[display("applied operation has conflict with the current did state")] DidStateConflict { source: DidStateConflictError }, + #[display("vdr operation is invalid: contains unknown protobuf fields")] + VdrOperationContainsUnknownFields, } #[derive(Debug, derive_more::Display, derive_more::Error)] diff --git a/lib/did-prism/src/protocol/v1.rs b/lib/did-prism/src/protocol/v1.rs index bbfa26be..bf850031 100644 --- a/lib/did-prism/src/protocol/v1.rs +++ b/lib/did-prism/src/protocol/v1.rs @@ -187,6 +187,7 @@ impl OperationProcessorOps for V1Processor { operation: ProtoCreateStorageEntry, prism_operation_special_fields: SpecialFields, ) -> Result { + ensure_no_vdr_unknown_fields(&prism_operation_special_fields, &operation.special_fields)?; let parsed_operation = CreateStorageOperation::parse(&operation).map_err(DidError::from)?; // clone and mutate candidate state @@ -211,6 +212,7 @@ impl OperationProcessorOps for V1Processor { operation: ProtoUpdateStorageEntry, prism_operation_special_fields: SpecialFields, ) -> Result { + ensure_no_vdr_unknown_fields(&prism_operation_special_fields, &operation.special_fields)?; let parsed_operation = UpdateStorageOperation::parse(&operation).map_err(DidError::from)?; // clone and mutate candidate state @@ -238,6 +240,7 @@ impl OperationProcessorOps for V1Processor { operation: ProtoDeactivateStorageEntry, prism_operation_special_fields: SpecialFields, ) -> Result { + ensure_no_vdr_unknown_fields(&prism_operation_special_fields, &operation.special_fields)?; let parsed_operation = DeactivateStorageOperation::parse(&operation).map_err(DidError::from)?; // clone and mutate candidate state @@ -255,6 +258,21 @@ impl OperationProcessorOps for V1Processor { } } +/// Returns `Err(VdrOperationContainsUnknownFields)` if either `outer` (the `PrismOperation` +/// wrapper) or `inner` (the storage-entry message itself) contains unknown protobuf fields. +/// +/// VDR (storage) operations are strict: unknown fields indicate a schema the current +/// implementation does not understand, so the operation is rejected. SSI (DID) operations +/// are intentionally NOT guarded this way to remain forward-compatible with future +/// protocol extensions. +fn ensure_no_vdr_unknown_fields(outer: &SpecialFields, inner: &SpecialFields) -> Result<(), ProcessError> { + let has_unknown = |sf: &SpecialFields| sf.unknown_fields().iter().next().is_some(); + if has_unknown(outer) || has_unknown(inner) { + return Err(ProcessError::VdrOperationContainsUnknownFields); + } + Ok(()) +} + trait Validator { fn validate_candidate_state(param: &OperationParameters, state: &DidStateRc) -> Result<(), ProcessError>; } diff --git a/lib/did-prism/tests/storage_operation.rs b/lib/did-prism/tests/storage_operation.rs index d0258249..a78e2999 100644 --- a/lib/did-prism/tests/storage_operation.rs +++ b/lib/did-prism/tests/storage_operation.rs @@ -5,6 +5,7 @@ use identus_apollo::hash::Sha256Digest; use identus_apollo::hex::HexStr; use identus_did_prism::did::operation::StorageData; use identus_did_prism::did::{CanonicalPrismDid, PrismDidOps}; +use identus_did_prism::prelude::MessageExt; use identus_did_prism::proto; use identus_did_prism::protocol::resolver; @@ -802,3 +803,139 @@ fn new_create_storage_op( }), ) } + +// --- unknown fields --- +// +// Field number 100 is used as the injected unknown field in all tests below. +// 100 is well above the highest field number used in any current proto schema, +// so it is guaranteed to be unknown to the current implementation. + +#[test] +fn create_storage_with_unknown_fields_is_invalid() { + let (create_did_op, _, did, _, vdr_sk) = create_did_with_vdr_key(); + let mut inner = proto::prism_storage::ProtoCreateStorageEntry { + did_prism_hash: did.suffix.to_vec(), + nonce: vec![0], + data: Some(proto::prism_storage::proto_create_storage_entry::Data::Bytes(vec![ + 1, 2, 3, + ])), + special_fields: Default::default(), + }; + inner.special_fields.mut_unknown_fields().add_varint(100, 0); + let (create_storage_op, _) = test_utils::new_signed_operation( + VDR_KEY_NAME, + &vdr_sk, + proto::prism::prism_operation::Operation::CreateStorageEntry(inner), + ); + + let operations = test_utils::populate_metadata(vec![create_did_op, create_storage_op]); + let state = resolver::resolve_published(operations).0.unwrap(); + + // Operation must be rejected — no storage entry should be recorded. + assert!(state.storage.is_empty()); +} + +#[test] +fn update_storage_with_unknown_fields_is_invalid() { + let (create_did_op, _, did, _, vdr_sk) = create_did_with_vdr_key(); + let (create_storage_op, create_storage_op_hash) = new_create_storage_op(&did, &vdr_sk, vec![1, 2, 3], vec![0]); + let mut inner = proto::prism_storage::ProtoUpdateStorageEntry { + previous_event_hash: create_storage_op_hash.to_vec(), + data: Some(proto::prism_storage::proto_update_storage_entry::Data::Bytes(vec![ + 4, 5, 6, + ])), + special_fields: Default::default(), + }; + inner.special_fields.mut_unknown_fields().add_varint(100, 0); + let (update_storage_op, _) = test_utils::new_signed_operation( + VDR_KEY_NAME, + &vdr_sk, + proto::prism::prism_operation::Operation::UpdateStorageEntry(inner), + ); + + let operations = test_utils::populate_metadata(vec![create_did_op, create_storage_op, update_storage_op]); + let state = resolver::resolve_published(operations).0.unwrap(); + + // Update must be rejected — original entry must remain unchanged. + assert_eq!(state.storage.len(), 1); + assert_eq!(*state.storage[0].data, StorageData::Bytes(vec![1, 2, 3])); +} + +#[test] +fn deactivate_storage_with_unknown_fields_is_invalid() { + let (create_did_op, _, did, _, vdr_sk) = create_did_with_vdr_key(); + let (create_storage_op, create_storage_op_hash) = new_create_storage_op(&did, &vdr_sk, vec![1, 2, 3], vec![0]); + let mut inner = proto::prism_storage::ProtoDeactivateStorageEntry { + previous_event_hash: create_storage_op_hash.to_vec(), + special_fields: Default::default(), + }; + inner.special_fields.mut_unknown_fields().add_varint(100, 0); + let (deactivate_storage_op, _) = test_utils::new_signed_operation( + VDR_KEY_NAME, + &vdr_sk, + proto::prism::prism_operation::Operation::DeactivateStorageEntry(inner), + ); + + let operations = test_utils::populate_metadata(vec![create_did_op, create_storage_op, deactivate_storage_op]); + let state = resolver::resolve_published(operations).0.unwrap(); + + // Deactivation must be rejected — entry must still be present (active entries + // are the only ones included in DidState.storage after finalization). + assert_eq!(state.storage.len(), 1); +} + +#[test] +fn create_storage_with_unknown_fields_in_prism_operation_wrapper_is_invalid() { + let (create_did_op, _, did, _, vdr_sk) = create_did_with_vdr_key(); + let inner = + proto::prism::prism_operation::Operation::CreateStorageEntry(proto::prism_storage::ProtoCreateStorageEntry { + did_prism_hash: did.suffix.to_vec(), + nonce: vec![0], + data: Some(proto::prism_storage::proto_create_storage_entry::Data::Bytes(vec![ + 1, 2, 3, + ])), + special_fields: Default::default(), + }); + // Add unknown field to the outer PrismOperation wrapper. + let mut prism_op = proto::prism::PrismOperation { + operation: Some(inner), + special_fields: Default::default(), + }; + prism_op.special_fields.mut_unknown_fields().add_varint(100, 0); + let create_storage_op = proto::prism::SignedPrismOperation { + signed_with: VDR_KEY_NAME.to_string(), + signature: vdr_sk.sign(&prism_op.encode_to_vec()), + operation: Some(prism_op).into(), + special_fields: Default::default(), + }; + + let operations = test_utils::populate_metadata(vec![create_did_op, create_storage_op]); + let state = resolver::resolve_published(operations).0.unwrap(); + + assert!(state.storage.is_empty()); +} + +#[test] +fn create_did_with_unknown_fields_in_prism_operation_wrapper_is_still_valid() { + // SSI (DID) operations must remain valid even when the PrismOperation wrapper + // carries unknown fields, ensuring forward-compatibility for the SSI chain. + let (signed_op, _, master_sk) = test_utils::new_create_did_operation(None); + let inner_op = signed_op.operation.as_ref().unwrap().operation.clone().unwrap(); + let mut prism_op = proto::prism::PrismOperation { + operation: Some(inner_op), + special_fields: Default::default(), + }; + prism_op.special_fields.mut_unknown_fields().add_varint(100, 0); + let signed_op_with_unknown = proto::prism::SignedPrismOperation { + signed_with: signed_op.signed_with.clone(), + signature: master_sk.sign(&prism_op.encode_to_vec()), + operation: Some(prism_op).into(), + special_fields: Default::default(), + }; + + let operations = test_utils::populate_metadata(vec![signed_op_with_unknown]); + let state = resolver::resolve_published(operations).0; + + // The DID must resolve successfully — SSI unknown fields are intentionally tolerated. + assert!(state.is_some()); +}