Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 21 additions & 17 deletions tinycloud-node-server/src/policy_v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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() {
Expand Down
36 changes: 36 additions & 0 deletions tinycloud-node-server/src/policy_v3/named_keys_gate_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,8 @@ const TC2192_KEY_PATH: &str = "share/tc-2192";
enum SourceDigest {
RawWrappedBytes,
LegacyWire,
WrongRawWrappedBytes,
WrongLegacyWire,
Missing,
}

Expand Down Expand Up @@ -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),
Expand All @@ -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 {
Expand Down Expand Up @@ -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<()> {
Expand Down
Loading