diff --git a/CHANGELOG.md b/CHANGELOG.md index a899f76e..74cc12e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Existing path-scoped shares continue to open across wrapped-key digest encodings. Details: TC-2192 + ## [1.25.14] - 2026-10-29 ### Changes diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index a1f946cb..8445fa3b 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -944,9 +944,9 @@ fn validate_registered_decrypt_source( { return Err(MISMATCH); } - // Path-scoped shares bind raw wrapped bytes and always require a digest. - // Legacy immutable sources use the signed wire digest when present; older - // digest-less policies retain their prior readable behavior. + // Path-scoped sources accept both the raw wrapped-byte digest and the + // legacy CLI digest of the canonical base64 string. Legacy immutable + // pathless sources keep their signed wire-digest behavior. let immutable = source.get("mode").and_then(Value::as_str) == Some("immutable"); if key_path.is_some() || immutable { if body.get("keyVersion").and_then(Value::as_u64) @@ -960,22 +960,26 @@ fn validate_registered_decrypt_source( { let mut expected = [0_u8; 32]; hex::decode_to_slice(digest, &mut expected).map_err(|_| MISMATCH)?; - let actual = if key_path.is_some() { - let wrapped = base64::decode( - body.get("encryptedSymmetricKey") - .and_then(Value::as_str) - .ok_or(MISMATCH)?, - ) - .map_err(|_| MISMATCH)?; - Sha256::digest(&wrapped) + let wrapped = body.get("encryptedSymmetricKey").ok_or(MISMATCH)?; + if !wrapped.is_string() { + return Err(MISMATCH); + } + let matches = if key_path.is_some() { + let encoded = wrapped.as_str().ok_or(MISMATCH)?; + let decoded = base64::decode(encoded).map_err(|_| MISMATCH)?; + let raw_digest = Sha256::digest(&decoded); + let mut wire_hasher = Sha256::new(); + wire_hasher.update(b"\""); + wire_hasher.update(encoded.as_bytes()); + wire_hasher.update(b"\""); + let wire_digest = wire_hasher.finalize(); + bool::from(raw_digest[..].ct_eq(&expected)) + | bool::from(wire_digest[..].ct_eq(&expected)) } else { - let wrapped = body.get("encryptedSymmetricKey").ok_or(MISMATCH)?; - if !wrapped.is_string() { - return Err(MISMATCH); - } - Sha256::digest(canonical_json_value(wrapped)) + let wire_digest = Sha256::digest(canonical_json_value(wrapped)); + bool::from(wire_digest[..].ct_eq(&expected)) }; - if !bool::from(actual[..].ct_eq(&expected)) { + if !matches { return Err(MISMATCH); } } else if key_path.is_some() { diff --git a/tinycloud-node-server/src/policy_v3/named_keys_gate_tests.rs b/tinycloud-node-server/src/policy_v3/named_keys_gate_tests.rs index b8cdcfe8..b353e755 100644 --- a/tinycloud-node-server/src/policy_v3/named_keys_gate_tests.rs +++ b/tinycloud-node-server/src/policy_v3/named_keys_gate_tests.rs @@ -870,6 +870,8 @@ const TC2192_KEY_PATH: &str = "share/tc-2192"; enum SourceDigest { RawWrappedBytes, LegacyWire, + WrongRawWrappedBytes, + WrongLegacyWire, Missing, } @@ -933,6 +935,7 @@ impl ShareDecrypt { } else { network.public_encryption_key }; + let wrong_wrapped = wrap_to_public_key(&public_key, &[0xEF; 32])?; let wrapped = wrap_to_public_key(&public_key, &[0xCD; 32])?; let leaf = match key_path { Some(path) => format!("{}/{path}", h.network), @@ -954,6 +957,12 @@ impl ShareDecrypt { SourceDigest::LegacyWire => { Some(canonical_hash(&json!(base64::encode(&wrapped)))) } + SourceDigest::WrongRawWrappedBytes => { + Some(hex::encode(Sha256::digest(&wrong_wrapped))) + } + SourceDigest::WrongLegacyWire => { + Some(canonical_hash(&json!(base64::encode(&wrong_wrapped)))) + } SourceDigest::Missing => None, }; match digest { @@ -1126,6 +1135,33 @@ async fn tc2192_legacy_immutable_source_without_digest_remains_compatible() -> a assert_eq!(status, Status::Ok, "{response}"); Ok(()) } +#[tokio::test] +async fn tc2192_path_scoped_share_decrypt_accepts_legacy_wire_digest() -> anyhow::Result<()> { + let share = ShareDecrypt::with_source_digest( + Some(TC2192_KEY_PATH), + "immutable", + SourceDigest::LegacyWire, + ) + .await?; + assert_registered_source_binding(share, "/invoke").await +} + +#[tokio::test] +async fn tc2192_path_scoped_share_decrypt_rejects_wrong_digest_conventions() -> anyhow::Result<()> { + for digest in [ + SourceDigest::WrongRawWrappedBytes, + SourceDigest::WrongLegacyWire, + ] { + let share = + ShareDecrypt::with_source_digest(Some(TC2192_KEY_PATH), "immutable", digest).await?; + let (status, response) = share + .invoke(&share.leaf(), &share.body(), None, Value::Null) + .await?; + assert_eq!(status, Status::Forbidden, "{response}"); + assert_eq!(response, "policy-decrypt-content-source-mismatch"); + } + Ok(()) +} #[tokio::test] async fn tc2192_immutable_share_decrypt_binds_registered_source() -> anyhow::Result<()> {