Skip to content
Merged
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,9 +2,11 @@

## [Unreleased]

### Changes
- PolicyV3 decrypt checks the authoritative registered `contentSource`, never caller metadata: the capability resource, owner-qualified `encryptionNetwork` and optional `encryptionKeyPath` must match exactly. Every path-scoped Share source, whether mutable or immutable, requires a registered `encryptedSymmetricKeyDigestHex` (SHA-256 of the base64-decoded wrapped key) and binds that digest and `keyVersion` at use-time, before decrypt. Legacy immutable sources use their signed canonical wire digest, while older digest-less policies retain prior behavior; mutable legacy sources retain wrapped-key rotation. A copied `exact` selector cannot authorize a descendant or sibling resource. Details: TC-2192
- Path-scoped Share policy sessions (`contentSource.encryptionKeyPath = share/<shareId>`) are minted with `remainingRedelegationDepth = 0`, preventing re-delegation. Policies without that field retain depth 8 and their existing delegation behavior; ordinary delegations are unchanged. Details: TC-2192
- Direct network decrypt accepts exactly one decrypt capability for the route's network and authorizes it without executing an invocation. Route/key-path, registered-source and route/body network checks precede decrypt, so a refused mixed decrypt/delete request cannot commit its bundled KV delete. Details: TC-2192
- New Policy/v3 registrations must name their share key (`encryptionKeyPath`) and its digest (`encryptedSymmetricKeyDigestHex`); registrations already stored are unchanged. Details: TC-2242

## [1.25.13] - 2026-10-29

Expand Down
98 changes: 73 additions & 25 deletions tinycloud-node-server/src/policy_v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2141,6 +2141,9 @@ pub async fn register_policy(
}
return Err((Status::Conflict, "policy-registration-conflict".into()));
}
// A registration the node already stores answered above. A new one must
// bind its content source to one share key.
require_bound_content_source(&request.policy)?;

