From 56001a0ece26248d03249a7f747f9dac332cc27d Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:06:46 +0000 Subject: [PATCH 1/2] fix(encryption): v2 column format with key ids and per-purpose keys TC-2516 TC-2517 --- CHANGELOG.md | 1 + README.md | 10 + .../tests/sql_isolation.rs | 1 + .../tests/tc119_registry_wire_paths.rs | 1 + tinycloud-core/src/auth_graph.rs | 15 +- tinycloud-core/src/db.rs | 85 ++- tinycloud-core/src/encryption.rs | 524 +++++++++++++++--- .../src/encryption_network/backend.rs | 25 +- .../src/encryption_network/tests.rs | 2 +- tinycloud-core/src/kv_orphans.rs | 1 + tinycloud-core/src/lib.rs | 2 +- .../m20230510_101010_init_tables.rs | 78 ++- .../migrations/m20260724_010000_current_kv.rs | 14 +- .../m20260725_000000_request_path_indexes.rs | 33 +- ...05_000000_deactivate_hook_subscriptions.rs | 6 +- ...0261109_000000_column_encryption_format.rs | 93 ++++ tinycloud-core/src/migrations/mod.rs | 2 + tinycloud-core/src/models/delegation.rs | 16 +- .../src/models/delegation_cid_alias.rs | 210 +++++-- .../src/models/hook_subscription.rs | 1 + tinycloud-core/src/models/invocation.rs | 17 +- .../src/models/owner_share_policy.rs | 2 + tinycloud-core/src/models/revocation.rs | 2 + tinycloud-core/src/read_audit.rs | 137 ++++- tinycloud-core/src/write_hooks.rs | 3 + .../tests/historical_cacao_revocation.rs | 34 +- tinycloud-node-server/src/config.rs | 67 +++ .../src/invocation_replay.rs | 1 + tinycloud-node-server/src/lib.rs | 24 +- tinycloud-node-server/src/policy_v3.rs | 1 + .../src/policy_v3/named_keys_gate_tests.rs | 6 +- tinycloud-node-server/src/routes/hooks.rs | 43 +- tinycloud-node-server/src/routes/mod.rs | 27 +- .../routes/tests/delegation_issued_query.rs | 1 + .../src/routes/tests/encryption_keys.rs | 3 +- tinycloud-node-server/src/share_v2.rs | 123 +++- .../share_v2/tests/alias_revocation_tests.rs | 12 +- .../src/webhook_dispatcher.rs | 106 +++- tinycloud.toml | 5 + 39 files changed, 1467 insertions(+), 267 deletions(-) create mode 100644 tinycloud-core/src/migrations/m20261109_000000_column_encryption_format.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e174d0bf1..15a51e294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] ### Changes +- Encryption: v2 column format with key ids, per-purpose keys and recorded format metadata. Optional strict reads are off by default. Details: TC-2516, TC-2517. - Deploys: the workflow supports an explicit, reason-recorded rollback to an earlier released dstack image on the unchanged N0 compose. It checks image labels, release tag, ancestry, and identical migration sources while preserving the normal forward-release guard. - A new Policy/v3 registration records the share key its content source names (`share/` on the owner's `default` network) in the registration's transaction, so publishing a share needs no `tinycloud.encryption/key.create` grant: the publisher encrypts to the key's public key and registers. A missing key is created at `keyVersion` 1 and counts toward the network's key limit; at the limit the registration answers 403 `key_limit_exceeded` and stores nothing. An existing key keeps its state. The registration now also requires the source's network to exist and be active. Docs: `docs/encryption-named-keys.md`. Details: TC-2525 - Share-email issuer trust bundles can pin one next key for the same issuer DID during an explicit UTC overlap window. The legacy key stops at `issuerNotAfter`; malformed, duplicate, unbounded or expired overlaps fail startup and `--validate-config`. Existing single-key bundles remain unchanged. This is node-side trust support only: the OpenCredentials witness and js-sdk policy contract must also support key rotation before activation. Older node images reject overlap fields; restore the previous single-key bundle before rollback. See `deploy/share-email/production-trust-bundle-contract.md`. diff --git a/README.md b/README.md index b9b3d4fec..f5592fad2 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,7 @@ The following common options are available: | keys.type | TINYCLOUD_KEYS_TYPE | Set the type of host key store, options are "Static" | | spaces.allowlist | TINYCLOUD_SPACES_ALLOWLIST | Set the URL of an allowlist service for gating the creation of Space Peers | | telemetry.enabled | TINYCLOUD_TELEMETRY__ENABLED | Enable Prometheus latency metrics on the configured Prometheus port | +| column_encryption.strict | TINYCLOUD_COLUMN_ENCRYPTION__STRICT | Require authenticated envelopes for recorded legacy columns; default `false`. Recorded v2 columns always require v2. | | (n/a) | ROCKET_MAX_BLOCKING | Set Rocket's blocking-thread-pool cap (default 512). Note the `ROCKET_` prefix, not `TINYCLOUD_` — Rocket builds its tokio runtime from its own Figment before TinyCloud's config is loaded, so `TINYCLOUD_MAX_BLOCKING` is silently ignored for this setting | | (n/a) | NODE_STATS_READ_TOKEN | Bearer token for the read-only `GET /admin/stats` (TC-1482). Unset, blank, shorter than 32 bytes, or equal to `TINYCLOUD_ADMIN_SECRET` disables the route (`404`). Generate it with `openssl rand -hex 32` (64 hex characters). Separate from `TINYCLOUD_ADMIN_SECRET`, which it does not replace and which does not open it. Secret: set it in the deployment secret store, never in `tinycloud.toml` or a committed env file. See [Admin stats](#admin-stats-get-adminstats) | @@ -323,6 +324,15 @@ The keepalive options are set by a vendored, patched sqlx-core 0.8.6 (`vendor/sq TinyCloud Protocol will automatically apply the relevant migrations to your chosen SQL database. Use caution if you are sharing this database with another application. +New encrypted delegation, invocation (including read-audit), share-policy and +webhook-secret writes use the v2 column format with key ids and row-bound +authentication. Columns and webhook secrets use separate derived keys. Format +metadata is stored alongside each value; existing rows retain format `0`. +Existing v1 values remain readable. Set `strict = true` in +`[global.column_encryption]` to require authenticated legacy reads as well. +Startup adds format metadata without rewriting existing values. Details: +TC-2516, TC-2517. + ### Staging Config TinyCloud Protocol will temporarily stage files it receives before writing them. It can do this in memory or in temporary files. This can be configured by setting `storage.staging` to `Memory` or `FileSystem`. Default is `Memory`. diff --git a/test/w5-policy-runtime-node-e2e/tests/sql_isolation.rs b/test/w5-policy-runtime-node-e2e/tests/sql_isolation.rs index 4b0d7b92a..93ce8ea40 100644 --- a/test/w5-policy-runtime-node-e2e/tests/sql_isolation.rs +++ b/test/w5-policy-runtime-node-e2e/tests/sql_isolation.rs @@ -700,6 +700,7 @@ async fn persist_grant( not_before: Set(None), facts: Set(None), serialization: Set(format!("tc780-n1-test-row:{ability}").into_bytes()), + serialization_format: Set(0), } .insert(conn) .await?; diff --git a/test/w5-policy-runtime-node-e2e/tests/tc119_registry_wire_paths.rs b/test/w5-policy-runtime-node-e2e/tests/tc119_registry_wire_paths.rs index 55a4253a1..b011a17b7 100644 --- a/test/w5-policy-runtime-node-e2e/tests/tc119_registry_wire_paths.rs +++ b/test/w5-policy-runtime-node-e2e/tests/tc119_registry_wire_paths.rs @@ -91,6 +91,7 @@ async fn persist_grant( not_before: Set(None), facts: Set(None), serialization: Set(format!("tc119-test-row:{ability}").into_bytes()), + serialization_format: Set(0), } .insert(conn) .await?; diff --git a/tinycloud-core/src/auth_graph.rs b/tinycloud-core/src/auth_graph.rs index b798e0fb2..c0b04f784 100644 --- a/tinycloud-core/src/auth_graph.rs +++ b/tinycloud-core/src/auth_graph.rs @@ -211,7 +211,7 @@ async fn load_closure( /// Each branch projects the same columns, with a distinct slot for each /// underlying SQL type. In particular, JSON and text never share a UNION /// slot, and the legacy model decoders still parse every field in Rust. -fn snapshot_branch(kind: i32, fields: [SimpleExpr; 12]) -> SelectStatement { +fn snapshot_branch(kind: i32, fields: [SimpleExpr; 13]) -> SelectStatement { let mut query = Query::select(); query.expr_as(Expr::val(kind), Alias::new("kind")); for (name, expr) in [ @@ -224,6 +224,7 @@ fn snapshot_branch(kind: i32, fields: [SimpleExpr; 12]) -> SelectStatement { "not_before", "facts", "serialization", + "serialization_format", "resource", "ability", "caveats", @@ -386,6 +387,7 @@ fn snapshot_query(roots: &[Hash]) -> WithQuery { Expr::col((delegation::Entity, delegation::Column::NotBefore)).into(), Expr::col((delegation::Entity, delegation::Column::Facts)).into(), Expr::col((delegation::Entity, delegation::Column::Serialization)).into(), + Expr::col((delegation::Entity, delegation::Column::SerializationFormat)).into(), Expr::col((abilities::Entity, abilities::Column::Resource)).into(), Expr::col((abilities::Entity, abilities::Column::Ability)).into(), Expr::col((abilities::Entity, abilities::Column::Caveats)).into(), @@ -411,6 +413,7 @@ fn snapshot_query(roots: &[Hash]) -> WithQuery { null(), null(), null(), + null(), ], ); edge_rows.from(bounded_name.clone()); @@ -427,6 +430,7 @@ fn snapshot_query(roots: &[Hash]) -> WithQuery { Expr::col((delegation::Entity, delegation::Column::NotBefore)).into(), Expr::col((delegation::Entity, delegation::Column::Facts)).into(), Expr::col((delegation::Entity, delegation::Column::Serialization)).into(), + Expr::col((delegation::Entity, delegation::Column::SerializationFormat)).into(), null(), null(), null(), @@ -454,6 +458,7 @@ fn snapshot_query(roots: &[Hash]) -> WithQuery { null(), null(), null(), + null(), Expr::col((abilities::Entity, abilities::Column::Resource)).into(), Expr::col((abilities::Entity, abilities::Column::Ability)).into(), Expr::col((abilities::Entity, abilities::Column::Caveats)).into(), @@ -470,7 +475,7 @@ fn snapshot_query(roots: &[Hash]) -> WithQuery { .and_where(within_budget()); query.union(UnionType::All, abilities_query); let revocation_fields = || { - let mut fields: [SimpleExpr; 12] = std::array::from_fn(|_| null()); + let mut fields: [SimpleExpr; 13] = std::array::from_fn(|_| null()); fields[0] = node(lookup_name.clone()); fields }; @@ -602,6 +607,7 @@ impl AuthGraphSnapshot { not_before: row.try_get("", "not_before")?, facts: row.try_get("", "facts")?, serialization: row.try_get("", "serialization")?, + serialization_format: row.try_get("", "serialization_format")?, }, ); } @@ -935,6 +941,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(db) .await @@ -1136,6 +1143,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(db) .await @@ -1322,7 +1330,7 @@ mod tests { // rather than changing unrelated migrations for a query equivalence test. for ddl in [ "CREATE TABLE actor (id VARCHAR(191) PRIMARY KEY)", - "CREATE TABLE delegation (id VARBINARY(34) PRIMARY KEY, delegator VARCHAR(191) NOT NULL, delegatee VARCHAR(191) NOT NULL, expiry DATETIME NULL, issued_at DATETIME NULL, not_before DATETIME NULL, facts JSON NULL, serialization BLOB NOT NULL)", + "CREATE TABLE delegation (id VARBINARY(34) PRIMARY KEY, delegator VARCHAR(191) NOT NULL, delegatee VARCHAR(191) NOT NULL, expiry DATETIME NULL, issued_at DATETIME NULL, not_before DATETIME NULL, facts JSON NULL, serialization BLOB NOT NULL, serialization_format SMALLINT NOT NULL DEFAULT 0)", "CREATE TABLE parent_delegation (child VARBINARY(34) NOT NULL, parent VARBINARY(34) NOT NULL, PRIMARY KEY (parent, child))", "CREATE TABLE ability (resource VARCHAR(255) NOT NULL, ability VARCHAR(255) NOT NULL, delegation VARBINARY(34) NOT NULL, caveats JSON NOT NULL, PRIMARY KEY (resource, ability, delegation))", "CREATE TABLE revocation (id VARBINARY(34) PRIMARY KEY, revoker VARCHAR(191) NOT NULL, revoked VARBINARY(34) NOT NULL, serialization BLOB NOT NULL, revoked_at DATETIME NULL)", @@ -1351,6 +1359,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(db) .await diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 1b2ba8120..032ffd2c2 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -188,6 +188,7 @@ pub struct PendingWebhookDelivery { pub attempts: i64, pub callback_url: String, pub encrypted_secret: Vec, + pub encrypted_secret_format: i16, pub secret_key_id: String, pub subscription_active: bool, pub subscriber_did: String, @@ -964,7 +965,16 @@ where else { return Ok(None); }; - let bytes = crate::encryption::maybe_decrypt(self.encryption.as_ref(), &row.serialization)?; + let bytes = crate::encryption::maybe_decrypt( + self.encryption.as_ref(), + &row.serialization, + crate::encryption::RowBinding::new( + "delegation", + "serialization", + &[&Vec::::from(row.id)], + ), + row.serialization_format, + )?; let delegation = TinyCloudDelegation::from_bytes(&bytes)?; let info = DelegationInfo::try_from(delegation)?; let event = SerializedEvent(info, bytes); @@ -1126,6 +1136,7 @@ where attempts: delivery.attempts, callback_url: subscription.callback_url, encrypted_secret: subscription.encrypted_secret, + encrypted_secret_format: subscription.encrypted_secret_format, secret_key_id: subscription.secret_key_id, subscription_active: subscription.active, subscriber_did: subscription.subscriber_did, @@ -4949,11 +4960,19 @@ async fn get_valid_delegations( && del.not_before.map(|n| n <= now).unwrap_or(true) && ability.iter().any(|a| a.resource.space() == Some(space_id)) { - let serialization = - match crate::encryption::maybe_decrypt(encryption, &del.serialization) { - Ok(s) => s, - Err(e) => return Some(Err(TxError::Encryption(e))), - }; + let serialization = match crate::encryption::maybe_decrypt( + encryption, + &del.serialization, + crate::encryption::RowBinding::new( + "delegation", + "serialization", + &[&Vec::::from(del.id)], + ), + del.serialization_format, + ) { + Ok(s) => s, + Err(e) => return Some(Err(TxError::Encryption(e))), + }; Some( decode_stored_delegation(serialization) .map(|event| (del.id, event)) @@ -5599,11 +5618,19 @@ async fn get_filtered_delegations s, - Err(e) => return Some(Err(TxError::Encryption(e))), - }; + let serialization = match crate::encryption::maybe_decrypt( + encryption, + &del.serialization, + crate::encryption::RowBinding::new( + "delegation", + "serialization", + &[&Vec::::from(del.id)], + ), + del.serialization_format, + ) { + Ok(s) => s, + Err(e) => return Some(Err(TxError::Encryption(e))), + }; Some( decode_stored_delegation(serialization) .map(|event| (del.id, event)) @@ -5666,7 +5693,16 @@ async fn get_delegation_chain( .all(db) .await?; - let serialization = crate::encryption::maybe_decrypt(encryption, &del.serialization)?; + let serialization = crate::encryption::maybe_decrypt( + encryption, + &del.serialization, + crate::encryption::RowBinding::new( + "delegation", + "serialization", + &[&Vec::::from(del.id)], + ), + del.serialization_format, + )?; chain.push(decode_stored_delegation(serialization)?); // Move to the first parent (if any) to continue the chain @@ -7255,6 +7291,7 @@ mod test { issued_at: Set(OffsetDateTime::now_utc()), facts: Set(None), serialization: Set(vec![index as u8]), + serialization_format: Set(0), } .insert(conn) .await @@ -7440,6 +7477,7 @@ mod test { issued_at: Set(OffsetDateTime::now_utc()), facts: Set(None), serialization: Set(vec![6]), + serialization_format: Set(0), } .insert(&db.conn) .await @@ -7969,6 +8007,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(b"race-parent".to_vec()), + serialization_format: Set(0), } .insert(&db.conn) .await @@ -7984,6 +8023,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(b"race-child".to_vec()), + serialization_format: Set(0), } .insert(&db.conn) .await @@ -8079,6 +8119,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(&db.conn) .await @@ -8119,6 +8160,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(later_id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(&db.conn) .await @@ -8185,6 +8227,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(control_id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(&db.conn) .await @@ -8214,6 +8257,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(&db.conn) .await @@ -8242,6 +8286,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(later_id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(&db.conn) .await @@ -8440,6 +8485,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), })) .exec(db) .await @@ -8933,6 +8979,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), }); for ability_index in 0..6 { let (resource, ability) = if delegation_index == 0 && ability_index == 0 { @@ -8969,6 +9016,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(later_id.as_ref().to_vec()), + serialization_format: Set(0), }); ability_rows.push(abilities::ActiveModel { resource: Set(format!("tinycloud:{principal_suffix}:default/delegation") @@ -9040,6 +9088,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(&db.conn) .await @@ -9142,6 +9191,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(&db.conn) .await @@ -9193,6 +9243,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(label.as_bytes().to_vec()), + serialization_format: Set(0), } .insert(&db.conn) .await @@ -10210,6 +10261,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(hash.as_ref().to_vec()), + serialization_format: Set(0), } .insert(db) .await @@ -10304,6 +10356,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(hash.as_ref().to_vec()), + serialization_format: Set(0), }) .on_conflict( OnConflict::column(delegation::Column::Id) @@ -10373,6 +10426,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(ser.clone()), + serialization_format: Set(0), } .insert(&tx) .await @@ -10401,6 +10455,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(ser), + serialization_format: Set(0), }) .on_conflict( OnConflict::column(delegation::Column::Id) @@ -10452,6 +10507,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(ser), + serialization_format: Set(0), }; let tx = db.conn.begin().await.unwrap(); @@ -10524,6 +10580,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(retained_hash.as_ref().to_vec()), + serialization_format: Set(0), }) .on_conflict( OnConflict::column(delegation::Column::Id) @@ -10746,6 +10803,7 @@ mod test { not_before: Set(None), facts: Set(None), serialization: Set(hash.as_ref().to_vec()), + serialization_format: Set(0), }) .on_conflict( OnConflict::column(delegation::Column::Id) @@ -10848,6 +10906,7 @@ mod test { abilities_json: None, callback_url: "https://example.com/hook".to_string(), encrypted_secret: vec![0u8; 4], + encrypted_secret_format: 0, secret_key_id: "kid".to_string(), active: true, created_at: rfc3339(OffsetDateTime::now_utc()), @@ -11135,6 +11194,7 @@ mod test { not_before: Set(not_before), facts: Set(None), serialization: Set(label.as_bytes().to_vec()), + serialization_format: Set(0), } .insert(db) .await @@ -12280,6 +12340,7 @@ mod test { abilities_json: None, callback_url: format!("https://example.com/{id}"), encrypted_secret: Vec::new(), + encrypted_secret_format: 0, secret_key_id: "primary".to_string(), active: true, created_at: id.to_string(), diff --git a/tinycloud-core/src/encryption.rs b/tinycloud-core/src/encryption.rs index 9a56f14c3..c51e4667b 100644 --- a/tinycloud-core/src/encryption.rs +++ b/tinycloud-core/src/encryption.rs @@ -1,21 +1,76 @@ -//! Application-level column encryption using AES-256-GCM. -//! -//! Encrypted values are prefixed with a version byte: -//! - `0x01` followed by 12-byte nonce, ciphertext, and 16-byte GCM tag -//! - Any other first byte is treated as legacy plaintext (existing data) -//! -//! This allows gradual migration: new writes are encrypted, old reads still work. +//! AES-256-GCM column keyrings. V2 is `0x02 || kid || nonce(12) || ciphertext || tag(16)`. +//! The authenticated header and length-delimited database identity match the +//! column re-encryption codec. Stored format metadata selects compatibility reads. + +use std::{collections::BTreeMap, sync::Arc}; use aes_gcm::{ - aead::{Aead, KeyInit, OsRng}, + aead::{consts::U12, Aead, AeadInPlace, KeyInit, OsRng, Payload}, AeadCore, Aes256Gcm, Nonce, }; +use hkdf::Hkdf; +use sha2::Sha256; +use zeroize::Zeroizing; + +const VERSION_LEGACY: u8 = 0x01; +const VERSION_KEYRING: u8 = 0x02; + +/// Persisted compatibility namespace for existing values. +pub const FORMAT_LEGACY: i16 = 0; +/// Persisted format for authenticated, row-bound v2 values. +pub const FORMAT_V2: i16 = 2; + +/// Domain separation for storage keys derived from the existing root key. +#[derive(Clone, Copy, Debug)] +pub enum EncryptionPurpose { + Columns, + Webhooks, +} + +/// Stable database identity; PK components are raw database bytes in PK order. +#[derive(Clone, Copy, Debug)] +pub struct RowBinding<'a> { + pub table: &'a str, + pub column: &'a str, + pub pk: &'a [&'a [u8]], +} + +impl<'a> RowBinding<'a> { + pub const fn new(table: &'a str, column: &'a str, pk: &'a [&'a [u8]]) -> Self { + Self { table, column, pk } + } -const VERSION_ENCRYPTED: u8 = 0x01; + fn aad(self, kid: u8) -> Vec { + let capacity = 2 + + 24 + + self.table.len() + + self.column.len() + + self + .pk + .iter() + .map(|component| 8 + component.len()) + .sum::(); + let mut aad = Vec::with_capacity(capacity); + aad.extend_from_slice(&[VERSION_KEYRING, kid]); + for label in [self.table.as_bytes(), self.column.as_bytes()] { + aad.extend_from_slice(&(label.len() as u64).to_be_bytes()); + aad.extend_from_slice(label); + } + aad.extend_from_slice(&(self.pk.len() as u64).to_be_bytes()); + for component in self.pk { + aad.extend_from_slice(&(component.len() as u64).to_be_bytes()); + aad.extend_from_slice(component); + } + aad + } +} +/// Selected write generation and shared read keys. Debug never exposes keys. #[derive(Clone)] pub struct ColumnEncryption { - cipher: Aes256Gcm, + write_kid: u8, + keys: Arc>, + strict: bool, } impl std::fmt::Debug for ColumnEncryption { @@ -30,80 +85,241 @@ pub enum EncryptionError { Decrypt(String), #[error("encrypted data too short")] TooShort, - #[error("unsupported version byte")] + #[error("unsupported encryption format")] UnsupportedVersion, + #[error("missing encryption key for generation {0}")] + MissingKey(u8), + #[error("duplicate encryption key for generation {0}")] + DuplicateKey(u8), + #[error("column encryption is required for the recorded format")] + EncryptionRequired, } impl ColumnEncryption { + /// Construct a generation-zero keyring. Column writes use v2. pub fn new(key: [u8; 32]) -> Self { - Self { - cipher: Aes256Gcm::new_from_slice(&key).expect("valid 32-byte key"), + Self::with_keys(0, [(0, key)]).expect("one generation-zero key") + } + + /// Derive a purpose-specific v2 writer from storage root material, retaining + /// a separately supplied old key for v1 reads. + pub fn for_purpose(root: [u8; 32], legacy_key: [u8; 32], purpose: EncryptionPurpose) -> Self { + let root = Zeroizing::new(root); + let info: &[u8] = match purpose { + EncryptionPurpose::Columns => b"tinycloud/columns", + EncryptionPurpose::Webhooks => b"tinycloud/hooks/webhook-secrets", + }; + let mut derived = Zeroizing::new([0u8; 32]); + Hkdf::::new(Some(b"tinycloud/column-encryption/v2"), root.as_ref()) + .expand(info, derived.as_mut()) + .expect("32-byte HKDF output"); + Self::with_keys(1, [(0, legacy_key), (1, *derived)]).expect("distinct key generations") + } + + /// Require authenticated envelopes even in the recorded legacy namespace. + pub fn with_strict(mut self, strict: bool) -> Self { + self.strict = strict; + self + } + + /// Construct a keyring, refusing duplicate generations or a missing write key. + pub fn with_keys( + write_kid: u8, + keys: impl IntoIterator, + ) -> Result { + let mut ciphers = BTreeMap::new(); + for (kid, key) in keys { + let key = Zeroizing::new(key); + match ciphers.entry(kid) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert( + Aes256Gcm::new_from_slice(key.as_ref()).expect("valid 32-byte key"), + ); + } + std::collections::btree_map::Entry::Occupied(_) => { + return Err(EncryptionError::DuplicateKey(kid)); + } + } + } + if !ciphers.contains_key(&write_kid) { + return Err(EncryptionError::MissingKey(write_kid)); } + Ok(Self { + write_kid, + keys: Arc::new(ciphers), + strict: false, + }) + } + + pub fn write_kid(&self) -> u8 { + self.write_kid + } + + pub fn has_key(&self, kid: u8) -> bool { + self.keys.contains_key(&kid) } - /// Encrypt plaintext. Returns: 0x01 || nonce(12B) || ciphertext || tag(16B) - pub fn encrypt(&self, plaintext: &[u8]) -> Vec { + /// Select a different writer while retaining every read key and read policy. + pub fn for_kid(&self, kid: u8) -> Result { + if !self.has_key(kid) { + return Err(EncryptionError::MissingKey(kid)); + } + Ok(Self { + write_kid: kid, + keys: Arc::clone(&self.keys), + strict: self.strict, + }) + } + + /// Encrypt as `0x02 || kid || nonce(12) || ciphertext || tag(16)`. + pub fn encrypt(&self, plaintext: &[u8], binding: RowBinding<'_>) -> Vec { let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - let ciphertext = self - .cipher - .encrypt(&nonce, plaintext) + self.encrypt_with_nonce(plaintext, &nonce, binding) + } + + fn encrypt_with_nonce( + &self, + plaintext: &[u8], + nonce: &Nonce, + binding: RowBinding<'_>, + ) -> Vec { + let header = [VERSION_KEYRING, self.write_kid]; + let aad = binding.aad(self.write_kid); + Self::seal(&self.keys[&self.write_kid], plaintext, nonce, &header, &aad) + } + + /// Explicit unchanged v1 encoding for existing client and seal protocols. + pub fn encrypt_legacy(&self, plaintext: &[u8]) -> Vec { + let cipher = self + .keys + .get(&0) + .expect("legacy wire encryption requires generation zero"); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + Self::seal(cipher, plaintext, &nonce, &[VERSION_LEGACY], &[]) + } + + fn seal( + cipher: &Aes256Gcm, + plaintext: &[u8], + nonce: &Nonce, + header: &[u8], + aad: &[u8], + ) -> Vec { + let mut result = Vec::with_capacity(header.len() + 12 + plaintext.len() + 16); + result.extend_from_slice(header); + result.extend_from_slice(nonce); + result.extend_from_slice(plaintext); + let tag = cipher + .encrypt_in_place_detached(nonce, aad, &mut result[header.len() + 12..]) .expect("encryption should not fail"); - let mut result = Vec::with_capacity(1 + 12 + ciphertext.len()); - result.push(VERSION_ENCRYPTED); - result.extend_from_slice(&nonce); - result.extend_from_slice(&ciphertext); + result.extend_from_slice(&tag); result } - /// Strict decrypt for wire envelopes: requires the 0x01 version byte and - /// never passes unversioned input through (unlike `decrypt`). - pub fn decrypt_strict(&self, data: &[u8]) -> Result, EncryptionError> { - if data.first() != Some(&VERSION_ENCRYPTED) { + /// Decode only the unchanged authenticated v1 protocol envelope. + pub fn decrypt_legacy(&self, data: &[u8]) -> Result, EncryptionError> { + if data.first().copied() != Some(VERSION_LEGACY) { return Err(EncryptionError::UnsupportedVersion); } - self.decrypt(data) + self.open(data, 0, 1, &[]) } - /// Decrypt data. Handles version dispatch: - /// - 0x01: AES-256-GCM encrypted - /// - Anything else: return as-is (legacy plaintext) - pub fn decrypt(&self, data: &[u8]) -> Result, EncryptionError> { - if data.is_empty() { - return Ok(data.to_vec()); - } - if data[0] != VERSION_ENCRYPTED { - // Legacy plaintext - return as-is - return Ok(data.to_vec()); + /// Decode authenticated v1 or v2. Plaintext requires recorded legacy metadata. + pub fn decrypt( + &self, + data: &[u8], + binding: RowBinding<'_>, + ) -> Result, EncryptionError> { + let kid = match data.first().copied() { + Some(VERSION_LEGACY) => return self.decrypt_legacy(data), + Some(VERSION_KEYRING) => *data.get(1).ok_or(EncryptionError::TooShort)?, + _ => return Err(EncryptionError::UnsupportedVersion), + }; + let aad = binding.aad(kid); + self.open(data, kid, 2, &aad) + } + + /// Select compatibility by stored metadata, independently of the value prefix. + /// Format 2 requires v2; strict mode also refuses plaintext in format 0. + pub fn decrypt_recorded( + &self, + data: &[u8], + binding: RowBinding<'_>, + format: i16, + ) -> Result, EncryptionError> { + match format { + FORMAT_V2 if data.first().copied() == Some(VERSION_KEYRING) => { + self.decrypt(data, binding) + } + FORMAT_LEGACY => { + if !self.strict + && !matches!( + data.first().copied(), + Some(VERSION_LEGACY | VERSION_KEYRING) + ) + { + Ok(data.to_vec()) + } else { + self.decrypt(data, binding) + } + } + _ => Err(EncryptionError::UnsupportedVersion), } - // Encrypted: 0x01 || nonce(12) || ciphertext+tag - if data.len() < 1 + 12 + 16 { + } + + fn open( + &self, + data: &[u8], + kid: u8, + header_len: usize, + aad: &[u8], + ) -> Result, EncryptionError> { + if data.len() < header_len + 12 + 16 { return Err(EncryptionError::TooShort); } - let nonce_bytes: [u8; 12] = data[1..13].try_into().expect("slice is 12 bytes"); - let nonce = Nonce::from(nonce_bytes); - let ciphertext = &data[13..]; - self.cipher - .decrypt(&nonce, ciphertext) + let cipher = self + .keys + .get(&kid) + .ok_or(EncryptionError::MissingKey(kid))?; + let nonce_bytes: [u8; 12] = data[header_len..header_len + 12] + .try_into() + .expect("validated nonce length"); + cipher + .decrypt( + &Nonce::from(nonce_bytes), + Payload { + msg: &data[header_len + 12..], + aad, + }, + ) .map_err(|e| EncryptionError::Decrypt(e.to_string())) } } -/// Helper: encrypt if encryption is configured, otherwise return plaintext. -pub fn maybe_encrypt(enc: Option<&ColumnEncryption>, data: &[u8]) -> Vec { +/// Encrypt when configured; callers persist format 2 with the resulting value. +pub fn maybe_encrypt( + enc: Option<&ColumnEncryption>, + data: &[u8], + binding: RowBinding<'_>, +) -> Vec { match enc { - Some(e) => e.encrypt(data), + Some(e) => e.encrypt(data, binding), None => data.to_vec(), } } -/// Helper: decrypt if encryption is configured, otherwise return as-is. +/// Decode with the recorded format, requiring a key for recorded v2 values. pub fn maybe_decrypt( enc: Option<&ColumnEncryption>, data: &[u8], + binding: RowBinding<'_>, + format: i16, ) -> Result, EncryptionError> { match enc { - Some(e) => e.decrypt(data), - None => Ok(data.to_vec()), + Some(e) => e.decrypt_recorded(data, binding, format), + None if format == FORMAT_LEGACY => Ok(data.to_vec()), + None if format == FORMAT_V2 => Err(EncryptionError::EncryptionRequired), + None => Err(EncryptionError::UnsupportedVersion), } } @@ -111,67 +327,193 @@ pub fn maybe_decrypt( mod tests { use super::*; - fn test_key() -> [u8; 32] { - [0x42u8; 32] + const V1: &str = "01000000000000000000000000cea7403d4d606b6e074ec5d3baf39d18d0d1c8a799996bf0265b98b5d48ab919"; + const V2_KID0: &str = "0200000102030405060708090a0b88cb335200fd8ff9c27852e5fd03de0cb6a3ed7253524a4c1b2b36f15e49bc75"; + const V2_KID1: &str = "0201000102030405060708090a0b4702d61bc5e5c21b8d41978bb1e9786dc3fe0e1bfee3e92116d273784dbb3e16"; + const NONCE: [u8; 12] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + + fn unhex(value: &str) -> Vec { + value + .as_bytes() + .chunks_exact(2) + .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()) + .collect() + } + + fn keyring() -> ColumnEncryption { + ColumnEncryption::with_keys(1, [(0, [0; 32]), (1, std::array::from_fn(|i| i as u8))]) + .unwrap() + } + + fn binding() -> RowBinding<'static> { + RowBinding::new("delegation", "serialization", &[b"ab", b"c"]) } #[test] - fn round_trip() { - let enc = ColumnEncryption::new(test_key()); - let plaintext = b"hello world"; - let encrypted = enc.encrypt(plaintext); - assert_eq!(encrypted[0], VERSION_ENCRYPTED); - assert_ne!(&encrypted[1..], plaintext); - let decrypted = enc.decrypt(&encrypted).unwrap(); - assert_eq!(decrypted, plaintext); + fn new_column_writes_use_v2() { + let ring = keyring(); + let encrypted = ring.encrypt(b"stored column", binding()); + assert_eq!(&encrypted[..2], &[2, 1]); + assert_eq!( + ring.decrypt_recorded(&encrypted, binding(), FORMAT_V2) + .unwrap(), + b"stored column" + ); } #[test] - fn legacy_plaintext_passthrough() { - let enc = ColumnEncryption::new(test_key()); - // CBOR starts with 0xa_, not 0x01 - let cbor_data = vec![0xa2, 0x01, 0x02, 0x03]; - let result = enc.decrypt(&cbor_data).unwrap(); - assert_eq!(result, cbor_data); + fn migration_known_answers_remain_compatible() { + let ring = keyring(); + for expected in [V1, V2_KID0, V2_KID1] { + assert_eq!( + ring.decrypt_recorded(&unhex(expected), binding(), FORMAT_LEGACY) + .unwrap(), + [0; 16] + ); + } + for (kid, expected) in [(0, V2_KID0), (1, V2_KID1)] { + assert_eq!( + ring.for_kid(kid).unwrap().encrypt_with_nonce( + &[0; 16], + &Nonce::from(NONCE), + binding() + ), + unhex(expected) + ); + } } #[test] - fn empty_data() { - let enc = ColumnEncryption::new(test_key()); - let result = enc.decrypt(&[]).unwrap(); - assert!(result.is_empty()); + fn row_column_table_and_primary_key_tuple_are_bound() { + let ring = keyring(); + let encrypted = ring.encrypt(b"secret", binding()); + for moved in [ + RowBinding::new("invocation", "serialization", &[b"ab", b"c"]), + RowBinding::new("delegation", "other", &[b"ab", b"c"]), + RowBinding::new("delegation", "serialization", &[b"ab", b"d"]), + RowBinding::new("delegation", "serialization", &[b"a", b"bc"]), + RowBinding::new("delegation", "serialization", &[b"abc"]), + RowBinding::new("delegation", "serialization", &[b"c", b"ab"]), + RowBinding::new("delegation", "serialization", &[b"ab", b"c", b""]), + ] { + assert!(ring.decrypt_recorded(&encrypted, moved, FORMAT_V2).is_err()); + } } #[test] - fn wrong_key_fails() { - let enc1 = ColumnEncryption::new([0x42u8; 32]); - let enc2 = ColumnEncryption::new([0x43u8; 32]); - let encrypted = enc1.encrypt(b"secret"); - assert!(enc2.decrypt(&encrypted).is_err()); + fn purpose_keys_are_distinct_and_v1_reads_still_work() { + let columns = ColumnEncryption::for_purpose([8; 32], [7; 32], EncryptionPurpose::Columns); + let webhooks = ColumnEncryption::for_purpose([8; 32], [7; 32], EncryptionPurpose::Webhooks); + for (writer, reader) in [(&columns, &webhooks), (&webhooks, &columns)] { + let encrypted = writer.encrypt(b"secret", binding()); + assert!(reader + .decrypt_recorded(&encrypted, binding(), FORMAT_V2) + .is_err()); + assert_eq!( + writer + .decrypt_recorded(&encrypted, binding(), FORMAT_V2) + .unwrap(), + b"secret" + ); + let legacy = ColumnEncryption::new([7; 32]).encrypt_legacy(b"old value"); + assert_eq!( + reader + .decrypt_recorded(&legacy, binding(), FORMAT_LEGACY) + .unwrap(), + b"old value" + ); + } } #[test] - fn truncated_data_fails() { - let enc = ColumnEncryption::new(test_key()); - let encrypted = enc.encrypt(b"test"); - // Truncate to just version + partial nonce - assert!(enc.decrypt(&encrypted[..5]).is_err()); + fn recorded_v2_refuses_plaintext_legacy_and_unknown_formats() { + let ring = keyring(); + for value in [b"".as_slice(), b"plaintext", &[0xa2, 1, 2], &unhex(V1)] { + assert!(ring.decrypt_recorded(value, binding(), FORMAT_V2).is_err()); + } + let value = ring.encrypt(b"secret", binding()); + assert!(ring.decrypt_recorded(&value, binding(), 3).is_err()); + assert!(maybe_decrypt(None, &value, binding(), FORMAT_V2).is_err()); + assert!(maybe_decrypt(None, b"plain", binding(), 3).is_err()); } #[test] - fn maybe_helpers_none() { - let data = b"plaintext"; - assert_eq!(maybe_encrypt(None, data), data.to_vec()); - assert_eq!(maybe_decrypt(None, data).unwrap(), data.to_vec()); + fn strict_refuses_legacy_plaintext_but_accepts_authenticated_formats() { + let ring = keyring(); + for plain in [ + b"".as_slice(), + b"JWT.payload.signature", + b"{\"json\":true}", + &[0xa2, 1, 2], + ] { + assert_eq!( + ring.decrypt_recorded(plain, binding(), FORMAT_LEGACY) + .unwrap(), + plain + ); + assert!(ring + .clone() + .with_strict(true) + .decrypt_recorded(plain, binding(), FORMAT_LEGACY) + .is_err()); + assert!(ring.decrypt(plain, binding()).is_err()); + } + for value in [V1, V2_KID0, V2_KID1] { + assert_eq!( + ring.clone() + .with_strict(true) + .decrypt_recorded(&unhex(value), binding(), FORMAT_LEGACY) + .unwrap(), + [0; 16] + ); + } + } + + #[test] + fn envelope_header_ciphertext_and_tag_are_authenticated() { + let key = std::array::from_fn(|i| i as u8); + let ring = ColumnEncryption::with_keys(1, [(0, key), (1, key)]).unwrap(); + let encrypted = unhex(V2_KID1); + for index in [0, 1, 14, encrypted.len() - 1] { + let mut changed = encrypted.clone(); + changed[index] ^= 1; + assert!(ring + .decrypt_recorded(&changed, binding(), FORMAT_V2) + .is_err()); + } + let wrong = ColumnEncryption::with_keys(1, [(1, [9; 32])]).unwrap(); + assert!(wrong.decrypt(&encrypted, binding()).is_err()); + } + + #[test] + fn truncated_envelopes_never_pass_through() { + let ring = keyring(); + for (input, minimum) in [(V1, 29), (V2_KID0, 30), (V2_KID1, 30)] { + let encrypted = unhex(input); + for length in 1..minimum { + assert!(ring + .decrypt_recorded(&encrypted[..length], binding(), FORMAT_LEGACY) + .is_err()); + } + } } #[test] - fn maybe_helpers_some() { - let enc = ColumnEncryption::new(test_key()); - let plaintext = b"hello"; - let encrypted = maybe_encrypt(Some(&enc), plaintext); - assert_eq!(encrypted[0], VERSION_ENCRYPTED); - let decrypted = maybe_decrypt(Some(&enc), &encrypted).unwrap(); - assert_eq!(decrypted, plaintext); + fn keyring_requires_unique_keys_and_a_write_key() { + assert!(matches!( + ColumnEncryption::with_keys(1, [(0, [0; 32])]), + Err(EncryptionError::MissingKey(1)) + )); + assert!(matches!( + ColumnEncryption::with_keys(0, [(0, [0; 32]), (0, [0; 32])]), + Err(EncryptionError::DuplicateKey(0)) + )); + assert!(matches!( + keyring().for_kid(2), + Err(EncryptionError::MissingKey(2)) + )); + let ring = ColumnEncryption::with_keys(1, [(1, std::array::from_fn(|i| i as u8))]).unwrap(); + assert!(ring.decrypt(&unhex(V1), binding()).is_err()); + assert_eq!(ring.decrypt(&unhex(V2_KID1), binding()).unwrap(), [0; 16]); } } diff --git a/tinycloud-core/src/encryption_network/backend.rs b/tinycloud-core/src/encryption_network/backend.rs index c65fd604a..7421558bc 100644 --- a/tinycloud-core/src/encryption_network/backend.rs +++ b/tinycloud-core/src/encryption_network/backend.rs @@ -126,7 +126,7 @@ pub fn wrap_to_public_key( let cipher = ColumnEncryption::new(*shared.as_bytes()); let mut envelope = Vec::with_capacity(32 + plaintext.len() + 32); envelope.extend_from_slice(ephemeral_pub.as_bytes()); - let ct = cipher.encrypt(plaintext); + let ct = cipher.encrypt_legacy(plaintext); envelope.extend_from_slice(&ct); Ok(envelope) } @@ -156,7 +156,7 @@ fn unwrap_with_secret(secret: &StaticSecret, wrapped: &[u8]) -> Result, return Err(KeyBackendError::NonContributory); } let cipher = ColumnEncryption::new(*shared.as_bytes()); - Ok(cipher.decrypt_strict(&wrapped[32..])?) + Ok(cipher.decrypt_legacy(&wrapped[32..])?) } const NAMED_KEY_SALT: &[u8] = b"tinycloud.encryption.named-key/v1"; @@ -237,7 +237,7 @@ impl KeyBackend for LocalOneOfOneBackend { &self, sealed_private_key: &[u8], ) -> Result { - let opened = Zeroizing::new(self.seal.decrypt(sealed_private_key)?); + let opened = Zeroizing::new(self.seal.decrypt_legacy(sealed_private_key)?); if opened.len() != 32 { return Err(KeyBackendError::InvalidSealedKey); } @@ -251,7 +251,7 @@ impl KeyBackend for LocalOneOfOneBackend { fn generate(&self) -> Result { let secret = StaticSecret::random_from_rng(OsRng); let public = PublicKey::from(&secret); - let sealed = self.seal.encrypt(secret.as_bytes()); + let sealed = self.seal.encrypt_legacy(secret.as_bytes()); Ok(GeneratedKey { public_key: public.as_bytes().to_vec(), sealed_private_key: sealed, @@ -294,12 +294,12 @@ impl KeyBackend for LocalOneOfOneBackend { fn generate_seed(&self) -> Result<(KeySeed, Vec), KeyBackendError> { let mut bytes = Zeroizing::new([0u8; 32]); OsRng.fill_bytes(bytes.as_mut_slice()); - let sealed = self.seal.encrypt(bytes.as_slice()); + let sealed = self.seal.encrypt_legacy(bytes.as_slice()); Ok((KeySeed::from_bytes(*bytes), sealed)) } fn open_seed(&self, sealed_seed: &[u8]) -> Result { - let opened = Zeroizing::new(self.seal.decrypt_strict(sealed_seed)?); + let opened = Zeroizing::new(self.seal.decrypt_legacy(sealed_seed)?); let bytes: [u8; 32] = opened .as_slice() .try_into() @@ -334,6 +334,17 @@ mod tests { assert_eq!(recovered, symmetric); } + #[test] + fn network_key_opening_requires_a_legacy_seal() { + let backend = LocalOneOfOneBackend::new(seal_key()); + assert!(backend.open_network_secret(&[0x42; 32]).is_err()); + let generated = backend.generate().unwrap(); + assert_eq!(generated.sealed_private_key[0], 1); + assert!(backend + .open_network_secret(&generated.sealed_private_key) + .is_ok()); + } + #[test] fn rewrap_to_receiver_key() { let backend = LocalOneOfOneBackend::new(seal_key()); @@ -598,7 +609,7 @@ mod tests { ); } // A sealed value that is not 32 bytes is refused. - let short = ColumnEncryption::new([7u8; 32]).encrypt(&[1u8; 16]); + let short = ColumnEncryption::new([7u8; 32]).encrypt_legacy(&[1u8; 16]); assert!(matches!( backend.open_seed(&short), Err(KeyBackendError::InvalidSealedKey) diff --git a/tinycloud-core/src/encryption_network/tests.rs b/tinycloud-core/src/encryption_network/tests.rs index 01e930863..448db1725 100644 --- a/tinycloud-core/src/encryption_network/tests.rs +++ b/tinycloud-core/src/encryption_network/tests.rs @@ -238,7 +238,7 @@ fn unwrap_with_secret(secret: &X25519StaticSecret, wrapped: &[u8]) -> Vec { let pub_peer = PublicKey::from(peer); let shared = secret.diffie_hellman(&pub_peer); ColumnEncryption::new(*shared.as_bytes()) - .decrypt(&wrapped[32..]) + .decrypt_legacy(&wrapped[32..]) .expect("decrypt rewrapped key") } diff --git a/tinycloud-core/src/kv_orphans.rs b/tinycloud-core/src/kv_orphans.rs index ae50f5d97..f832ef3bc 100644 --- a/tinycloud-core/src/kv_orphans.rs +++ b/tinycloud-core/src/kv_orphans.rs @@ -2798,6 +2798,7 @@ mod tests { issued_at: sea_orm::ActiveValue::Set(time::OffsetDateTime::now_utc()), facts: sea_orm::ActiveValue::Set(None), serialization: sea_orm::ActiveValue::Set(vec![1]), + serialization_format: Default::default(), }) .exec(&fixture.conn) .await diff --git a/tinycloud-core/src/lib.rs b/tinycloud-core/src/lib.rs index 295a6c47c..7f006a159 100644 --- a/tinycloud-core/src/lib.rs +++ b/tinycloud-core/src/lib.rs @@ -47,7 +47,7 @@ pub use db::{ TxStoreError, }; pub use db_pool::RecoveringConnection; -pub use encryption::ColumnEncryption; +pub use encryption::{ColumnEncryption, RowBinding}; pub use libp2p; pub use read_audit::{ReadAuditError, ReadAuditHealth, DEFAULT_READ_AUDIT_WAIT}; pub use sea_orm; diff --git a/tinycloud-core/src/migrations/m20230510_101010_init_tables.rs b/tinycloud-core/src/migrations/m20230510_101010_init_tables.rs index 9b23e74cb..0bbe432e2 100644 --- a/tinycloud-core/src/migrations/m20230510_101010_init_tables.rs +++ b/tinycloud-core/src/migrations/m20230510_101010_init_tables.rs @@ -19,11 +19,85 @@ impl MigrationTrait for Migration { manager .create_table(schema.create_table_from_entity(epoch::Entity)) .await?; + // Freeze signed-record tables at their original shape. Their recorded + // formats are added by the same later migration on installs and upgrades. manager - .create_table(schema.create_table_from_entity(delegation::Entity)) + .create_table( + Table::create() + .table(delegation::Entity) + .col( + ColumnDef::new(delegation::Column::Id) + .binary() + .not_null() + .unique_key() + .primary_key(), + ) + .col( + ColumnDef::new(delegation::Column::Delegator) + .string() + .not_null(), + ) + .col( + ColumnDef::new(delegation::Column::Delegatee) + .string() + .not_null(), + ) + .col(ColumnDef::new(delegation::Column::Expiry).timestamp_with_time_zone()) + .col(ColumnDef::new(delegation::Column::IssuedAt).timestamp_with_time_zone()) + .col(ColumnDef::new(delegation::Column::NotBefore).timestamp_with_time_zone()) + .col(ColumnDef::new(delegation::Column::Facts).json()) + .col( + ColumnDef::new(delegation::Column::Serialization) + .binary() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .from(delegation::Entity, delegation::Column::Delegator) + .to(actor::Entity, actor::Column::Id), + ) + .foreign_key( + ForeignKey::create() + .from(delegation::Entity, delegation::Column::Delegatee) + .to(actor::Entity, actor::Column::Id), + ) + .to_owned(), + ) .await?; manager - .create_table(schema.create_table_from_entity(invocation::Entity)) + .create_table( + Table::create() + .table(invocation::Entity) + .col( + ColumnDef::new(invocation::Column::Id) + .binary() + .not_null() + .unique_key() + .primary_key(), + ) + .col( + ColumnDef::new(invocation::Column::Invoker) + .string() + .not_null(), + ) + .col( + ColumnDef::new(invocation::Column::IssuedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col(ColumnDef::new(invocation::Column::Facts).json()) + .col( + ColumnDef::new(invocation::Column::Serialization) + .binary() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .from(invocation::Entity, invocation::Column::Invoker) + .to(actor::Entity, actor::Column::Id), + ) + .to_owned(), + ) .await?; // Keep the initial schema at its historical shape. Additive columns // belong in later migrations so both clean installs and upgrades run diff --git a/tinycloud-core/src/migrations/m20260724_010000_current_kv.rs b/tinycloud-core/src/migrations/m20260724_010000_current_kv.rs index b8a5d2ab7..9ffb3794b 100644 --- a/tinycloud-core/src/migrations/m20260724_010000_current_kv.rs +++ b/tinycloud-core/src/migrations/m20260724_010000_current_kv.rs @@ -199,14 +199,15 @@ mod tests { ) -> kv_write::Model { let invocation_id = hash(format!("invocation-{label}").as_bytes()); let epoch_id = hash(format!("epoch-{label}").as_bytes()); - invocation::ActiveModel { + invocation::Entity::insert(invocation::ActiveModel { id: Set(invocation_id), invoker: Set("did:key:migration".to_string()), issued_at: Set(OffsetDateTime::now_utc()), facts: Set(None), serialization: Set(label.as_bytes().to_vec()), - } - .insert(db) + serialization_format: Default::default(), + }) + .exec(db) .await .unwrap(); epoch::ActiveModel { @@ -286,14 +287,15 @@ mod tests { ("delete-old", "old-tombstone", &tombstoned_old), ] { let delete_invocation = hash(label.as_bytes()); - invocation::ActiveModel { + invocation::Entity::insert(invocation::ActiveModel { id: Set(delete_invocation), invoker: Set("did:key:migration".to_string()), issued_at: Set(OffsetDateTime::now_utc()), facts: Set(None), serialization: Set(label.as_bytes().to_vec()), - } - .insert(&db) + serialization_format: Default::default(), + }) + .exec(&db) .await .unwrap(); kv_delete::ActiveModel { diff --git a/tinycloud-core/src/migrations/m20260725_000000_request_path_indexes.rs b/tinycloud-core/src/migrations/m20260725_000000_request_path_indexes.rs index 77c9fb06b..1a40c7c4b 100644 --- a/tinycloud-core/src/migrations/m20260725_000000_request_path_indexes.rs +++ b/tinycloud-core/src/migrations/m20260725_000000_request_path_indexes.rs @@ -296,7 +296,7 @@ mod tests { use crate::types::{Ability, Caveats, Metadata, Resource, SpaceIdWrap}; use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ConnectOptions, Database, DbBackend, DbConn, DbErr, - EntityTrait, PaginatorTrait, QueryResult, Statement, + EntityTrait, PaginatorTrait, QueryResult, QuerySelect, Statement, }; use sea_orm_migration::MigratorTrait; use std::collections::BTreeMap; @@ -434,7 +434,7 @@ mod tests { let target_delegation = hash(b"tc282-target-delegation"); for i in 0..300i64 { let id = hash(format!("tc282-delegation-{i}").as_bytes()); - delegation::ActiveModel { + delegation::Entity::insert(delegation::ActiveModel { id: Set(id), delegator: Set(format!("did:key:delegator-{i}")), delegatee: Set(format!("did:key:delegatee-{i}")), @@ -443,8 +443,9 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), - } - .insert(db) + serialization_format: Default::default(), + }) + .exec(db) .await?; abilities::ActiveModel { @@ -476,7 +477,7 @@ mod tests { .insert(db) .await?; } - delegation::ActiveModel { + delegation::Entity::insert(delegation::ActiveModel { id: Set(target_delegation), delegator: Set("did:key:target-delegator".to_string()), delegatee: Set("did:key:target-delegatee".to_string()), @@ -485,8 +486,9 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(target_delegation.as_ref().to_vec()), - } - .insert(db) + serialization_format: Default::default(), + }) + .exec(db) .await?; abilities::ActiveModel { resource: Set(Resource::from_str("urn:tc282:target-resource").unwrap()), @@ -553,7 +555,7 @@ mod tests { // hook_delivery + hook_subscription for i in 0..300i64 { - hook_subscription::ActiveModel { + hook_subscription::Entity::insert(hook_subscription::ActiveModel { id: Set(format!("tc282-sub-{i}")), subscriber_did: Set("did:key:actor".to_string()), space_id: Set(if i % 2 == 0 { @@ -566,11 +568,12 @@ mod tests { abilities_json: Set(None), callback_url: Set(format!("https://example.test/{i}")), encrypted_secret: Set(vec![0u8; 8]), + encrypted_secret_format: Default::default(), secret_key_id: Set("key-1".to_string()), active: Set(i % 5 != 0), created_at: Set(OffsetDateTime::now_utc().to_string()), - } - .insert(db) + }) + .exec(db) .await?; hook_delivery::ActiveModel { @@ -848,7 +851,15 @@ mod tests { } // Sanity check: seeding actually produced the volumes we assumed. - assert_eq!(delegation::Entity::find().count(&db).await.unwrap(), 301); + assert_eq!( + delegation::Entity::find() + .select_only() + .column(delegation::Column::Id) + .count(&db) + .await + .unwrap(), + 301 + ); } /// TC-319: `transact` now computes the next per-space sequence with one diff --git a/tinycloud-core/src/migrations/m20261005_000000_deactivate_hook_subscriptions.rs b/tinycloud-core/src/migrations/m20261005_000000_deactivate_hook_subscriptions.rs index c7dbc9b45..69dcf0270 100644 --- a/tinycloud-core/src/migrations/m20261005_000000_deactivate_hook_subscriptions.rs +++ b/tinycloud-core/src/migrations/m20261005_000000_deactivate_hook_subscriptions.rs @@ -77,6 +77,7 @@ mod tests { abilities_json: None, callback_url: "https://hooks.example/callback".to_string(), encrypted_secret: vec![1, 2, 3], + encrypted_secret_format: 0, secret_key_id: "primary".to_string(), active, created_at: "2026-04-09T00:00:00Z".to_string(), @@ -109,7 +110,10 @@ mod tests { subscription("sub_c", "tinycloud:space-b", "did:key:b", true), subscription("sub_d", "tinycloud:space-b", "did:key:b", false), ] - .map(hook_subscription::ActiveModel::from), + .map(|model| hook_subscription::ActiveModel { + encrypted_secret_format: Default::default(), + ..hook_subscription::ActiveModel::from(model) + }), ) .exec(&db) .await diff --git a/tinycloud-core/src/migrations/m20261109_000000_column_encryption_format.rs b/tinycloud-core/src/migrations/m20261109_000000_column_encryption_format.rs new file mode 100644 index 000000000..16fc42f12 --- /dev/null +++ b/tinycloud-core/src/migrations/m20261109_000000_column_encryption_format.rs @@ -0,0 +1,93 @@ +//! Record column storage formats without rewriting existing bytes. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const COLUMNS: [(&str, &str); 5] = [ + ("delegation", "serialization_format"), + ("invocation", "serialization_format"), + ("hook_subscription", "encrypted_secret_format"), + ("owner_share_policy", "policy_bytes_format"), + ("owner_share_policy", "enforcement_delegation_bytes_format"), +]; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for (table, column) in COLUMNS { + manager + .alter_table( + Table::alter() + .table(Alias::new(table)) + .add_column( + ColumnDef::new(Alias::new(column)) + .small_integer() + .not_null() + .default(0), + ) + .to_owned(), + ) + .await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for (table, column) in COLUMNS.into_iter().rev() { + manager + .alter_table( + Table::alter() + .table(Alias::new(table)) + .drop_column(Alias::new(column)) + .to_owned(), + ) + .await?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sea_orm::{ConnectionTrait, Database, DbBackend, Statement}; + + #[tokio::test] + async fn existing_rows_keep_bytes_and_default_to_compatibility_format() { + let db = Database::connect("sqlite::memory:").await.unwrap(); + for table in [ + "delegation", + "invocation", + "hook_subscription", + "owner_share_policy", + ] { + db.execute(Statement::from_string( + DbBackend::Sqlite, + format!("CREATE TABLE {table} (bytes BLOB NOT NULL)"), + )) + .await + .unwrap(); + db.execute(Statement::from_string( + DbBackend::Sqlite, + format!("INSERT INTO {table} (bytes) VALUES (X'010203')"), + )) + .await + .unwrap(); + } + Migration.up(&SchemaManager::new(&db)).await.unwrap(); + for (table, column) in COLUMNS { + let row = db + .query_one(Statement::from_string( + DbBackend::Sqlite, + format!("SELECT bytes, {column} AS format FROM {table}"), + )) + .await + .unwrap() + .unwrap(); + assert_eq!(row.try_get::>("", "bytes").unwrap(), [1, 2, 3]); + assert_eq!(row.try_get::("", "format").unwrap(), 0); + } + } +} diff --git a/tinycloud-core/src/migrations/mod.rs b/tinycloud-core/src/migrations/mod.rs index 69021552b..a3c1c49bb 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -33,6 +33,7 @@ pub mod m20261021_000000_sql_store_manifest; pub mod m20261022_000000_encryption_network_key; pub mod m20261023_010000_kv_orphan_candidates; pub mod m20261026_000000_kv_write_space_value_index; +pub mod m20261109_000000_column_encryption_format; pub struct Migrator; @@ -74,6 +75,7 @@ impl MigratorTrait for Migrator { Box::new(m20261022_000000_encryption_network_key::Migration), Box::new(m20261023_010000_kv_orphan_candidates::Migration), Box::new(m20261026_000000_kv_write_space_value_index::Migration), + Box::new(m20261109_000000_column_encryption_format::Migration), ] } } diff --git a/tinycloud-core/src/models/delegation.rs b/tinycloud-core/src/models/delegation.rs index 145e50735..4a6da7ffb 100644 --- a/tinycloud-core/src/models/delegation.rs +++ b/tinycloud-core/src/models/delegation.rs @@ -29,6 +29,7 @@ pub struct Model { pub not_before: Option, pub facts: Option, pub serialization: Vec, + pub serialization_format: i16, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -1072,7 +1073,15 @@ async fn save( let hash: Hash = crate::hash::hash(&serialization); // Encrypt for storage if encryption is configured - let stored_serialization = crate::encryption::maybe_encrypt(encryption, &serialization); + let stored_serialization = crate::encryption::maybe_encrypt( + encryption, + &serialization, + crate::encryption::RowBinding::new( + "delegation", + "serialization", + &[&Vec::::from(hash)], + ), + ); // Persist the signed-in `delegationMode` marker. We store ONLY the // marker we recognize natively (not the full UCAN facts), keeping the @@ -1109,6 +1118,7 @@ async fn save( not_before: delegation.not_before, facts, serialization: stored_serialization, + serialization_format: if encryption.is_some() { 2 } else { 0 }, })) .on_conflict(OnConflict::column(Column::Id).do_nothing().to_owned()); if db.get_database_backend() == DbBackend::Postgres { @@ -1279,6 +1289,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(db) .await @@ -1316,6 +1327,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(ser_a.clone()), + serialization_format: Set(0), }; // First insert → row is new. @@ -1408,6 +1420,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(ser), + serialization_format: Set(0), }) .on_conflict(OnConflict::column(Column::Id).do_nothing().to_owned()) .exec(&db) @@ -2088,6 +2101,7 @@ mod tests { serde_json::json!("terminal"), )])))), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(db) .await diff --git a/tinycloud-core/src/models/delegation_cid_alias.rs b/tinycloud-core/src/models/delegation_cid_alias.rs index 9f320a24e..7d27ed1f6 100644 --- a/tinycloud-core/src/models/delegation_cid_alias.rs +++ b/tinycloud-core/src/models/delegation_cid_alias.rs @@ -60,47 +60,69 @@ pub(crate) fn canonical_hash(serialization: &[u8]) -> Result { Ok(hash(&canonical)) } -/// Validate one indexed encrypted record on every boot, including after the -/// backfill is complete. A completed index must not permit keyless restarts. +/// Validate indexed encrypted and plaintext records on every boot, including +/// after the backfill is complete, so keyless and strict restarts stay checked. async fn validate_encryption( db: &C, encryption: Option<&ColumnEncryption>, ) -> Result<(), DbErr> { - let Some((id, stored)) = delegation::Entity::find() - .select_only() - .column(delegation::Column::Id) - .column(delegation::Column::Serialization) - .join_rev(JoinType::InnerJoin, Relation::Delegation.def()) - .filter(Column::Encrypted.eq(true)) - .into_tuple::<(Hash, Vec)>() - .one(db) - .await? - else { - return Ok(()); - }; - let encryption = encryption.ok_or_else(|| { - DbErr::Custom( - "delegation CID alias backfill: encrypted delegation requires a column encryption key" - .into(), - ) - })?; - let plaintext = encryption - .decrypt(&stored) - .map_err(|_| DbErr::Custom("delegation CID alias backfill: decryption failed".into()))?; - if hash(&plaintext) != id { - return Err(DbErr::Custom( - "delegation CID alias backfill: plaintext content hash does not match stored ID".into(), - )); + for encrypted in [true, false] { + let Some((id, stored, format)) = delegation::Entity::find() + .select_only() + .column(delegation::Column::Id) + .column(delegation::Column::Serialization) + .column(delegation::Column::SerializationFormat) + .join_rev(JoinType::InnerJoin, Relation::Delegation.def()) + .filter(Column::Encrypted.eq(encrypted)) + .into_tuple::<(Hash, Vec, i16)>() + .one(db) + .await? + else { + continue; + }; + let plaintext = stored_plaintext(encryption, id, &stored, format)?; + if hash(&plaintext) != id { + return Err(DbErr::Custom( + "delegation CID alias backfill: plaintext content hash does not match stored ID" + .into(), + )); + } } Ok(()) } +fn stored_plaintext<'a>( + encryption: Option<&ColumnEncryption>, + id: Hash, + stored: &'a [u8], + format: i16, +) -> Result, DbErr> { + let encrypted = matches!(stored.first(), Some(0x01 | 0x02)); + if encryption.is_none() { + if encrypted || format != 0 { + return Err(DbErr::Custom( + "delegation CID alias backfill: encrypted delegation requires a column encryption key" + .into(), + )); + } + return Ok(Cow::Borrowed(stored)); + } + maybe_decrypt( + encryption, + stored, + crate::encryption::RowBinding::new("delegation", "serialization", &[&Vec::::from(id)]), + format, + ) + .map(Cow::Owned) + .map_err(|_| DbErr::Custom("delegation CID alias backfill: decryption failed".into())) +} + /// Backfill only persisted delegations with no alias, in bounded keyset pages. /// The caller must use its boot transaction so any failure rolls back all /// aliases written by this call. Encrypted rows require the configured key; /// decryption, plaintext content-hash, and decoder failures abort startup. /// Original IDs, serializations, proof edges, and revocation targets are never -/// updated. Completed restarts validate only one indexed encrypted record. +/// updated. Completed restarts validate indexed encrypted and plaintext records. pub(crate) async fn backfill( db: &C, encryption: Option<&ColumnEncryption>, @@ -130,6 +152,7 @@ pub(crate) async fn backfill( .select_only() .column(delegation::Column::Id) .column(delegation::Column::Serialization) + .column(delegation::Column::SerializationFormat) .filter( Expr::exists( Query::select() @@ -148,28 +171,14 @@ pub(crate) async fn backfill( if let Some(id) = after { query = query.filter(delegation::Column::Id.gt(id)); } - let rows = query.into_tuple::<(Hash, Vec)>().all(db).await?; + let rows = query.into_tuple::<(Hash, Vec, i16)>().all(db).await?; if rows.is_empty() { return validate_encryption(db, encryption).await; } let mut aliases = Vec::with_capacity(rows.len()); - for (id, stored) in rows { - // This is ColumnEncryption's version marker. Borrow legacy - // plaintext instead of copying it through the passthrough helper. - let serialization = if stored.first() == Some(&0x01) { - let encryption = encryption.ok_or_else(|| { - DbErr::Custom( - "delegation CID alias backfill: encrypted delegation requires a column encryption key" - .into(), - ) - })?; - Cow::Owned(maybe_decrypt(Some(encryption), &stored).map_err(|_| { - DbErr::Custom("delegation CID alias backfill: decryption failed".into()) - })?) - } else { - Cow::Borrowed(stored.as_slice()) - }; + for (id, stored, format) in rows { + let serialization = stored_plaintext(encryption, id, &stored, format)?; if hash(serialization.as_ref()) != id { return Err(DbErr::Custom( "delegation CID alias backfill: plaintext content hash does not match stored ID" @@ -179,7 +188,7 @@ pub(crate) async fn backfill( aliases.push(ActiveModel { id: Set(id), canonical: Set(canonical_hash(serialization.as_ref())?), - encrypted: Set(stored.first() == Some(&0x01)), + encrypted: Set(matches!(stored.first(), Some(0x01 | 0x02))), }); after = Some(id); } @@ -228,6 +237,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(serialization), + serialization_format: Set(0), } .insert(db) .await @@ -249,7 +259,7 @@ mod tests { let encryption = ColumnEncryption::new([0x42; 32]); let serialization = ucan_serialization(); let id = hash(&serialization); - let stored = encryption.encrypt(&serialization); + let stored = encryption.encrypt_legacy(&serialization); insert_delegation(&db, id, stored.clone()).await; backfill(&db, Some(&encryption)).await.unwrap(); @@ -285,7 +295,7 @@ mod tests { insert_delegation( &db, hash(&serialization), - encryption.encrypt(&serialization), + encryption.encrypt_legacy(&serialization), ) .await; @@ -295,6 +305,97 @@ mod tests { assert_eq!(Entity::find().count(&db).await.unwrap(), 0); } + #[tokio::test] + async fn recorded_v2_backfill_rejects_plaintext_and_legacy_ciphertext() { + let encryption = ColumnEncryption::new([0x42; 32]); + let serialization = ucan_serialization(); + for stored in [ + serialization.clone(), + encryption.encrypt_legacy(&serialization), + ] { + let db = database().await; + let id = hash(&serialization); + insert_delegation(&db, id, stored).await; + delegation::Entity::update_many() + .col_expr(delegation::Column::SerializationFormat, Expr::value(2)) + .exec(&db) + .await + .unwrap(); + assert!(backfill(&db, Some(&encryption)).await.is_err()); + assert!(backfill(&db, None).await.is_err()); + assert_eq!(Entity::find().count(&db).await.unwrap(), 0); + } + } + + #[tokio::test] + async fn v2_backfill_preserves_binding_and_records_encrypted_alias() { + let encryption = ColumnEncryption::new([0x42; 32]); + let serialization = ucan_serialization(); + let id = hash(&serialization); + let stored = encryption.encrypt( + &serialization, + crate::encryption::RowBinding::new( + "delegation", + "serialization", + &[&Vec::::from(id)], + ), + ); + let db = database().await; + insert_delegation(&db, id, stored.clone()).await; + delegation::Entity::update_many() + .col_expr(delegation::Column::SerializationFormat, Expr::value(2)) + .exec(&db) + .await + .unwrap(); + backfill(&db, Some(&encryption)).await.unwrap(); + assert!( + Entity::find_by_id(id) + .one(&db) + .await + .unwrap() + .unwrap() + .encrypted + ); + assert!(backfill(&db, None).await.is_err()); + assert_eq!( + delegation::Entity::find_by_id(id) + .one(&db) + .await + .unwrap() + .unwrap() + .serialization, + stored, + ); + let compatibility = database().await; + insert_delegation(&compatibility, id, stored.clone()).await; + backfill(&compatibility, Some(&encryption)).await.unwrap(); + assert!( + Entity::find_by_id(id) + .one(&compatibility) + .await + .unwrap() + .unwrap() + .encrypted + ); + + let moved = database().await; + insert_delegation(&moved, hash(b"other row"), stored).await; + assert!(backfill(&moved, Some(&encryption)).await.is_err()); + assert_eq!(Entity::find().count(&moved).await.unwrap(), 0); + } + + #[tokio::test] + async fn strict_backfill_refuses_legacy_plaintext() { + let db = database().await; + let serialization = ucan_serialization(); + insert_delegation(&db, hash(&serialization), serialization).await; + let encryption = ColumnEncryption::new([0x42; 32]).with_strict(true); + assert!(backfill(&db, Some(&encryption)).await.is_err()); + assert_eq!(Entity::find().count(&db).await.unwrap(), 0); + backfill(&db, None).await.unwrap(); + assert!(backfill(&db, Some(&encryption)).await.is_err()); + } + #[tokio::test] async fn mismatched_plaintext_hash_is_not_aliased() { let db = database().await; @@ -380,7 +481,18 @@ mod tests { } copy_records!(actor::Entity); copy_records!(space::Entity); - copy_records!(delegation::Entity); + let delegations = delegation::Entity::find() + .from_raw_sql(Statement::from_string( + sea_orm::DbBackend::Sqlite, + "SELECT *, 0 AS serialization_format FROM delegation", + )) + .all(&old) + .await + .unwrap(); + delegation::Entity::insert_many(delegations.into_iter().map(|row| row.into_active_model())) + .exec(&fixture.conn) + .await + .unwrap(); copy_records!(abilities::Entity); copy_records!(parent_delegations::Entity); copy_records!(revocation::Entity); diff --git a/tinycloud-core/src/models/hook_subscription.rs b/tinycloud-core/src/models/hook_subscription.rs index a12b79819..691461666 100644 --- a/tinycloud-core/src/models/hook_subscription.rs +++ b/tinycloud-core/src/models/hook_subscription.rs @@ -13,6 +13,7 @@ pub struct Model { pub abilities_json: Option, pub callback_url: String, pub encrypted_secret: Vec, + pub encrypted_secret_format: i16, pub secret_key_id: String, pub active: bool, pub created_at: String, diff --git a/tinycloud-core/src/models/invocation.rs b/tinycloud-core/src/models/invocation.rs index 1ed6c9139..a835a23d2 100644 --- a/tinycloud-core/src/models/invocation.rs +++ b/tinycloud-core/src/models/invocation.rs @@ -33,6 +33,7 @@ pub struct Model { pub issued_at: OffsetDateTime, pub facts: Option, pub serialization: Vec, + pub serialization_format: i16, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -759,7 +760,15 @@ async fn save( let invoker = invocation.invoker.clone(); // Encrypt for storage if encryption is configured - let stored_serialization = crate::encryption::maybe_encrypt(encryption, &serialization); + let stored_serialization = crate::encryption::maybe_encrypt( + encryption, + &serialization, + crate::encryption::RowBinding::new( + "invocation", + "serialization", + &[&Vec::::from(hash)], + ), + ); // Ensure the invoker actor exists before inserting the invocation match actor::Entity::insert(actor::ActiveModel::from(actor::Model { @@ -783,6 +792,7 @@ async fn save( id: hash, issued_at, serialization: stored_serialization, + serialization_format: if encryption.is_some() { 2 } else { 0 }, facts: None, invoker, })) @@ -1516,6 +1526,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(&db) .await @@ -1696,6 +1707,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(&db) .await @@ -2114,6 +2126,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(&db) .await @@ -2281,6 +2294,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(&db) .await @@ -2368,6 +2382,7 @@ mod tests { )], )))), serialization: Set(parent.as_ref().to_vec()), + serialization_format: Set(0), } .insert(&db) .await diff --git a/tinycloud-core/src/models/owner_share_policy.rs b/tinycloud-core/src/models/owner_share_policy.rs index 15addb4de..4c64288ca 100644 --- a/tinycloud-core/src/models/owner_share_policy.rs +++ b/tinycloud-core/src/models/owner_share_policy.rs @@ -19,7 +19,9 @@ pub struct Model { #[sea_orm(unique)] pub enforcement_delegation_cid: String, pub policy_bytes: Vec, + pub policy_bytes_format: i16, pub enforcement_delegation_bytes: Vec, + pub enforcement_delegation_bytes_format: i16, pub policy_proof: String, pub policy_digest: String, pub matcher_digest: String, diff --git a/tinycloud-core/src/models/revocation.rs b/tinycloud-core/src/models/revocation.rs index 97baa70b1..814a35867 100644 --- a/tinycloud-core/src/models/revocation.rs +++ b/tinycloud-core/src/models/revocation.rs @@ -916,6 +916,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(db) .await @@ -1138,6 +1139,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), } .insert(db) .await diff --git a/tinycloud-core/src/read_audit.rs b/tinycloud-core/src/read_audit.rs index 7704a3121..d53f30d85 100644 --- a/tinycloud-core/src/read_audit.rs +++ b/tinycloud-core/src/read_audit.rs @@ -8,7 +8,7 @@ use crate::{ db_pool::{is_transient_error, transient_error_class, RecoveringConnection}, - encryption::{maybe_encrypt, ColumnEncryption}, + encryption::{maybe_encrypt, ColumnEncryption, RowBinding}, events::Invocation, hash::Hash, models::{actor, invocation}, @@ -40,17 +40,24 @@ struct ReadAuditRecord { invoker: String, issued_at: OffsetDateTime, serialization: Vec, + serialization_format: i16, abilities: Vec<(Resource, Ability)>, parents: Vec, } impl ReadAuditRecord { fn from_invocation(invocation: &Invocation, encryption: Option<&ColumnEncryption>) -> Self { + let id = invocation.content_hash(); Self { - id: invocation.content_hash(), + id, invoker: invocation.0.invoker.clone(), issued_at: OffsetDateTime::now_utc(), - serialization: maybe_encrypt(encryption, invocation.serialized_bytes()), + serialization: maybe_encrypt( + encryption, + invocation.serialized_bytes(), + RowBinding::new("invocation", "serialization", &[&Vec::::from(id)]), + ), + serialization_format: if encryption.is_some() { 2 } else { 0 }, abilities: invocation .0 .capabilities @@ -766,6 +773,7 @@ fn audit_inserts(batch: &[Command]) -> Option { issued_at: Set(record.issued_at), facts: Set(None), serialization: Set(record.serialization.clone()), + serialization_format: Set(record.serialization_format), }) .collect(); let invocations = invocation::Entity::insert_many(invocations).on_conflict( @@ -885,6 +893,7 @@ mod tests { invoker: "did:key:read-audit-test".to_string(), issued_at: OffsetDateTime::now_utc(), serialization: format!("serialized-{index}").into_bytes(), + serialization_format: 0, abilities: Vec::new(), parents: Vec::new(), } @@ -1066,6 +1075,7 @@ mod tests { invoker: invoker.to_string(), issued_at: OffsetDateTime::now_utc(), serialization: format!("serialized-{seed}").into_bytes(), + serialization_format: 0, abilities, parents, } @@ -1108,6 +1118,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: Set(0), }) .exec(conn) .await @@ -1120,6 +1131,94 @@ mod tests { assert_batched_persist_contents(&conn, persist_batch_boxed).await; } + #[tokio::test] + async fn read_audit_persists_v2_format_with_bound_ciphertext() { + use tinycloud_auth::{ + authorization::{make_invocation, InvocationOptions}, + resolver::DID_METHODS, + ssi::jwk::JWK, + }; + + let jwk = JWK::generate_ed25519().unwrap(); + let did = DID_METHODS.generate(&jwk, "key").unwrap().to_string(); + let verification_method = format!("{did}#{}", did.rsplit(':').next().unwrap()); + let parent = crate::hash::hash(b"audit-parent").to_cid(0x55); + let signed = make_invocation::>( + vec![], + &parent, + &jwk, + &verification_method, + (OffsetDateTime::now_utc() + time::Duration::minutes(5)).unix_timestamp() as f64, + InvocationOptions { + proof: Some(vec![]), + ..Default::default() + }, + ) + .unwrap(); + let plaintext = signed.encode().unwrap().into_bytes(); + let invocation = crate::events::SerializedEvent( + crate::util::InvocationInfo::try_from(signed).unwrap(), + plaintext.clone(), + ); + let id = invocation.content_hash(); + for encrypted in [false, true] { + let conn = database("sqlite::memory:".to_string(), 1).await; + let encryption = encrypted.then(|| ColumnEncryption::new([0x42; 32])); + let record = ReadAuditRecord::from_invocation(&invocation, encryption.as_ref()); + persist_batch_boxed(&conn, &[command(record)]) + .await + .unwrap(); + let row = invocation::Entity::find_by_id(id) + .one(&conn) + .await + .unwrap() + .unwrap(); + assert_eq!(row.serialization_format, if encrypted { 2 } else { 0 }); + if encrypted { + assert_eq!(row.serialization[0], 2); + assert_ne!(row.serialization, plaintext); + } else { + assert_eq!(row.serialization, plaintext); + } + let id_bytes = Vec::::from(id); + let pk = [id_bytes.as_slice()]; + let binding = RowBinding::new("invocation", "serialization", &pk); + assert_eq!( + crate::encryption::maybe_decrypt( + encryption.as_ref(), + &row.serialization, + binding, + row.serialization_format, + ) + .unwrap(), + plaintext, + ); + if encrypted { + assert!(crate::encryption::maybe_decrypt( + None, + &row.serialization, + binding, + row.serialization_format, + ) + .is_err()); + assert!(crate::encryption::maybe_decrypt( + encryption.as_ref(), + &row.serialization, + RowBinding::new("invocation", "serialization", &[b"other row"]), + row.serialization_format, + ) + .is_err()); + assert!(crate::encryption::maybe_decrypt( + encryption.as_ref(), + &plaintext, + binding, + row.serialization_format, + ) + .is_err()); + } + } + } + /// How a test persists a batch: `persist_batch` (one statement on /// PostgreSQL), or the transaction of per-table INSERTs it replaces there. type Persist = for<'a> fn( @@ -1440,40 +1539,16 @@ mod tests { } #[test] - fn single_statement_audit_covers_every_table_and_falls_back_past_the_parameter_limit() { - assert!(audit_inserts(&[]).is_none()); + fn single_statement_audit_falls_back_past_the_parameter_limit() { let single_statement = |batch: &[Command]| { postgres_single_statement(&audit_inserts(batch).expect("a non-empty batch")) }; let get = ability("tinycloud.kv/get"); - let batch = vec![command(rich_record( - "shape", - "did:key:shape", - vec![( - resource("tinycloud:did:key:ne-delegator:files/kv/a"), - get.clone(), - )], - vec![crate::hash::hash(b"parent")], - ))]; - let sql = single_statement(&batch) - .expect("an ordinary batch is one statement") - .sql; - assert!(sql.starts_with("WITH "), "{sql}"); - for table in [ - r#"INSERT INTO "actor""#, - r#"INSERT INTO "invoked_abilities""#, - r#"INSERT INTO "parent_delegation""#, - r#"INSERT INTO "invocation""#, - ] { - assert_eq!(sql.matches(table).count(), 1, "{table}: {sql}"); - } - assert_eq!(sql.matches("ON CONFLICT").count(), 4, "{sql}"); - assert!(!sql.contains("RETURNING"), "{sql}"); - // 6 parameters for the actor and invocation, 3 per invoked ability: + // 7 parameters for the actor and invocation, 3 per invoked ability: // past 65,535 the batch takes the transactional path instead. - let fits = (65_535 - 6) / 3; + let fits = (65_535 - 7) / 3; let abilities = |count: usize| { (0..count) .map(|index| { @@ -1619,6 +1694,7 @@ mod tests { issued_at: Set(OffsetDateTime::now_utc()), facts: Set(None), serialization: Set(b"held by another transaction".to_vec()), + serialization_format: Set(0), }) .exec(&tx) .await @@ -2708,6 +2784,7 @@ mod tests { invoker: record.invoker.clone(), issued_at: OffsetDateTime::now_utc(), serialization: record.serialization.clone(), + serialization_format: record.serialization_format, abilities: record.abilities.clone(), parents: record.parents.clone(), }; diff --git a/tinycloud-core/src/write_hooks.rs b/tinycloud-core/src/write_hooks.rs index 7695382d6..2ec802bae 100644 --- a/tinycloud-core/src/write_hooks.rs +++ b/tinycloud-core/src/write_hooks.rs @@ -300,6 +300,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(vec![]), + serialization_format: Set(0), } .insert(db) .await @@ -338,6 +339,7 @@ mod tests { )])) })), serialization: Set(vec![]), + serialization_format: Set(0), } .insert(db) .await @@ -469,6 +471,7 @@ mod tests { ), callback_url: "https://example.com/hooks".to_string(), encrypted_secret: vec![1, 2, 3], + encrypted_secret_format: 0, secret_key_id: "primary".to_string(), active: true, created_at: "2026-04-09T00:00:00Z".to_string(), diff --git a/tinycloud-core/tests/historical_cacao_revocation.rs b/tinycloud-core/tests/historical_cacao_revocation.rs index 255a82eac..c4cbe7372 100644 --- a/tinycloud-core/tests/historical_cacao_revocation.rs +++ b/tinycloud-core/tests/historical_cacao_revocation.rs @@ -59,12 +59,12 @@ fn copy_database(fixture: &Fixture) -> (tempfile::TempDir, PathBuf) { (directory, path) } -// Snapshot only immutable signed records and edges; new alias/migration rows -// and ordinary new invocation events are deliberately outside this snapshot. +// Snapshot immutable signed-record fields and edges, independently of additive +// storage metadata. New alias/migration rows and invocation events are excluded. fn signed_snapshot(path: &Path) -> Vec>> { let db = rusqlite::Connection::open(path).unwrap(); [ - "SELECT * FROM delegation ORDER BY id", + "SELECT id, delegator, delegatee, expiry, issued_at, not_before, facts, serialization FROM delegation ORDER BY id", "SELECT * FROM parent_delegation WHERE child IN (SELECT id FROM delegation) OR child IN (SELECT id FROM revocation) ORDER BY child, parent", "SELECT * FROM revocation ORDER BY id", ].into_iter().map(|sql| { @@ -105,13 +105,29 @@ async fn assert_signed_rows_untouched(db: &TestDb, fixture: &Fixture) { .await .unwrap() .unwrap(); - let root_bytes = - tinycloud_core::encryption::maybe_decrypt(encryption.as_ref(), &root.serialization) - .unwrap(); + let root_bytes = tinycloud_core::encryption::maybe_decrypt( + encryption.as_ref(), + &root.serialization, + tinycloud_core::RowBinding::new( + "delegation", + "serialization", + &[&Vec::::from(root.id)], + ), + root.serialization_format, + ) + .unwrap(); assert_eq!(root_bytes, URL_SAFE.decode(&row.header).unwrap()); - let child_bytes = - tinycloud_core::encryption::maybe_decrypt(encryption.as_ref(), &child.serialization) - .unwrap(); + let child_bytes = tinycloud_core::encryption::maybe_decrypt( + encryption.as_ref(), + &child.serialization, + tinycloud_core::RowBinding::new( + "delegation", + "serialization", + &[&Vec::::from(child.id)], + ), + child.serialization_format, + ) + .unwrap(); assert_eq!(child_bytes, row.child_header.as_bytes()); let parents = parent_delegations::Entity::find() .filter(parent_delegations::Column::Child.eq(child.id)) diff --git a/tinycloud-node-server/src/config.rs b/tinycloud-node-server/src/config.rs index 0b49fba87..9da335832 100644 --- a/tinycloud-node-server/src/config.rs +++ b/tinycloud-node-server/src/config.rs @@ -59,9 +59,18 @@ pub struct Config { #[serde(default)] pub encryption_network: EncryptionNetworkConfig, #[serde(default)] + pub column_encryption: ColumnEncryptionConfig, + #[serde(default)] pub sql: SqlConfig, } +/// Recorded column reads may require encrypted storage when explicitly enabled. +#[derive(Serialize, Deserialize, Debug, Default, Clone, Hash, PartialEq, Eq)] +pub struct ColumnEncryptionConfig { + #[serde(default)] + pub strict: bool, +} + /// Release gates for the encryption network routes. `named_keys_v1` /// advertises the `encryption-named-keys-v1` feature in `/info` and turns on /// named keys: the key create, describe, public key, block, unblock and @@ -2144,6 +2153,64 @@ mod tests { use std::io::Write; use tempfile::NamedTempFile; + #[test] + fn column_encryption_strict_defaults_off_and_accepts_opt_in() { + assert!(!Config::default().column_encryption.strict); + assert!( + !serde_json::from_str::("{}") + .unwrap() + .strict + ); + assert!( + serde_json::from_str::(r#"{"strict":true}"#) + .unwrap() + .strict + ); + } + + #[test] + fn storage_keyrings_separate_purposes_and_apply_strict_mode() { + use tinycloud_core::encryption::RowBinding; + + let root = tinycloud_core::keys::StaticSecret::new(vec![7; 32]).unwrap(); + let binding = RowBinding::new("hook_subscription", "encrypted_secret", &[b"hook"]); + let config = Config::default(); + let (columns, webhooks) = crate::storage_keyrings(&root, &config.column_encryption); + let sealed = columns.encrypt(b"stored bytes", binding); + assert!(webhooks.decrypt_recorded(&sealed, binding, 2).is_err()); + let legacy_seed = tinycloud_core::ColumnEncryption::for_purpose( + root.derive_key(b"tinycloud/hooks/webhook-secrets"), + root.derive_key(b"tinycloud/hooks/webhook-secrets"), + tinycloud_core::encryption::EncryptionPurpose::Columns, + ); + assert!(legacy_seed.decrypt_recorded(&sealed, binding, 2).is_err()); + let sealed = webhooks.encrypt(b"stored bytes", binding); + assert!(columns.decrypt_recorded(&sealed, binding, 2).is_err()); + let legacy = tinycloud_core::ColumnEncryption::new( + root.derive_key(b"tinycloud/hooks/webhook-secrets"), + ) + .encrypt_legacy(b"legacy bytes"); + for ring in [&columns, &webhooks] { + assert_eq!( + ring.decrypt_recorded(&legacy, binding, 0).unwrap(), + b"legacy bytes" + ); + assert_eq!( + ring.decrypt_recorded(b"plaintext", binding, 0).unwrap(), + b"plaintext" + ); + } + let (columns, webhooks) = + crate::storage_keyrings(&root, &ColumnEncryptionConfig { strict: true }); + for ring in [&columns, &webhooks] { + assert!(ring.decrypt_recorded(b"plaintext", binding, 0).is_err()); + assert_eq!( + ring.decrypt_recorded(&legacy, binding, 0).unwrap(), + b"legacy bytes" + ); + } + } + /// TC-1581: `[global.storage.sql] ddl_requires_schema` defaults to /// `enforce` and accepts only `off`, `log` and `enforce`. #[test] diff --git a/tinycloud-node-server/src/invocation_replay.rs b/tinycloud-node-server/src/invocation_replay.rs index 9e3a16d47..b48f4b80b 100644 --- a/tinycloud-node-server/src/invocation_replay.rs +++ b/tinycloud-node-server/src/invocation_replay.rs @@ -425,6 +425,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(b"synthetic database race fixture".to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&transaction) .await diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index 681a85b8d..2a5a90c08 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -299,6 +299,23 @@ pub type BlockStores = Either; pub type BlockConfig = Either; pub type BlockStage = Either; +/// Builds the serving storage rings, retaining the prior key for legacy reads. +pub fn storage_keyrings( + key_setup: &StaticSecret, + config: &config::ColumnEncryptionConfig, +) -> (ColumnEncryption, ColumnEncryption) { + use tinycloud_core::encryption::EncryptionPurpose; + + let root = key_setup.derive_key(b"tinycloud/column-encryption/root"); + let legacy_key = key_setup.derive_key(b"tinycloud/hooks/webhook-secrets"); + ( + ColumnEncryption::for_purpose(root, legacy_key, EncryptionPurpose::Columns) + .with_strict(config.strict), + ColumnEncryption::for_purpose(root, legacy_key, EncryptionPurpose::Webhooks) + .with_strict(config.strict), + ) +} + impl From for BlockConfig { fn from(c: BlockStorage) -> BlockConfig { match c { @@ -745,8 +762,8 @@ pub async fn app_with_control( // TC-359: publish the derived public halves so an operator can read the // invitation key a dstack-keyed node actually signs with. let node_public_keys = NodePublicKeys::derive(&key_setup); - let webhook_encryption = - ColumnEncryption::new(key_setup.derive_key(b"tinycloud/hooks/webhook-secrets")); + let (column_encryption, webhook_encryption) = + storage_keyrings(&key_setup, &tinycloud_config.column_encryption); let hook_runtime = HookRuntime::new( tinycloud_config.hooks.clone(), key_setup.derive_key(hooks::HOOK_TICKET_KEY_CONTEXT), @@ -838,7 +855,7 @@ pub async fn app_with_control( database_connection, block_store, key_setup.setup(()).await?, - Some(webhook_encryption.clone()), + Some(column_encryption), ) .await? .with_sql_sizes(sql_sizes.clone()) @@ -1891,6 +1908,7 @@ mod sqlite_tuning_tests { not_before: Set(None), facts: Set(None), serialization: Set(b"synthetic database race fixture".to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&transaction) .await diff --git a/tinycloud-node-server/src/policy_v3.rs b/tinycloud-node-server/src/policy_v3.rs index 20fe06117..b50cd8b51 100644 --- a/tinycloud-node-server/src/policy_v3.rs +++ b/tinycloud-node-server/src/policy_v3.rs @@ -7067,6 +7067,7 @@ mod tests { not_before: Set(Some(now)), facts: Set(None), serialization: Set(authorization.as_bytes().to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&runtime.conn) .await?; 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 d50aa77fa..4bca9fb97 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 @@ -1103,7 +1103,7 @@ async fn tc2192_legacy_immutable_share_decrypt_uses_signed_wire_digest() -> anyh let shared = share.receiver.diffie_hellman(&peer); assert_eq!( tinycloud_core::encryption::ColumnEncryption::new(*shared.as_bytes()) - .decrypt_strict(sealed)?, + .decrypt_legacy(sealed)?, vec![0xCD; 32], ); @@ -1197,7 +1197,7 @@ async fn assert_registered_source_binding(share: ShareDecrypt, route: &str) -> a let shared = share.receiver.diffie_hellman(&peer); assert_eq!( tinycloud_core::encryption::ColumnEncryption::new(*shared.as_bytes()) - .decrypt_strict(sealed)?, + .decrypt_legacy(sealed)?, vec![0xCD; 32], ); Ok(()) @@ -1371,7 +1371,7 @@ async fn tc2192_legacy_mutable_source_preserves_wrapped_key_rotation() -> anyhow let shared = share.receiver.diffie_hellman(&peer); assert_eq!( tinycloud_core::encryption::ColumnEncryption::new(*shared.as_bytes()) - .decrypt_strict(sealed)?, + .decrypt_legacy(sealed)?, vec![0xCD; 32], ); Ok(()) diff --git a/tinycloud-node-server/src/routes/hooks.rs b/tinycloud-node-server/src/routes/hooks.rs index 8f08b44fd..53d03de6f 100644 --- a/tinycloud-node-server/src/routes/hooks.rs +++ b/tinycloud-node-server/src/routes/hooks.rs @@ -28,6 +28,7 @@ use serde::{Deserialize, Serialize}; use std::{future::Future, net::IpAddr, time::Duration, time::Instant}; use time::{format_description::well_known::Rfc3339, OffsetDateTime}; use tinycloud_core::{ + encryption::RowBinding, events::Invocation, hash::Blake3Hasher, hash::Hash, @@ -499,20 +500,26 @@ pub async fn create_webhook( let created_at = OffsetDateTime::now_utc() .format(&Rfc3339) .expect("current timestamps should format as RFC3339"); + let id = hook_subscription_id( + &invocation.info().invoker, + &normalized, + callback_url.as_str(), + &created_at, + ); + let encrypted_secret = webhook_encryption.encrypt( + request.secret.as_bytes(), + RowBinding::new("hook_subscription", "encrypted_secret", &[id.as_bytes()]), + ); let model = hook_subscription::Model { - id: hook_subscription_id( - &invocation.info().invoker, - &normalized, - callback_url.as_str(), - &created_at, - ), + encrypted_secret_format: 2, + id, subscriber_did: invocation.info().invoker.clone(), space_id: normalized.space.clone(), target_service: normalized.service.clone(), path_prefix: normalized.path_prefix.clone(), abilities_json: hook_subscription::Model::set_abilities(&normalized.abilities), callback_url: callback_url.to_string(), - encrypted_secret: webhook_encryption.encrypt(request.secret.as_bytes()), + encrypted_secret, secret_key_id: HOOK_WEBHOOK_SECRET_KEY_ID.to_string(), active: true, created_at, @@ -1541,6 +1548,7 @@ mod tests { fixture .tinycloud .create_hook_subscription(hook_subscription::Model { + encrypted_secret_format: 2, id: id.clone(), subscriber_did: did_of(&fixture.session.1).into(), space_id: fixture.space.to_string(), @@ -1548,7 +1556,10 @@ mod tests { path_prefix: Some("documents".into()), abilities_json: None, callback_url: (*callback).into(), - encrypted_secret: ColumnEncryption::new([3; 32]).encrypt(b"secret"), + encrypted_secret: ColumnEncryption::new([3; 32]).encrypt( + b"secret", + RowBinding::new("hook_subscription", "encrypted_secret", &[id.as_bytes()]), + ), secret_key_id: "primary".into(), active: true, created_at: "2026-10-14T00:00:00Z".into(), @@ -1653,8 +1664,17 @@ mod tests { .await? .unwrap(); assert_eq!(stored.callback_url, callback); + assert_eq!(stored.encrypted_secret_format, 2); assert_eq!( - ColumnEncryption::new([3; 32]).decrypt(&stored.encrypted_secret)?, + ColumnEncryption::new([3; 32]).decrypt_recorded( + &stored.encrypted_secret, + RowBinding::new( + "hook_subscription", + "encrypted_secret", + &[stored.id.as_bytes()] + ), + stored.encrypted_secret_format, + )?, secret.as_bytes() ); } @@ -1901,6 +1921,7 @@ mod tests { #[tokio::test] async fn converts_subscription_model_without_secret() { let model = hook_subscription::Model { + encrypted_secret_format: 0, id: "sub_01".to_string(), subscriber_did: "did:key:test".to_string(), space_id: "tinycloud:space".to_string(), @@ -2264,6 +2285,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(b"hooks-session".to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&db) .await?; @@ -2838,6 +2860,7 @@ mod tests { let existing = fixture .tinycloud .create_hook_subscription(hook_subscription::Model { + encrypted_secret_format: 0, id: "sub_existing".to_string(), subscriber_did: "did:key:someone-else".to_string(), space_id: fixture.space.to_string(), @@ -3130,6 +3153,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&fixture.db) .await?; @@ -3357,6 +3381,7 @@ mod tests { hook_route_fixture(&[("hooks", "kv/my_docs", "tinycloud.hooks/list")]).await?; let space = fixture.space.to_string(); let row = |id: &str, path_prefix: &str| hook_subscription::Model { + encrypted_secret_format: 0, id: id.to_string(), subscriber_did: "did:key:other-subscriber".to_string(), space_id: space.clone(), diff --git a/tinycloud-node-server/src/routes/mod.rs b/tinycloud-node-server/src/routes/mod.rs index bc5fadb9e..ed14c89e7 100644 --- a/tinycloud-node-server/src/routes/mod.rs +++ b/tinycloud-node-server/src/routes/mod.rs @@ -5228,6 +5228,7 @@ mod tests { abilities: &[&str], ) -> hook_subscription::Model { hook_subscription::Model { + encrypted_secret_format: 0, id: id.to_string(), subscriber_did: "did:key:test".to_string(), space_id: space.to_string(), @@ -5906,6 +5907,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(b"w1-rocket-http-parent".to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&conn) .await?; @@ -6155,6 +6157,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(b"w1-malformed-fact-parent".to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&conn) .await?; @@ -6535,6 +6538,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(serialization), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&conn) .await?; @@ -6914,6 +6918,7 @@ mod tests { not_before: Set(None), facts: Set(Some(Facts(facts))), serialization: Set(br#"{"policyId":"pol_w5_email_domain","terminal":true}"#.to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&conn) .await?; @@ -7119,6 +7124,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(b"parent-delegation-bytes".to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&conn) .await?; @@ -7354,6 +7360,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(bytes.to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&conn) .await?; @@ -7367,6 +7374,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(child_serialization), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&conn) .await?; @@ -7380,6 +7388,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(b"status-expired".to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&conn) .await?; @@ -7450,6 +7459,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(bytes.to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&conn) .await?; @@ -7591,6 +7601,7 @@ mod tests { not_before: Set(not_before), facts: Set(None), serialization: Set(bytes.to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&conn) .await?; @@ -7640,6 +7651,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(b"foreign-account-record".to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&conn) .await?; @@ -8061,6 +8073,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(ser.as_bytes().to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&conn) .await?; @@ -8396,6 +8409,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(name.as_bytes().to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&conn) .await?; @@ -11752,6 +11766,7 @@ mod tests { not_before: Set(not_before), facts: Set(None), serialization: Set(label.as_bytes().to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&self.conn) .await?; @@ -12577,6 +12592,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(label.as_bytes().to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&self.conn) .await?; @@ -15885,6 +15901,7 @@ mod tests { let (callback_url, receiver) = f43_callback_server().await?; hook_subscription::Entity::insert(hook_subscription::ActiveModel::from( hook_subscription::Model { + encrypted_secret_format: 2, id: id.to_string(), subscriber_did: subscriber, space_id: f.space.to_string(), @@ -15892,8 +15909,14 @@ mod tests { path_prefix: Some("documents".to_string()), abilities_json: None, callback_url, - encrypted_secret: ColumnEncryption::new(F43_WEBHOOK_KEY) - .encrypt(b"webhook-secret"), + encrypted_secret: ColumnEncryption::new(F43_WEBHOOK_KEY).encrypt( + b"webhook-secret", + tinycloud_core::encryption::RowBinding::new( + "hook_subscription", + "encrypted_secret", + &[id.as_bytes()], + ), + ), secret_key_id: "primary".to_string(), active: true, created_at: rfc3339(OffsetDateTime::now_utc()), diff --git a/tinycloud-node-server/src/routes/tests/delegation_issued_query.rs b/tinycloud-node-server/src/routes/tests/delegation_issued_query.rs index d32f02d3a..f4213ff1f 100644 --- a/tinycloud-node-server/src/routes/tests/delegation_issued_query.rs +++ b/tinycloud-node-server/src/routes/tests/delegation_issued_query.rs @@ -169,6 +169,7 @@ impl Fixture { not_before: Set(None), facts: Set(None), serialization: Set(label.as_bytes().to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&self.conn) .await?; diff --git a/tinycloud-node-server/src/routes/tests/encryption_keys.rs b/tinycloud-node-server/src/routes/tests/encryption_keys.rs index a1de7a955..2713932fb 100644 --- a/tinycloud-node-server/src/routes/tests/encryption_keys.rs +++ b/tinycloud-node-server/src/routes/tests/encryption_keys.rs @@ -1101,6 +1101,7 @@ async fn grant_owner_to_device( not_before: Set(None), facts: Set(None), serialization: Set(label.as_bytes().to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&fixture.conn) .await?; @@ -1499,7 +1500,7 @@ impl DecryptCase { "response ephemeral key is non-contributory" ); ColumnEncryption::new(*shared.as_bytes()) - .decrypt_strict(sealed) + .decrypt_legacy(sealed) .expect("response opens with the receiver secret") } } diff --git a/tinycloud-node-server/src/share_v2.rs b/tinycloud-node-server/src/share_v2.rs index 710e2fd37..4345bce03 100644 --- a/tinycloud-node-server/src/share_v2.rs +++ b/tinycloud-node-server/src/share_v2.rs @@ -35,7 +35,7 @@ use tinycloud_auth::{ share_email_evidence::{normalized_email_hash, verify_detached_ed25519}, }; use tinycloud_core::{ - encryption::{maybe_decrypt, ColumnEncryption}, + encryption::{maybe_decrypt, ColumnEncryption, RowBinding}, encryption_network::NetworkId, hash::Hash, keys::StaticSecret, @@ -271,9 +271,17 @@ impl ShareV2Runtime { fn encryption_probe(&self) -> bool { let plaintext = b"tinycloud-share-v2-readiness"; - let encrypted = self.policy_encryption.encrypt(plaintext); - maybe_decrypt(Some(&self.policy_encryption), &encrypted) - .is_ok_and(|decrypted| decrypted == plaintext) + let encrypted = self.policy_encryption.encrypt( + plaintext, + RowBinding::new("owner_share_policy", "policy_bytes", &[b"readiness-probe"]), + ); + maybe_decrypt( + Some(&self.policy_encryption), + &encrypted, + RowBinding::new("owner_share_policy", "policy_bytes", &[b"readiness-probe"]), + 2, + ) + .is_ok_and(|decrypted| decrypted == plaintext) } pub fn capability(&self) -> Option { @@ -395,6 +403,7 @@ pub async fn compose( tee_context: Option, tee_key_derived: bool, tinycloud: Arc, + policy_encryption: ColumnEncryption, ) -> anyhow::Result { let conn = conn.into(); verify_configured_invitation_key(&config, key_setup)?; @@ -467,9 +476,7 @@ pub async fn compose( let runtime = ShareV2Runtime { conn, tinycloud, - policy_encryption: ColumnEncryption::new( - key_setup.derive_key(b"tinycloud/hooks/webhook-secrets"), - ), + policy_encryption, signer: Arc::new(signer), enforcer_signer: Arc::new(enforcer_signer), signer_did: receipt_signer_did, @@ -928,8 +935,24 @@ pub async fn register_policy( registration_cid: Set(registration.registration_cid.clone()), owner_delegation_cid: Set(request.owner_delegation.cid.clone()), enforcement_delegation_cid: Set(request.enforcement_delegation.cid.clone()), - policy_bytes: Set(runtime.policy_encryption.encrypt(&policy_bytes)), - enforcement_delegation_bytes: Set(runtime.policy_encryption.encrypt(&enforcement_record)), + policy_bytes: Set(runtime.policy_encryption.encrypt( + &policy_bytes, + RowBinding::new( + "owner_share_policy", + "policy_bytes", + &[request.policy.cid.as_bytes()], + ), + )), + enforcement_delegation_bytes: Set(runtime.policy_encryption.encrypt( + &enforcement_record, + RowBinding::new( + "owner_share_policy", + "enforcement_delegation_bytes", + &[request.policy.cid.as_bytes()], + ), + )), + policy_bytes_format: Set(2), + enforcement_delegation_bytes_format: Set(2), policy_proof: Set(request.policy.proof.clone()), policy_digest: Set(b64_digest(&policy_bytes)), matcher_digest: Set(b64_digest(&jcs::canonicalize(&matcher))), @@ -1266,8 +1289,17 @@ async fn verify_owner_delegation( .await .map_err(|_| ())? .ok_or(())?; - let stored = - maybe_decrypt(Some(&runtime.policy_encryption), &row.serialization).map_err(|_| ())?; + let stored = maybe_decrypt( + Some(&runtime.policy_encryption), + &row.serialization, + RowBinding::new( + "delegation", + "serialization", + &[Vec::::from(row.id).as_slice()], + ), + row.serialization_format, + ) + .map_err(|_| ())?; if stored != bytes { return Err(()); } @@ -1835,8 +1867,17 @@ async fn registered_policy( if row.revoked_at.is_some() || row.policy_cid == row.registration_cid { return Err(()); } - let bytes = - maybe_decrypt(Some(&runtime.policy_encryption), &row.policy_bytes).map_err(|_| ())?; + let bytes = maybe_decrypt( + Some(&runtime.policy_encryption), + &row.policy_bytes, + RowBinding::new( + "owner_share_policy", + "policy_bytes", + &[row.policy_cid.as_bytes()], + ), + row.policy_bytes_format, + ) + .map_err(|_| ())?; if raw_sha256_cid(&bytes) != row.policy_cid || b64_digest(&bytes) != row.policy_digest || row.policy_proof.is_empty() @@ -1982,8 +2023,17 @@ async fn verify_registered_ancestors( .await .map_err(|_| ())? .ok_or(())?; - let bytes = - maybe_decrypt(Some(&runtime.policy_encryption), &row.serialization).map_err(|_| ())?; + let bytes = maybe_decrypt( + Some(&runtime.policy_encryption), + &row.serialization, + RowBinding::new( + "delegation", + "serialization", + &[Vec::::from(row.id).as_slice()], + ), + row.serialization_format, + ) + .map_err(|_| ())?; let owner = OwnerDelegationInput { cid: registered.row.owner_delegation_cid.clone(), dag_cbor: encode_config(bytes, URL_SAFE_NO_PAD), @@ -1995,6 +2045,12 @@ async fn verify_registered_ancestors( let stored = maybe_decrypt( Some(&runtime.policy_encryption), ®istered.row.enforcement_delegation_bytes, + RowBinding::new( + "owner_share_policy", + "enforcement_delegation_bytes", + &[registered.row.policy_cid.as_bytes()], + ), + registered.row.enforcement_delegation_bytes_format, ) .map_err(|_| ())?; serde_json::from_slice(&stored).map_err(|_| ())? @@ -4033,6 +4089,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(id.as_ref().to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&db) .await @@ -4276,6 +4333,7 @@ mod tests { tee_context, tee_key_derived, Arc::new(tinycloud), + crate::storage_keyrings(key_setup, &crate::config::ColumnEncryptionConfig::default()).0, ) .await .expect("share v2 runtime composes") @@ -4375,6 +4433,11 @@ mod tests { Some(TeeContext::derive_local(&key_setup)), true, Arc::new(tinycloud), + crate::storage_keyrings( + &key_setup, + &crate::config::ColumnEncryptionConfig::default(), + ) + .0, ) .await; @@ -4471,6 +4534,7 @@ mod tests { not_before: Set(None), facts: Set(None), serialization: Set(delegation_id.as_ref().to_vec()), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(&conn) .await @@ -4567,6 +4631,11 @@ mod tests { Some(tee_context), true, Arc::new(tinycloud), + crate::storage_keyrings( + &key_setup, + &crate::config::ColumnEncryptionConfig::default(), + ) + .0, ) .await .expect("share v2 runtime composes against a populated database"); @@ -4685,6 +4754,11 @@ mod tests { Some(tee_context), true, Arc::new(tinycloud.clone()), + crate::storage_keyrings( + &key_setup, + &crate::config::ColumnEncryptionConfig::default(), + ) + .0, ) .await .expect("share v2 runtime composes without the legacy v1 authority material"); @@ -4698,10 +4772,21 @@ mod tests { // Ordinary non-TEE production startup shape (no dstack, no // local-tee-derived context) must stay fail-closed even with the // exact same v2-only config. - let production_runtime = - compose(conn, &key_setup, config, None, false, Arc::new(tinycloud)) - .await - .expect("share v2 runtime composes even without a TEE context"); + let production_runtime = compose( + conn, + &key_setup, + config, + None, + false, + Arc::new(tinycloud), + crate::storage_keyrings( + &key_setup, + &crate::config::ColumnEncryptionConfig::default(), + ) + .0, + ) + .await + .expect("share v2 runtime composes even without a TEE context"); let production_response = production_runtime.readiness().await; assert!( !production_response.ready, diff --git a/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs b/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs index 66703c46c..c24097121 100644 --- a/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs +++ b/tinycloud-node-server/src/share_v2/tests/alias_revocation_tests.rs @@ -142,7 +142,17 @@ async fn signed_alias_consumers(reverse: bool) { .unwrap() .unwrap(); assert_eq!( - maybe_decrypt(Some(&runtime.policy_encryption), &row.serialization).unwrap(), + maybe_decrypt( + Some(&runtime.policy_encryption), + &row.serialization, + RowBinding::new( + "delegation", + "serialization", + &[Vec::::from(row.id).as_slice()] + ), + row.serialization_format, + ) + .unwrap(), *bytes, "stored content and its hash must retain the actual signed wire bytes" ); diff --git a/tinycloud-node-server/src/webhook_dispatcher.rs b/tinycloud-node-server/src/webhook_dispatcher.rs index 69f5a0ccf..8ce567ce0 100644 --- a/tinycloud-node-server/src/webhook_dispatcher.rs +++ b/tinycloud-node-server/src/webhook_dispatcher.rs @@ -8,7 +8,7 @@ use hmac::{Hmac, Mac}; use sha2::Sha256; use std::{collections::HashMap, sync::Arc, time::Duration}; use time::{Duration as TimeDuration, OffsetDateTime}; -use tinycloud_core::ColumnEncryption; +use tinycloud_core::{encryption::RowBinding, ColumnEncryption}; type WebhookMac = Hmac; @@ -127,7 +127,15 @@ impl WebhookDispatcher { return Ok(()); } - let secret = match self.encryption.decrypt(&delivery.encrypted_secret) { + let secret = match self.encryption.decrypt_recorded( + &delivery.encrypted_secret, + RowBinding::new( + "hook_subscription", + "encrypted_secret", + &[delivery.subscription_id.as_bytes()], + ), + delivery.encrypted_secret_format, + ) { Ok(secret) => secret, Err(error) => { self.record_terminal_failure( @@ -399,6 +407,7 @@ mod tests { ) -> Result<()> { hook_subscription::Entity::insert(hook_subscription::ActiveModel::from( hook_subscription::Model { + encrypted_secret_format: 2, id: subscription_id.to_string(), subscriber_did: subscriber_did.to_string(), space_id: SPACE_ID.to_string(), @@ -408,7 +417,14 @@ mod tests { serde_json::to_string(&vec!["tinycloud.kv/put".to_string()]).unwrap(), ), callback_url: callback_url.to_string(), - encrypted_secret: encryption.encrypt(b"hook-secret"), + encrypted_secret: encryption.encrypt( + b"hook-secret", + RowBinding::new( + "hook_subscription", + "encrypted_secret", + &[subscription_id.as_bytes()], + ), + ), secret_key_id: PRIMARY_SECRET_KEY_ID.to_string(), active: true, created_at: "2026-04-09T00:00:00Z".to_string(), @@ -542,6 +558,75 @@ mod tests { Ok(()) } + #[tokio::test] + async fn dispatcher_preserves_legacy_secret_reads() -> Result<()> { + let (_tempdir, tinycloud, db, encryption) = test_tinycloud().await?; + let (callback_url, mut receiver) = spawn_callback_server(StatusCode::OK).await?; + let delivery_id = insert_pending_delivery(&db, &encryption, &callback_url, 0).await?; + let mut subscription = hook_subscription::Entity::find_by_id("sub_01") + .one(&db) + .await? + .unwrap() + .into_active_model(); + subscription.encrypted_secret = + ActiveValue::Set(encryption.encrypt_legacy(b"legacy-secret")); + subscription.encrypted_secret_format = ActiveValue::Set(0); + subscription.update(&db).await?; + WebhookDispatcher::new( + tinycloud, + HooksConfig::default(), + encryption, + EgressPolicy::test_loopback(), + )? + .dispatch_due_once() + .await?; + let (body, signature, _) = tokio::time::timeout(Duration::from_secs(2), receiver.recv()) + .await? + .expect("legacy callback request"); + assert_eq!(signature, sign_payload(b"legacy-secret", body.as_bytes())?); + let row = hook_delivery::Entity::find_by_id(delivery_id) + .one(&db) + .await? + .unwrap(); + assert_eq!( + row.status, + tinycloud_core::db::HOOK_DELIVERY_STATUS_DELIVERED + ); + Ok(()) + } + + #[tokio::test] + async fn dispatcher_requires_v2_for_recorded_v2_secret() -> Result<()> { + let (_tempdir, tinycloud, db, encryption) = test_tinycloud().await?; + let (callback_url, mut receiver) = spawn_callback_server(StatusCode::OK).await?; + let delivery_id = insert_pending_delivery(&db, &encryption, &callback_url, 0).await?; + let mut subscription = hook_subscription::Entity::find_by_id("sub_01") + .one(&db) + .await? + .unwrap() + .into_active_model(); + subscription.encrypted_secret = ActiveValue::Set(b"unsealed-secret".to_vec()); + subscription.update(&db).await?; + WebhookDispatcher::new( + tinycloud, + HooksConfig::default(), + encryption, + EgressPolicy::test_loopback(), + )? + .dispatch_due_once() + .await?; + let row = hook_delivery::Entity::find_by_id(delivery_id) + .one(&db) + .await? + .unwrap(); + assert_eq!( + row.status, + tinycloud_core::db::HOOK_DELIVERY_STATUS_DEAD_LETTER + ); + assert!(receiver.try_recv().is_err(), "no callback was sent"); + Ok(()) + } + #[tokio::test] async fn retries_then_dead_letters_failed_delivery() -> Result<()> { let (_tempdir, tinycloud, db, encryption) = test_tinycloud().await?; @@ -607,7 +692,19 @@ mod tests { Migrator::up(&db, Some(before_deactivation)).await?; let encryption = ColumnEncryption::new([9u8; 32]); let (callback_url, mut receiver) = spawn_callback_server(StatusCode::OK).await?; - let delivery_id = insert_pending_delivery(&db, &encryption, &callback_url, 0).await?; + // This fixture predates the recorded-format columns. + use tinycloud_core::sea_orm::{ConnectionTrait, Statement}; + db.execute(Statement::from_sql_and_values( + db.get_database_backend(), + "INSERT INTO hook_subscription (id,subscriber_did,space_id,target_service,callback_url,encrypted_secret,secret_key_id,active,created_at) VALUES (?,?,?,?,?,?,?,?,?)", + [ + "sub_01".into(), OWNER_DID.into(), SPACE_ID.into(), "kv".into(), + callback_url.clone().into(), encryption.encrypt_legacy(b"hook-secret").into(), + PRIMARY_SECRET_KEY_ID.into(), true.into(), "2026-04-09T00:00:00Z".into(), + ], + )).await?; + let delivery_id = "delivery_01".to_string(); + insert_delivery(&db, "sub_01", &delivery_id, 0).await?; // Booting the upgraded node applies the remaining migration. let tempdir = TempDir::new()?; @@ -1056,6 +1153,7 @@ mod tests { not_before: ActiveValue::Set(None), facts: ActiveValue::Set(None), serialization: ActiveValue::Set(vec![]), + serialization_format: tinycloud_core::sea_orm::ActiveValue::Set(0), } .insert(db) .await diff --git a/tinycloud.toml b/tinycloud.toml index 167e353b5..a3d5b6baf 100644 --- a/tinycloud.toml +++ b/tinycloud.toml @@ -93,6 +93,11 @@ cors = true ## Issuer queries remain refused. Opt-in uses the unhardened SSI client (TC-1080). # network_methods = false +[global.column_encryption] + ## Require encrypted values on recorded column reads. Off preserves legacy plaintext. + ## Env: TINYCLOUD_COLUMN_ENCRYPTION__STRICT + strict = false + [global.encryption_network] ## Serve named keys and advertise the `encryption-named-keys-v1` feature in ## GET /info. Only while on are the key routes mounted, key and key-prefix From d8ff9fc3fce0e86956f59c1589a52675d051e6e1 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:23:33 +0000 Subject: [PATCH 2/2] fix(encryption): keep legacy reads separate from v2 keys TC-2516 TC-2517 --- tinycloud-core/src/encryption.rs | 64 +++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/tinycloud-core/src/encryption.rs b/tinycloud-core/src/encryption.rs index c51e4667b..f9b705316 100644 --- a/tinycloud-core/src/encryption.rs +++ b/tinycloud-core/src/encryption.rs @@ -65,11 +65,16 @@ impl<'a> RowBinding<'a> { } } +struct ReadKeys { + v2: BTreeMap, + legacy_v1: Option, +} + /// Selected write generation and shared read keys. Debug never exposes keys. #[derive(Clone)] pub struct ColumnEncryption { write_kid: u8, - keys: Arc>, + keys: Arc, strict: bool, } @@ -105,6 +110,7 @@ impl ColumnEncryption { /// a separately supplied old key for v1 reads. pub fn for_purpose(root: [u8; 32], legacy_key: [u8; 32], purpose: EncryptionPurpose) -> Self { let root = Zeroizing::new(root); + let legacy_key = Zeroizing::new(legacy_key); let info: &[u8] = match purpose { EncryptionPurpose::Columns => b"tinycloud/columns", EncryptionPurpose::Webhooks => b"tinycloud/hooks/webhook-secrets", @@ -113,7 +119,19 @@ impl ColumnEncryption { Hkdf::::new(Some(b"tinycloud/column-encryption/v2"), root.as_ref()) .expand(info, derived.as_mut()) .expect("32-byte HKDF output"); - Self::with_keys(1, [(0, legacy_key), (1, *derived)]).expect("distinct key generations") + Self { + write_kid: 1, + keys: Arc::new(ReadKeys { + v2: BTreeMap::from([( + 1, + Aes256Gcm::new_from_slice(derived.as_ref()).expect("valid 32-byte key"), + )]), + legacy_v1: Some( + Aes256Gcm::new_from_slice(legacy_key.as_ref()).expect("valid 32-byte key"), + ), + }), + strict: false, + } } /// Require authenticated envelopes even in the recorded legacy namespace. @@ -146,7 +164,10 @@ impl ColumnEncryption { } Ok(Self { write_kid, - keys: Arc::new(ciphers), + keys: Arc::new(ReadKeys { + v2: ciphers, + legacy_v1: None, + }), strict: false, }) } @@ -156,7 +177,7 @@ impl ColumnEncryption { } pub fn has_key(&self, kid: u8) -> bool { - self.keys.contains_key(&kid) + self.keys.v2.contains_key(&kid) } /// Select a different writer while retaining every read key and read policy. @@ -185,13 +206,21 @@ impl ColumnEncryption { ) -> Vec { let header = [VERSION_KEYRING, self.write_kid]; let aad = binding.aad(self.write_kid); - Self::seal(&self.keys[&self.write_kid], plaintext, nonce, &header, &aad) + Self::seal( + &self.keys.v2[&self.write_kid], + plaintext, + nonce, + &header, + &aad, + ) } /// Explicit unchanged v1 encoding for existing client and seal protocols. + /// Requires a generation-zero protocol key, not a serving purpose ring. pub fn encrypt_legacy(&self, plaintext: &[u8]) -> Vec { let cipher = self .keys + .v2 .get(&0) .expect("legacy wire encryption requires generation zero"); let nonce = Aes256Gcm::generate_nonce(&mut OsRng); @@ -277,10 +306,15 @@ impl ColumnEncryption { if data.len() < header_len + 12 + 16 { return Err(EncryptionError::TooShort); } - let cipher = self - .keys - .get(&kid) - .ok_or(EncryptionError::MissingKey(kid))?; + let cipher = if header_len == 1 { + self.keys + .legacy_v1 + .as_ref() + .or_else(|| self.keys.v2.get(&0)) + } else { + self.keys.v2.get(&kid) + } + .ok_or(EncryptionError::MissingKey(kid))?; let nonce_bytes: [u8; 12] = data[header_len..header_len + 12] .try_into() .expect("validated nonce length"); @@ -425,6 +459,18 @@ mod tests { } } + #[test] + fn purpose_keyrings_only_use_the_legacy_key_for_v1() { + let old_v2 = ColumnEncryption::new([7; 32]).encrypt(b"old key", binding()); + for purpose in [EncryptionPurpose::Columns, EncryptionPurpose::Webhooks] { + let ring = ColumnEncryption::for_purpose([8; 32], [7; 32], purpose); + for format in [FORMAT_LEGACY, FORMAT_V2] { + assert!(ring.decrypt_recorded(&old_v2, binding(), format).is_err()); + } + assert!(ring.for_kid(0).is_err()); + } + } + #[test] fn recorded_v2_refuses_plaintext_legacy_and_unknown_formats() { let ring = keyring();