let now = OffsetDateTime::now_utc();
let policy_status = initial_status_checkpoint(
Expand Down Expand Up @@ -4641,6 +4644,31 @@ struct RegistrationProjections {
attenuation: Value,
}

/// A new registration's content source names the one share key it opens
/// (`encryptionKeyPath`) and that key's digest (`encryptedSymmetricKeyDigestHex`),
/// mutable or immutable. This runs only when a registration is stored;
/// `registration_projections` also validates stored registrations, which keep
/// their original shape.
fn require_bound_content_source(policy: &Value) -> Result<(), (Status, String)> {
let source = policy
.get("contentSource")
.and_then(Value::as_object)
.ok_or((Status::BadRequest, "content-source-invalid".into()))?;
if !source.contains_key("encryptionKeyPath") {
return Err((
Status::BadRequest,
"content-source-key-path-required".into(),
));
}
if !source.contains_key("encryptedSymmetricKeyDigestHex") {
return Err((
Status::BadRequest,
"content-source-key-digest-required".into(),
));
}
Ok(())
}

fn registration_projections(policy: &Value) -> Result<RegistrationProjections, (Status, String)> {
let object = policy
.as_object()
Expand Down Expand Up @@ -8777,23 +8805,23 @@ mod tests {

#[tokio::test]
async fn policy_v2_admits_v3_account_and_v4_accountless_receivers() -> anyhow::Result<()> {
run_policy_v2_flow(PolicyOwner::SpaceOwner).await
Box::pin(run_policy_v2_flow(PolicyOwner::SpaceOwner)).await
}

/// The real Share and CLI shape: the policy owner is a session key the
/// space owner delegated to. It registers, mints and reads; once the
/// space owner revokes that delegation, no new session can be minted.
#[tokio::test]
async fn policy_owner_may_be_a_session_the_space_owner_delegated_to() -> anyhow::Result<()> {
run_policy_v2_flow(PolicyOwner::DelegatedSession).await
Box::pin(run_policy_v2_flow(PolicyOwner::DelegatedSession)).await
}

/// The policy owner signs a valid, self-consistent policy over content in
/// a space it holds no authority in (the victim hosts and seeds it).
/// Registration must refuse it, so no session can ever read the bytes.
#[tokio::test]
async fn policy_registration_requires_the_owner_to_hold_what_it_grants() -> anyhow::Result<()> {
run_policy_v2_flow(PolicyOwner::Unauthorized).await
Box::pin(run_policy_v2_flow(PolicyOwner::Unauthorized)).await
}

#[derive(Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -10339,28 +10367,44 @@ mod tests {
&StaticSecret::new(vec![29; 32]).unwrap(),
)),
);
let register_request = client
.post("/policy/v3/policies")
.header(ContentType::JSON)
.body(
json!({
"policyCid": policy_cid,
"policy": policy,
"policyRoot": policy_root_authorization,
"enforcementRoot": enforcement_root_authorization,
"contentSourceDigestHex": projections.content_source_digest_hex,
"nativeProjectionHashHex": projections.native_projection_hash_hex,
"attestedEnforcerBinding": live_binding,
})
.to_string(),
);
let register_response = tinycloud_core::request_telemetry::scope(
telemetry.clone(),
register_request.dispatch(),
)
.await;
let register_status = register_response.status();
let register_body = register_response.into_string().await.unwrap_or_default();
let registration = json!({
"policyCid": policy_cid,
"policy": policy,
"policyRoot": policy_root_authorization,
"enforcementRoot": enforcement_root_authorization,
"contentSourceDigestHex": projections.content_source_digest_hex,
"nativeProjectionHashHex": projections.native_projection_hash_hex,
"attestedEnforcerBinding": live_binding,
});
let (register_status, register_body) = if policy_owner == PolicyOwner::Unauthorized {
let register_response = tinycloud_core::request_telemetry::scope(
telemetry.clone(),
client
.post("/policy/v3/policies")
.header(ContentType::JSON)
.body(registration.to_string())
.dispatch(),
)
.await;
(
register_response.status(),
register_response.into_string().await.unwrap_or_default(),
)
} else {
// TC-2242: this policy has no `encryptionKeyPath`, a shape the
// route refuses for a new registration. What follows is about a
// registration that is already stored, so it is stored as one.
// Boxed: this test's future is already near the test thread's stack.
let stored = tinycloud_core::request_telemetry::scope(
telemetry.clone(),
Box::pin(super::test_harness::Harness::store_registration_on(
&client,
registration,
)),
)
.await?;
(Status::Ok, serde_json::to_string(&stored)?)
};
if policy_owner == PolicyOwner::Unauthorized {
assert_eq!(
register_status,
Expand Down Expand Up @@ -12015,3 +12059,7 @@ mod registration_probe_tests;
#[cfg(test)]
#[path = "policy_v3/delivery_admission_tests.rs"]
mod delivery_admission_tests;

#[cfg(test)]
#[path = "policy_v3/unbound_source_tests.rs"]
mod unbound_source_tests;
15 changes: 9 additions & 6 deletions tinycloud-node-server/src/policy_v3/named_keys_gate_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -873,8 +873,8 @@ enum SourceDigest {
Missing,
}

struct ShareDecrypt {
h: Harness,
pub(super) struct ShareDecrypt {
pub(super) h: Harness,
session: String,
public_key: Vec<u8>,
wrapped: Vec<u8>,
Expand All @@ -888,7 +888,10 @@ impl ShareDecrypt {
Self::with_source(Some(TC2192_KEY_PATH), "immutable").await
}

async fn with_source(key_path: Option<&'static str>, mode: &str) -> anyhow::Result<Self> {
pub(super) async fn with_source(
key_path: Option<&'static str>,
mode: &str,
) -> anyhow::Result<Self> {
let digest_format = if key_path.is_some() {
SourceDigest::RawWrappedBytes
} else {
Expand Down Expand Up @@ -980,7 +983,7 @@ impl ShareDecrypt {
})
}

fn body(&self) -> Value {
pub(super) fn body(&self) -> Value {
use tinycloud_core::encryption_network::{canonical::canonical_hash, DECRYPT_REQUEST_TYPE};
let wrapped = json!(base64::encode(&self.wrapped));
let receiver = json!(base64::encode(
Expand All @@ -1003,7 +1006,7 @@ impl ShareDecrypt {
body
}

async fn invoke(
pub(super) async fn invoke(
&self,
resource: &str,
body: &Value,
Expand Down Expand Up @@ -1072,7 +1075,7 @@ impl ShareDecrypt {
Ok((status, body))
}

fn leaf(&self) -> String {
pub(super) fn leaf(&self) -> String {
match self.key_path {
Some(path) => format!("{}/{path}", self.h.network),
None => self.h.network.clone(),
Expand Down
10 changes: 7 additions & 3 deletions tinycloud-node-server/src/policy_v3/registration_probe_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
//! main as well. PostgreSQL only; the probe runs in CI, not locally.

use super::test_harness::*;
use super::unbound_source_tests::{boot, bound_source, grant_owner_key};
use super::*;
use std::future::Future;
use tinycloud_core::sea_orm::{
Expand Down Expand Up @@ -106,15 +107,18 @@ fn key_free_and_rows(url: String, key: i64) -> anyhow::Result<(bool, Rows)> {
#[tokio::test]
async fn postgres_refused_registration_releases_its_space_lock() -> anyhow::Result<()> {
let test = "postgres_refused_registration_releases_its_space_lock";
let Some(h) = Harness::new(Backend::Postgres, Shape::KeySession, test).await? else {
// TC-2242: the route refuses a pathless registration before it opens the
// transaction, so the registration is path-bound (this needs named keys).
let Some((h, _ledger)) = boot(Backend::Postgres, test).await? else {
return Ok(());
};
h.grant_owner(policy_window()).await?;
let (source, leaf) = bound_source(&h, "immutable", Some(&"aa".repeat(32)));
grant_owner_key(&h, &leaf).await?;
let url = h.database_url().to_owned();
install_refusal(url.clone())?;
let before = rows(url.clone())?;

let (status, body, _) = h.register(policy_window()).await?;
let (status, body, _) = h.register_source(policy_window(), source, &leaf).await?;
anyhow::ensure!(
status == Status::Conflict,
"refused register: {status} {body}"
Expand Down
Loading
Loading