From 8bd381bc4eaa725206fc152e0fe58a0c74682fb8 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 06:59:54 +0000 Subject: [PATCH 1/6] feat: add resumable storage re-encryption (TC-2713) --- docs/root-reencryption.md | 74 ++ tinycloud-core/src/db.rs | 3 + tinycloud-core/src/encryption.rs | 91 +- .../src/encryption_network/backend.rs | 26 +- .../src/encryption_network/tests.rs | 2 +- tinycloud-core/src/lib.rs | 2 + .../m20261110_000000_storage_rekey.rs | 31 + tinycloud-core/src/migrations/mod.rs | 3 + tinycloud-core/src/reencrypt.rs | 798 ++++++++++++++++++ tinycloud-core/src/reencrypt_tests.rs | 214 +++++ 10 files changed, 1220 insertions(+), 24 deletions(-) create mode 100644 docs/root-reencryption.md create mode 100644 tinycloud-core/src/migrations/m20261110_000000_storage_rekey.rs create mode 100644 tinycloud-core/src/reencrypt.rs create mode 100644 tinycloud-core/src/reencrypt_tests.rs diff --git a/docs/root-reencryption.md b/docs/root-reencryption.md new file mode 100644 index 00000000..ed1faa65 --- /dev/null +++ b/docs/root-reencryption.md @@ -0,0 +1,74 @@ +# Resumable storage re-encryption + +The node storage migration API in `tinycloud-core::reencrypt` moves existing +storage ciphertext to generation 2. It has no production root provider, CLI +activation flag, or change to the current ROOT acquisition path. The injectable +`NewRootSource` supplies independent new material; `Rings::from_source` retains +the existing generation 1 ROOT and the exact legacy column and seal keys. +Existing serving configuration continues selecting its existing keys. + +## Storage behavior + +Column and webhook keys retain #535's HKDF salt and purpose labels. A new +network-seal purpose separates the next seal key from both. Readers retain +legacy v1 keys, generation 1 purpose keys, and generation 0 seals. New writes +through the injected rings use generation 2. Client wrap-v1 and wrap-v2 envelopes +are unchanged: the job opens and re-seals server private keys and seeds, without +changing the underlying X25519 keys or any client ciphertext. + +The inventory covers delegation and invocation serialization (including durable +read-audit invocations), hook secrets, both owner-share-policy byte fields, network +private keys, and named-key seeds. CID-alias rows hold hashes and an encryption +marker, not ciphertext. Delegation hashes and canonical aliases remain stable; +the job updates the alias encryption marker when converting plaintext storage. + +Column rewrites retain the exact table, column and ordered PK binding from #535. +The network backend opens sealed bytes through stable purpose bindings with an +empty PK. Network public keys and available named-key public keys provide +identity checks during migration. Every migrated value is authenticated and +checked for byte-for-byte plaintext equality after it is written back. + +## Activation and recovery + +Before an integration calls `activate`, every serving instance must hold the +injected read rings and select the new writer. Activation installs SQLite or +PostgreSQL triggers and advances a durable singleton in one transaction. These +triggers reject stale encrypted inserts and changes, including writes behind the +cursor. Stale or keyless column writers also fail database construction after +activation. The migration does not change any root configuration itself. + +Each `step` locks the singleton, processes at most the requested page size, +authenticates each value with its recorded kid and format, checks available +content identities, and compares-and-swaps ciphertext and format. It reads the +stored replacement back and verifies exact bytes, decryption and identity before +committing ciphertext, alias markers, cursor and counts together. An error stops +the page; dropping an interrupted transaction rolls it back. A committed page is +resumed by calling `step` again with the same rings. Multiple workers serialize +on the singleton. The final census requires no legacy, plaintext or other-kid +values before setting completion. Repeated calls after completion are idempotent. + +`census` returns only table/column counts by kid plus legacy, plaintext and null +counts. `progress` returns only rewritten count and completion. Errors disclose +no row identities, key material, plaintext or ciphertext. The database cursor +contains PKs and must be treated as database data, never logged. Database tracing +must not be configured to emit bound values during an operational migration. +The fencing migration refuses downgrade after activation; keep old roots until +verification and a separately approved retirement plan are complete. + +## Identity and invitation rollout plan + +Identity and invitation keys require a separate protocol rollout. No rotation +code for them is included here. First inventory client pins, cached node DIDs, +invitation verification kids, trust bundles and outstanding invitation expiry. +Publish the new node DID and invitation kid alongside the old entries before +changing either signing key. Distribute overlapping trust bundles, allow clients +to refresh caches, and verify that both identities are accepted across supported +clients and existing sessions. Keep issuance on the old key during publication. + +Switch new signing and invitation issuance only after overlap adoption is +verified. Continue validating old signatures and outstanding invitations through +their supported lifetime; retain old public verification entries and explicit +kid selection throughout the overlap. Remove old signing access and later retire +old public entries only after client adoption, invitation expiry and rollback +requirements are satisfied. Storage re-encryption alone does not update clients, +rewrite signed delegation history or authorize identity retirement. diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 032ffd2c..5ce18e89 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -604,6 +604,9 @@ where tx.execute_unprepared(crate::db_pool::EXEMPT_STATEMENT_TIMEOUT) .await?; } + crate::reencrypt::check_writer(&tx, encryption.as_ref()) + .await + .map_err(|_| DbErr::Custom("storage generation fence".into()))?; delegation_cid_alias::backfill(&tx, encryption.as_ref()).await?; tx.commit().await?; let writer_lock = (pool.get_database_backend() == sea_orm::DatabaseBackend::Sqlite) diff --git a/tinycloud-core/src/encryption.rs b/tinycloud-core/src/encryption.rs index f9b70531..ab261bbc 100644 --- a/tinycloud-core/src/encryption.rs +++ b/tinycloud-core/src/encryption.rs @@ -25,6 +25,7 @@ pub const FORMAT_V2: i16 = 2; pub enum EncryptionPurpose { Columns, Webhooks, + NetworkSeals, } /// Stable database identity; PK components are raw database bytes in PK order. @@ -109,29 +110,64 @@ impl ColumnEncryption { /// 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); + Self::for_roots(1, [(1, root)], legacy_key, purpose).expect("one root") + } + + /// Build purpose rings for an explicitly injected set of root generations. + /// Existing serving generation is 1; callers retain its exact root source. + pub fn for_roots( + write_kid: u8, + roots: impl IntoIterator, + legacy_key: [u8; 32], + purpose: EncryptionPurpose, + ) -> Result { let legacy_key = Zeroizing::new(legacy_key); let info: &[u8] = match purpose { EncryptionPurpose::Columns => b"tinycloud/columns", EncryptionPurpose::Webhooks => b"tinycloud/hooks/webhook-secrets", + EncryptionPurpose::NetworkSeals => b"tinycloud/encryption/network-seal", }; - 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 { - write_kid: 1, + let mut keys = BTreeMap::new(); + for (kid, root) in roots { + let root = Zeroizing::new(root); + 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"); + if keys + .insert( + kid, + Aes256Gcm::new_from_slice(derived.as_ref()).expect("32 bytes"), + ) + .is_some() + { + return Err(EncryptionError::DuplicateKey(kid)); + } + } + if matches!(purpose, EncryptionPurpose::NetworkSeals) { + // Serving seals originally use this unmodified source as generation 0. + // Keep v2 seals written before activation, as well as v1 seals, readable. + if keys + .insert( + 0, + Aes256Gcm::new_from_slice(legacy_key.as_ref()).expect("32 bytes"), + ) + .is_some() + { + return Err(EncryptionError::DuplicateKey(0)); + } + } + if !keys.contains_key(&write_kid) { + return Err(EncryptionError::MissingKey(write_kid)); + } + Ok(Self { + write_kid, 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"), - ), + v2: keys, + legacy_v1: Some(Aes256Gcm::new_from_slice(legacy_key.as_ref()).expect("32 bytes")), }), strict: false, - } + }) } /// Require authenticated envelopes even in the recorded legacy namespace. @@ -379,6 +415,31 @@ mod tests { .unwrap() } + #[test] + fn second_root_keeps_purpose_and_legacy_reads() { + for purpose in [EncryptionPurpose::Columns, EncryptionPurpose::Webhooks] { + let old = ColumnEncryption::for_purpose([8; 32], [7; 32], purpose); + let new = + ColumnEncryption::for_roots(2, [(1, [8; 32]), (2, [9; 32])], [7; 32], purpose) + .unwrap(); + let bytes = old.encrypt(b"stored", binding()); + assert_eq!( + new.decrypt_recorded(&bytes, binding(), FORMAT_V2).unwrap(), + b"stored" + ); + let legacy = ColumnEncryption::new([7; 32]).encrypt_legacy(b"legacy"); + assert_eq!( + new.decrypt_recorded(&legacy, binding(), FORMAT_LEGACY) + .unwrap(), + b"legacy" + ); + assert_eq!(&new.encrypt(b"new", binding())[..2], &[2, 2]); + assert!(old + .decrypt(&new.encrypt(b"new", binding()), binding()) + .is_err()); + } + } + fn binding() -> RowBinding<'static> { RowBinding::new("delegation", "serialization", &[b"ab", b"c"]) } diff --git a/tinycloud-core/src/encryption_network/backend.rs b/tinycloud-core/src/encryption_network/backend.rs index c8813965..6c1c2cdc 100644 --- a/tinycloud-core/src/encryption_network/backend.rs +++ b/tinycloud-core/src/encryption_network/backend.rs @@ -519,6 +519,13 @@ fn named_key_secret( /// Local one-of-one backend. The DB encryption key is reused to seal the /// network private key at rest — the same protection used elsewhere for /// sensitive DB columns. +// Seals predate row-aware backend APIs. These stable purpose bindings allow +// sealed values to be opened by every existing backend call without row IDs. +pub const NETWORK_SEAL_BINDING: crate::encryption::RowBinding<'static> = + crate::encryption::RowBinding::new("encryption_network", "sealed_private_key", &[]); +pub const SEED_SEAL_BINDING: crate::encryption::RowBinding<'static> = + crate::encryption::RowBinding::new("encryption_network_key", "sealed_seed", &[]); + pub struct LocalOneOfOneBackend { seal: ColumnEncryption, } @@ -538,7 +545,10 @@ impl KeyBackend for LocalOneOfOneBackend { &self, sealed_private_key: &[u8], ) -> Result { - let opened = Zeroizing::new(self.seal.decrypt_legacy(sealed_private_key)?); + let opened = Zeroizing::new( + self.seal + .decrypt(sealed_private_key, NETWORK_SEAL_BINDING)?, + ); if opened.len() != 32 { return Err(KeyBackendError::InvalidSealedKey); } @@ -552,7 +562,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_legacy(secret.as_bytes()); + let sealed = self.seal.encrypt(secret.as_bytes(), NETWORK_SEAL_BINDING); Ok(GeneratedKey { public_key: public.as_bytes().to_vec(), sealed_private_key: sealed, @@ -597,12 +607,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_legacy(bytes.as_slice()); + let sealed = self.seal.encrypt(bytes.as_slice(), SEED_SEAL_BINDING); Ok((KeySeed::from_bytes(*bytes), sealed)) } fn open_seed(&self, sealed_seed: &[u8]) -> Result { - let opened = Zeroizing::new(self.seal.decrypt_legacy(sealed_seed)?); + let opened = Zeroizing::new(self.seal.decrypt(sealed_seed, SEED_SEAL_BINDING)?); let bytes: [u8; 32] = opened .as_slice() .try_into() @@ -670,12 +680,12 @@ mod tests { let backend = LocalOneOfOneBackend::new(seal_key()); assert!(backend.open_network_secret(&[7u8; 32]).is_err()); let generated = backend.generate().unwrap(); - assert_eq!(generated.sealed_private_key[0], 0x01); + assert_eq!(generated.sealed_private_key[0], 0x02); assert!(backend .open_network_secret(&generated.sealed_private_key) .is_ok()); let (_, seed) = backend.generate_seed().unwrap(); - assert_eq!(seed[0], 0x01); + assert_eq!(seed[0], 0x02); assert!(backend.open_seed(&seed).is_ok()); } @@ -1010,11 +1020,11 @@ mod tests { } #[test] - fn network_key_opening_requires_a_legacy_seal() { + fn network_key_opening_requires_an_authenticated_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_eq!(generated.sealed_private_key[0], 2); assert!(backend .open_network_secret(&generated.sealed_private_key) .is_ok()); diff --git a/tinycloud-core/src/encryption_network/tests.rs b/tinycloud-core/src/encryption_network/tests.rs index 95578a48..0491ccfc 100644 --- a/tinycloud-core/src/encryption_network/tests.rs +++ b/tinycloud-core/src/encryption_network/tests.rs @@ -1736,7 +1736,7 @@ async fn app_key_is_deletable_seeded_and_differs_from_the_deterministic_derivati let row = key_row(&db, "apps/foo/x").await; let sealed = row.sealed_seed.expect("a deletable key stores a seed"); - assert_eq!(sealed[0], 0x01, "sealed with the column key"); + assert_eq!(sealed[0], 0x02, "sealed with the storage keyring"); let backend = LocalOneOfOneBackend::new(ColumnEncryption::new([0xABu8; 32])); assert!(backend.open_seed(&sealed).is_ok()); diff --git a/tinycloud-core/src/lib.rs b/tinycloud-core/src/lib.rs index 7f006a15..a0cff0ea 100644 --- a/tinycloud-core/src/lib.rs +++ b/tinycloud-core/src/lib.rs @@ -53,3 +53,5 @@ pub use read_audit::{ReadAuditError, ReadAuditHealth, DEFAULT_READ_AUDIT_WAIT}; pub use sea_orm; pub use sea_orm_migration; pub use sql_sizes::{SizeTrackingArtifactRepository, SqlSizes}; + +pub mod reencrypt; diff --git a/tinycloud-core/src/migrations/m20261110_000000_storage_rekey.rs b/tinycloud-core/src/migrations/m20261110_000000_storage_rekey.rs new file mode 100644 index 00000000..cb0037bd --- /dev/null +++ b/tinycloud-core/src/migrations/m20261110_000000_storage_rekey.rs @@ -0,0 +1,31 @@ +use sea_orm_migration::prelude::*; +#[derive(DeriveMigrationName)] +pub struct Migration; +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager.get_connection().execute_unprepared("CREATE TABLE storage_rekey (id INTEGER PRIMARY KEY, target INTEGER NOT NULL, column_index INTEGER NOT NULL, cursor TEXT NULL, rewritten BIGINT NOT NULL, complete BOOLEAN NOT NULL)").await?; + manager + .get_connection() + .execute_unprepared("INSERT INTO storage_rekey VALUES (1,0,0,NULL,0,FALSE)") + .await?; + Ok(()) + } + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + let state = db + .query_one(sea_orm::Statement::from_string( + db.get_database_backend(), + "SELECT target FROM storage_rekey WHERE id=1", + )) + .await? + .ok_or_else(|| DbErr::Custom("missing storage fence".into()))?; + if state.try_get::("", "target")? != 0 { + return Err(DbErr::Custom( + "activated storage rekey fencing cannot be downgraded".into(), + )); + } + db.execute_unprepared("DROP TABLE storage_rekey").await?; + Ok(()) + } +} diff --git a/tinycloud-core/src/migrations/mod.rs b/tinycloud-core/src/migrations/mod.rs index a3c1c49b..b47540da 100644 --- a/tinycloud-core/src/migrations/mod.rs +++ b/tinycloud-core/src/migrations/mod.rs @@ -35,6 +35,8 @@ pub mod m20261023_010000_kv_orphan_candidates; pub mod m20261026_000000_kv_write_space_value_index; pub mod m20261109_000000_column_encryption_format; +pub mod m20261110_000000_storage_rekey; + pub struct Migrator; #[async_trait::async_trait] @@ -76,6 +78,7 @@ impl MigratorTrait for Migrator { 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), + Box::new(m20261110_000000_storage_rekey::Migration), ] } } diff --git a/tinycloud-core/src/reencrypt.rs b/tinycloud-core/src/reencrypt.rs new file mode 100644 index 00000000..b1df017b --- /dev/null +++ b/tinycloud-core/src/reencrypt.rs @@ -0,0 +1,798 @@ +//! Fenced, bounded and resumable storage re-encryption. No serving root selection. +use crate::encryption::{ColumnEncryption, RowBinding}; +use crate::encryption_network::{ + backend::{KeyBackend, KeySeed, LocalOneOfOneBackend}, + key_id::KeyLocation, + keys::canonical_key_urn, + NetworkId, +}; +use crate::hash::hash; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use sea_orm::{ConnectionTrait, DbBackend, DbErr, QueryResult, Statement, TransactionTrait, Value}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use tinycloud_auth::{ + ipld_core::cid::Cid, + multihash_codetable::{Code, MultihashDigest}, +}; +use x25519_dalek::{PublicKey, StaticSecret}; +use zeroize::Zeroizing; + +/// Future root acquisition boundary. No production implementation or config flag. +/// Implementations must return independent new material, never silently reuse ROOT. +pub trait NewRootSource { + fn new_root(&self) -> Result, Error>; +} +#[derive(Clone)] +pub struct Rings { + pub columns: ColumnEncryption, + pub webhooks: ColumnEncryption, + pub seals: ColumnEncryption, +} +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("database operation failed")] + Database, + #[error("invalid re-encryption checkpoint or options")] + Checkpoint, + #[error("incompatible storage write generation")] + Fence, + #[error("storage decryption or verification failed")] + Verify, + #[error("plaintext integrity mismatch in {table}.{column}")] + Integrity { + table: &'static str, + column: &'static str, + }, + #[error("compare-and-swap conflict")] + Conflict, +} +impl From for Error { + fn from(_: DbErr) -> Self { + Self::Database + } +} + +#[derive(Clone, Copy)] +enum Identity { + Network, + Seed, + Webhook, + Policy, + Enforcement, + Hash, +} + +#[derive(Clone, Copy)] +struct Column { + table: &'static str, + column: &'static str, + pk: &'static [&'static str], + identity: Identity, +} + +const COLUMNS: [Column; 7] = [ + Column { + table: "encryption_network", + column: "sealed_private_key", + pk: &["network_id"], + identity: Identity::Network, + }, + Column { + table: "encryption_network_key", + column: "sealed_seed", + pk: &["network_id", "key_path"], + identity: Identity::Seed, + }, + Column { + table: "hook_subscription", + column: "encrypted_secret", + pk: &["id"], + identity: Identity::Webhook, + }, + Column { + table: "owner_share_policy", + column: "policy_bytes", + pk: &["policy_cid"], + identity: Identity::Policy, + }, + Column { + table: "owner_share_policy", + column: "enforcement_delegation_bytes", + pk: &["policy_cid"], + identity: Identity::Enforcement, + }, + Column { + table: "delegation", + column: "serialization", + pk: &["id"], + identity: Identity::Hash, + }, + Column { + table: "invocation", + column: "serialization", + pk: &["id"], + identity: Identity::Hash, + }, +]; + +impl Column { + fn format(self) -> Option<&'static str> { + match self.column { + "serialization" => Some("serialization_format"), + "encrypted_secret" => Some("encrypted_secret_format"), + "policy_bytes" => Some("policy_bytes_format"), + "enforcement_delegation_bytes" => Some("enforcement_delegation_bytes_format"), + _ => None, + } + } + fn ring(self, rings: &Rings) -> &ColumnEncryption { + match self.identity { + Identity::Network | Identity::Seed => &rings.seals, + Identity::Webhook => &rings.webhooks, + _ => &rings.columns, + } + } + fn binding<'a>(&self, pk: &'a [&'a [u8]]) -> RowBinding<'a> { + match self.identity { + Identity::Network => crate::encryption_network::backend::NETWORK_SEAL_BINDING, + Identity::Seed => crate::encryption_network::backend::SEED_SEAL_BINDING, + _ => RowBinding::new(self.table, self.column, pk), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +enum Key { + Text(String), + Pair(String, String), + Bytes(Vec), +} + +impl Key { + fn encoded(&self) -> Result { + serde_json::to_string(self).map_err(|_| Error::Checkpoint) + } + + fn values(&self) -> Vec { + match self { + Self::Text(value) => vec![value.clone().into()], + Self::Pair(first, second) => vec![first.clone().into(), second.clone().into()], + Self::Bytes(value) => vec![value.clone().into()], + } + } +} + +struct Row { + key: Key, + blob: Option>, + public_key: Option>, + network_blob: Option>, + network_public: Option>, + enforcement_cid: Option, + format: i16, +} + +// Only static SQL and fixed identifiers from COLUMNS reach this helper. Values +// are always bound, including bytea values and both parts of composite PKs. +fn bound(backend: DbBackend, sql: impl Into, values: Vec) -> Statement { + let sql = sql.into(); + if backend == DbBackend::Postgres { + let mut parameter = 0; + let sql = sql + .split('?') + .enumerate() + .fold(String::new(), |mut result, (index, part)| { + if index != 0 { + parameter += 1; + result.push('$'); + result.push_str(¶meter.to_string()); + } + result.push_str(part); + result + }); + Statement::from_sql_and_values(backend, sql, values) + } else { + Statement::from_sql_and_values(backend, sql, values) + } +} + +fn key_from_row(row: &QueryResult, spec: Column) -> Result { + Ok(if matches!(spec.identity, Identity::Hash) { + Key::Bytes(row.try_get("", "pk0")?) + } else if spec.pk.len() == 2 { + Key::Pair(row.try_get("", "pk0")?, row.try_get("", "pk1")?) + } else { + Key::Text(row.try_get("", "pk0")?) + }) +} + +async fn select_batch( + db: &C, + spec: Column, + after: Option<&Key>, + limit: u64, +) -> Result, Error> { + let keys = spec + .pk + .iter() + .enumerate() + .map(|(i, key)| format!("t.{key} AS pk{i}")) + .collect::>() + .join(", "); + let format = spec + .format() + .map(|f| format!(", t.{f} AS format")) + .unwrap_or_else(|| ", CAST(0 AS SMALLINT) AS format".into()); + let extra = match spec.identity { + Identity::Network => ", t.public_key AS expected_public", + Identity::Seed => ", t.public_key AS expected_public, n.sealed_private_key AS network_blob, n.public_key AS network_public", + Identity::Enforcement => ", t.enforcement_delegation_cid AS expected_cid", + _ => "", + }; + let join = if matches!(spec.identity, Identity::Seed) { + " LEFT JOIN encryption_network n ON n.network_id = t.network_id" + } else { + "" + }; + let mut values = Vec::new(); + let filter = match after { + None => String::new(), + Some(Key::Pair(first, second)) if spec.pk.len() == 2 => { + values.extend([ + first.clone().into(), + first.clone().into(), + second.clone().into(), + ]); + format!( + " WHERE (t.{} > ? OR (t.{} = ? AND t.{} > ?))", + spec.pk[0], spec.pk[0], spec.pk[1] + ) + } + Some(key) if spec.pk.len() == 1 && !matches!(key, Key::Pair(_, _)) => { + values.extend(key.values()); + format!(" WHERE t.{} > ?", spec.pk[0]) + } + _ => return Err(Error::Checkpoint), + }; + values.push((limit as i64).into()); + let order = spec + .pk + .iter() + .map(|key| format!("t.{key}")) + .collect::>() + .join(", "); + let sql = format!( + "SELECT {keys}, t.{} AS blob{extra}{format} FROM {} t{join}{filter} ORDER BY {order} LIMIT ?", + spec.column, spec.table + ); + db.query_all(bound(db.get_database_backend(), sql, values)) + .await? + .iter() + .map(|row| { + Ok(Row { + format: row.try_get("", "format")?, + key: key_from_row(row, spec)?, + blob: row.try_get("", "blob")?, + public_key: if matches!(spec.identity, Identity::Network | Identity::Seed) { + Some(row.try_get("", "expected_public")?) + } else { + None + }, + network_blob: if matches!(spec.identity, Identity::Seed) { + row.try_get("", "network_blob")? + } else { + None + }, + network_public: if matches!(spec.identity, Identity::Seed) { + row.try_get("", "network_public")? + } else { + None + }, + enforcement_cid: if matches!(spec.identity, Identity::Enforcement) { + Some(row.try_get("", "expected_cid")?) + } else { + None + }, + }) + }) + .collect() +} + +fn raw_sha256_cid(bytes: &[u8]) -> String { + Cid::new_v1(0x55, Code::Sha2_256.digest(bytes)).to_string() +} + +fn matches_raw_cid(expected: &str, bytes: &[u8]) -> bool { + let Ok(expected) = expected.parse::() else { + return false; + }; + if expected.codec() != 0x55 { + return false; + } + match Code::try_from(expected.hash().code()) { + Ok(code @ (Code::Sha2_256 | Code::Blake3_256)) => { + Cid::new_v1(0x55, code.digest(bytes)) == expected + } + _ => false, + } +} + +fn identity( + spec: Column, + row: &Row, + plaintext: &[u8], + seal: &ColumnEncryption, +) -> Result { + let mismatch = || Error::Integrity { + table: spec.table, + column: spec.column, + }; + match spec.identity { + Identity::Webhook => Ok(false), + Identity::Hash => { + let Key::Bytes(expected) = &row.key else { + return Err(mismatch()); + }; + let actual: Vec = hash(plaintext).into(); + if &actual != expected { + return Err(mismatch()); + } + Ok(true) + } + Identity::Policy => { + let Key::Text(expected) = &row.key else { + return Err(mismatch()); + }; + if &raw_sha256_cid(plaintext) != expected { + return Err(mismatch()); + } + Ok(true) + } + Identity::Enforcement => { + let record: serde_json::Value = + serde_json::from_slice(plaintext).map_err(|_| mismatch())?; + let expected = row.enforcement_cid.as_deref().ok_or_else(mismatch)?; + let cid = record + .get("cid") + .and_then(|value| value.as_str()) + .ok_or_else(mismatch)?; + let bytes = record + .get("dagCbor") + .and_then(|value| value.as_str()) + .ok_or_else(mismatch)?; + let bytes = URL_SAFE_NO_PAD.decode(bytes).map_err(|_| mismatch())?; + if cid != expected || !matches_raw_cid(expected, &bytes) { + return Err(mismatch()); + } + // The stored JSON has additional uncommitted fields. The CID checks + // the signed delegation bytes, not provenance of the entire record. + Ok(false) + } + Identity::Network => { + let bytes = Zeroizing::new(<[u8; 32]>::try_from(plaintext).map_err(|_| mismatch())?); + let secret = StaticSecret::from(*bytes); + if row.public_key.as_deref() != Some(PublicKey::from(&secret).as_bytes().as_slice()) { + return Err(mismatch()); + } + Ok(true) + } + Identity::Seed => { + let bytes = Zeroizing::new(<[u8; 32]>::try_from(plaintext).map_err(|_| mismatch())?); + let Some(network_blob) = &row.network_blob else { + return Ok(false); + }; + let network_bytes = Zeroizing::new( + seal.decrypt( + network_blob, + crate::encryption_network::backend::NETWORK_SEAL_BINDING, + ) + .map_err(|_| mismatch())?, + ); + let network_bytes = Zeroizing::new( + <[u8; 32]>::try_from(network_bytes.as_slice()).map_err(|_| mismatch())?, + ); + let secret = StaticSecret::from(*network_bytes); + if row.network_public.as_deref() != Some(PublicKey::from(&secret).as_bytes().as_slice()) + { + return Err(mismatch()); + } + let Key::Pair(network, path) = &row.key else { + return Err(mismatch()); + }; + let network: NetworkId = network.parse().map_err(|_| mismatch())?; + let location = KeyLocation::new(&network, path).map_err(|_| mismatch())?; + let backend = LocalOneOfOneBackend::new(seal.clone()); + let public = backend + .derive_named_public_key( + &secret, + &canonical_key_urn(&location), + Some(&KeySeed::from_bytes(*bytes)), + ) + .map_err(|_| mismatch())?; + if row.public_key.as_deref() != Some(public.as_slice()) { + return Err(mismatch()); + } + Ok(true) + } + } +} + +impl Rings { + /// Inject the next ROOT while preserving the serving ROOT and v1 keys. + pub fn from_source( + source: &impl NewRootSource, + old_root: [u8; 32], + legacy_columns: [u8; 32], + legacy_seal: [u8; 32], + ) -> Result { + use crate::encryption::EncryptionPurpose; + let old_root = Zeroizing::new(old_root); + let legacy_columns = Zeroizing::new(legacy_columns); + let legacy_seal = Zeroizing::new(legacy_seal); + let new_root = source.new_root()?; + if *new_root == *old_root { + return Err(Error::Fence); + } + let make = |legacy, purpose| { + ColumnEncryption::for_roots(2, [(1, *old_root), (2, *new_root)], legacy, purpose) + .map_err(|_| Error::Fence) + }; + Ok(Self { + columns: make(*legacy_columns, EncryptionPurpose::Columns)?, + webhooks: make(*legacy_columns, EncryptionPurpose::Webhooks)?, + seals: make(*legacy_seal, EncryptionPurpose::NetworkSeals)?, + }) + } + fn kid(&self) -> Result { + let kid = self.columns.write_kid(); + if kid != 2 || self.webhooks.write_kid() != kid || self.seals.write_kid() != kid { + return Err(Error::Fence); + } + Ok(kid) + } +} + +#[derive(Debug, Default, Serialize)] +pub struct Count { + pub table: &'static str, + pub column: &'static str, + pub legacy: u64, + pub plaintext: u64, + pub nulls: u64, + pub kids: BTreeMap, +} +/// Counts only; scans in bounded pages without logging row data. +pub async fn census(db: &C) -> Result, Error> { + let mut counts = Vec::new(); + for spec in COLUMNS { + let mut count = Count { + table: spec.table, + column: spec.column, + ..Default::default() + }; + let mut after = None; + loop { + let rows = select_batch(db, spec, after.as_ref(), 128).await?; + if rows.is_empty() { + break; + } + for row in rows { + match row.blob.as_deref() { + None => count.nulls += 1, + Some(bytes) + if bytes.first() == Some(&1) && row.format == 0 && bytes.len() >= 29 => + { + count.legacy += 1 + } + Some(bytes) if bytes.first() == Some(&2) && bytes.len() >= 30 => { + *count.kids.entry(bytes[1]).or_default() += 1 + } + Some(_) if row.format == 0 && spec.format().is_some() => count.plaintext += 1, + _ => return Err(Error::Verify), + } + after = Some(row.key); + } + } + counts.push(count); + } + Ok(counts) +} + +#[derive(Debug)] +pub struct Progress { + pub rewritten: i64, + pub complete: bool, +} +pub async fn progress(db: &C) -> Result { + let row = db + .query_one(Statement::from_string( + db.get_database_backend(), + "SELECT rewritten, complete FROM storage_rekey WHERE id=1", + )) + .await? + .ok_or(Error::Checkpoint)?; + Ok(Progress { + rewritten: row.try_get("", "rewritten")?, + complete: row.try_get("", "complete")?, + }) +} +/// Reject stale/keyless boot after activation; triggers also fence live writers. +pub async fn check_writer( + db: &C, + ring: Option<&ColumnEncryption>, +) -> Result<(), Error> { + let row = db + .query_one(Statement::from_string( + db.get_database_backend(), + "SELECT target FROM storage_rekey WHERE id=1", + )) + .await? + .ok_or(Error::Checkpoint)?; + let target: i32 = row.try_get("", "target")?; + if target != 0 && ring.map(|r| i32::from(r.write_kid())) != Some(target) { + return Err(Error::Fence); + } + Ok(()) +} + +fn filter(spec: Column) -> String { + spec.pk + .iter() + .map(|k| format!("{k}=?")) + .collect::>() + .join(" AND ") +} + +/// Atomically install a permanent database write fence and initialize progress. +/// All serving instances must already hold both roots before calling this API. +/// Stale writers fail at the database boundary, including inserts behind cursors. +pub async fn activate( + db: &C, + rings: &Rings, +) -> Result<(), Error> { + let kid = rings.kid()?; + let tx = db.begin().await?; + // A write obtains SQLite's writer reservation; PostgreSQL locks this singleton. + tx.execute_unprepared("UPDATE storage_rekey SET target=target WHERE id=1") + .await?; + let state = tx + .query_one(Statement::from_string( + tx.get_database_backend(), + "SELECT target FROM storage_rekey WHERE id=1", + )) + .await? + .ok_or(Error::Checkpoint)?; + let target: i32 = state.try_get("", "target")?; + if target == i32::from(kid) { + tx.commit().await?; + return Ok(()); + } + if target != 0 { + return Err(Error::Fence); + } + let backend = tx.get_database_backend(); + if !matches!(backend, DbBackend::Sqlite | DbBackend::Postgres) { + return Err(Error::Fence); + } + for (i, spec) in COLUMNS.iter().enumerate() { + let column = spec.column; + let table = spec.table; + let valid = if backend == DbBackend::Sqlite { + format!("length(NEW.{column}) >= 30 AND substr(NEW.{column},1,2)=X'02{kid:02x}'") + } else { + format!("octet_length(NEW.{column}) >= 30 AND substring(NEW.{column} from 1 for 2)=decode('02{kid:02x}','hex')") + }; + let valid = if let Some(fmt) = spec.format() { + format!("({valid}) AND NEW.{fmt}=2") + } else { + valid + }; + if backend == DbBackend::Sqlite { + for event in ["INSERT", "UPDATE"] { + let changed = if event == "UPDATE" { + format!( + " AND (NEW.{column} IS NOT OLD.{column}{} )", + spec.format() + .map(|f| format!(" OR NEW.{f} IS NOT OLD.{f}")) + .unwrap_or_default() + ) + } else { + String::new() + }; + tx.execute_unprepared(&format!("CREATE TRIGGER storage_rekey_{i}_{event} BEFORE {event} ON {table} WHEN NEW.{column} IS NOT NULL{changed} AND NOT ({valid}) BEGIN SELECT RAISE(ABORT,'storage generation fence'); END")).await?; + } + } else { + let changed = format!( + "NEW.{column} IS DISTINCT FROM OLD.{column}{}", + spec.format() + .map(|f| format!(" OR NEW.{f} IS DISTINCT FROM OLD.{f}")) + .unwrap_or_default() + ); + tx.execute_unprepared(&format!("CREATE FUNCTION storage_rekey_{i}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.{column} IS NOT NULL AND (TG_OP='INSERT' OR {changed}) AND NOT ({valid}) THEN RAISE EXCEPTION 'storage generation fence'; END IF; RETURN NEW; END $$")).await?; + tx.execute_unprepared(&format!("CREATE TRIGGER storage_rekey_{i} BEFORE INSERT OR UPDATE ON {table} FOR EACH ROW EXECUTE FUNCTION storage_rekey_{i}()")).await?; + } + } + tx.execute(bound( + backend, + "UPDATE storage_rekey SET target=? WHERE id=1", + vec![i32::from(kid).into()], + )) + .await?; + tx.commit().await?; + Ok(()) +} + +/// One transaction, one bounded page. Dropping this future rolls the whole page +/// back; committed cursors and counters advance with the verified ciphertext. +pub async fn step( + db: &C, + rings: &Rings, + batch: u64, +) -> Result { + step_page( + db, + rings, + batch, + #[cfg(test)] + None, + ) + .await +} + +async fn step_page( + db: &C, + rings: &Rings, + batch: u64, + #[cfg(test)] abort_after: Option, +) -> Result { + if batch == 0 || batch > 10000 { + return Err(Error::Checkpoint); + } + let kid = rings.kid()?; + let tx = db.begin().await?; + tx.execute_unprepared("UPDATE storage_rekey SET target=target WHERE id=1") + .await?; + let backend = tx.get_database_backend(); + let state = tx + .query_one(Statement::from_string( + backend, + "SELECT target, column_index, cursor, rewritten FROM storage_rekey WHERE id=1", + )) + .await? + .ok_or(Error::Checkpoint)?; + if state.try_get::("", "target")? != i32::from(kid) { + return Err(Error::Fence); + } + let index = state.try_get::("", "column_index")? as usize; + let cursor: Option = state.try_get("", "cursor")?; + let after: Option = cursor + .map(|v| serde_json::from_str(&v).map_err(|_| Error::Checkpoint)) + .transpose()?; + let rewritten: i64 = state.try_get("", "rewritten")?; + if index >= COLUMNS.len() { + // Fence prevents new old-kid rows; verify every value, even already-target + // rows, before declaring completion. Reset to a fresh pass for idempotence. + let remaining = census(&tx).await?; + if remaining + .iter() + .any(|c| c.legacy > 0 || c.plaintext > 0 || c.kids.keys().any(|k| *k != kid)) + { + return Err(Error::Verify); + } + tx.execute_unprepared("UPDATE storage_rekey SET complete=TRUE WHERE id=1") + .await?; + tx.commit().await?; + return Ok(Progress { + rewritten, + complete: true, + }); + } + let spec = COLUMNS[index]; + let rows = select_batch(&tx, spec, after.as_ref(), batch).await?; + let mut done = 0i64; + let mut last = None; + for row in &rows { + last = Some(row.key.encoded()?); + let Some(old) = &row.blob else { + continue; + }; + let keybytes: Vec> = match &row.key { + Key::Bytes(b) => vec![b.clone()], + Key::Text(s) => vec![s.as_bytes().to_vec()], + Key::Pair(a, b) => vec![a.as_bytes().to_vec(), b.as_bytes().to_vec()], + }; + let pk: Vec<&[u8]> = keybytes.iter().map(Vec::as_slice).collect(); + let binding = spec.binding(&pk); + let ring = spec.ring(rings); + let plain = Zeroizing::new( + if spec.format().is_some() { + ring.decrypt_recorded(old, binding, row.format) + } else { + ring.decrypt(old, binding) + } + .map_err(|_| Error::Verify)?, + ); + identity(spec, row, &plain, &rings.seals)?; + if old.get(..2) == Some(&[2, kid]) && (spec.format().is_none() || row.format == 2) { + continue; + } + let new = ring.encrypt(&plain, binding); + let mut values = vec![new.clone().into()]; + values.extend(row.key.values()); + values.push(old.clone().into()); + let format_set = spec + .format() + .map(|f| format!(", {f}=2")) + .unwrap_or_default(); + let format_cas = spec + .format() + .map(|f| { + values.push(row.format.into()); + format!(" AND {f}=?") + }) + .unwrap_or_default(); + let updated = tx + .execute(bound( + backend, + format!( + "UPDATE {} SET {}=?{format_set} WHERE {} AND {}=?{format_cas}", + spec.table, + spec.column, + filter(spec), + spec.column + ), + values, + )) + .await?; + if updated.rows_affected() != 1 { + return Err(Error::Conflict); + } + if spec.table == "delegation" { + tx.execute(bound( + backend, + "UPDATE delegation_cid_alias SET encrypted=TRUE WHERE id=?", + row.key.values(), + )) + .await?; + } + + let saved = tx + .query_one(bound( + backend, + format!( + "SELECT {} AS blob FROM {} WHERE {}", + spec.column, + spec.table, + filter(spec) + ), + row.key.values(), + )) + .await? + .ok_or(Error::Conflict)?; + let saved: Vec = saved.try_get("", "blob")?; + if saved != new + || Zeroizing::new(ring.decrypt(&saved, binding).map_err(|_| Error::Verify)?).as_slice() + != plain.as_slice() + { + return Err(Error::Verify); + } + identity(spec, row, &plain, &rings.seals)?; + done += 1; + #[cfg(test)] + if abort_after == Some(done) { + return Err(Error::Verify); + } + } + let next = if rows.is_empty() { index + 1 } else { index }; + tx.execute(bound(backend,"UPDATE storage_rekey SET column_index=?, cursor=?, rewritten=rewritten+?, complete=FALSE WHERE id=1",vec![(next as i32).into(),last.into(),done.into()])).await?; + tx.commit().await?; + Ok(Progress { + rewritten: rewritten + done, + complete: false, + }) +} + +#[cfg(test)] +#[path = "reencrypt_tests.rs"] +mod tests; diff --git a/tinycloud-core/src/reencrypt_tests.rs b/tinycloud-core/src/reencrypt_tests.rs new file mode 100644 index 00000000..e922e7ff --- /dev/null +++ b/tinycloud-core/src/reencrypt_tests.rs @@ -0,0 +1,214 @@ +use super::*; +use sea_orm::{Database, DatabaseConnection}; +use sea_orm_migration::MigratorTrait; + +const OWNER: &str = "did:pkh:eip155:1:0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"; +fn rings() -> Rings { + struct Source; + impl NewRootSource for Source { + fn new_root(&self) -> Result, Error> { + Ok(Zeroizing::new([10; 32])) + } + } + Rings::from_source(&Source, [8; 32], [7; 32], [9; 32]).unwrap() +} +async fn database() -> DatabaseConnection { + let db = Database::connect("sqlite::memory:").await.unwrap(); + crate::migrations::Migrator::up(&db, None).await.unwrap(); + db +} +pub(super) async fn exec(db: &impl ConnectionTrait, sql: &str, values: Vec) { + db.execute(bound(db.get_database_backend(), sql, values)) + .await + .unwrap(); +} + +async fn actors(db: &impl ConnectionTrait) { + exec(db, "INSERT INTO actor (id) VALUES ('owner'), ('node'), ('historical-owner'), ('historical-node') ON CONFLICT DO NOTHING", vec![]).await; +} + +pub(super) async fn hook(db: &impl ConnectionTrait, id: &str, bytes: Vec) { + exec(db, "INSERT INTO hook_subscription (id,subscriber_did,space_id,target_service,callback_url,encrypted_secret,secret_key_id,active,created_at) VALUES (?, 'subscriber', 'space', 'kv', 'https://invalid.test', ?, 'primary', TRUE, 'now')", vec![id.into(), bytes.into()]).await; +} + +pub(super) async fn fixtures(db: &impl ConnectionTrait) { + actors(db).await; + let Rings { seals: seal, .. } = rings(); + let backend = LocalOneOfOneBackend::new(seal.clone()); + for (name, secret_byte) in [("alpha", 31), ("beta", 32)] { + let network = NetworkId::new(OWNER, name).unwrap().to_string(); + let secret = StaticSecret::from([secret_byte; 32]); + exec(db, "INSERT INTO encryption_network (network_id,owner_did,name,alg,key_version,public_key,state,threshold_n,threshold_t,key_backend,sealed_private_key,created_at,updated_at) VALUES (?, ?, ?, 'X25519-AES256GCM', 1, ?, 'active', 1, 1, 'local-1of1', ?, 'now', 'now')", vec![network.clone().into(), OWNER.into(), name.into(), PublicKey::from(&secret).as_bytes().to_vec().into(), ColumnEncryption::new([9; 32]).encrypt_legacy(secret.as_bytes()).into()]).await; + for path in ["apps/app/a", "apps/app/z"] { + let seed = KeySeed::from_bytes([43; 32]); + let public = backend + .derive_named_public_key(&secret, &format!("{network}/{path}"), Some(&seed)) + .unwrap(); + exec(db, "INSERT INTO encryption_network_key (network_id,key_path,kind,state,public_key,sealed_seed,key_version,created_at,updated_at) VALUES (?, ?, 'deletable', 'active', ?, ?, 1, 'now', 'now')", vec![network.clone().into(), path.into(), public.into(), ColumnEncryption::new([9; 32]).encrypt_legacy(&[43; 32]).into()]).await; + } + } + hook( + db, + "hook", + ColumnEncryption::new([7; 32]).encrypt_legacy(b"webhook-secret"), + ) + .await; + let policy = br#"{"policy":"identity"}"#; + let delegation = b"synthetic enforcement dag-cbor bytes"; + let enforcement_cid = raw_sha256_cid(delegation); + let record = serde_json::to_vec( + &serde_json::json!({"cid": enforcement_cid, "dagCbor": URL_SAFE_NO_PAD.encode(delegation)}), + ) + .unwrap(); + exec(db, "INSERT INTO owner_share_policy (policy_cid,registration_cid,owner_delegation_cid,enforcement_delegation_cid,policy_bytes,enforcement_delegation_bytes,policy_proof,policy_digest,matcher_digest,content_source_digest,owner_did,share_key_did,enforcer_did,node_audience,target_origin,space_id,resource_kind,resource_path,actions,registered_at,expires_at) VALUES (?, 'registration', 'owner-delegation', ?, ?, ?, 'proof', 'digest', 'matcher', 'content', 'owner', 'share', 'node', 'node', 'https://invalid.test', 'space', 'kv', '', '[]', 'now', 'later')", vec![raw_sha256_cid(policy).into(), enforcement_cid.into(), ColumnEncryption::new([7; 32]).encrypt_legacy(policy).into(), ColumnEncryption::new([7; 32]).encrypt_legacy(&record).into()]).await; + for table in ["delegation", "invocation"] { + let plaintext = if table == "delegation" { + use tinycloud_auth::ssi::jwk::{Algorithm, JWK}; + crate::test_support::did_resolution::signed_ucan( + OWNER, + &JWK::generate_ed25519().unwrap(), + Algorithm::EdDSA, + ) + .encode() + .unwrap() + } else { + format!("synthetic {table} serialization") + }; + let id: Vec = hash(plaintext.as_bytes()).into(); + let bytes = ColumnEncryption::new([7; 32]).encrypt_legacy(plaintext.as_bytes()); + let sql = if table == "delegation" { + "INSERT INTO delegation (id,delegator,delegatee,serialization) VALUES (?, 'owner', 'node', ?)" + } else { + "INSERT INTO invocation (id,invoker,issued_at,serialization) VALUES (?, 'owner', '2026-11-06T00:00:00Z', ?)" + }; + exec(db, sql, vec![id.into(), bytes.into()]).await; + } +} + +async fn exercise(db: &DatabaseConnection) { + fixtures(db).await; + let rings = rings(); + for (id, kid) in [("old-purpose", 1), ("new-purpose", 2)] { + let bytes = rings.webhooks.for_kid(kid).unwrap().encrypt( + b"hook", + RowBinding::new("hook_subscription", "encrypted_secret", &[id.as_bytes()]), + ); + hook(db, id, bytes).await; + exec( + db, + "UPDATE hook_subscription SET encrypted_secret_format=2 WHERE id=?", + vec![id.into()], + ) + .await; + } + let before = census(db).await.unwrap(); + assert!(before.iter().any(|c| c.legacy > 0)); + activate(db, &rings).await.unwrap(); + assert!(check_writer(db, Some(&rings.columns.for_kid(1).unwrap())) + .await + .is_err()); + // Fail after a CAS, before commit: ciphertext, cursor and count all roll back. + assert!(step_page(db, &rings, 2, Some(1)).await.is_err()); + assert_eq!(progress(db).await.unwrap().rewritten, 0); + assert_eq!(census(db).await.unwrap()[0].legacy, before[0].legacy); + // A committed batch followed by process loss must resume from its cursor. + assert!(!step(db, &rings, 1).await.unwrap().complete); + let checkpoint = progress(db).await.unwrap(); + assert_eq!(checkpoint.rewritten, 1); + loop { + // The actual boot constructor validates alias backfill in each mixed state. + use crate::{db::SpaceDatabase, keys::StaticSecret, storage::memory::MemoryStore}; + let boot = SpaceDatabase::new_with_encryption( + db.clone(), + MemoryStore::default(), + StaticSecret::new(vec![31; 32]).unwrap(), + Some(rings.columns.clone()), + ) + .await; + assert!(boot.is_ok(), "mixed state boot failed: {:?}", boot.err()); + if step(db, &rings, 1).await.unwrap().complete { + break; + } + } + let after = census(db).await.unwrap(); + assert!(after + .iter() + .all(|c| c.legacy == 0 && c.kids.keys().all(|k| *k == 2))); + let done = progress(db).await.unwrap().rewritten; + assert!(step(db, &rings, 10).await.unwrap().complete); + assert_eq!(progress(db).await.unwrap().rewritten, done); + // The database fence rejects stale writers, including inserts behind cursor. + let stale = ColumnEncryption::new([7; 32]).encrypt_legacy(b"stale"); + assert!(db + .execute(bound( + db.get_database_backend(), + "UPDATE hook_subscription SET encrypted_secret=?", + vec![stale.into()] + )) + .await + .is_err()); +} +#[tokio::test] +async fn sqlite_resume_mixed_kids_and_fence() { + exercise(&database().await).await; +} +#[tokio::test] +async fn postgres_resume_mixed_kids_and_fence() { + let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_rekey").await else { + return; + }; + exercise(&f.conn).await; +} + +#[tokio::test] +async fn integrity_failure_aborts_at_first_bad_row_without_advancing() { + let db = database().await; + fixtures(&db).await; + let rings = rings(); + exec( + &db, + "UPDATE encryption_network SET public_key=?", + vec![vec![0u8; 32].into()], + ) + .await; + activate(&db, &rings).await.unwrap(); + assert!(matches!( + step(&db, &rings, 10).await, + Err(Error::Integrity { .. }) + )); + assert_eq!(progress(&db).await.unwrap().rewritten, 0); + assert_eq!(census(&db).await.unwrap()[0].legacy, 2); +} + +#[test] +fn mixed_seals_and_wrap_v2_survive_root_change() { + use crate::encryption_network::backend::*; + let old = LocalOneOfOneBackend::new(ColumnEncryption::new([9; 32])); + let new = LocalOneOfOneBackend::new(rings().seals); + let generated = old.generate().unwrap(); + assert!(new + .open_network_secret(&generated.sealed_private_key) + .is_ok()); + let (seed, bytes) = old.generate_seed().unwrap(); + assert!(new.open_seed(&bytes).is_ok()); + drop(seed); + let secret = StaticSecret::from([42; 32]); + let context = KeyWrapContext { + purpose: KeyWrapPurpose::NetworkKey, + network_id: "", + key_path: "docs", + key_version: 1, + metadata: None, + }; + let wrapped = wrap_to_public_key_with_context( + PublicKey::from(&secret).as_bytes(), + b"symmetric", + &context, + KeyWrapVersion::V2, + ) + .unwrap(); + let seal = rings() + .seals + .encrypt(secret.as_bytes(), NETWORK_SEAL_BINDING); + assert_eq!(new.unwrap(&seal, &wrapped, &context).unwrap(), b"symmetric"); +} From fa4ae8784d5fca141f26a812a23f1558633a1106 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 07:54:04 +0000 Subject: [PATCH 2/6] feat: bind storage seals and checkpoint verification (TC-2713) --- docs/root-reencryption.md | 55 +- tinycloud-core/src/db.rs | 31 +- tinycloud-core/src/encryption.rs | 7 + .../src/encryption_network/backend.rs | 202 +++++-- tinycloud-core/src/encryption_network/keys.rs | 30 +- .../src/encryption_network/service.rs | 10 +- .../src/encryption_network/tests.rs | 46 +- .../m20261110_000000_storage_rekey.rs | 6 +- tinycloud-core/src/reencrypt.rs | 393 +++++++++++-- tinycloud-core/src/reencrypt_tests.rs | 553 +++++++++++++++++- tinycloud-node-server/src/lib.rs | 11 +- 11 files changed, 1187 insertions(+), 157 deletions(-) diff --git a/docs/root-reencryption.md b/docs/root-reencryption.md index ed1faa65..46f0fa7e 100644 --- a/docs/root-reencryption.md +++ b/docs/root-reencryption.md @@ -11,8 +11,8 @@ Existing serving configuration continues selecting its existing keys. Column and webhook keys retain #535's HKDF salt and purpose labels. A new network-seal purpose separates the next seal key from both. Readers retain -legacy v1 keys, generation 1 purpose keys, and generation 0 seals. New writes -through the injected rings use generation 2. Client wrap-v1 and wrap-v2 envelopes +legacy v1 keys, generation 1 purpose keys, and generation 0 seals. Injected rings initially keep the existing writers and add generation 2 read +keys. After activation commits, the returned rings select generation 2 writers. Client wrap-v1 and wrap-v2 envelopes are unchanged: the job opens and re-seals server private keys and seeds, without changing the underlying X25519 keys or any client ciphertext. @@ -23,19 +23,32 @@ marker, not ciphertext. Delegation hashes and canonical aliases remain stable; the job updates the alias encryption marker when converting plaintext storage. Column rewrites retain the exact table, column and ordered PK binding from #535. -The network backend opens sealed bytes through stable purpose bindings with an -empty PK. Network public keys and available named-key public keys provide -identity checks during migration. Every migrated value is authenticated and -checked for byte-for-byte plaintext equality after it is written back. +Network private-key bindings include `network_id`; seed bindings include +`network_id` and `key_path` in database PK order. Serving opens and migration +verify the network private key against the row's public key. Named-key opens +also check the derived public key against the key row. Legacy v1 seals remain an +explicit authenticated compatibility path; rewritten seals use PK-bound v2. +Every migrated value is authenticated and checked for byte-for-byte plaintext +equality after it is written back. ## Activation and recovery -Before an integration calls `activate`, every serving instance must hold the -injected read rings and select the new writer. Activation installs SQLite or -PostgreSQL triggers and advances a durable singleton in one transaction. These -triggers reject stale encrypted inserts and changes, including writes behind the -cursor. Stale or keyless column writers also fail database construction after -activation. The migration does not change any root configuration itself. +The rollout order is read overlap, database fence, then writer switch. First, +install the injected rings on every serving instance with old writer kids +(columns/webhooks 1 and seals 0), retaining all required old and new read keys. +The schema's overlap guards keep kid 2 read-only before activation. +`activate` validates that overlap, installs SQLite or PostgreSQL triggers, and +advances the durable singleton in one transaction. Only after that transaction +commits does it return rings selecting writer kid 2. Switch serving writers to +those returned rings; stale writes are rejected by the database fence. + +`SpaceDatabase::new_with_storage_rings` checks the column, webhook and seal rings +against one locked activation snapshot, holding the lock through alias backfill. +Before activation, overlap rings must retain their old writers. After activation, +all three rings must select the target writer and retain the required read +generations through authenticated verification. Key retirement remains a separate +integration step. The migration does not change any production root configuration +itself. Each `step` locks the singleton, processes at most the requested page size, authenticates each value with its recorded kid and format, checks available @@ -44,11 +57,23 @@ stored replacement back and verifies exact bytes, decryption and identity before committing ciphertext, alias markers, cursor and counts together. An error stops the page; dropping an interrupted transaction rolls it back. A committed page is resumed by calling `step` again with the same rings. Multiple workers serialize -on the singleton. The final census requires no legacy, plaintext or other-kid -values before setting completion. Repeated calls after completion are idempotent. +on the singleton. + +After rewriting, a durable verification phase starts from the beginning. Each +step reads at most one bounded page, validates required seal presence and row +state, requires the target format and kid, decrypts every value, checks content +identity, and checks delegation aliases. Missing network parents or required +sealed values stop the job. NULL values are accepted only for valid lifecycle +states that intentionally hold no material. Verification cursors and counts +commit with each checked page and resume after interruption. + +Database mutation triggers invalidate the verification revision. A write during +verification restarts that bounded pass, so completion requires a complete +authenticated pass at one unchanged revision. Repeated calls after completion +are idempotent while that revision remains current. `census` returns only table/column counts by kid plus legacy, plaintext and null -counts. `progress` returns only rewritten count and completion. Errors disclose +counts. `progress` returns only rewrite/verification counts, phase and completion. Errors disclose no row identities, key material, plaintext or ciphertext. The database cursor contains PKs and must be treated as database data, never logged. Database tracing must not be configured to emit bound values during an operational migration. diff --git a/tinycloud-core/src/db.rs b/tinycloud-core/src/db.rs index 5ce18e89..06fc719d 100644 --- a/tinycloud-core/src/db.rs +++ b/tinycloud-core/src/db.rs @@ -594,6 +594,35 @@ where storage: B, secrets: K, encryption: Option, + ) -> Result { + Self::new_with_ring_options(conn, storage, secrets, encryption, None, None).await + } + + /// Boot with all three serving storage rings validated in the same transaction. + pub async fn new_with_storage_rings( + conn: C, + storage: B, + secrets: K, + rings: crate::reencrypt::Rings, + ) -> Result { + Self::new_with_ring_options( + conn, + storage, + secrets, + Some(rings.columns), + Some(rings.webhooks), + Some(rings.seals), + ) + .await + } + + async fn new_with_ring_options( + conn: C, + storage: B, + secrets: K, + encryption: Option, + webhooks: Option, + seals: Option, ) -> Result { let pool: RecoveringConnection = conn.clone().into(); // sea-orm-migration's schema manager accepts only the raw sea-orm @@ -604,7 +633,7 @@ where tx.execute_unprepared(crate::db_pool::EXEMPT_STATEMENT_TIMEOUT) .await?; } - crate::reencrypt::check_writer(&tx, encryption.as_ref()) + crate::reencrypt::check_writer(&tx, encryption.as_ref(), webhooks.as_ref(), seals.as_ref()) .await .map_err(|_| DbErr::Custom("storage generation fence".into()))?; delegation_cid_alias::backfill(&tx, encryption.as_ref()).await?; diff --git a/tinycloud-core/src/encryption.rs b/tinycloud-core/src/encryption.rs index ab261bbc..647cdce3 100644 --- a/tinycloud-core/src/encryption.rs +++ b/tinycloud-core/src/encryption.rs @@ -216,6 +216,13 @@ impl ColumnEncryption { self.keys.v2.contains_key(&kid) } + /// Internal capability check; reports generations without exposing material. + pub(crate) fn has_exact_read_generations(&self, kids: &[u8], legacy: bool) -> bool { + self.keys.v2.len() == kids.len() + && kids.iter().all(|kid| self.has_key(*kid)) + && (self.keys.legacy_v1.is_some() || self.keys.v2.contains_key(&0)) == legacy + } + /// 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) { diff --git a/tinycloud-core/src/encryption_network/backend.rs b/tinycloud-core/src/encryption_network/backend.rs index 6c1c2cdc..1f95c850 100644 --- a/tinycloud-core/src/encryption_network/backend.rs +++ b/tinycloud-core/src/encryption_network/backend.rs @@ -340,11 +340,12 @@ pub fn open_wrapped_key( pub trait KeyBackend: Send + Sync { fn kind(&self) -> super::types::KeyBackendKind; - fn generate(&self) -> Result; + fn generate(&self, network_id: &str) -> Result; fn unwrap( &self, sealed_private_key: &[u8], + expected_public_key: &[u8], wrapped_key: &[u8], context: &KeyWrapContext<'_>, ) -> Result, KeyBackendError>; @@ -362,6 +363,8 @@ pub trait KeyBackend: Send + Sync { fn open_network_secret( &self, sealed_private_key: &[u8], + network_id: &str, + expected_public_key: &[u8], ) -> Result; /// Public key of the named key at `canonical_urn` under `network_secret`. @@ -387,12 +390,21 @@ pub trait KeyBackend: Send + Sync { /// A fresh 32-byte seed from the OS CSPRNG, with its sealed form for /// storage. - fn generate_seed(&self) -> Result<(KeySeed, Vec), KeyBackendError>; + fn generate_seed( + &self, + network_id: &str, + key_path: &str, + ) -> Result<(KeySeed, Vec), KeyBackendError>; /// Opens a sealed seed. Only the strict unseal is used: input that is not /// a versioned sealed value, or that does not hold exactly 32 bytes, is /// refused. - fn open_seed(&self, sealed_seed: &[u8]) -> Result; + fn open_seed( + &self, + sealed_seed: &[u8], + network_id: &str, + key_path: &str, + ) -> Result; } fn valid_v1_ephemeral_prefix(public: &[u8]) -> bool { @@ -519,13 +531,6 @@ fn named_key_secret( /// Local one-of-one backend. The DB encryption key is reused to seal the /// network private key at rest — the same protection used elsewhere for /// sensitive DB columns. -// Seals predate row-aware backend APIs. These stable purpose bindings allow -// sealed values to be opened by every existing backend call without row IDs. -pub const NETWORK_SEAL_BINDING: crate::encryption::RowBinding<'static> = - crate::encryption::RowBinding::new("encryption_network", "sealed_private_key", &[]); -pub const SEED_SEAL_BINDING: crate::encryption::RowBinding<'static> = - crate::encryption::RowBinding::new("encryption_network_key", "sealed_seed", &[]); - pub struct LocalOneOfOneBackend { seal: ColumnEncryption, } @@ -544,11 +549,13 @@ impl KeyBackend for LocalOneOfOneBackend { fn open_network_secret( &self, sealed_private_key: &[u8], + network_id: &str, + expected_public_key: &[u8], ) -> Result { - let opened = Zeroizing::new( - self.seal - .decrypt(sealed_private_key, NETWORK_SEAL_BINDING)?, - ); + let pk = [network_id.as_bytes()]; + let binding = + crate::encryption::RowBinding::new("encryption_network", "sealed_private_key", &pk); + let opened = Zeroizing::new(self.seal.decrypt(sealed_private_key, binding)?); if opened.len() != 32 { return Err(KeyBackendError::InvalidSealedKey); } @@ -556,13 +563,20 @@ impl KeyBackend for LocalOneOfOneBackend { arr.copy_from_slice(&opened); let secret = StaticSecret::from(arr); arr.zeroize(); + if PublicKey::from(&secret).as_bytes().as_slice() != expected_public_key { + return Err(KeyBackendError::InvalidSealedKey); + } Ok(secret) } - fn generate(&self) -> Result { + fn generate(&self, network_id: &str) -> Result { let secret = StaticSecret::random_from_rng(OsRng); let public = PublicKey::from(&secret); - let sealed = self.seal.encrypt(secret.as_bytes(), NETWORK_SEAL_BINDING); + let pk = [network_id.as_bytes()]; + let sealed = self.seal.encrypt( + secret.as_bytes(), + crate::encryption::RowBinding::new("encryption_network", "sealed_private_key", &pk), + ); Ok(GeneratedKey { public_key: public.as_bytes().to_vec(), sealed_private_key: sealed, @@ -573,11 +587,12 @@ impl KeyBackend for LocalOneOfOneBackend { fn unwrap( &self, sealed_private_key: &[u8], + expected_public_key: &[u8], wrapped_key: &[u8], context: &KeyWrapContext<'_>, ) -> Result, KeyBackendError> { let secret = self - .open_network_secret(sealed_private_key) + .open_network_secret(sealed_private_key, context.network_id, expected_public_key) .map_err(|_| KeyBackendError::SealedKeyUnreadable)?; open_wrapped_key(&secret, wrapped_key, context) } @@ -604,15 +619,32 @@ impl KeyBackend for LocalOneOfOneBackend { open_wrapped_key(&secret, wrapped_key, context) } - fn generate_seed(&self) -> Result<(KeySeed, Vec), KeyBackendError> { + fn generate_seed( + &self, + network_id: &str, + key_path: &str, + ) -> 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(), SEED_SEAL_BINDING); + let pk = [network_id.as_bytes(), key_path.as_bytes()]; + let sealed = self.seal.encrypt( + bytes.as_slice(), + crate::encryption::RowBinding::new("encryption_network_key", "sealed_seed", &pk), + ); Ok((KeySeed::from_bytes(*bytes), sealed)) } - fn open_seed(&self, sealed_seed: &[u8]) -> Result { - let opened = Zeroizing::new(self.seal.decrypt(sealed_seed, SEED_SEAL_BINDING)?); + fn open_seed( + &self, + sealed_seed: &[u8], + network_id: &str, + key_path: &str, + ) -> Result { + let pk = [network_id.as_bytes(), key_path.as_bytes()]; + let opened = Zeroizing::new(self.seal.decrypt( + sealed_seed, + crate::encryption::RowBinding::new("encryption_network_key", "sealed_seed", &pk), + )?); let bytes: [u8; 32] = opened .as_slice() .try_into() @@ -634,6 +666,7 @@ impl KeyBackend for LocalOneOfOneBackend { #[cfg(test)] mod tests { use super::*; + const NETWORK: &str = "urn:tinycloud:encryption:did:key:z6Mk:default"; fn seal_key() -> ColumnEncryption { ColumnEncryption::new([7u8; 32]) @@ -642,7 +675,7 @@ mod tests { fn legacy_context() -> KeyWrapContext<'static> { KeyWrapContext { purpose: KeyWrapPurpose::NetworkKey, - network_id: "", + network_id: NETWORK, key_path: "", key_version: 1, metadata: None, @@ -678,15 +711,21 @@ mod tests { #[test] fn network_secrets_require_sealed_storage_and_new_seals_are_versioned() { let backend = LocalOneOfOneBackend::new(seal_key()); - assert!(backend.open_network_secret(&[7u8; 32]).is_err()); - let generated = backend.generate().unwrap(); + assert!(backend + .open_network_secret(&[7u8; 32], NETWORK, &[0; 32]) + .is_err()); + let generated = backend.generate(NETWORK).unwrap(); assert_eq!(generated.sealed_private_key[0], 0x02); assert!(backend - .open_network_secret(&generated.sealed_private_key) + .open_network_secret( + &generated.sealed_private_key, + NETWORK, + &generated.public_key + ) .is_ok()); - let (_, seed) = backend.generate_seed().unwrap(); + let (_, seed) = backend.generate_seed(NETWORK, "seed-path").unwrap(); assert_eq!(seed[0], 0x02); - assert!(backend.open_seed(&seed).is_ok()); + assert!(backend.open_seed(&seed, NETWORK, "seed-path").is_ok()); } #[test] @@ -1010,11 +1049,16 @@ mod tests { #[test] fn generate_and_unwrap_round_trip() { let backend = LocalOneOfOneBackend::new(seal_key()); - let gk = backend.generate().unwrap(); + let gk = backend.generate(NETWORK).unwrap(); let symmetric = [0xABu8; 32]; let wrapped = wrap_to_public_key(&gk.public_key, &symmetric).unwrap(); let recovered = backend - .unwrap(&gk.sealed_private_key, &wrapped, &legacy_context()) + .unwrap( + &gk.sealed_private_key, + &gk.public_key, + &wrapped, + &legacy_context(), + ) .unwrap(); assert_eq!(recovered, symmetric); } @@ -1022,11 +1066,17 @@ mod tests { #[test] fn network_key_opening_requires_an_authenticated_seal() { let backend = LocalOneOfOneBackend::new(seal_key()); - assert!(backend.open_network_secret(&[0x42; 32]).is_err()); - let generated = backend.generate().unwrap(); + assert!(backend + .open_network_secret(&[0x42; 32], NETWORK, &[0; 32]) + .is_err()); + let generated = backend.generate(NETWORK).unwrap(); assert_eq!(generated.sealed_private_key[0], 2); assert!(backend - .open_network_secret(&generated.sealed_private_key) + .open_network_secret( + &generated.sealed_private_key, + NETWORK, + &generated.public_key + ) .is_ok()); } @@ -1052,12 +1102,12 @@ mod tests { #[test] fn unwrap_fails_when_sealed_key_corrupted() { let backend = LocalOneOfOneBackend::new(seal_key()); - let gk = backend.generate().unwrap(); + let gk = backend.generate(NETWORK).unwrap(); let wrapped = wrap_to_public_key(&gk.public_key, &[1u8; 32]).unwrap(); let mut corrupted = gk.sealed_private_key.clone(); corrupted[5] ^= 0xFF; assert!(backend - .unwrap(&corrupted, &wrapped, &legacy_context()) + .unwrap(&corrupted, &gk.public_key, &wrapped, &legacy_context()) .is_err()); } @@ -1219,8 +1269,10 @@ mod tests { #[test] fn named_key_round_trip_and_isolation() { let backend = LocalOneOfOneBackend::new(seal_key()); - let gk = backend.generate().unwrap(); - let network = backend.open_network_secret(&gk.sealed_private_key).unwrap(); + let gk = backend.generate(NETWORK).unwrap(); + let network = backend + .open_network_secret(&gk.sealed_private_key, NETWORK, &gk.public_key) + .unwrap(); let named_pub = backend .derive_named_public_key(&network, KAT_URN_NOTES, None) .unwrap(); @@ -1235,18 +1287,27 @@ mod tests { .unwrap_named(&network, KAT_URN_A, None, &wrapped, &legacy_context()) .is_err()); assert!(backend - .unwrap(&gk.sealed_private_key, &wrapped, &legacy_context()) + .unwrap( + &gk.sealed_private_key, + &gk.public_key, + &wrapped, + &legacy_context() + ) .is_err()); } #[test] fn seeded_key_round_trip_and_isolation() { let backend = LocalOneOfOneBackend::new(seal_key()); - let gk = backend.generate().unwrap(); - let network = backend.open_network_secret(&gk.sealed_private_key).unwrap(); - let (seed, sealed_seed) = backend.generate_seed().unwrap(); - let (other_seed, _) = backend.generate_seed().unwrap(); - let opened = backend.open_seed(&sealed_seed).unwrap(); + let gk = backend.generate(NETWORK).unwrap(); + let network = backend + .open_network_secret(&gk.sealed_private_key, NETWORK, &gk.public_key) + .unwrap(); + let (seed, sealed_seed) = backend.generate_seed(NETWORK, "seed-path").unwrap(); + let (other_seed, _) = backend.generate_seed(NETWORK, "seed-path").unwrap(); + let opened = backend + .open_seed(&sealed_seed, NETWORK, "seed-path") + .unwrap(); let public = backend .derive_named_public_key(&network, KAT_URN_NOTES, Some(&seed)) @@ -1288,7 +1349,12 @@ mod tests { ) .is_err()); assert!(backend - .unwrap(&gk.sealed_private_key, &wrapped, &legacy_context()) + .unwrap( + &gk.sealed_private_key, + &gk.public_key, + &wrapped, + &legacy_context() + ) .is_err()); // The same URN with and without a seed names different keys. assert_ne!( @@ -1302,13 +1368,16 @@ mod tests { #[test] fn generated_seeds_differ_and_open_to_the_stored_bytes() { let backend = LocalOneOfOneBackend::new(seal_key()); - let (first, first_sealed) = backend.generate_seed().unwrap(); - let (second, second_sealed) = backend.generate_seed().unwrap(); + let (first, first_sealed) = backend.generate_seed(NETWORK, "seed-path").unwrap(); + let (second, second_sealed) = backend.generate_seed(NETWORK, "seed-path").unwrap(); assert_ne!(first.as_bytes(), second.as_bytes()); assert_ne!(first_sealed, second_sealed); assert_ne!(first.as_bytes(), &[0u8; 32]); assert_eq!( - backend.open_seed(&first_sealed).unwrap().as_bytes(), + backend + .open_seed(&first_sealed, NETWORK, "seed-path") + .unwrap() + .as_bytes(), first.as_bytes() ); } @@ -1326,7 +1395,7 @@ mod tests { vec![0x01; 10], ] { assert!( - backend.open_seed(&refused).is_err(), + backend.open_seed(&refused, NETWORK, "seed-path").is_err(), "opened {} bytes", refused.len() ); @@ -1334,14 +1403,14 @@ mod tests { // A sealed value that is not 32 bytes is refused. let short = ColumnEncryption::new([7u8; 32]).encrypt_legacy(&[1u8; 16]); assert!(matches!( - backend.open_seed(&short), + backend.open_seed(&short, NETWORK, "seed-path"), Err(KeyBackendError::InvalidSealedKey) )); // A seed sealed under another key does not open. let (_, other) = LocalOneOfOneBackend::new(ColumnEncryption::new([8u8; 32])) - .generate_seed() + .generate_seed(NETWORK, "seed-path") .unwrap(); - assert!(backend.open_seed(&other).is_err()); + assert!(backend.open_seed(&other, NETWORK, "seed-path").is_err()); } #[test] @@ -1424,3 +1493,36 @@ mod tests { )); } } + +#[cfg(test)] +mod seal_binding_regression_tests { + use super::*; + #[test] + fn legacy_seals_require_expected_public_and_v2_never_falls_back_to_empty_pk() { + let ring = ColumnEncryption::new([7; 32]); + let backend = LocalOneOfOneBackend::new(ring.clone()); + let secret = StaticSecret::from([42; 32]); + let public = PublicKey::from(&secret); + let legacy = ring.encrypt_legacy(secret.as_bytes()); + assert!(backend + .open_network_secret(&legacy, "network-a", public.as_bytes()) + .is_ok()); + assert!(backend + .open_network_secret(&legacy, "network-a", &[0; 32]) + .is_err()); + let empty = ring.encrypt( + secret.as_bytes(), + crate::encryption::RowBinding::new("encryption_network", "sealed_private_key", &[]), + ); + assert!(backend + .open_network_secret(&empty, "network-a", public.as_bytes()) + .is_err()); + let empty_seed = ring.encrypt( + &[43; 32], + crate::encryption::RowBinding::new("encryption_network_key", "sealed_seed", &[]), + ); + assert!(backend + .open_seed(&empty_seed, "network-a", "path-a") + .is_err()); + } +} diff --git a/tinycloud-core/src/encryption_network/keys.rs b/tinycloud-core/src/encryption_network/keys.rs index 82844535..de75c16b 100644 --- a/tinycloud-core/src/encryption_network/keys.rs +++ b/tinycloud-core/src/encryption_network/keys.rs @@ -309,7 +309,8 @@ impl EncryptionService { self.check_key_caps(tx, network, loc).await?; - let (network_secret, seed, sealed_seed) = self.open_secret_and_new_seed(&net, kind)?; + let (network_secret, seed, sealed_seed) = + self.open_secret_and_new_seed(&net, kind, loc.path())?; let public = self.backend.derive_named_public_key( &network_secret, &canonical_key_urn(loc), @@ -343,16 +344,19 @@ impl EncryptionService { &self, net: &encryption_network::Model, kind: KeyKind, + key_path: &str, ) -> Result { let sealed = net .sealed_private_key .as_deref() .ok_or(KeyBackendError::SealedKeyMissing)?; - let network_secret = self.backend.open_network_secret(sealed)?; + let network_secret = + self.backend + .open_network_secret(sealed, &net.network_id, &net.public_key)?; match kind { KeyKind::Deterministic => Ok((network_secret, None, None)), KeyKind::Deletable => { - let (seed, sealed_seed) = self.backend.generate_seed()?; + let (seed, sealed_seed) = self.backend.generate_seed(&net.network_id, key_path)?; Ok((network_secret, Some(seed), Some(sealed_seed))) } } @@ -401,7 +405,7 @@ impl EncryptionService { row: encryption_network_key::Model, ) -> Result { let (network_secret, seed, sealed_seed) = - self.open_secret_and_new_seed(net, KeyKind::Deletable)?; + self.open_secret_and_new_seed(net, KeyKind::Deletable, loc.path())?; let public = self.backend.derive_named_public_key( &network_secret, &canonical_key_urn(loc), @@ -551,7 +555,9 @@ impl EncryptionService { .sealed_private_key .as_deref() .ok_or(KeyBackendError::SealedKeyMissing)?; - let network_secret = self.backend.open_network_secret(sealed)?; + let network_secret = + self.backend + .open_network_secret(sealed, &net.network_id, &net.public_key)?; let public = self.backend .derive_named_public_key(&network_secret, &canonical_key_urn(loc), None)?; @@ -595,7 +601,9 @@ impl EncryptionService { .sealed_private_key .as_deref() .ok_or(KeyBackendError::SealedKeyMissing)?; - let network_secret = self.backend.open_network_secret(sealed)?; + let network_secret = + self.backend + .open_network_secret(sealed, &net.network_id, &net.public_key)?; let seed = match kind { KeyKind::Deterministic => None, KeyKind::Deletable => { @@ -605,11 +613,19 @@ impl EncryptionService { .ok_or(EncryptionServiceError::CorruptKeyRow)?; Some( self.backend - .open_seed(sealed_seed) + .open_seed(sealed_seed, &row.network_id, &row.key_path) .map_err(|_| EncryptionServiceError::CorruptKeyRow)?, ) } }; + let derived = self.backend.derive_named_public_key( + &network_secret, + &canonical_key_urn(loc), + seed.as_ref(), + )?; + if derived != row.public_key { + return Err(EncryptionServiceError::CorruptKeyRow); + } let network_urn = network.to_string(); let context = KeyWrapContext { purpose: KeyWrapPurpose::NetworkKey, diff --git a/tinycloud-core/src/encryption_network/service.rs b/tinycloud-core/src/encryption_network/service.rs index bdc03e46..e9973f3b 100644 --- a/tinycloud-core/src/encryption_network/service.rs +++ b/tinycloud-core/src/encryption_network/service.rs @@ -432,7 +432,7 @@ impl EncryptionService { .insert(&self.db) .await?; - let generated = self.backend.generate()?; + let generated = self.backend.generate(&network_id.to_string())?; let transcript = hash_hex(&generated.public_key); let model = encryption_network::ActiveModel { @@ -934,6 +934,7 @@ impl EncryptionService { }; let symmetric = Zeroizing::new(self.backend.unwrap( sealed_private_key, + &network_row.public_key, &wrapped_key_bytes, &context, )?); @@ -1155,7 +1156,12 @@ impl EncryptionService { ))?; let symmetric = self .backend - .unwrap(sealed_private_key, &wrapped_key_bytes, &context) + .unwrap( + sealed_private_key, + &descriptor.public_encryption_key, + &wrapped_key_bytes, + &context, + ) .map_err(|err| { if matches!( err, diff --git a/tinycloud-core/src/encryption_network/tests.rs b/tinycloud-core/src/encryption_network/tests.rs index 0491ccfc..b01c19b5 100644 --- a/tinycloud-core/src/encryption_network/tests.rs +++ b/tinycloud-core/src/encryption_network/tests.rs @@ -1738,7 +1738,9 @@ async fn app_key_is_deletable_seeded_and_differs_from_the_deterministic_derivati let sealed = row.sealed_seed.expect("a deletable key stores a seed"); assert_eq!(sealed[0], 0x02, "sealed with the storage keyring"); let backend = LocalOneOfOneBackend::new(ColumnEncryption::new([0xABu8; 32])); - assert!(backend.open_seed(&sealed).is_ok()); + assert!(backend + .open_seed(&sealed, &network_id().to_string(), "apps/foo/x") + .is_ok()); // The same URN derived without the seed is a different key. let network_row = @@ -1748,7 +1750,11 @@ async fn app_key_is_deletable_seeded_and_differs_from_the_deterministic_derivati .unwrap() .unwrap(); let network_secret = backend - .open_network_secret(network_row.sealed_private_key.as_deref().unwrap()) + .open_network_secret( + network_row.sealed_private_key.as_deref().unwrap(), + &network_row.network_id, + &network_row.public_key, + ) .unwrap(); let unseeded = backend .derive_named_public_key(&network_secret, &canonical_key_urn(&loc), None) @@ -2710,3 +2716,39 @@ async fn native_decrypt_checks_wrap_context_and_outer_algorithm() { } } } + +#[tokio::test] +async fn serving_derivation_refuses_mismatched_network_public_key() { + let (db, svc) = service_with_network().await; + use crate::models::encryption_network; + let mut row: encryption_network::ActiveModel = + encryption_network::Entity::find_by_id(network_id().to_string()) + .one(&db) + .await + .unwrap() + .unwrap() + .into(); + row.public_key = Set(vec![0u8; 32]); + row.update(&db).await.unwrap(); + let loc = KeyLocation::new(&network_id(), "docs/row-binding").unwrap(); + assert!(svc.public_key(&loc).await.is_err()); + assert!(svc.create_key(&loc, false).await.is_err()); +} + +#[tokio::test] +async fn serving_named_unwrap_refuses_seed_transplant() { + let (db, svc) = service_with_network().await; + let first = KeyLocation::new(&network_id(), "apps/app/first").unwrap(); + let second = KeyLocation::new(&network_id(), "apps/app/second").unwrap(); + let created = svc.create_key(&first, true).await.unwrap(); + svc.create_key(&second, true).await.unwrap(); + let seed = key_row(&db, first.path()).await.sealed_seed; + let mut target: encryption_network_key::ActiveModel = key_row(&db, second.path()).await.into(); + target.sealed_seed = Set(seed); + target.update(&db).await.unwrap(); + let wrapped = wrap_to_public_key(&created.public_encryption_key, &[42; 32]).unwrap(); + assert!(matches!( + svc.unwrap_named_key(&second, &wrapped, 1).await, + Err(EncryptionServiceError::CorruptKeyRow) + )); +} diff --git a/tinycloud-core/src/migrations/m20261110_000000_storage_rekey.rs b/tinycloud-core/src/migrations/m20261110_000000_storage_rekey.rs index cb0037bd..5a9b6cb1 100644 --- a/tinycloud-core/src/migrations/m20261110_000000_storage_rekey.rs +++ b/tinycloud-core/src/migrations/m20261110_000000_storage_rekey.rs @@ -4,11 +4,12 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager.get_connection().execute_unprepared("CREATE TABLE storage_rekey (id INTEGER PRIMARY KEY, target INTEGER NOT NULL, column_index INTEGER NOT NULL, cursor TEXT NULL, rewritten BIGINT NOT NULL, complete BOOLEAN NOT NULL)").await?; + manager.get_connection().execute_unprepared("CREATE TABLE storage_rekey (id INTEGER PRIMARY KEY, target INTEGER NOT NULL, column_index INTEGER NOT NULL, cursor TEXT NULL, rewritten BIGINT NOT NULL, complete BOOLEAN NOT NULL, phase INTEGER NOT NULL DEFAULT 0, verified BIGINT NOT NULL DEFAULT 0, revision BIGINT NOT NULL DEFAULT 0, verify_revision BIGINT NOT NULL DEFAULT 0)").await?; manager .get_connection() - .execute_unprepared("INSERT INTO storage_rekey VALUES (1,0,0,NULL,0,FALSE)") + .execute_unprepared("INSERT INTO storage_rekey (id,target,column_index,cursor,rewritten,complete) VALUES (1,0,0,NULL,0,FALSE)") .await?; + crate::reencrypt::pre_activation_guards(manager.get_connection(), true).await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { @@ -25,6 +26,7 @@ impl MigrationTrait for Migration { "activated storage rekey fencing cannot be downgraded".into(), )); } + crate::reencrypt::pre_activation_guards(db, false).await?; db.execute_unprepared("DROP TABLE storage_rekey").await?; Ok(()) } diff --git a/tinycloud-core/src/reencrypt.rs b/tinycloud-core/src/reencrypt.rs index b1df017b..b575cded 100644 --- a/tinycloud-core/src/reencrypt.rs +++ b/tinycloud-core/src/reencrypt.rs @@ -134,11 +134,7 @@ impl Column { } } fn binding<'a>(&self, pk: &'a [&'a [u8]]) -> RowBinding<'a> { - match self.identity { - Identity::Network => crate::encryption_network::backend::NETWORK_SEAL_BINDING, - Identity::Seed => crate::encryption_network::backend::SEED_SEAL_BINDING, - _ => RowBinding::new(self.table, self.column, pk), - } + RowBinding::new(self.table, self.column, pk) } } @@ -171,6 +167,10 @@ struct Row { network_public: Option>, enforcement_cid: Option, format: i16, + state: Option, + kind: Option, + backend: Option, + key_version: Option, } // Only static SQL and fixed identifiers from COLUMNS reach this helper. Values @@ -225,8 +225,8 @@ async fn select_batch( .map(|f| format!(", t.{f} AS format")) .unwrap_or_else(|| ", CAST(0 AS SMALLINT) AS format".into()); let extra = match spec.identity { - Identity::Network => ", t.public_key AS expected_public", - Identity::Seed => ", t.public_key AS expected_public, n.sealed_private_key AS network_blob, n.public_key AS network_public", + Identity::Network => ", t.public_key AS expected_public, t.state AS state, t.key_backend AS backend, t.key_version AS key_version", + Identity::Seed => ", t.public_key AS expected_public, n.sealed_private_key AS network_blob, n.public_key AS network_public, t.state AS state, t.kind AS kind, t.key_version AS key_version", Identity::Enforcement => ", t.enforcement_delegation_cid AS expected_cid", _ => "", }; @@ -272,6 +272,27 @@ async fn select_batch( .map(|row| { Ok(Row { format: row.try_get("", "format")?, + state: if matches!(spec.identity, Identity::Network | Identity::Seed) { + Some(row.try_get("", "state")?) + } else { + None + }, + kind: if matches!(spec.identity, Identity::Seed) { + Some(row.try_get("", "kind")?) + } else { + None + }, + backend: if matches!(spec.identity, Identity::Network) { + Some(row.try_get("", "backend")?) + } else { + None + }, + key_version: if matches!(spec.identity, Identity::Network | Identity::Seed) { + Some(row.try_get("", "key_version")?) + } else { + None + }, + key: key_from_row(row, spec)?, blob: row.try_get("", "blob")?, public_key: if matches!(spec.identity, Identity::Network | Identity::Seed) { @@ -318,6 +339,81 @@ fn matches_raw_cid(expected: &str, bytes: &[u8]) -> bool { } } +// NULL is accepted only for a lifecycle state that intentionally holds no +// sealed material. Required seals, invalid metadata and orphan key rows abort. +fn validate_shape(spec: Column, row: &Row, seal: &ColumnEncryption) -> Result<(), Error> { + use crate::encryption_network::{key_id::KeyKind, types::NetworkState}; + match spec.identity { + Identity::Network => { + let Key::Text(id) = &row.key else { + return Err(Error::Verify); + }; + id.parse::().map_err(|_| Error::Verify)?; + let state = NetworkState::parse(row.state.as_deref().ok_or(Error::Verify)?) + .ok_or(Error::Verify)?; + if row.backend.as_deref() != Some("local-one-of-one") + || row.key_version.is_none_or(|v| v < 1) + { + return Err(Error::Verify); + } + if row.blob.is_none() { + if !matches!( + state, + NetworkState::Pending | NetworkState::Generating | NetworkState::Failed + ) || row.public_key.as_deref() != Some(&[]) + { + return Err(Error::Verify); + } + } else if row.public_key.as_ref().is_none_or(|p| p.len() != 32) { + return Err(Error::Verify); + } + } + Identity::Seed => { + let Key::Pair(network, path) = &row.key else { + return Err(Error::Verify); + }; + let network_id: NetworkId = network.parse().map_err(|_| Error::Verify)?; + let location = KeyLocation::new(&network_id, path).map_err(|_| Error::Verify)?; + let kind = + KeyKind::parse(row.kind.as_deref().ok_or(Error::Verify)?).ok_or(Error::Verify)?; + if kind != KeyKind::for_path(path) + || !matches!(row.state.as_deref(), Some("active" | "blocked" | "deleted")) + || row.key_version.is_none_or(|v| v < 1) + || row.public_key.as_ref().is_none_or(|p| p.len() != 32) + { + return Err(Error::Verify); + } + let backend = LocalOneOfOneBackend::new(seal.clone()); + let secret = backend + .open_network_secret( + row.network_blob.as_deref().ok_or(Error::Integrity { + table: spec.table, + column: spec.column, + })?, + network, + row.network_public.as_deref().ok_or(Error::Verify)?, + ) + .map_err(|_| Error::Verify)?; + match (kind, row.state.as_deref(), row.blob.is_some()) { + (KeyKind::Deterministic, _, false) => { + let public = backend + .derive_named_public_key(&secret, &canonical_key_urn(&location), None) + .map_err(|_| Error::Verify)?; + if Some(public.as_slice()) != row.public_key.as_deref() { + return Err(Error::Verify); + } + } + (KeyKind::Deletable, Some("deleted"), false) => {} + (KeyKind::Deletable, Some("active" | "blocked"), true) => {} + _ => return Err(Error::Verify), + } + } + _ if row.blob.is_none() => return Err(Error::Verify), + _ => {} + } + Ok(()) +} + fn identity( spec: Column, row: &Row, @@ -379,13 +475,15 @@ fn identity( } Identity::Seed => { let bytes = Zeroizing::new(<[u8; 32]>::try_from(plaintext).map_err(|_| mismatch())?); - let Some(network_blob) = &row.network_blob else { - return Ok(false); + let network_blob = row.network_blob.as_ref().ok_or_else(mismatch)?; + let Key::Pair(network, path) = &row.key else { + return Err(mismatch()); }; + let pk = [network.as_bytes()]; let network_bytes = Zeroizing::new( seal.decrypt( network_blob, - crate::encryption_network::backend::NETWORK_SEAL_BINDING, + RowBinding::new("encryption_network", "sealed_private_key", &pk), ) .map_err(|_| mismatch())?, ); @@ -397,9 +495,6 @@ fn identity( { return Err(mismatch()); } - let Key::Pair(network, path) = &row.key else { - return Err(mismatch()); - }; let network: NetworkId = network.parse().map_err(|_| mismatch())?; let location = KeyLocation::new(&network, path).map_err(|_| mismatch())?; let backend = LocalOneOfOneBackend::new(seal.clone()); @@ -435,8 +530,21 @@ impl Rings { return Err(Error::Fence); } let make = |legacy, purpose| { - ColumnEncryption::for_roots(2, [(1, *old_root), (2, *new_root)], legacy, purpose) - .map_err(|_| Error::Fence) + ColumnEncryption::for_roots( + if matches!(purpose, EncryptionPurpose::NetworkSeals) { + 0 + } else { + 1 + }, + if matches!(purpose, EncryptionPurpose::NetworkSeals) { + vec![(2, *new_root)] + } else { + vec![(1, *old_root), (2, *new_root)] + }, + legacy, + purpose, + ) + .map_err(|_| Error::Fence) }; Ok(Self { columns: make(*legacy_columns, EncryptionPurpose::Columns)?, @@ -444,6 +552,28 @@ impl Rings { seals: make(*legacy_seal, EncryptionPurpose::NetworkSeals)?, }) } + fn select_writer(&self, kid: u8) -> Result { + Ok(Self { + columns: self.columns.for_kid(kid).map_err(|_| Error::Fence)?, + webhooks: self.webhooks.for_kid(kid).map_err(|_| Error::Fence)?, + seals: self.seals.for_kid(kid).map_err(|_| Error::Fence)?, + }) + } + fn overlap_readers(&self) -> bool { + self.columns.has_exact_read_generations(&[1, 2], true) + && self.webhooks.has_exact_read_generations(&[1, 2], true) + && self.seals.has_exact_read_generations(&[0, 2], true) + } + fn overlap(&self) -> Result<(), Error> { + if self.columns.write_kid() != 1 + || self.webhooks.write_kid() != 1 + || self.seals.write_kid() != 0 + || !self.overlap_readers() + { + return Err(Error::Fence); + } + Ok(()) + } fn kid(&self) -> Result { let kid = self.columns.write_kid(); if kid != 2 || self.webhooks.write_kid() != kid || self.seals.write_kid() != kid { @@ -502,26 +632,36 @@ pub async fn census(db: &C) -> Result, Error> { #[derive(Debug)] pub struct Progress { pub rewritten: i64, + pub verified: i64, + pub verifying: bool, pub complete: bool, } pub async fn progress(db: &C) -> Result { let row = db .query_one(Statement::from_string( db.get_database_backend(), - "SELECT rewritten, complete FROM storage_rekey WHERE id=1", + "SELECT rewritten, verified, phase, complete FROM storage_rekey WHERE id=1", )) .await? .ok_or(Error::Checkpoint)?; Ok(Progress { rewritten: row.try_get("", "rewritten")?, + verified: row.try_get("", "verified")?, + verifying: row.try_get::("", "phase")? == 1, complete: row.try_get("", "complete")?, }) } -/// Reject stale/keyless boot after activation; triggers also fence live writers. + +/// Validate all serving rings against one locked database generation snapshot. +/// The caller holds its boot write transaction through alias validation. pub async fn check_writer( db: &C, - ring: Option<&ColumnEncryption>, + columns: Option<&ColumnEncryption>, + webhooks: Option<&ColumnEncryption>, + seals: Option<&ColumnEncryption>, ) -> Result<(), Error> { + db.execute_unprepared("UPDATE storage_rekey SET target=target WHERE id=1") + .await?; let row = db .query_one(Statement::from_string( db.get_database_backend(), @@ -530,9 +670,44 @@ pub async fn check_writer( .await? .ok_or(Error::Checkpoint)?; let target: i32 = row.try_get("", "target")?; - if target != 0 && ring.map(|r| i32::from(r.write_kid())) != Some(target) { + let options = [columns, webhooks, seals]; + if target == 0 { + if columns.is_some_and(|r| r.write_kid() > 1) + || webhooks.is_some_and(|r| r.write_kid() > 1) + || seals.is_some_and(|r| r.write_kid() != 0) + { + return Err(Error::Fence); + } + if options.into_iter().flatten().any(|r| r.has_key(2)) { + let rings = Rings { + columns: columns.ok_or(Error::Fence)?.clone(), + webhooks: webhooks.ok_or(Error::Fence)?.clone(), + seals: seals.ok_or(Error::Fence)?.clone(), + }; + rings.overlap()?; + } else if webhooks.is_some() || seals.is_some() { + // Normal serving, before any next ROOT is injected. + if columns.is_none_or(|r| !r.has_exact_read_generations(&[1], true)) + || webhooks.is_none_or(|r| !r.has_exact_read_generations(&[1], true)) + || seals.is_none_or(|r| !r.has_exact_read_generations(&[0], true)) + { + return Err(Error::Fence); + } + } + } else if target == 2 { + let rings = Rings { + columns: columns.ok_or(Error::Fence)?.clone(), + webhooks: webhooks.ok_or(Error::Fence)?.clone(), + seals: seals.ok_or(Error::Fence)?.clone(), + }; + rings.kid()?; + if !rings.overlap_readers() { + return Err(Error::Fence); + } + } else { return Err(Error::Fence); } + Ok(()) } @@ -544,14 +719,50 @@ fn filter(spec: Column) -> String { .join(" AND ") } +/// Migration-owned guards keep generation 2 read-only until activation commits. +pub(crate) async fn pre_activation_guards( + db: &C, + install: bool, +) -> Result<(), DbErr> { + let backend = db.get_database_backend(); + if !matches!(backend, DbBackend::Sqlite | DbBackend::Postgres) { + return Ok(()); + } + for (i, spec) in COLUMNS.iter().enumerate() { + let column = spec.column; + let table = spec.table; + let name = format!("storage_rekey_overlap_{i}"); + if backend == DbBackend::Sqlite { + for event in ["INSERT", "UPDATE"] { + if install { + db.execute_unprepared(&format!("CREATE TRIGGER {name}_{event} BEFORE {event} ON {table} WHEN NEW.{column} IS NOT NULL AND substr(NEW.{column},1,2)=X'0202' AND (SELECT target FROM storage_rekey WHERE id=1)=0 BEGIN SELECT RAISE(ABORT,'storage generation fence'); END")).await?; + } else { + db.execute_unprepared(&format!("DROP TRIGGER {name}_{event}")) + .await?; + } + } + } else if install { + db.execute_unprepared(&format!("CREATE FUNCTION {name}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.{column} IS NOT NULL AND substring(NEW.{column} from 1 for 2)=decode('0202','hex') AND (SELECT target FROM storage_rekey WHERE id=1)=0 THEN RAISE EXCEPTION 'storage generation fence'; END IF; RETURN NEW; END $$")).await?; + db.execute_unprepared(&format!("CREATE TRIGGER {name} BEFORE INSERT OR UPDATE ON {table} FOR EACH ROW EXECUTE FUNCTION {name}()")).await?; + } else { + db.execute_unprepared(&format!("DROP TRIGGER {name} ON {table}")) + .await?; + db.execute_unprepared(&format!("DROP FUNCTION {name}()")) + .await?; + } + } + Ok(()) +} + /// Atomically install a permanent database write fence and initialize progress. -/// All serving instances must already hold both roots before calling this API. +/// All serving instances first install overlap readers with old writers. +/// Only after the fence commits does this return rings selecting the new writer. /// Stale writers fail at the database boundary, including inserts behind cursors. pub async fn activate( db: &C, rings: &Rings, -) -> Result<(), Error> { - let kid = rings.kid()?; +) -> Result { + let kid = 2u8; let tx = db.begin().await?; // A write obtains SQLite's writer reservation; PostgreSQL locks this singleton. tx.execute_unprepared("UPDATE storage_rekey SET target=target WHERE id=1") @@ -565,12 +776,16 @@ pub async fn activate( .ok_or(Error::Checkpoint)?; let target: i32 = state.try_get("", "target")?; if target == i32::from(kid) { + if !rings.overlap_readers() || (rings.overlap().is_err() && rings.kid().is_err()) { + return Err(Error::Fence); + } tx.commit().await?; - return Ok(()); + return rings.select_writer(kid); } if target != 0 { return Err(Error::Fence); } + rings.overlap()?; let backend = tx.get_database_backend(); if !matches!(backend, DbBackend::Sqlite | DbBackend::Postgres) { return Err(Error::Fence); @@ -613,6 +828,20 @@ pub async fn activate( tx.execute_unprepared(&format!("CREATE TRIGGER storage_rekey_{i} BEFORE INSERT OR UPDATE ON {table} FOR EACH ROW EXECUTE FUNCTION storage_rekey_{i}()")).await?; } } + // Any data mutation invalidates a paged verification snapshot. These updates + // use the same singleton lock as each job page and boot validation. + let mut tables = std::collections::BTreeSet::from(["delegation_cid_alias"]); + tables.extend(COLUMNS.iter().map(|s| s.table)); + for (i, table) in tables.into_iter().enumerate() { + if backend == DbBackend::Sqlite { + for event in ["INSERT", "UPDATE", "DELETE"] { + tx.execute_unprepared(&format!("CREATE TRIGGER storage_rekey_revision_{i}_{event} AFTER {event} ON {table} BEGIN UPDATE storage_rekey SET revision=revision+1, complete=FALSE WHERE id=1; END")).await?; + } + } else { + tx.execute_unprepared(&format!("CREATE FUNCTION storage_rekey_revision_{i}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN UPDATE storage_rekey SET revision=revision+1, complete=FALSE WHERE id=1; RETURN NULL; END $$")).await?; + tx.execute_unprepared(&format!("CREATE TRIGGER storage_rekey_revision_{i} AFTER INSERT OR UPDATE OR DELETE ON {table} FOR EACH ROW EXECUTE FUNCTION storage_rekey_revision_{i}()")).await?; + } + } tx.execute(bound( backend, "UPDATE storage_rekey SET target=? WHERE id=1", @@ -620,6 +849,42 @@ pub async fn activate( )) .await?; tx.commit().await?; + rings.select_writer(kid) +} + +// Alias hashes are verified alongside the signed delegation that defines them. +async fn check_alias( + db: &C, + spec: Column, + row: &Row, + plain: &[u8], + create: bool, +) -> Result<(), Error> { + if spec.table != "delegation" { + return Ok(()); + } + let canonical: Vec = crate::models::delegation_cid_alias::canonical_hash(plain) + .map_err(|_| Error::Verify)? + .into(); + let Key::Bytes(id) = &row.key else { + return Err(Error::Verify); + }; + if create { + db.execute(bound(db.get_database_backend(),"INSERT INTO delegation_cid_alias (id,canonical,encrypted) VALUES (?,?,TRUE) ON CONFLICT (id) DO NOTHING",vec![id.clone().into(),canonical.clone().into()])).await?; + } + let alias = db + .query_one(bound( + db.get_database_backend(), + "SELECT canonical,encrypted FROM delegation_cid_alias WHERE id=?", + vec![id.clone().into()], + )) + .await? + .ok_or(Error::Verify)?; + if alias.try_get::>("", "canonical")? != canonical + || (!create && !alias.try_get::("", "encrypted")?) + { + return Err(Error::Verify); + } Ok(()) } @@ -654,47 +919,52 @@ async fn step_page( tx.execute_unprepared("UPDATE storage_rekey SET target=target WHERE id=1") .await?; let backend = tx.get_database_backend(); - let state = tx - .query_one(Statement::from_string( - backend, - "SELECT target, column_index, cursor, rewritten FROM storage_rekey WHERE id=1", - )) - .await? - .ok_or(Error::Checkpoint)?; + let state=tx.query_one(Statement::from_string(backend,"SELECT target,column_index,cursor,phase,verified,revision,verify_revision FROM storage_rekey WHERE id=1")).await?.ok_or(Error::Checkpoint)?; if state.try_get::("", "target")? != i32::from(kid) { return Err(Error::Fence); } - let index = state.try_get::("", "column_index")? as usize; - let cursor: Option = state.try_get("", "cursor")?; - let after: Option = cursor - .map(|v| serde_json::from_str(&v).map_err(|_| Error::Checkpoint)) - .transpose()?; - let rewritten: i64 = state.try_get("", "rewritten")?; - if index >= COLUMNS.len() { - // Fence prevents new old-kid rows; verify every value, even already-target - // rows, before declaring completion. Reset to a fresh pass for idempotence. - let remaining = census(&tx).await?; - if remaining - .iter() - .any(|c| c.legacy > 0 || c.plaintext > 0 || c.kids.keys().any(|k| *k != kid)) - { - return Err(Error::Verify); + let mut index = state.try_get::("", "column_index")? as usize; + let mut cursor: Option = state.try_get("", "cursor")?; + let phase = state.try_get::("", "phase")?; + let verifying = phase == 1; + if !matches!(phase, 0 | 1) || index > COLUMNS.len() { + return Err(Error::Checkpoint); + } + let revision: i64 = state.try_get("", "revision")?; + let snapshot: i64 = state.try_get("", "verify_revision")?; + let mut verified = state.try_get::("", "verified")?; + if verifying && revision != snapshot { + // A serving write during verification requires a new complete pass. + index = 0; + cursor = None; + verified = 0; + tx.execute_unprepared("UPDATE storage_rekey SET column_index=0,cursor=NULL,verified=0,verify_revision=revision,complete=FALSE WHERE id=1").await?; + } + if index == COLUMNS.len() { + if verifying { + tx.execute_unprepared("UPDATE storage_rekey SET complete=TRUE WHERE id=1") + .await?; + } else { + tx.execute_unprepared("UPDATE storage_rekey SET phase=1,column_index=0,cursor=NULL,verified=0,verify_revision=revision WHERE id=1").await?; } - tx.execute_unprepared("UPDATE storage_rekey SET complete=TRUE WHERE id=1") - .await?; + let report = progress(&tx).await?; tx.commit().await?; - return Ok(Progress { - rewritten, - complete: true, - }); + return Ok(report); } + let after: Option = cursor + .map(|v| serde_json::from_str(&v).map_err(|_| Error::Checkpoint)) + .transpose()?; let spec = COLUMNS[index]; let rows = select_batch(&tx, spec, after.as_ref(), batch).await?; let mut done = 0i64; let mut last = None; for row in &rows { last = Some(row.key.encoded()?); + validate_shape(spec, row, &rings.seals)?; let Some(old) = &row.blob else { + if verifying { + verified += 1; + } continue; }; let keybytes: Vec> = match &row.key { @@ -714,6 +984,19 @@ async fn step_page( .map_err(|_| Error::Verify)?, ); identity(spec, row, &plain, &rings.seals)?; + if verifying { + if old.get(..2) != Some(&[2, kid]) || (spec.format().is_some() && row.format != 2) { + return Err(Error::Verify); + } + check_alias(&tx, spec, row, &plain, false).await?; + verified += 1; + #[cfg(test)] + if abort_after == Some(verified - state.try_get::("", "verified")?) { + return Err(Error::Verify); + } + continue; + } + check_alias(&tx, spec, row, &plain, true).await?; if old.get(..2) == Some(&[2, kid]) && (spec.format().is_none() || row.format == 2) { continue; } @@ -785,12 +1068,10 @@ async fn step_page( } } let next = if rows.is_empty() { index + 1 } else { index }; - tx.execute(bound(backend,"UPDATE storage_rekey SET column_index=?, cursor=?, rewritten=rewritten+?, complete=FALSE WHERE id=1",vec![(next as i32).into(),last.into(),done.into()])).await?; + tx.execute(bound(backend,"UPDATE storage_rekey SET column_index=?,cursor=?,rewritten=rewritten+?,verified=?,complete=FALSE WHERE id=1",vec![(next as i32).into(),last.into(),done.into(),verified.into()])).await?; + let report = progress(&tx).await?; tx.commit().await?; - Ok(Progress { - rewritten: rewritten + done, - complete: false, - }) + Ok(report) } #[cfg(test)] diff --git a/tinycloud-core/src/reencrypt_tests.rs b/tinycloud-core/src/reencrypt_tests.rs index e922e7ff..5405467b 100644 --- a/tinycloud-core/src/reencrypt_tests.rs +++ b/tinycloud-core/src/reencrypt_tests.rs @@ -28,7 +28,7 @@ async fn actors(db: &impl ConnectionTrait) { } pub(super) async fn hook(db: &impl ConnectionTrait, id: &str, bytes: Vec) { - exec(db, "INSERT INTO hook_subscription (id,subscriber_did,space_id,target_service,callback_url,encrypted_secret,secret_key_id,active,created_at) VALUES (?, 'subscriber', 'space', 'kv', 'https://invalid.test', ?, 'primary', TRUE, 'now')", vec![id.into(), bytes.into()]).await; + exec(db, "INSERT INTO hook_subscription (id,subscriber_did,space_id,target_service,callback_url,encrypted_secret,secret_key_id,active,created_at,encrypted_secret_format) VALUES (?, 'subscriber', 'space', 'kv', 'https://invalid.test', ?, 'primary', TRUE, 'now', ?)", vec![id.into(), bytes.clone().into(), (if bytes.first()==Some(&2) {2i16} else {0i16}).into()]).await; } pub(super) async fn fixtures(db: &impl ConnectionTrait) { @@ -38,7 +38,7 @@ pub(super) async fn fixtures(db: &impl ConnectionTrait) { for (name, secret_byte) in [("alpha", 31), ("beta", 32)] { let network = NetworkId::new(OWNER, name).unwrap().to_string(); let secret = StaticSecret::from([secret_byte; 32]); - exec(db, "INSERT INTO encryption_network (network_id,owner_did,name,alg,key_version,public_key,state,threshold_n,threshold_t,key_backend,sealed_private_key,created_at,updated_at) VALUES (?, ?, ?, 'X25519-AES256GCM', 1, ?, 'active', 1, 1, 'local-1of1', ?, 'now', 'now')", vec![network.clone().into(), OWNER.into(), name.into(), PublicKey::from(&secret).as_bytes().to_vec().into(), ColumnEncryption::new([9; 32]).encrypt_legacy(secret.as_bytes()).into()]).await; + exec(db, "INSERT INTO encryption_network (network_id,owner_did,name,alg,key_version,public_key,state,threshold_n,threshold_t,key_backend,sealed_private_key,created_at,updated_at) VALUES (?, ?, ?, 'X25519-AES256GCM', 1, ?, 'active', 1, 1, 'local-one-of-one', ?, 'now', 'now')", vec![network.clone().into(), OWNER.into(), name.into(), PublicKey::from(&secret).as_bytes().to_vec().into(), ColumnEncryption::new([9; 32]).encrypt_legacy(secret.as_bytes()).into()]).await; for path in ["apps/app/a", "apps/app/z"] { let seed = KeySeed::from_bytes([43; 32]); let public = backend @@ -88,7 +88,7 @@ pub(super) async fn fixtures(db: &impl ConnectionTrait) { async fn exercise(db: &DatabaseConnection) { fixtures(db).await; let rings = rings(); - for (id, kid) in [("old-purpose", 1), ("new-purpose", 2)] { + for (id, kid) in [("old-purpose", 1)] { let bytes = rings.webhooks.for_kid(kid).unwrap().encrypt( b"hook", RowBinding::new("hook_subscription", "encrypted_secret", &[id.as_bytes()]), @@ -103,10 +103,31 @@ async fn exercise(db: &DatabaseConnection) { } let before = census(db).await.unwrap(); assert!(before.iter().any(|c| c.legacy > 0)); - activate(db, &rings).await.unwrap(); - assert!(check_writer(db, Some(&rings.columns.for_kid(1).unwrap())) - .await - .is_err()); + let rings = activate(db, &rings).await.unwrap(); + let id = "new-purpose"; + hook( + db, + id, + rings.webhooks.encrypt( + b"hook", + RowBinding::new("hook_subscription", "encrypted_secret", &[id.as_bytes()]), + ), + ) + .await; + exec( + db, + "UPDATE hook_subscription SET encrypted_secret_format=2 WHERE id=?", + vec![id.into()], + ) + .await; + assert!(check_writer( + db, + Some(&rings.columns.for_kid(1).unwrap()), + Some(&rings.webhooks), + Some(&rings.seals) + ) + .await + .is_err()); // Fail after a CAS, before commit: ciphertext, cursor and count all roll back. assert!(step_page(db, &rings, 2, Some(1)).await.is_err()); assert_eq!(progress(db).await.unwrap().rewritten, 0); @@ -118,11 +139,11 @@ async fn exercise(db: &DatabaseConnection) { loop { // The actual boot constructor validates alias backfill in each mixed state. use crate::{db::SpaceDatabase, keys::StaticSecret, storage::memory::MemoryStore}; - let boot = SpaceDatabase::new_with_encryption( + let boot = SpaceDatabase::new_with_storage_rings( db.clone(), MemoryStore::default(), StaticSecret::new(vec![31; 32]).unwrap(), - Some(rings.columns.clone()), + rings.clone(), ) .await; assert!(boot.is_ok(), "mixed state boot failed: {:?}", boot.err()); @@ -171,7 +192,7 @@ async fn integrity_failure_aborts_at_first_bad_row_without_advancing() { vec![vec![0u8; 32].into()], ) .await; - activate(&db, &rings).await.unwrap(); + let rings = activate(&db, &rings).await.unwrap(); assert!(matches!( step(&db, &rings, 10).await, Err(Error::Integrity { .. }) @@ -181,16 +202,26 @@ async fn integrity_failure_aborts_at_first_bad_row_without_advancing() { } #[test] -fn mixed_seals_and_wrap_v2_survive_root_change() { +fn existing_v1_and_v2_kid0_seals_and_wrap_v2_survive_root_change() { use crate::encryption_network::backend::*; let old = LocalOneOfOneBackend::new(ColumnEncryption::new([9; 32])); let new = LocalOneOfOneBackend::new(rings().seals); - let generated = old.generate().unwrap(); + let generated = old.generate("").unwrap(); + assert_eq!(&generated.sealed_private_key[..2], &[2, 0]); + assert!(rings().seals.has_key(0)); + assert!(!rings().seals.has_key(1)); + assert!(rings().seals.has_key(2)); + let legacy_secret = StaticSecret::from([41; 32]); + let legacy = ColumnEncryption::new([9; 32]).encrypt_legacy(legacy_secret.as_bytes()); + assert!(new + .open_network_secret(&legacy, "", PublicKey::from(&legacy_secret).as_bytes()) + .is_ok()); + assert!(new - .open_network_secret(&generated.sealed_private_key) + .open_network_secret(&generated.sealed_private_key, "", &generated.public_key) .is_ok()); - let (seed, bytes) = old.generate_seed().unwrap(); - assert!(new.open_seed(&bytes).is_ok()); + let (seed, bytes) = old.generate_seed("", "docs").unwrap(); + assert!(new.open_seed(&bytes, "", "docs").is_ok()); drop(seed); let secret = StaticSecret::from([42; 32]); let context = KeyWrapContext { @@ -207,8 +238,492 @@ fn mixed_seals_and_wrap_v2_survive_root_change() { KeyWrapVersion::V2, ) .unwrap(); - let seal = rings() - .seals - .encrypt(secret.as_bytes(), NETWORK_SEAL_BINDING); - assert_eq!(new.unwrap(&seal, &wrapped, &context).unwrap(), b"symmetric"); + let seal = rings().seals.encrypt( + secret.as_bytes(), + RowBinding::new("encryption_network", "sealed_private_key", &[b""]), + ); + assert_eq!( + new.unwrap( + &seal, + PublicKey::from(&secret).as_bytes(), + &wrapped, + &context + ) + .unwrap(), + b"symmetric" + ); +} + +#[tokio::test] +async fn overlap_precedes_fence_and_writer_switch() { + let db = database().await; + let overlap = rings(); + assert_eq!(overlap.columns.write_kid(), 1); + assert_eq!(overlap.webhooks.write_kid(), 1); + assert_eq!(overlap.seals.write_kid(), 0); + let active = activate(&db, &overlap).await.unwrap(); + assert_eq!(active.columns.write_kid(), 2); + assert_eq!(active.webhooks.write_kid(), 2); + assert_eq!(active.seals.write_kid(), 2); +} + +#[tokio::test] +async fn boot_refuses_each_mixed_ring_combination() { + let db = database().await; + let overlap = rings(); + let active = activate(&db, &overlap).await.unwrap(); + for missing in 0..3 { + let mut mixed = active.clone(); + match missing { + 0 => mixed.columns = overlap.columns.clone(), + 1 => mixed.webhooks = overlap.webhooks.clone(), + _ => mixed.seals = overlap.seals.clone(), + } + use crate::{db::SpaceDatabase, keys::StaticSecret, storage::memory::MemoryStore}; + assert!(SpaceDatabase::new_with_storage_rings( + db.clone(), + MemoryStore::default(), + StaticSecret::new(vec![31; 32]).unwrap(), + mixed + ) + .await + .is_err()); + } +} + +#[test] +fn sealed_rows_reject_transplants_and_wrong_network_public_key() { + let backend = LocalOneOfOneBackend::new(rings().seals); + let key = backend.generate("network-a").unwrap(); + assert!(backend + .open_network_secret(&key.sealed_private_key, "network-a", &key.public_key) + .is_ok()); + assert!(backend + .open_network_secret(&key.sealed_private_key, "network-b", &key.public_key) + .is_err()); + assert!(backend + .open_network_secret(&key.sealed_private_key, "network-a", &[0; 32]) + .is_err()); + let (_, seed) = backend.generate_seed("network-a", "path-a").unwrap(); + assert!(backend.open_seed(&seed, "network-a", "path-a").is_ok()); + assert!(backend.open_seed(&seed, "network-b", "path-a").is_err()); + assert!(backend.open_seed(&seed, "network-a", "path-b").is_err()); +} + +async fn verification_review_cases(db: &DatabaseConnection) { + fixtures(db).await; + let rings = activate(db, &rings()).await.unwrap(); + loop { + let page = step(db, &rings, 1).await.unwrap(); + if page.verifying { + break; + } + assert!(!page.complete); + } + let first = step(db, &rings, 1).await.unwrap(); + assert!(first.verifying && !first.complete); + assert_eq!(first.verified, 1); + // Prefix, size and kid remain valid; final verification must authenticate. + let rows = select_batch(db, COLUMNS[2], None, 1).await.unwrap(); + let mut corrupt = rows[0].blob.clone().unwrap(); + *corrupt.last_mut().unwrap() ^= 1; + exec( + db, + "UPDATE hook_subscription SET encrypted_secret=?", + vec![corrupt.into()], + ) + .await; + let mut failed = false; + for _ in 0..100 { + let before = progress(db).await.unwrap(); + match step(db, &rings, 1).await { + Ok(page) => { + assert!(!page.complete); + assert!(page.verified - before.verified <= 1); + } + Err(Error::Verify) => { + failed = true; + assert_eq!(progress(db).await.unwrap().verified, before.verified); + break; + } + Err(other) => panic!("unexpected final verification failure: {other}"), + } + } + assert!(failed); + assert!(!progress(db).await.unwrap().complete); +} +#[tokio::test] +async fn sqlite_final_verification_is_bounded_durable_and_authenticated() { + verification_review_cases(&database().await).await; +} +#[tokio::test] +async fn postgres_final_verification_is_bounded_durable_and_authenticated() { + let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_rekey_verification").await + else { + return; + }; + verification_review_cases(&f.conn).await; +} + +async fn orphan_review_case(db: &DatabaseConnection) { + fixtures(db).await; + exec(db,"UPDATE encryption_network_key SET network_id=? WHERE network_id=(SELECT MIN(network_id) FROM encryption_network_key)",vec![NetworkId::new(OWNER,"missing").unwrap().to_string().into()]).await; + let rings = activate(db, &rings()).await.unwrap(); + let mut failed = false; + for _ in 0..100 { + match step(db, &rings, 1).await { + Ok(p) => assert!(!p.complete), + Err(Error::Integrity { .. }) => { + failed = true; + break; + } + Err(other) => panic!("unexpected orphan failure: {other}"), + } + } + assert!(failed); + assert!(!progress(db).await.unwrap().complete); +} +#[tokio::test] +async fn sqlite_orphan_seed_refuses_completion() { + orphan_review_case(&database().await).await; +} +#[tokio::test] +async fn postgres_orphan_seed_refuses_completion() { + let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_rekey_orphan").await + else { + return; + }; + orphan_review_case(&f.conn).await; +} + +async fn legacy_seal_review_case(db: &DatabaseConnection) { + fixtures(db).await; + let network = NetworkId::new(OWNER, "alpha").unwrap().to_string(); + let old = select_batch(db, COLUMNS[0], None, 1) + .await + .unwrap() + .remove(0) + .blob + .unwrap(); + assert!(ColumnEncryption::new([9; 32]).decrypt_legacy(&old).is_ok()); + let active = activate(db, &rings()).await.unwrap(); + while !step(db, &active, 1).await.unwrap().complete {} + let networks = select_batch(db, COLUMNS[0], None, 10).await.unwrap(); + let backend = LocalOneOfOneBackend::new(active.seals.clone()); + for row in networks { + let Key::Text(id) = row.key else { + panic!("network key type"); + }; + let sealed = row.blob.unwrap(); + assert!(ColumnEncryption::new([9; 32]) + .decrypt_legacy(&sealed) + .is_err()); + assert!(backend + .open_network_secret(&sealed, &id, row.public_key.as_deref().unwrap()) + .is_ok()); + assert!(backend + .open_network_secret( + &sealed, + "different-network", + row.public_key.as_deref().unwrap() + ) + .is_err()); + } + for row in select_batch(db, COLUMNS[1], None, 10).await.unwrap() { + let Key::Pair(id, path) = row.key else { + panic!("seed key type"); + }; + let sealed = row.blob.unwrap(); + assert!(ColumnEncryption::new([9; 32]) + .decrypt_legacy(&sealed) + .is_err()); + assert!(backend.open_seed(&sealed, &id, &path).is_ok()); + assert!(backend + .open_seed(&sealed, &network, "different-path") + .is_err()); + } +} +#[tokio::test] +async fn sqlite_genuine_v1_seals_migrate_to_pk_bound_v2() { + legacy_seal_review_case(&database().await).await; +} +#[tokio::test] +async fn postgres_genuine_v1_seals_migrate_to_pk_bound_v2() { + let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_rekey_legacy_seals").await + else { + return; + }; + legacy_seal_review_case(&f.conn).await; +} + +async fn missing_read_review_case(db: &DatabaseConnection) { + use crate::encryption::EncryptionPurpose; + let overlap = rings(); + for index in 0..3 { + let mut incomplete = overlap.clone(); + match index { + 0 => { + incomplete.columns = ColumnEncryption::for_roots( + 1, + [(1, [8; 32])], + [7; 32], + EncryptionPurpose::Columns, + ) + .unwrap() + } + 1 => { + incomplete.webhooks = ColumnEncryption::for_roots( + 1, + [(1, [8; 32])], + [7; 32], + EncryptionPurpose::Webhooks, + ) + .unwrap() + } + _ => incomplete.seals = ColumnEncryption::new([9; 32]), + } + let tx = db.begin().await.unwrap(); + assert!(check_writer( + &tx, + Some(&incomplete.columns), + Some(&incomplete.webhooks), + Some(&incomplete.seals) + ) + .await + .is_err()); + tx.rollback().await.unwrap(); + assert!(activate(db, &incomplete).await.is_err()); + } + let premature = overlap.select_writer(2).unwrap(); + let tx = db.begin().await.unwrap(); + assert!(check_writer( + &tx, + Some(&premature.columns), + Some(&premature.webhooks), + Some(&premature.seals) + ) + .await + .is_err()); + tx.rollback().await.unwrap(); + assert!(activate(db, &premature).await.is_err()); + let active = activate(db, &overlap).await.unwrap(); + for index in 0..3 { + let mut incomplete = active.clone(); + match index { + 0 => { + incomplete.columns = ColumnEncryption::for_roots( + 2, + [(2, [10; 32])], + [7; 32], + EncryptionPurpose::Columns, + ) + .unwrap() + } + 1 => { + incomplete.webhooks = ColumnEncryption::for_roots( + 2, + [(2, [10; 32])], + [7; 32], + EncryptionPurpose::Webhooks, + ) + .unwrap() + } + _ => incomplete.seals = ColumnEncryption::with_keys(2, [(2, [10; 32])]).unwrap(), + } + let tx = db.begin().await.unwrap(); + assert!(check_writer( + &tx, + Some(&incomplete.columns), + Some(&incomplete.webhooks), + Some(&incomplete.seals) + ) + .await + .is_err()); + tx.rollback().await.unwrap(); + } + for index in 0..2 { + let mut incomplete = active.clone(); + let missing_v1 = ColumnEncryption::with_keys(2, [(1, [8; 32]), (2, [10; 32])]).unwrap(); + if index == 0 { + incomplete.columns = missing_v1; + } else { + incomplete.webhooks = missing_v1; + } + let tx = db.begin().await.unwrap(); + assert!(check_writer( + &tx, + Some(&incomplete.columns), + Some(&incomplete.webhooks), + Some(&incomplete.seals) + ) + .await + .is_err()); + tx.rollback().await.unwrap(); + } +} +#[tokio::test] +async fn sqlite_boot_requires_old_and_new_read_generations() { + missing_read_review_case(&database().await).await; +} +#[tokio::test] +async fn postgres_boot_requires_old_and_new_read_generations() { + let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_rekey_read_overlap").await + else { + return; + }; + missing_read_review_case(&f.conn).await; +} + +async fn null_review_case(db: &DatabaseConnection, seed: bool) { + fixtures(db).await; + exec( + db, + if seed { + "UPDATE encryption_network_key SET sealed_seed=NULL" + } else { + "UPDATE encryption_network SET sealed_private_key=NULL" + }, + vec![], + ) + .await; + let active = activate(db, &rings()).await.unwrap(); + let mut failed = false; + for _ in 0..100 { + match step(db, &active, 1).await { + Ok(p) => assert!(!p.complete), + Err(Error::Verify | Error::Integrity { .. }) => { + failed = true; + break; + } + Err(other) => panic!("unexpected NULL failure: {other}"), + } + } + assert!(failed); +} +#[tokio::test] +async fn sqlite_final_verification_refuses_required_null_seals() { + null_review_case(&database().await, false).await; + null_review_case(&database().await, true).await; +} +#[tokio::test] +async fn postgres_final_verification_refuses_required_null_seals() { + let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_rekey_null_network").await + else { + return; + }; + null_review_case(&f.conn, false).await; + let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_rekey_null_seed").await + else { + return; + }; + null_review_case(&f.conn, true).await; +} + +async fn valid_null_review_case(db: &DatabaseConnection) { + fixtures(db).await; + exec( + db, + "UPDATE encryption_network_key SET state='deleted',sealed_seed=NULL", + vec![], + ) + .await; + let parent = NetworkId::new(OWNER, "alpha").unwrap(); + let path = "docs/deterministic"; + let backend = LocalOneOfOneBackend::new(rings().seals); + let public = backend + .derive_named_public_key( + &StaticSecret::from([31; 32]), + &canonical_key_urn(&KeyLocation::new(&parent, path).unwrap()), + None, + ) + .unwrap(); + exec(db,"INSERT INTO encryption_network_key (network_id,key_path,kind,state,public_key,sealed_seed,key_version,created_at,updated_at) VALUES (?,?,'deterministic','active',?,NULL,1,'now','now')",vec![parent.to_string().into(),path.into(),public.into()]).await; + let network = NetworkId::new(OWNER, "pending").unwrap().to_string(); + exec(db,"INSERT INTO encryption_network (network_id,owner_did,name,alg,key_version,public_key,state,threshold_n,threshold_t,key_backend,sealed_private_key,created_at,updated_at) VALUES (?,?,'pending','x25519-aes256gcm/v1',1,?,'pending',1,1,'local-one-of-one',NULL,'now','now')",vec![network.into(),OWNER.into(),Vec::::new().into()]).await; + let active = activate(db, &rings()).await.unwrap(); + while !step(db, &active, 1).await.unwrap().complete {} + assert!(progress(db).await.unwrap().verified > 0); +} +#[tokio::test] +async fn sqlite_schema_valid_nulls_complete() { + valid_null_review_case(&database().await).await; +} +#[tokio::test] +async fn postgres_schema_valid_nulls_complete() { + let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_rekey_valid_null").await + else { + return; + }; + valid_null_review_case(&f.conn).await; +} + +async fn verification_crash_and_alias_case(db: &DatabaseConnection) { + fixtures(db).await; + let active = activate(db, &rings()).await.unwrap(); + while !step(db, &active, 1).await.unwrap().verifying {} + // A failed verification page must not commit its cursor or checked count. + assert!(step_page(db, &active, 2, Some(1)).await.is_err()); + assert_eq!(progress(db).await.unwrap().verified, 0); + assert_eq!(step(db, &active, 1).await.unwrap().verified, 1); + // A subsequent write invalidates that verified prefix and restarts the pass. + exec( + db, + "UPDATE delegation_cid_alias SET canonical=?", + vec![vec![0u8; 34].into()], + ) + .await; + assert_eq!(step(db, &active, 1).await.unwrap().verified, 1); + let mut refused = false; + for _ in 0..100 { + match step(db, &active, 1).await { + Ok(p) => assert!(!p.complete), + Err(Error::Verify) => { + refused = true; + break; + } + Err(other) => panic!("unexpected alias verification failure: {other}"), + } + } + assert!(refused); + assert!(!progress(db).await.unwrap().complete); +} +#[tokio::test] +async fn sqlite_verification_crash_resume_and_alias_authentication() { + verification_crash_and_alias_case(&database().await).await; +} +#[tokio::test] +async fn postgres_verification_crash_resume_and_alias_authentication() { + let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_rekey_verify_crash").await + else { + return; + }; + verification_crash_and_alias_case(&f.conn).await; +} + +async fn early_write_case(db: &DatabaseConnection) { + fixtures(db).await; + let overlap = rings(); + let bytes = overlap.webhooks.for_kid(2).unwrap().encrypt( + b"hook", + RowBinding::new("hook_subscription", "encrypted_secret", &[b"hook"]), + ); + let update = || { + bound(db.get_database_backend(),"UPDATE hook_subscription SET encrypted_secret=?,encrypted_secret_format=2 WHERE id='hook'",vec![bytes.clone().into()]) + }; + assert!(db.execute(update()).await.is_err()); + let active = activate(db, &overlap).await.unwrap(); + assert!(db.execute(update()).await.is_ok()); + while !step(db, &active, 1).await.unwrap().complete {} +} +#[tokio::test] +async fn sqlite_database_refuses_new_kid_before_fence() { + early_write_case(&database().await).await; +} +#[tokio::test] +async fn postgres_database_refuses_new_kid_before_fence() { + let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_rekey_early_write").await + else { + return; + }; + early_write_case(&f.conn).await; } diff --git a/tinycloud-node-server/src/lib.rs b/tinycloud-node-server/src/lib.rs index a2d6110d..58cc1785 100644 --- a/tinycloud-node-server/src/lib.rs +++ b/tinycloud-node-server/src/lib.rs @@ -829,7 +829,8 @@ pub async fn app_with_control( // private key into DStack-derived key management. let encryption_seal = ColumnEncryption::new(key_setup.derive_key(b"tinycloud/encryption/network-seal")); - let encryption_backend = std::sync::Arc::new(LocalOneOfOneBackend::new(encryption_seal)); + let encryption_backend = + std::sync::Arc::new(LocalOneOfOneBackend::new(encryption_seal.clone())); let node_keypair = key_setup.node_keypair(); let encryption_service = EncryptionService::new_with_node_keypair( database_connection.clone(), @@ -853,11 +854,15 @@ pub async fn app_with_control( Either::B(local) => Either::B(local.open().await?), }; let orphan_store = block_store.clone(); - let tinycloud = TinyCloud::new_with_encryption( + let tinycloud = TinyCloud::new_with_storage_rings( database_connection, block_store, key_setup.setup(()).await?, - Some(column_encryption), + tinycloud_core::reencrypt::Rings { + columns: column_encryption, + webhooks: webhook_encryption.clone(), + seals: encryption_seal, + }, ) .await? .with_sql_sizes(sql_sizes.clone()) From ad6760a9851f091f320f91f870fbaf1a8d985046 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:26:41 +0000 Subject: [PATCH 3/6] feat: require storage migration identity witnesses (TC-2713) --- docs/root-reencryption.md | 34 +- tinycloud-core/src/encryption.rs | 21 +- .../src/encryption_network/backend.rs | 29 +- tinycloud-core/src/reencrypt.rs | 173 +++++++-- tinycloud-core/src/reencrypt_tests.rs | 365 +++++++++++++++++- 5 files changed, 550 insertions(+), 72 deletions(-) diff --git a/docs/root-reencryption.md b/docs/root-reencryption.md index 46f0fa7e..f0f3244a 100644 --- a/docs/root-reencryption.md +++ b/docs/root-reencryption.md @@ -10,9 +10,11 @@ Existing serving configuration continues selecting its existing keys. ## Storage behavior Column and webhook keys retain #535's HKDF salt and purpose labels. A new -network-seal purpose separates the next seal key from both. Readers retain -legacy v1 keys, generation 1 purpose keys, and generation 0 seals. Injected rings initially keep the existing writers and add generation 2 read -keys. After activation commits, the returned rings select generation 2 writers. Client wrap-v1 and wrap-v2 envelopes +network-seal purpose separates the next seal key from both. Column readers retain +legacy v1 keys and generation 1 purpose keys; migration retains legacy seal keys. +Serving accepts PK-bound v2 seals with generation 0 or 2. Injected rings initially +keep the existing writers and add generation 2 read keys. After activation commits, +the returned rings select generation 2 writers. Client wrap-v1 and wrap-v2 envelopes are unchanged: the job opens and re-seals server private keys and seeds, without changing the underlying X25519 keys or any client ciphertext. @@ -26,11 +28,33 @@ Column rewrites retain the exact table, column and ordered PK binding from #535. Network private-key bindings include `network_id`; seed bindings include `network_id` and `key_path` in database PK order. Serving opens and migration verify the network private key against the row's public key. Named-key opens -also check the derived public key against the key row. Legacy v1 seals remain an -explicit authenticated compatibility path; rewritten seals use PK-bound v2. +also check the derived public key against the key row. Serving rejects v1 private-key +and seed seals. Only the migration may decode legacy seals after checking the +independent witnesses described below; rewritten seals use PK-bound v2. Every migrated value is authenticated and checked for byte-for-byte plaintext equality after it is written back. +## Legacy seal migration prerequisites + +Before re-sealing a v1 network private key, the migration requires exactly one +`initial` ceremony for its network. The ceremony must be completed, record its +completion, and have `transcript_hash = hash_hex(public_key)`. The unsealed +private key must also derive that public key. Missing, duplicate, conflicting, +incomplete or mismatching ceremony evidence aborts the page before re-sealing. + +V1 named-key seeds require a caller-supplied `TrustedSeedIdentities` map keyed by +`(network_id, key_path, key_version)`. Populate it only from an independently +authenticated external manifest or equivalent trusted creation evidence. The +migration does not supply a manifest automatically, and the rows being migrated +must never be used to synthesize it. Each supplied public key must match the key +derived from the unsealed seed and the registered row public key. + +Pass this map explicitly to every `step` call. An empty map supplies no legacy +seed witnesses: a missing or mismatching entry aborts the page, preserves its +checkpoint, and prevents completion and old-root retirement. Serving requests +for unmigrated v1 seals fail closed until the witnessed migration succeeds. +Client-wrapped key v1 and v2 protocols remain unchanged. + ## Activation and recovery The rollout order is read overlap, database fence, then writer switch. First, diff --git a/tinycloud-core/src/encryption.rs b/tinycloud-core/src/encryption.rs index 647cdce3..30eb77ce 100644 --- a/tinycloud-core/src/encryption.rs +++ b/tinycloud-core/src/encryption.rs @@ -302,11 +302,22 @@ impl ColumnEncryption { 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), - }; + if data.first().copied() == Some(VERSION_LEGACY) { + return self.decrypt_legacy(data); + } + self.decrypt_v2(data, binding) + } + + /// Decode only authenticated, row-bound v2 storage envelopes. + pub fn decrypt_v2( + &self, + data: &[u8], + binding: RowBinding<'_>, + ) -> Result, EncryptionError> { + if data.first().copied() != Some(VERSION_KEYRING) { + return Err(EncryptionError::UnsupportedVersion); + } + let kid = *data.get(1).ok_or(EncryptionError::TooShort)?; let aad = binding.aad(kid); self.open(data, kid, 2, &aad) } diff --git a/tinycloud-core/src/encryption_network/backend.rs b/tinycloud-core/src/encryption_network/backend.rs index 1f95c850..2f1abebc 100644 --- a/tinycloud-core/src/encryption_network/backend.rs +++ b/tinycloud-core/src/encryption_network/backend.rs @@ -358,7 +358,8 @@ pub trait KeyBackend: Send + Sync { version: KeyWrapVersion, ) -> Result, KeyBackendError>; - /// Unseal the network private key. A failure here is a backend fault, + /// Open a PK-bound v2 network seal. Legacy seals require migration. + /// A failure here is a backend fault, /// reported separately from a failed unwrap. fn open_network_secret( &self, @@ -396,9 +397,8 @@ pub trait KeyBackend: Send + Sync { key_path: &str, ) -> Result<(KeySeed, Vec), KeyBackendError>; - /// Opens a sealed seed. Only the strict unseal is used: input that is not - /// a versioned sealed value, or that does not hold exactly 32 bytes, is - /// refused. + /// Open only a PK-bound v2 seed containing exactly 32 bytes. + /// Legacy seals require migration and are refused by serving paths. fn open_seed( &self, sealed_seed: &[u8], @@ -555,7 +555,7 @@ impl KeyBackend for LocalOneOfOneBackend { let pk = [network_id.as_bytes()]; let binding = crate::encryption::RowBinding::new("encryption_network", "sealed_private_key", &pk); - let opened = Zeroizing::new(self.seal.decrypt(sealed_private_key, binding)?); + let opened = Zeroizing::new(self.seal.decrypt_v2(sealed_private_key, binding)?); if opened.len() != 32 { return Err(KeyBackendError::InvalidSealedKey); } @@ -641,7 +641,7 @@ impl KeyBackend for LocalOneOfOneBackend { key_path: &str, ) -> Result { let pk = [network_id.as_bytes(), key_path.as_bytes()]; - let opened = Zeroizing::new(self.seal.decrypt( + let opened = Zeroizing::new(self.seal.decrypt_v2( sealed_seed, crate::encryption::RowBinding::new("encryption_network_key", "sealed_seed", &pk), )?); @@ -1401,7 +1401,14 @@ mod tests { ); } // A sealed value that is not 32 bytes is refused. - let short = ColumnEncryption::new([7u8; 32]).encrypt_legacy(&[1u8; 16]); + let short = ColumnEncryption::new([7u8; 32]).encrypt( + &[1u8; 16], + crate::encryption::RowBinding::new( + "encryption_network_key", + "sealed_seed", + &[NETWORK.as_bytes(), b"seed-path"], + ), + ); assert!(matches!( backend.open_seed(&short, NETWORK, "seed-path"), Err(KeyBackendError::InvalidSealedKey) @@ -1498,7 +1505,7 @@ mod tests { mod seal_binding_regression_tests { use super::*; #[test] - fn legacy_seals_require_expected_public_and_v2_never_falls_back_to_empty_pk() { + fn persisted_seals_require_v2_and_never_fall_back_to_empty_pk() { let ring = ColumnEncryption::new([7; 32]); let backend = LocalOneOfOneBackend::new(ring.clone()); let secret = StaticSecret::from([42; 32]); @@ -1506,7 +1513,11 @@ mod seal_binding_regression_tests { let legacy = ring.encrypt_legacy(secret.as_bytes()); assert!(backend .open_network_secret(&legacy, "network-a", public.as_bytes()) - .is_ok()); + .is_err()); + let legacy_seed = ring.encrypt_legacy(&[43; 32]); + assert!(backend + .open_seed(&legacy_seed, "network-a", "path-a") + .is_err()); assert!(backend .open_network_secret(&legacy, "network-a", &[0; 32]) .is_err()); diff --git a/tinycloud-core/src/reencrypt.rs b/tinycloud-core/src/reencrypt.rs index b575cded..20c2c797 100644 --- a/tinycloud-core/src/reencrypt.rs +++ b/tinycloud-core/src/reencrypt.rs @@ -29,6 +29,11 @@ pub struct Rings { pub webhooks: ColumnEncryption, pub seals: ColumnEncryption, } +/// Migration-only public identities, keyed by (network_id, key_path, key_version). +/// Populate only from an independently authenticated manifest, never from the +/// rows being migrated. An empty map does not authorize legacy seed conversion. +pub type TrustedSeedIdentities = BTreeMap<(String, String, i64), [u8; 32]>; + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("database operation failed")] @@ -339,9 +344,79 @@ fn matches_raw_cid(expected: &str, bytes: &[u8]) -> bool { } } +// V1 seals have no row AAD. The migration alone may open them after checking +// independent creation evidence. Serving backends accept only v2 seals. +async fn legacy_network_witness( + db: &C, + network: &str, + public: &[u8], +) -> Result<(), Error> { + let mismatch = || Error::Integrity { + table: "encryption_network", + column: "sealed_private_key", + }; + let evidence=db.query_all(bound(db.get_database_backend(),"SELECT state,transcript_hash,completed_at,failure FROM encryption_ceremony WHERE network_id=? AND kind='initial' LIMIT 2",vec![network.into()])).await?; + if evidence.len() != 1 { + return Err(mismatch()); + } + let row = &evidence[0]; + if row.try_get::("", "state")? != "completed" + || row + .try_get::>("", "completed_at")? + .is_none_or(|s| s.is_empty()) + || row.try_get::>("", "failure")?.is_some() + || row + .try_get::>("", "transcript_hash")? + .as_deref() + != Some(crate::encryption_network::canonical::hash_hex(public).as_str()) + { + return Err(mismatch()); + } + Ok(()) +} + +async fn migration_network_secret( + db: &C, + seal: &ColumnEncryption, + network: &str, + blob: &[u8], + public: &[u8], +) -> Result { + let opened = Zeroizing::new( + if blob.first() == Some(&1) { + legacy_network_witness(db, network, public).await?; + seal.decrypt_legacy(blob) + } else { + seal.decrypt_v2( + blob, + RowBinding::new( + "encryption_network", + "sealed_private_key", + &[network.as_bytes()], + ), + ) + } + .map_err(|_| Error::Verify)?, + ); + let bytes = Zeroizing::new(<[u8; 32]>::try_from(opened.as_slice()).map_err(|_| Error::Verify)?); + let secret = StaticSecret::from(*bytes); + if PublicKey::from(&secret).as_bytes().as_slice() != public { + return Err(Error::Integrity { + table: "encryption_network", + column: "sealed_private_key", + }); + } + Ok(secret) +} + // NULL is accepted only for a lifecycle state that intentionally holds no // sealed material. Required seals, invalid metadata and orphan key rows abort. -fn validate_shape(spec: Column, row: &Row, seal: &ColumnEncryption) -> Result<(), Error> { +async fn validate_shape( + db: &C, + spec: Column, + row: &Row, + seal: &ColumnEncryption, +) -> Result<(), Error> { use crate::encryption_network::{key_id::KeyKind, types::NetworkState}; match spec.identity { Identity::Network => { @@ -384,16 +459,17 @@ fn validate_shape(spec: Column, row: &Row, seal: &ColumnEncryption) -> Result<() return Err(Error::Verify); } let backend = LocalOneOfOneBackend::new(seal.clone()); - let secret = backend - .open_network_secret( - row.network_blob.as_deref().ok_or(Error::Integrity { - table: spec.table, - column: spec.column, - })?, - network, - row.network_public.as_deref().ok_or(Error::Verify)?, - ) - .map_err(|_| Error::Verify)?; + let secret = migration_network_secret( + db, + seal, + network, + row.network_blob.as_deref().ok_or(Error::Integrity { + table: spec.table, + column: spec.column, + })?, + row.network_public.as_deref().ok_or(Error::Verify)?, + ) + .await?; match (kind, row.state.as_deref(), row.blob.is_some()) { (KeyKind::Deterministic, _, false) => { let public = backend @@ -414,7 +490,9 @@ fn validate_shape(spec: Column, row: &Row, seal: &ColumnEncryption) -> Result<() Ok(()) } -fn identity( +async fn identity( + db: &C, + trusted: &TrustedSeedIdentities, spec: Column, row: &Row, plaintext: &[u8], @@ -479,22 +557,27 @@ fn identity( let Key::Pair(network, path) = &row.key else { return Err(mismatch()); }; - let pk = [network.as_bytes()]; - let network_bytes = Zeroizing::new( - seal.decrypt( - network_blob, - RowBinding::new("encryption_network", "sealed_private_key", &pk), + let secret = migration_network_secret( + db, + seal, + network, + network_blob, + row.network_public.as_deref().ok_or_else(mismatch)?, + ) + .await?; + let expected = if row.blob.as_deref().is_some_and(|b| b.first() == Some(&1)) { + Some( + trusted + .get(&( + network.clone(), + path.clone(), + row.key_version.ok_or_else(mismatch)?, + )) + .ok_or_else(mismatch)?, ) - .map_err(|_| mismatch())?, - ); - let network_bytes = Zeroizing::new( - <[u8; 32]>::try_from(network_bytes.as_slice()).map_err(|_| mismatch())?, - ); - let secret = StaticSecret::from(*network_bytes); - if row.network_public.as_deref() != Some(PublicKey::from(&secret).as_bytes().as_slice()) - { - return Err(mismatch()); - } + } else { + None + }; let network: NetworkId = network.parse().map_err(|_| mismatch())?; let location = KeyLocation::new(&network, path).map_err(|_| mismatch())?; let backend = LocalOneOfOneBackend::new(seal.clone()); @@ -505,6 +588,9 @@ fn identity( Some(&KeySeed::from_bytes(*bytes)), ) .map_err(|_| mismatch())?; + if expected.is_some_and(|expected| expected.as_slice() != public.as_slice()) { + return Err(mismatch()); + } if row.public_key.as_deref() != Some(public.as_slice()) { return Err(mismatch()); } @@ -830,7 +916,8 @@ pub async fn activate( } // Any data mutation invalidates a paged verification snapshot. These updates // use the same singleton lock as each job page and boot validation. - let mut tables = std::collections::BTreeSet::from(["delegation_cid_alias"]); + let mut tables = + std::collections::BTreeSet::from(["delegation_cid_alias", "encryption_ceremony"]); tables.extend(COLUMNS.iter().map(|s| s.table)); for (i, table) in tables.into_iter().enumerate() { if backend == DbBackend::Sqlite { @@ -894,11 +981,13 @@ pub async fn step( db: &C, rings: &Rings, batch: u64, + trusted: &TrustedSeedIdentities, ) -> Result { step_page( db, rings, batch, + trusted, #[cfg(test)] None, ) @@ -909,6 +998,7 @@ async fn step_page( db: &C, rings: &Rings, batch: u64, + trusted: &TrustedSeedIdentities, #[cfg(test)] abort_after: Option, ) -> Result { if batch == 0 || batch > 10000 { @@ -960,7 +1050,7 @@ async fn step_page( let mut last = None; for row in &rows { last = Some(row.key.encoded()?); - validate_shape(spec, row, &rings.seals)?; + validate_shape(&tx, spec, row, &rings.seals).await?; let Some(old) = &row.blob else { if verifying { verified += 1; @@ -978,12 +1068,25 @@ async fn step_page( let plain = Zeroizing::new( if spec.format().is_some() { ring.decrypt_recorded(old, binding, row.format) + } else if old.first() == Some(&1) { + if matches!(spec.identity, Identity::Network) { + let Key::Text(network) = &row.key else { + return Err(Error::Verify); + }; + legacy_network_witness( + &tx, + network, + row.public_key.as_deref().ok_or(Error::Verify)?, + ) + .await?; + } + ring.decrypt_legacy(old) } else { - ring.decrypt(old, binding) + ring.decrypt_v2(old, binding) } .map_err(|_| Error::Verify)?, ); - identity(spec, row, &plain, &rings.seals)?; + identity(&tx, trusted, spec, row, &plain, &rings.seals).await?; if verifying { if old.get(..2) != Some(&[2, kid]) || (spec.format().is_some() && row.format != 2) { return Err(Error::Verify); @@ -1055,12 +1158,16 @@ async fn step_page( .ok_or(Error::Conflict)?; let saved: Vec = saved.try_get("", "blob")?; if saved != new - || Zeroizing::new(ring.decrypt(&saved, binding).map_err(|_| Error::Verify)?).as_slice() + || Zeroizing::new( + ring.decrypt_v2(&saved, binding) + .map_err(|_| Error::Verify)?, + ) + .as_slice() != plain.as_slice() { return Err(Error::Verify); } - identity(spec, row, &plain, &rings.seals)?; + identity(&tx, trusted, spec, row, &plain, &rings.seals).await?; done += 1; #[cfg(test)] if abort_after == Some(done) { diff --git a/tinycloud-core/src/reencrypt_tests.rs b/tinycloud-core/src/reencrypt_tests.rs index 5405467b..6039e59a 100644 --- a/tinycloud-core/src/reencrypt_tests.rs +++ b/tinycloud-core/src/reencrypt_tests.rs @@ -12,6 +12,29 @@ fn rings() -> Rings { } Rings::from_source(&Source, [8; 32], [7; 32], [9; 32]).unwrap() } +// Test identities come from trusted creation inputs, never queried row fields. +fn trusted_seed_identities() -> TrustedSeedIdentities { + let mut trusted = TrustedSeedIdentities::new(); + let backend = LocalOneOfOneBackend::new(rings().seals); + for (name, byte) in [("alpha", 31), ("beta", 32)] { + let network = NetworkId::new(OWNER, name).unwrap(); + for path in ["apps/app/a", "apps/app/z"] { + let location = KeyLocation::new(&network, path).unwrap(); + let public = backend + .derive_named_public_key( + &StaticSecret::from([byte; 32]), + &canonical_key_urn(&location), + Some(&KeySeed::from_bytes([43; 32])), + ) + .unwrap(); + trusted.insert( + (network.to_string(), path.to_string(), 1), + public.try_into().unwrap(), + ); + } + } + trusted +} async fn database() -> DatabaseConnection { let db = Database::connect("sqlite::memory:").await.unwrap(); crate::migrations::Migrator::up(&db, None).await.unwrap(); @@ -39,6 +62,7 @@ pub(super) async fn fixtures(db: &impl ConnectionTrait) { let network = NetworkId::new(OWNER, name).unwrap().to_string(); let secret = StaticSecret::from([secret_byte; 32]); exec(db, "INSERT INTO encryption_network (network_id,owner_did,name,alg,key_version,public_key,state,threshold_n,threshold_t,key_backend,sealed_private_key,created_at,updated_at) VALUES (?, ?, ?, 'X25519-AES256GCM', 1, ?, 'active', 1, 1, 'local-one-of-one', ?, 'now', 'now')", vec![network.clone().into(), OWNER.into(), name.into(), PublicKey::from(&secret).as_bytes().to_vec().into(), ColumnEncryption::new([9; 32]).encrypt_legacy(secret.as_bytes()).into()]).await; + exec(db,"INSERT INTO encryption_ceremony (ceremony_id,network_id,kind,state,transcript_hash,started_at,completed_at) VALUES (?,?,'initial','completed',?,'now','now')",vec![format!("initial:{name}").into(),network.clone().into(),crate::encryption_network::canonical::hash_hex(PublicKey::from(&secret).as_bytes()).into()]).await; for path in ["apps/app/a", "apps/app/z"] { let seed = KeySeed::from_bytes([43; 32]); let public = backend @@ -129,11 +153,20 @@ async fn exercise(db: &DatabaseConnection) { .await .is_err()); // Fail after a CAS, before commit: ciphertext, cursor and count all roll back. - assert!(step_page(db, &rings, 2, Some(1)).await.is_err()); + assert!( + step_page(db, &rings, 2, &trusted_seed_identities(), Some(1)) + .await + .is_err() + ); assert_eq!(progress(db).await.unwrap().rewritten, 0); assert_eq!(census(db).await.unwrap()[0].legacy, before[0].legacy); // A committed batch followed by process loss must resume from its cursor. - assert!(!step(db, &rings, 1).await.unwrap().complete); + assert!( + !step(db, &rings, 1, &trusted_seed_identities()) + .await + .unwrap() + .complete + ); let checkpoint = progress(db).await.unwrap(); assert_eq!(checkpoint.rewritten, 1); loop { @@ -147,7 +180,11 @@ async fn exercise(db: &DatabaseConnection) { ) .await; assert!(boot.is_ok(), "mixed state boot failed: {:?}", boot.err()); - if step(db, &rings, 1).await.unwrap().complete { + if step(db, &rings, 1, &trusted_seed_identities()) + .await + .unwrap() + .complete + { break; } } @@ -156,7 +193,12 @@ async fn exercise(db: &DatabaseConnection) { .iter() .all(|c| c.legacy == 0 && c.kids.keys().all(|k| *k == 2))); let done = progress(db).await.unwrap().rewritten; - assert!(step(db, &rings, 10).await.unwrap().complete); + assert!( + step(db, &rings, 10, &trusted_seed_identities()) + .await + .unwrap() + .complete + ); assert_eq!(progress(db).await.unwrap().rewritten, done); // The database fence rejects stale writers, including inserts behind cursor. let stale = ColumnEncryption::new([7; 32]).encrypt_legacy(b"stale"); @@ -194,7 +236,7 @@ async fn integrity_failure_aborts_at_first_bad_row_without_advancing() { .await; let rings = activate(&db, &rings).await.unwrap(); assert!(matches!( - step(&db, &rings, 10).await, + step(&db, &rings, 10, &trusted_seed_identities()).await, Err(Error::Integrity { .. }) )); assert_eq!(progress(&db).await.unwrap().rewritten, 0); @@ -202,7 +244,7 @@ async fn integrity_failure_aborts_at_first_bad_row_without_advancing() { } #[test] -fn existing_v1_and_v2_kid0_seals_and_wrap_v2_survive_root_change() { +fn serving_v2_kid0_seals_remain_compatible_and_v1_requires_migration() { use crate::encryption_network::backend::*; let old = LocalOneOfOneBackend::new(ColumnEncryption::new([9; 32])); let new = LocalOneOfOneBackend::new(rings().seals); @@ -215,7 +257,7 @@ fn existing_v1_and_v2_kid0_seals_and_wrap_v2_survive_root_change() { let legacy = ColumnEncryption::new([9; 32]).encrypt_legacy(legacy_secret.as_bytes()); assert!(new .open_network_secret(&legacy, "", PublicKey::from(&legacy_secret).as_bytes()) - .is_ok()); + .is_err()); assert!(new .open_network_secret(&generated.sealed_private_key, "", &generated.public_key) @@ -314,13 +356,17 @@ async fn verification_review_cases(db: &DatabaseConnection) { fixtures(db).await; let rings = activate(db, &rings()).await.unwrap(); loop { - let page = step(db, &rings, 1).await.unwrap(); + let page = step(db, &rings, 1, &trusted_seed_identities()) + .await + .unwrap(); if page.verifying { break; } assert!(!page.complete); } - let first = step(db, &rings, 1).await.unwrap(); + let first = step(db, &rings, 1, &trusted_seed_identities()) + .await + .unwrap(); assert!(first.verifying && !first.complete); assert_eq!(first.verified, 1); // Prefix, size and kid remain valid; final verification must authenticate. @@ -336,7 +382,7 @@ async fn verification_review_cases(db: &DatabaseConnection) { let mut failed = false; for _ in 0..100 { let before = progress(db).await.unwrap(); - match step(db, &rings, 1).await { + match step(db, &rings, 1, &trusted_seed_identities()).await { Ok(page) => { assert!(!page.complete); assert!(page.verified - before.verified <= 1); @@ -371,7 +417,7 @@ async fn orphan_review_case(db: &DatabaseConnection) { let rings = activate(db, &rings()).await.unwrap(); let mut failed = false; for _ in 0..100 { - match step(db, &rings, 1).await { + match step(db, &rings, 1, &trusted_seed_identities()).await { Ok(p) => assert!(!p.complete), Err(Error::Integrity { .. }) => { failed = true; @@ -407,7 +453,11 @@ async fn legacy_seal_review_case(db: &DatabaseConnection) { .unwrap(); assert!(ColumnEncryption::new([9; 32]).decrypt_legacy(&old).is_ok()); let active = activate(db, &rings()).await.unwrap(); - while !step(db, &active, 1).await.unwrap().complete {} + while !step(db, &active, 1, &trusted_seed_identities()) + .await + .unwrap() + .complete + {} let networks = select_batch(db, COLUMNS[0], None, 10).await.unwrap(); let backend = LocalOneOfOneBackend::new(active.seals.clone()); for row in networks { @@ -589,7 +639,7 @@ async fn null_review_case(db: &DatabaseConnection, seed: bool) { let active = activate(db, &rings()).await.unwrap(); let mut failed = false; for _ in 0..100 { - match step(db, &active, 1).await { + match step(db, &active, 1, &trusted_seed_identities()).await { Ok(p) => assert!(!p.complete), Err(Error::Verify | Error::Integrity { .. }) => { failed = true; @@ -641,7 +691,11 @@ async fn valid_null_review_case(db: &DatabaseConnection) { let network = NetworkId::new(OWNER, "pending").unwrap().to_string(); exec(db,"INSERT INTO encryption_network (network_id,owner_did,name,alg,key_version,public_key,state,threshold_n,threshold_t,key_backend,sealed_private_key,created_at,updated_at) VALUES (?,?,'pending','x25519-aes256gcm/v1',1,?,'pending',1,1,'local-one-of-one',NULL,'now','now')",vec![network.into(),OWNER.into(),Vec::::new().into()]).await; let active = activate(db, &rings()).await.unwrap(); - while !step(db, &active, 1).await.unwrap().complete {} + while !step(db, &active, 1, &trusted_seed_identities()) + .await + .unwrap() + .complete + {} assert!(progress(db).await.unwrap().verified > 0); } #[tokio::test] @@ -660,11 +714,25 @@ async fn postgres_schema_valid_nulls_complete() { async fn verification_crash_and_alias_case(db: &DatabaseConnection) { fixtures(db).await; let active = activate(db, &rings()).await.unwrap(); - while !step(db, &active, 1).await.unwrap().verifying {} + while !step(db, &active, 1, &trusted_seed_identities()) + .await + .unwrap() + .verifying + {} // A failed verification page must not commit its cursor or checked count. - assert!(step_page(db, &active, 2, Some(1)).await.is_err()); + assert!( + step_page(db, &active, 2, &trusted_seed_identities(), Some(1)) + .await + .is_err() + ); assert_eq!(progress(db).await.unwrap().verified, 0); - assert_eq!(step(db, &active, 1).await.unwrap().verified, 1); + assert_eq!( + step(db, &active, 1, &trusted_seed_identities()) + .await + .unwrap() + .verified, + 1 + ); // A subsequent write invalidates that verified prefix and restarts the pass. exec( db, @@ -672,10 +740,16 @@ async fn verification_crash_and_alias_case(db: &DatabaseConnection) { vec![vec![0u8; 34].into()], ) .await; - assert_eq!(step(db, &active, 1).await.unwrap().verified, 1); + assert_eq!( + step(db, &active, 1, &trusted_seed_identities()) + .await + .unwrap() + .verified, + 1 + ); let mut refused = false; for _ in 0..100 { - match step(db, &active, 1).await { + match step(db, &active, 1, &trusted_seed_identities()).await { Ok(p) => assert!(!p.complete), Err(Error::Verify) => { refused = true; @@ -713,7 +787,11 @@ async fn early_write_case(db: &DatabaseConnection) { assert!(db.execute(update()).await.is_err()); let active = activate(db, &overlap).await.unwrap(); assert!(db.execute(update()).await.is_ok()); - while !step(db, &active, 1).await.unwrap().complete {} + while !step(db, &active, 1, &trusted_seed_identities()) + .await + .unwrap() + .complete + {} } #[tokio::test] async fn sqlite_database_refuses_new_kid_before_fence() { @@ -727,3 +805,250 @@ async fn postgres_database_refuses_new_kid_before_fence() { }; early_write_case(&f.conn).await; } + +async fn serving_v1_refusal_case(db: &DatabaseConnection) { + fixtures(db).await; + let backend = LocalOneOfOneBackend::new(rings().seals); + let net = select_batch(db, COLUMNS[0], None, 1) + .await + .unwrap() + .remove(0); + let Key::Text(id) = net.key else { + panic!("network key type"); + }; + assert!(backend + .open_network_secret( + net.blob.as_deref().unwrap(), + &id, + net.public_key.as_deref().unwrap() + ) + .is_err()); + let seed = select_batch(db, COLUMNS[1], None, 1) + .await + .unwrap() + .remove(0); + let Key::Pair(id, path) = seed.key else { + panic!("seed key type"); + }; + assert!(backend + .open_seed(seed.blob.as_deref().unwrap(), &id, &path) + .is_err()); +} +#[tokio::test] +async fn sqlite_serving_v1_refusal() { + serving_v1_refusal_case(&database().await).await; +} +#[tokio::test] +async fn postgres_serving_v1_refusal() { + let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_rekey_serving_v1").await + else { + return; + }; + serving_v1_refusal_case(&f.conn).await; +} +async fn legacy_network_evidence_case(db: &DatabaseConnection, variant: &str) { + fixtures(db).await; + exec(db, "DELETE FROM encryption_ceremony", vec![]).await; + let network = NetworkId::new(OWNER, "alpha").unwrap().to_string(); + let original = StaticSecret::from([31; 32]); + let expected = + crate::encryption_network::canonical::hash_hex(PublicKey::from(&original).as_bytes()); + if variant != "missing" { + exec(db,"INSERT INTO encryption_ceremony (ceremony_id,network_id,kind,state,transcript_hash,started_at,completed_at) VALUES ('witness',?,'initial','completed',?,'now','now')",vec![network.clone().into(),expected.clone().into()]).await; + } + match variant { + "transplant" => { + let replacement = StaticSecret::from([32; 32]); + exec(db,"UPDATE encryption_network SET public_key=?,sealed_private_key=? WHERE network_id=?",vec![PublicKey::from(&replacement).as_bytes().to_vec().into(),ColumnEncryption::new([9;32]).encrypt_legacy(replacement.as_bytes()).into(),network.clone().into()]).await; + } + "multiple" | "conflicting" => { + let digest = if variant == "multiple" { + expected + } else { + "different".into() + }; + exec(db,"INSERT INTO encryption_ceremony (ceremony_id,network_id,kind,state,transcript_hash,started_at,completed_at) VALUES ('duplicate',?,'initial','completed',?,'now','now')",vec![network.clone().into(),digest.into()]).await; + } + "incomplete" => { + exec(db,"UPDATE encryption_ceremony SET state='started',completed_at=NULL WHERE network_id=?",vec![network.clone().into()]).await; + } + "mismatch" => { + exec( + db, + "UPDATE encryption_ceremony SET transcript_hash='different' WHERE network_id=?", + vec![network.clone().into()], + ) + .await; + } + _ => {} + } + let active = activate(db, &rings()).await.unwrap(); + assert!(matches!( + step(db, &active, 1, &trusted_seed_identities()).await, + Err(Error::Integrity { .. }) + )); + assert_eq!(progress(db).await.unwrap().rewritten, 0); + assert_eq!(census(db).await.unwrap()[0].legacy, 2); +} +#[tokio::test] +async fn sqlite_legacy_network_evidence_rejects_transplant() { + legacy_network_evidence_case(&database().await, "transplant").await; +} +#[tokio::test] +async fn postgres_legacy_network_evidence_rejects_transplant() { + let Some(f) = + crate::test_support::PostgresTestSchema::new("postgres_rekey_ceremony_transplant").await + else { + return; + }; + legacy_network_evidence_case(&f.conn, "transplant").await; +} +async fn missing_legacy_seed_witness_case(db: &DatabaseConnection) { + fixtures(db).await; + let active = activate(db, &rings()).await.unwrap(); + // Commit both network rewrites and advance to the seed page. + assert!(step(db, &active, 2, &TrustedSeedIdentities::new()) + .await + .is_ok()); + assert!(step(db, &active, 2, &TrustedSeedIdentities::new()) + .await + .is_ok()); + let checkpoint = progress(db).await.unwrap().rewritten; + assert!(matches!( + step(db, &active, 2, &TrustedSeedIdentities::new()).await, + Err(Error::Integrity { .. }) + )); + assert_eq!(progress(db).await.unwrap().rewritten, checkpoint); + assert!(!progress(db).await.unwrap().complete); + assert_eq!(census(db).await.unwrap()[1].legacy, 4); +} +#[tokio::test] +async fn sqlite_legacy_seed_requires_external_witness() { + missing_legacy_seed_witness_case(&database().await).await; +} +#[tokio::test] +async fn postgres_legacy_seed_requires_external_witness() { + let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_rekey_seed_witness").await + else { + return; + }; + missing_legacy_seed_witness_case(&f.conn).await; +} + +#[tokio::test] +async fn sqlite_legacy_network_requires_unique_completed_ceremony() { + for variant in [ + "missing", + "multiple", + "conflicting", + "incomplete", + "mismatch", + ] { + legacy_network_evidence_case(&database().await, variant).await; + } +} +#[tokio::test] +async fn postgres_legacy_network_requires_unique_completed_ceremony() { + for variant in [ + "missing", + "multiple", + "conflicting", + "incomplete", + "mismatch", + ] { + let Some(f) = + crate::test_support::PostgresTestSchema::new("postgres_rekey_ceremony_evidence").await + else { + return; + }; + legacy_network_evidence_case(&f.conn, variant).await; + } +} + +async fn supplied_seed_witness_case(db: &DatabaseConnection, variant: &str) { + fixtures(db).await; + let mut trusted = trusted_seed_identities(); + let network = NetworkId::new(OWNER, "alpha").unwrap(); + let path = "apps/app/a"; + if variant == "mismatch" { + trusted.insert((network.to_string(), path.into(), 1), [0; 32]); + } else if variant == "transplant" { + let backend = LocalOneOfOneBackend::new(rings().seals); + let public = backend + .derive_named_public_key( + &StaticSecret::from([31; 32]), + &canonical_key_urn(&KeyLocation::new(&network, path).unwrap()), + Some(&KeySeed::from_bytes([44; 32])), + ) + .unwrap(); + exec(db,"UPDATE encryption_network_key SET public_key=?,sealed_seed=? WHERE network_id=? AND key_path=?",vec![public.into(),ColumnEncryption::new([9;32]).encrypt_legacy(&[44;32]).into(),network.to_string().into(),path.into()]).await; + } + let active = activate(db, &rings()).await.unwrap(); + assert!(step(db, &active, 2, &trusted).await.is_ok()); + assert!(step(db, &active, 2, &trusted).await.is_ok()); + let checkpoint = progress(db).await.unwrap().rewritten; + if variant != "valid" { + let before = select_batch(db, COLUMNS[1], None, 1) + .await + .unwrap() + .remove(0) + .blob; + assert!(matches!( + step(db, &active, 1, &trusted).await, + Err(Error::Integrity { .. }) + )); + assert!( + select_batch(db, COLUMNS[1], None, 1) + .await + .unwrap() + .remove(0) + .blob + == before + ); + assert_eq!(progress(db).await.unwrap().rewritten, checkpoint); + assert!(!progress(db).await.unwrap().complete); + assert_eq!(census(db).await.unwrap()[1].legacy, 4); + } else { + while !step(db, &active, 1, &trusted).await.unwrap().complete {} + assert!(census(db) + .await + .unwrap() + .iter() + .all(|c| c.legacy == 0 && c.kids.keys().all(|kid| *kid == 2))); + } +} + +#[tokio::test] +async fn sqlite_legacy_seed_trusted_external_witness_completes() { + supplied_seed_witness_case(&database().await, "valid").await; +} + +#[tokio::test] +async fn postgres_legacy_seed_trusted_external_witness_completes() { + let Some(f) = + crate::test_support::PostgresTestSchema::new("postgres_rekey_seed_valid_witness").await + else { + return; + }; + supplied_seed_witness_case(&f.conn, "valid").await; +} + +#[tokio::test] +async fn sqlite_legacy_seed_external_witness_rejects_mismatch_and_transplant() { + for variant in ["mismatch", "transplant"] { + supplied_seed_witness_case(&database().await, variant).await; + } +} + +#[tokio::test] +async fn postgres_legacy_seed_external_witness_rejects_mismatch_and_transplant() { + for variant in ["mismatch", "transplant"] { + let Some(f) = + crate::test_support::PostgresTestSchema::new("postgres_rekey_seed_invalid_witness") + .await + else { + return; + }; + supplied_seed_witness_case(&f.conn, variant).await; + } +} From a6995fed7f70b9d613ead85b920955f30f559adf Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:03:22 +0000 Subject: [PATCH 4/6] feat: verify snapshot manifests before storage migration (TC-2713) --- docs/rekey-manifest.md | 94 ++++ docs/root-reencryption.md | 61 ++- .../src/encryption_network/backend.rs | 13 +- tinycloud-core/src/encryption_network/keys.rs | 7 +- .../src/encryption_network/service.rs | 16 +- .../src/encryption_network/tests.rs | 34 ++ tinycloud-core/src/reencrypt.rs | 56 ++- tinycloud-core/src/reencrypt_manifest.rs | 253 ++++++++++ tinycloud-core/src/reencrypt_tests.rs | 453 +++++++++++++++++- .../src/routes/encryption.rs | 26 + tools/export-rekey-manifest.py | 133 +++++ tools/test_export_rekey_manifest.py | 38 ++ 12 files changed, 1127 insertions(+), 57 deletions(-) create mode 100644 docs/rekey-manifest.md create mode 100644 tinycloud-core/src/reencrypt_manifest.rs create mode 100644 tools/export-rekey-manifest.py create mode 100644 tools/test_export_rekey_manifest.py diff --git a/docs/rekey-manifest.md b/docs/rekey-manifest.md new file mode 100644 index 00000000..6b2edfd9 --- /dev/null +++ b/docs/rekey-manifest.md @@ -0,0 +1,94 @@ +# TC-2713 snapshot identity manifest + +One manifest covers network and named-key public identities. Its authority is +the separately pinned SHA-256 in the rotation owner's authenticated custody +record, not a hash generated from the target database during migration. + +## Source and custody + +The source is a dated, trusted PlanetScale backup snapshot of +`tinycloud-node-production-usw`. The custodian records the backup ID, RFC3339 +snapshot timestamp, archive SHA-256, exporter identity, storage location and +custody record reference. Restore the checked archive into an isolated local +database with no other writers. For PostgreSQL, use a different local database +name; the exporter refuses the live database name and non-loopback endpoints. +No production endpoint is queried by the exporter. + +The custodian must verify that the restored source corresponds to the recorded +archive, authenticate the export, and record the manifest SHA-256 in the separate +access-controlled custody record before the offline window. A checksum supplied +beside an untrusted file without that custody process is not an authenticated pin. +The runner takes the file and the independently obtained pin; it never chooses +the pin by hashing whichever file it receives. + +Networks or seeds created after the snapshot are not covered. Take a fresh +trusted snapshot immediately before the migration window. Missing identities +are blocking categories, never exemptions. Client-held authenticated key pins +are a possible later second source, not an implemented fallback. + +## Canonical version 1 encoding + +The file is compact UTF-8 JSON without a BOM, spaces between tokens or trailing +newline. Strings use normal JSON escaping with non-ASCII characters encoded as +UTF-8. Field order is fixed: + +- Top level: `version`, `provenance`, `rows`. +- Provenance: `source_database`, `backup_id`, `snapshot_timestamp`, + `snapshot_sha256`, `exported_by`, `storage_location`, `custody_record`. +- Each row: `kind`, `network_id`, `key_path`, `key_version`, `public_key`. + +`version` is the integer 1. `source_database` is exactly +`tinycloud-node-production-usw`. Custody fields must be nonempty. The snapshot +timestamp must be valid RFC3339 and cannot be in the future. `snapshot_sha256` +is the lowercase 64-character SHA-256 hex digest of the archive bytes. + +Rows use `kind` equal to `network` or `seed`. A network row has `key_path: null`; +a seed row has its canonical key path. Network IDs are canonical TinyCloud +encryption-network URNs. Key versions are positive signed 64-bit integers. +Public keys are 32-byte X25519 public keys encoded as 64 lowercase hex characters. +No private keys, sealed values or plaintext secrets belong in this file. + +Sort rows by kind (`network` before `seed`), network ID in UTF-8 byte order, +key path (`null` before text, then UTF-8 byte order), then numeric key version. +Duplicate identities are rejected even if their public keys agree. Unknown or +duplicate JSON fields, malformed values and noncanonical serialization are +rejected. SHA-256 covers every byte of the final canonical file. + +## Read-only exporter + +Run `python3 tools/export-rekey-manifest.py` in the checkout. It requires Python 3 +and, for a restored PostgreSQL snapshot, `psql`. Supply these explicit arguments: + +| Argument | Meaning | +| --- | --- | +| `--restored-postgres-url` | Loopback-only restored PostgreSQL database; URL query options are refused. | +| `--schema` | Restored schema, default `public`. | +| `--sqlite-snapshot` | Alternative read-only SQLite image; must also be `--snapshot-file`. | +| `--snapshot-file` | Actual checked backup archive whose bytes the exporter hashes. | +| `--backup-id` | PlanetScale backup identifier from the custody record. | +| `--snapshot-timestamp` | Snapshot's RFC3339 timestamp. | +| `--exported-by` | Authenticated custodian/exporter identity. | +| `--storage-location` | Snapshot archive's recorded storage location. | +| `--custody-record` | Reference to the separate authenticated custody record. | +| `--output` | New manifest file; existing files are never overwritten. | + +The exporter selects only public-key metadata in a read-only snapshot +transaction. It does not select sealed private keys or seeds. Its terminal output +contains only the manifest hash and network/seed counts; failures are generic. +No key values, row identifiers, connection strings or custody metadata are logged. + +## Runner contract + +The rotation integration calls `reencrypt::manifest::run_offline` with the +manifest file, its separately pinned digest, injected overlap rings and batch +size. It verifies the file before any database call or fence activation. A valid +file is converted into private network/seed maps inside `VerifiedManifest`; there +is no raw-map conversion into that type. Coverage runs in bounded pages and +reports only missing/mismatched network and seed counts. Any nonzero category +blocks activation. Per-row migration checks remain mandatory after coverage. + +This entry point does not provide a production root source or stop serving. +Do not deploy #541 standalone on nodes with v1 rows. It ships only within the +TC-2713 rotation event once the production-integrated runner, verified manifest +and offline migration window exist; serving resumes only after migration and +authenticated final verification complete. diff --git a/docs/root-reencryption.md b/docs/root-reencryption.md index f0f3244a..2f5e75b9 100644 --- a/docs/root-reencryption.md +++ b/docs/root-reencryption.md @@ -7,6 +7,20 @@ activation flag, or change to the current ROOT acquisition path. The injectable the existing generation 1 ROOT and the exact legacy column and seal keys. Existing serving configuration continues selecting its existing keys. +## Deployment restriction + +This PR must ship only as part of the TC-2713 rotation event. Do not deploy it +standalone to a node holding v1 network-key or seed rows. Before deployment, +the rotation integration must have a production-wired migration runner, a +verified snapshot manifest and custody record, and an offline migration window +with serving stopped. Migrate and verify all existing material before resuming +serving. Unmigrated v1 requests return HTTP 503 with `migration_required`. + +The library's `manifest::run_offline` entry verifies a file and its separately +supplied digest before any database access, checks manifest coverage, and drives +the fenced job. It does not acquire production roots or install new serving +rings. Those integration steps remain prerequisites for the rotation event. + ## Storage behavior Column and webhook keys retain #535's HKDF salt and purpose labels. A new @@ -30,30 +44,39 @@ Network private-key bindings include `network_id`; seed bindings include verify the network private key against the row's public key. Named-key opens also check the derived public key against the key row. Serving rejects v1 private-key and seed seals. Only the migration may decode legacy seals after checking the -independent witnesses described below; rewritten seals use PK-bound v2. +verified external manifest described below; rewritten seals use PK-bound v2. Every migrated value is authenticated and checked for byte-for-byte plaintext equality after it is written back. ## Legacy seal migration prerequisites -Before re-sealing a v1 network private key, the migration requires exactly one -`initial` ceremony for its network. The ceremony must be completed, record its -completion, and have `transcript_hash = hash_hex(public_key)`. The unsealed -private key must also derive that public key. Missing, duplicate, conflicting, -incomplete or mismatching ceremony evidence aborts the page before re-sealing. - -V1 named-key seeds require a caller-supplied `TrustedSeedIdentities` map keyed by -`(network_id, key_path, key_version)`. Populate it only from an independently -authenticated external manifest or equivalent trusted creation evidence. The -migration does not supply a manifest automatically, and the rows being migrated -must never be used to synthesize it. Each supplied public key must match the key -derived from the unsealed seed and the registered row public key. - -Pass this map explicitly to every `step` call. An empty map supplies no legacy -seed witnesses: a missing or mismatching entry aborts the page, preserves its -checkpoint, and prevents completion and old-root retirement. Serving requests -for unmigrated v1 seals fail closed until the witnessed migration succeeds. -Client-wrapped key v1 and v2 protocols remain unchanged. +Both networks and seeds require one externally pinned manifest from a dated, +trusted PlanetScale backup of `tinycloud-node-production-usw`, exported from a +local restored snapshot. The [manifest format and export procedure](rekey-manifest.md) +define its canonical encoding, snapshot checksum, custodian and custody record. +The expected manifest SHA-256 must be recorded separately before the window. +The target database must never supply its own trusted identities or expected pin. + +`VerifiedManifest` can be constructed only by checking the bytes against that +separate pin, the required provenance metadata, canonical encoding and identities. +Every `step` accepts this verified input, with no raw-map constructor or fallback. +For v1 network seals, the manifest entry `(network_id, key_version)` must equal +the stored public key and the public key derived from the private key. For v1 +seeds, `(network_id, key_path, key_version)` must equal the derived named-key +public key and the stored public key. + +Network ceremonies are additional database consistency checks, not independent +provenance. Exactly one completed `initial` ceremony, a completion record, no +failure and `transcript_hash = hash_hex(public_key)` are required in addition to +the external manifest. A missing or mismatching manifest entry or inconsistent +ceremony aborts the page, preserves ciphertext/checkpoint, and prevents completion +and old-root retirement. + +The runner reports missing or mismatched identities as counts-only blocking +coverage categories; it never skips them. Networks or seeds created after the +snapshot require a fresh trusted snapshot immediately before the migration +window. Authenticated client-held public-key pins may become a later second +source. Client-wrapped key v1 and v2 protocols remain unchanged. ## Activation and recovery diff --git a/tinycloud-core/src/encryption_network/backend.rs b/tinycloud-core/src/encryption_network/backend.rs index 2f1abebc..d91514fc 100644 --- a/tinycloud-core/src/encryption_network/backend.rs +++ b/tinycloud-core/src/encryption_network/backend.rs @@ -28,6 +28,8 @@ use super::types::{ALG_X25519_AES256GCM, ALG_X25519_HKDF_AES256GCM}; #[derive(Debug, Error)] pub enum KeyBackendError { + #[error("migration_required: stored encryption keys require offline migration before serving")] + MigrationRequired, #[error("private key material unavailable")] SealedKeyMissing, #[error("network private key could not be unsealed")] @@ -552,6 +554,9 @@ impl KeyBackend for LocalOneOfOneBackend { network_id: &str, expected_public_key: &[u8], ) -> Result { + if sealed_private_key.first() == Some(&1) { + return Err(KeyBackendError::MigrationRequired); + } let pk = [network_id.as_bytes()]; let binding = crate::encryption::RowBinding::new("encryption_network", "sealed_private_key", &pk); @@ -593,7 +598,10 @@ impl KeyBackend for LocalOneOfOneBackend { ) -> Result, KeyBackendError> { let secret = self .open_network_secret(sealed_private_key, context.network_id, expected_public_key) - .map_err(|_| KeyBackendError::SealedKeyUnreadable)?; + .map_err(|err| match err { + KeyBackendError::MigrationRequired => err, + _ => KeyBackendError::SealedKeyUnreadable, + })?; open_wrapped_key(&secret, wrapped_key, context) } @@ -640,6 +648,9 @@ impl KeyBackend for LocalOneOfOneBackend { network_id: &str, key_path: &str, ) -> Result { + if sealed_seed.first() == Some(&1) { + return Err(KeyBackendError::MigrationRequired); + } let pk = [network_id.as_bytes(), key_path.as_bytes()]; let opened = Zeroizing::new(self.seal.decrypt_v2( sealed_seed, diff --git a/tinycloud-core/src/encryption_network/keys.rs b/tinycloud-core/src/encryption_network/keys.rs index de75c16b..44df8f65 100644 --- a/tinycloud-core/src/encryption_network/keys.rs +++ b/tinycloud-core/src/encryption_network/keys.rs @@ -614,7 +614,12 @@ impl EncryptionService { Some( self.backend .open_seed(sealed_seed, &row.network_id, &row.key_path) - .map_err(|_| EncryptionServiceError::CorruptKeyRow)?, + .map_err(|err| match err { + KeyBackendError::MigrationRequired => { + EncryptionServiceError::MigrationRequired + } + _ => EncryptionServiceError::CorruptKeyRow, + })?, ) } }; diff --git a/tinycloud-core/src/encryption_network/service.rs b/tinycloud-core/src/encryption_network/service.rs index e9973f3b..353fd490 100644 --- a/tinycloud-core/src/encryption_network/service.rs +++ b/tinycloud-core/src/encryption_network/service.rs @@ -192,6 +192,8 @@ fn key_denial_outcome(err: &EncryptionServiceError) -> &'static str { #[derive(Debug, Error)] pub enum EncryptionServiceError { + #[error("migration_required: stored encryption keys require offline migration before serving")] + MigrationRequired, #[error("db error: {0}")] Db(#[from] DbErr), #[error("network_not_found: no network exists with this id")] @@ -227,7 +229,7 @@ pub enum EncryptionServiceError { #[error("nonce already used")] NonceReplay, #[error("key backend error: {0}")] - Backend(#[from] KeyBackendError), + Backend(KeyBackendError), #[error("decrypt request body is malformed: {0}")] InvalidBody(String), #[error("receiver_key_invalid: receiverPublicKey must be a 32-byte X25519 public key that is not a low-order point")] @@ -262,6 +264,15 @@ pub enum EncryptionServiceError { StrayAppKeyRows(u64), } +impl From for EncryptionServiceError { + fn from(err: KeyBackendError) -> Self { + match err { + KeyBackendError::MigrationRequired => Self::MigrationRequired, + other => Self::Backend(other), + } + } +} + pub struct EncryptionService { pub(super) db: RecoveringConnection, pub(super) node_did: String, @@ -1167,8 +1178,9 @@ impl EncryptionService { err, KeyBackendError::SealedKeyMissing | KeyBackendError::SealedKeyUnreadable + | KeyBackendError::MigrationRequired ) { - EncryptionServiceError::Backend(err) + EncryptionServiceError::from(err) } else { EncryptionServiceError::KeyUnwrapFailed(err) } diff --git a/tinycloud-core/src/encryption_network/tests.rs b/tinycloud-core/src/encryption_network/tests.rs index b01c19b5..9d8b39a8 100644 --- a/tinycloud-core/src/encryption_network/tests.rs +++ b/tinycloud-core/src/encryption_network/tests.rs @@ -2752,3 +2752,37 @@ async fn serving_named_unwrap_refuses_seed_transplant() { Err(EncryptionServiceError::CorruptKeyRow) )); } + +#[tokio::test] +async fn cond_legacy_network_returns_clear_migration_required() { + let (db, svc) = service_with_network().await; + use crate::models::encryption_network; + let mut row: encryption_network::ActiveModel = + encryption_network::Entity::find_by_id(network_id().to_string()) + .one(&db) + .await + .unwrap() + .unwrap() + .into(); + row.sealed_private_key = Set(Some( + ColumnEncryption::new([0xAB; 32]).encrypt_legacy(&[42; 32]), + )); + row.update(&db).await.unwrap(); + let loc = KeyLocation::new(&network_id(), "docs/migration").unwrap(); + let err = svc.public_key(&loc).await.unwrap_err(); + assert!(err.to_string().starts_with("migration_required:")); +} + +#[tokio::test] +async fn cond_legacy_seed_returns_clear_migration_required() { + let (db, svc) = service_with_network().await; + let loc = KeyLocation::new(&network_id(), "apps/app/migration").unwrap(); + svc.create_key(&loc, true).await.unwrap(); + let mut row: encryption_network_key::ActiveModel = key_row(&db, loc.path()).await.into(); + row.sealed_seed = Set(Some( + ColumnEncryption::new([0xAB; 32]).encrypt_legacy(&[43; 32]), + )); + row.update(&db).await.unwrap(); + let err = svc.unwrap_named_key(&loc, &[0; 64], 1).await.unwrap_err(); + assert!(err.to_string().starts_with("migration_required:")); +} diff --git a/tinycloud-core/src/reencrypt.rs b/tinycloud-core/src/reencrypt.rs index 20c2c797..341693e1 100644 --- a/tinycloud-core/src/reencrypt.rs +++ b/tinycloud-core/src/reencrypt.rs @@ -29,13 +29,17 @@ pub struct Rings { pub webhooks: ColumnEncryption, pub seals: ColumnEncryption, } -/// Migration-only public identities, keyed by (network_id, key_path, key_version). -/// Populate only from an independently authenticated manifest, never from the -/// rows being migrated. An empty map does not authorize legacy seed conversion. -pub type TrustedSeedIdentities = BTreeMap<(String, String, i64), [u8; 32]>; +/// Only hash-pinned, dated and custody-recorded manifests authorize migration. +#[path = "reencrypt_manifest.rs"] +pub mod manifest; +pub use manifest::VerifiedManifest; #[derive(Debug, thiserror::Error)] pub enum Error { + #[error("invalid manifest proof: verify the file, pinned digest, date and custody record")] + Manifest, + #[error("manifest coverage blocks migration: {0:?}")] + ManifestCoverage(manifest::Coverage), #[error("database operation failed")] Database, #[error("invalid re-encryption checkpoint or options")] @@ -170,6 +174,7 @@ struct Row { public_key: Option>, network_blob: Option>, network_public: Option>, + network_version: Option, enforcement_cid: Option, format: i16, state: Option, @@ -231,7 +236,7 @@ async fn select_batch( .unwrap_or_else(|| ", CAST(0 AS SMALLINT) AS format".into()); let extra = match spec.identity { Identity::Network => ", t.public_key AS expected_public, t.state AS state, t.key_backend AS backend, t.key_version AS key_version", - Identity::Seed => ", t.public_key AS expected_public, n.sealed_private_key AS network_blob, n.public_key AS network_public, t.state AS state, t.kind AS kind, t.key_version AS key_version", + Identity::Seed => ", t.public_key AS expected_public, n.sealed_private_key AS network_blob, n.public_key AS network_public, n.key_version AS network_version, t.state AS state, t.kind AS kind, t.key_version AS key_version", Identity::Enforcement => ", t.enforcement_delegation_cid AS expected_cid", _ => "", }; @@ -315,6 +320,11 @@ async fn select_batch( } else { None }, + network_version: if matches!(spec.identity, Identity::Seed) { + row.try_get("", "network_version")? + } else { + None + }, enforcement_cid: if matches!(spec.identity, Identity::Enforcement) { Some(row.try_get("", "expected_cid")?) } else { @@ -345,16 +355,21 @@ fn matches_raw_cid(expected: &str, bytes: &[u8]) -> bool { } // V1 seals have no row AAD. The migration alone may open them after checking -// independent creation evidence. Serving backends accept only v2 seals. -async fn legacy_network_witness( +// external manifest evidence. Ceremonies are corroborative DB consistency only. +async fn legacy_network_consistency( db: &C, network: &str, public: &[u8], + version: i64, + trusted: &VerifiedManifest, ) -> Result<(), Error> { let mismatch = || Error::Integrity { table: "encryption_network", column: "sealed_private_key", }; + if trusted.network(network, version).map(|p| p.as_slice()) != Some(public) { + return Err(mismatch()); + } let evidence=db.query_all(bound(db.get_database_backend(),"SELECT state,transcript_hash,completed_at,failure FROM encryption_ceremony WHERE network_id=? AND kind='initial' LIMIT 2",vec![network.into()])).await?; if evidence.len() != 1 { return Err(mismatch()); @@ -381,10 +396,12 @@ async fn migration_network_secret( network: &str, blob: &[u8], public: &[u8], + version: i64, + trusted: &VerifiedManifest, ) -> Result { let opened = Zeroizing::new( if blob.first() == Some(&1) { - legacy_network_witness(db, network, public).await?; + legacy_network_consistency(db, network, public, version, trusted).await?; seal.decrypt_legacy(blob) } else { seal.decrypt_v2( @@ -416,6 +433,7 @@ async fn validate_shape( spec: Column, row: &Row, seal: &ColumnEncryption, + trusted: &VerifiedManifest, ) -> Result<(), Error> { use crate::encryption_network::{key_id::KeyKind, types::NetworkState}; match spec.identity { @@ -468,6 +486,8 @@ async fn validate_shape( column: spec.column, })?, row.network_public.as_deref().ok_or(Error::Verify)?, + row.network_version.ok_or(Error::Verify)?, + trusted, ) .await?; match (kind, row.state.as_deref(), row.blob.is_some()) { @@ -492,7 +512,7 @@ async fn validate_shape( async fn identity( db: &C, - trusted: &TrustedSeedIdentities, + trusted: &VerifiedManifest, spec: Column, row: &Row, plaintext: &[u8], @@ -563,16 +583,14 @@ async fn identity( network, network_blob, row.network_public.as_deref().ok_or_else(mismatch)?, + row.network_version.ok_or_else(mismatch)?, + trusted, ) .await?; let expected = if row.blob.as_deref().is_some_and(|b| b.first() == Some(&1)) { Some( trusted - .get(&( - network.clone(), - path.clone(), - row.key_version.ok_or_else(mismatch)?, - )) + .seed(network, path, row.key_version.ok_or_else(mismatch)?) .ok_or_else(mismatch)?, ) } else { @@ -981,7 +999,7 @@ pub async fn step( db: &C, rings: &Rings, batch: u64, - trusted: &TrustedSeedIdentities, + trusted: &VerifiedManifest, ) -> Result { step_page( db, @@ -998,7 +1016,7 @@ async fn step_page( db: &C, rings: &Rings, batch: u64, - trusted: &TrustedSeedIdentities, + trusted: &VerifiedManifest, #[cfg(test)] abort_after: Option, ) -> Result { if batch == 0 || batch > 10000 { @@ -1050,7 +1068,7 @@ async fn step_page( let mut last = None; for row in &rows { last = Some(row.key.encoded()?); - validate_shape(&tx, spec, row, &rings.seals).await?; + validate_shape(&tx, spec, row, &rings.seals, trusted).await?; let Some(old) = &row.blob else { if verifying { verified += 1; @@ -1073,10 +1091,12 @@ async fn step_page( let Key::Text(network) = &row.key else { return Err(Error::Verify); }; - legacy_network_witness( + legacy_network_consistency( &tx, network, row.public_key.as_deref().ok_or(Error::Verify)?, + row.key_version.ok_or(Error::Verify)?, + trusted, ) .await?; } diff --git a/tinycloud-core/src/reencrypt_manifest.rs b/tinycloud-core/src/reencrypt_manifest.rs new file mode 100644 index 00000000..d7339516 --- /dev/null +++ b/tinycloud-core/src/reencrypt_manifest.rs @@ -0,0 +1,253 @@ +//! Canonical, separately hash-pinned public identities for offline migration. +use super::{select_batch, Error, Rings, COLUMNS}; +use crate::encryption_network::{key_id::KeyLocation, NetworkId}; +use sea_orm::{ConnectionTrait, TransactionTrait}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, path::Path}; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Provenance { + pub source_database: String, + pub backup_id: String, + pub snapshot_timestamp: String, + pub snapshot_sha256: String, + pub exported_by: String, + pub storage_location: String, + pub custody_record: String, +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Kind { + Network, + Seed, +} + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityRow { + pub kind: Kind, + pub network_id: String, + pub key_path: Option, + pub key_version: i64, + pub public_key: String, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Document { + pub version: u8, + pub provenance: Provenance, + pub rows: Vec, +} + +type TrustedNetworkIdentities = BTreeMap<(String, i64), [u8; 32]>; +type TrustedSeedIdentities = BTreeMap<(String, String, i64), [u8; 32]>; + +/// No public constructor or raw-map conversion: proof is checked before use. +#[derive(Clone)] +pub struct VerifiedManifest { + networks: TrustedNetworkIdentities, + seeds: TrustedSeedIdentities, + digest: String, +} + +fn hex32(value: &str) -> Result<[u8; 32], Error> { + if value.len() != 64 + || !value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(Error::Manifest); + } + hex::decode(value) + .map_err(|_| Error::Manifest)? + .try_into() + .map_err(|_| Error::Manifest) +} + +impl VerifiedManifest { + /// The expected digest must come from the custody record, separately from + /// the supplied file. Hashing the untrusted file to choose this pin is invalid. + pub fn verify(bytes: &[u8], expected_sha256: &str) -> Result { + hex32(expected_sha256)?; + let digest = crate::encryption_network::canonical::hash_hex(bytes); + if digest != expected_sha256 { + return Err(Error::Manifest); + } + let doc: Document = serde_json::from_slice(bytes).map_err(|_| Error::Manifest)?; + if serde_json::to_vec(&doc).map_err(|_| Error::Manifest)? != bytes || doc.version != 1 { + return Err(Error::Manifest); + } + let p = &doc.provenance; + if p.source_database != "tinycloud-node-production-usw" + || [ + &p.backup_id, + &p.exported_by, + &p.storage_location, + &p.custody_record, + ] + .iter() + .any(|s| s.trim().is_empty()) + { + return Err(Error::Manifest); + } + hex32(&p.snapshot_sha256)?; + let date = time::OffsetDateTime::parse( + &p.snapshot_timestamp, + &time::format_description::well_known::Rfc3339, + ) + .map_err(|_| Error::Manifest)?; + if date > time::OffsetDateTime::now_utc() { + return Err(Error::Manifest); + } + if doc.rows.windows(2).any(|p| p[0] > p[1]) { + return Err(Error::Manifest); + } + let mut manifest = Self { + networks: BTreeMap::new(), + seeds: BTreeMap::new(), + digest, + }; + for row in doc.rows { + let network: NetworkId = row.network_id.parse().map_err(|_| Error::Manifest)?; + if network.to_string() != row.network_id || row.key_version < 1 { + return Err(Error::Manifest); + } + let public = hex32(&row.public_key)?; + let duplicate = match (row.kind, row.key_path) { + (Kind::Network, None) => manifest + .networks + .insert((row.network_id, row.key_version), public) + .is_some(), + (Kind::Seed, Some(path)) => { + KeyLocation::new(&network, &path).map_err(|_| Error::Manifest)?; + manifest + .seeds + .insert((row.network_id, path, row.key_version), public) + .is_some() + } + _ => return Err(Error::Manifest), + }; + if duplicate { + return Err(Error::Manifest); + } + } + Ok(manifest) + } + + pub fn load(path: &Path, expected_sha256: &str) -> Result { + Self::verify( + &std::fs::read(path).map_err(|_| Error::Manifest)?, + expected_sha256, + ) + } + + pub fn sha256(&self) -> &str { + &self.digest + } + pub fn counts(&self) -> (usize, usize) { + (self.networks.len(), self.seeds.len()) + } + pub(super) fn network(&self, network: &str, version: i64) -> Option<&[u8; 32]> { + self.networks.get(&(network.to_owned(), version)) + } + pub(super) fn seed(&self, network: &str, path: &str, version: i64) -> Option<&[u8; 32]> { + self.seeds + .get(&(network.to_owned(), path.to_owned(), version)) + } +} + +/// Counts only, including post-snapshot identities, never row identifiers. +#[derive(Debug, Default, Serialize)] +pub struct Coverage { + pub missing_networks: u64, + pub missing_seeds: u64, + pub mismatched_networks: u64, + pub mismatched_seeds: u64, +} +impl Coverage { + pub fn blocked(&self) -> bool { + self.missing_networks + + self.missing_seeds + + self.mismatched_networks + + self.mismatched_seeds + != 0 + } +} + +pub async fn coverage( + db: &C, + proof: &VerifiedManifest, +) -> Result { + let mut counts = Coverage::default(); + for (index, spec) in COLUMNS[..2].iter().enumerate() { + let mut after = None; + loop { + let rows = select_batch(db, *spec, after.as_ref(), 128).await?; + if rows.is_empty() { + break; + } + for row in rows { + let previously_allocated = match (&row.key, row.key_version) { + (super::Key::Text(id), Some(version)) => proof.network(id, version).is_some(), + _ => false, + }; + if index == 1 + || previously_allocated + || row.blob.is_some() + || row.public_key.as_ref().is_some_and(|p| !p.is_empty()) + { + let version = row.key_version.ok_or(Error::Verify)?; + let expected = match &row.key { + super::Key::Text(id) => proof.network(id, version), + super::Key::Pair(id, path) => proof.seed(id, path, version), + _ => return Err(Error::Verify), + }; + match expected { + None if index == 0 => counts.missing_networks += 1, + None => counts.missing_seeds += 1, + Some(public) if Some(public.as_slice()) != row.public_key.as_deref() => { + if index == 0 { + counts.mismatched_networks += 1; + } else { + counts.mismatched_seeds += 1; + } + } + _ => {} + } + } + after = Some(row.key); + } + } + } + Ok(counts) +} + +/// Offline runner entry. Verify the file and separate pin before any DB call. +/// Serving must be stopped externally; root acquisition is supplied by the +/// rotation integration, never read from a new production configuration flag. +pub async fn run_offline( + db: &C, + overlap: &Rings, + path: &Path, + expected_sha256: &str, + batch: u64, +) -> Result { + let proof = VerifiedManifest::load(path, expected_sha256)?; + if batch == 0 || batch > 10000 { + return Err(Error::Checkpoint); + } + let counts = coverage(db, &proof).await?; + if counts.blocked() { + return Err(Error::ManifestCoverage(counts)); + } + let active = super::activate(db, overlap).await?; + loop { + let progress = super::step(db, &active, batch, &proof).await?; + if progress.complete { + return Ok(progress); + } + } +} diff --git a/tinycloud-core/src/reencrypt_tests.rs b/tinycloud-core/src/reencrypt_tests.rs index 6039e59a..6502d85e 100644 --- a/tinycloud-core/src/reencrypt_tests.rs +++ b/tinycloud-core/src/reencrypt_tests.rs @@ -13,11 +13,18 @@ fn rings() -> Rings { Rings::from_source(&Source, [8; 32], [7; 32], [9; 32]).unwrap() } // Test identities come from trusted creation inputs, never queried row fields. -fn trusted_seed_identities() -> TrustedSeedIdentities { - let mut trusted = TrustedSeedIdentities::new(); +fn trusted_document() -> manifest::Document { + let mut rows = Vec::new(); let backend = LocalOneOfOneBackend::new(rings().seals); for (name, byte) in [("alpha", 31), ("beta", 32)] { let network = NetworkId::new(OWNER, name).unwrap(); + rows.push(manifest::IdentityRow { + kind: manifest::Kind::Network, + network_id: network.to_string(), + key_path: None, + key_version: 1, + public_key: hex::encode(PublicKey::from(&StaticSecret::from([byte; 32])).as_bytes()), + }); for path in ["apps/app/a", "apps/app/z"] { let location = KeyLocation::new(&network, path).unwrap(); let public = backend @@ -27,13 +34,53 @@ fn trusted_seed_identities() -> TrustedSeedIdentities { Some(&KeySeed::from_bytes([43; 32])), ) .unwrap(); - trusted.insert( - (network.to_string(), path.to_string(), 1), - public.try_into().unwrap(), - ); + rows.push(manifest::IdentityRow { + kind: manifest::Kind::Seed, + network_id: network.to_string(), + key_path: Some(path.into()), + key_version: 1, + public_key: hex::encode(public), + }); } } - trusted + rows.sort(); + manifest::Document { + version: 1, + provenance: manifest::Provenance { + source_database: "tinycloud-node-production-usw".into(), + backup_id: "test-backup".into(), + snapshot_timestamp: "2026-01-01T00:00:00Z".into(), + snapshot_sha256: crate::encryption_network::canonical::hash_hex( + b"trusted test snapshot", + ), + exported_by: "test-custodian".into(), + storage_location: "test-snapshot://fixture".into(), + custody_record: "test-custody://record".into(), + }, + rows, + } +} +fn verify_document(mut document: manifest::Document) -> VerifiedManifest { + document.rows.sort(); + let bytes = serde_json::to_vec(&document).unwrap(); + VerifiedManifest::verify( + &bytes, + &crate::encryption_network::canonical::hash_hex(&bytes), + ) + .unwrap() +} +fn trusted_seed_identities() -> VerifiedManifest { + verify_document(trusted_document()) +} +fn empty_manifest() -> VerifiedManifest { + let mut doc = trusted_document(); + doc.rows.clear(); + verify_document(doc) +} +fn seedless_manifest() -> VerifiedManifest { + let mut doc = trusted_document(); + doc.rows.retain(|r| r.kind == manifest::Kind::Network); + verify_document(doc) } async fn database() -> DatabaseConnection { let db = Database::connect("sqlite::memory:").await.unwrap(); @@ -907,15 +954,11 @@ async fn missing_legacy_seed_witness_case(db: &DatabaseConnection) { fixtures(db).await; let active = activate(db, &rings()).await.unwrap(); // Commit both network rewrites and advance to the seed page. - assert!(step(db, &active, 2, &TrustedSeedIdentities::new()) - .await - .is_ok()); - assert!(step(db, &active, 2, &TrustedSeedIdentities::new()) - .await - .is_ok()); + assert!(step(db, &active, 2, &seedless_manifest()).await.is_ok()); + assert!(step(db, &active, 2, &seedless_manifest()).await.is_ok()); let checkpoint = progress(db).await.unwrap().rewritten; assert!(matches!( - step(db, &active, 2, &TrustedSeedIdentities::new()).await, + step(db, &active, 2, &seedless_manifest()).await, Err(Error::Integrity { .. }) )); assert_eq!(progress(db).await.unwrap().rewritten, checkpoint); @@ -967,11 +1010,20 @@ async fn postgres_legacy_network_requires_unique_completed_ceremony() { async fn supplied_seed_witness_case(db: &DatabaseConnection, variant: &str) { fixtures(db).await; - let mut trusted = trusted_seed_identities(); + let mut document = trusted_document(); let network = NetworkId::new(OWNER, "alpha").unwrap(); let path = "apps/app/a"; if variant == "mismatch" { - trusted.insert((network.to_string(), path.into(), 1), [0; 32]); + document + .rows + .iter_mut() + .find(|r| { + r.kind == manifest::Kind::Seed + && r.network_id == network.to_string() + && r.key_path.as_deref() == Some(path) + }) + .unwrap() + .public_key = hex::encode([0; 32]); } else if variant == "transplant" { let backend = LocalOneOfOneBackend::new(rings().seals); let public = backend @@ -983,6 +1035,7 @@ async fn supplied_seed_witness_case(db: &DatabaseConnection, variant: &str) { .unwrap(); exec(db,"UPDATE encryption_network_key SET public_key=?,sealed_seed=? WHERE network_id=? AND key_path=?",vec![public.into(),ColumnEncryption::new([9;32]).encrypt_legacy(&[44;32]).into(),network.to_string().into(),path.into()]).await; } + let trusted = verify_document(document); let active = activate(db, &rings()).await.unwrap(); assert!(step(db, &active, 2, &trusted).await.is_ok()); assert!(step(db, &active, 2, &trusted).await.is_ok()); @@ -1052,3 +1105,371 @@ async fn postgres_legacy_seed_external_witness_rejects_mismatch_and_transplant() supplied_seed_witness_case(&f.conn, variant).await; } } + +async fn forged_ceremony_case(db: &DatabaseConnection) { + fixtures(db).await; + exec(db, "DELETE FROM encryption_network_key", vec![]).await; + let network = NetworkId::new(OWNER, "alpha").unwrap().to_string(); + let copied = StaticSecret::from([32; 32]); + let public = PublicKey::from(&copied); + exec( + db, + "UPDATE encryption_network SET public_key=?,sealed_private_key=? WHERE network_id=?", + vec![ + public.as_bytes().to_vec().into(), + ColumnEncryption::new([9; 32]) + .encrypt_legacy(copied.as_bytes()) + .into(), + network.clone().into(), + ], + ) + .await; + exec( + db, + "UPDATE encryption_ceremony SET transcript_hash=? WHERE network_id=?", + vec![ + crate::encryption_network::canonical::hash_hex(public.as_bytes()).into(), + network.into(), + ], + ) + .await; + let active = activate(db, &rings()).await.unwrap(); + let mut blocked = false; + for _ in 0..100 { + match step(db, &active, 1, &trusted_seed_identities()).await { + Err(Error::Integrity { .. }) => { + blocked = true; + break; + } + Ok(page) => assert!(!page.complete), + Err(other) => panic!("unexpected migration failure: {other}"), + } + } + assert!(blocked); + assert_eq!(progress(db).await.unwrap().rewritten, 0); + assert_eq!(census(db).await.unwrap()[0].legacy, 2); +} + +#[tokio::test] +async fn cond_sqlite_forged_ceremony_cannot_authorize_network_transplant() { + forged_ceremony_case(&database().await).await; +} + +#[tokio::test] +async fn cond_postgres_forged_ceremony_cannot_authorize_network_transplant() { + let Some(f) = + crate::test_support::PostgresTestSchema::new("postgres_rekey_forged_ceremony").await + else { + return; + }; + forged_ceremony_case(&f.conn).await; +} + +async fn absent_network_witness_case(db: &DatabaseConnection) { + fixtures(db).await; + let active = activate(db, &rings()).await.unwrap(); + assert!(matches!( + step(db, &active, 1, &empty_manifest()).await, + Err(Error::Integrity { .. }) + )); + assert_eq!(progress(db).await.unwrap().rewritten, 0); + assert_eq!(census(db).await.unwrap()[0].legacy, 2); +} + +#[tokio::test] +async fn cond_sqlite_network_requires_external_witness() { + absent_network_witness_case(&database().await).await; +} + +#[tokio::test] +async fn cond_postgres_network_requires_external_witness() { + let Some(f) = + crate::test_support::PostgresTestSchema::new("postgres_rekey_absent_network_witness").await + else { + return; + }; + absent_network_witness_case(&f.conn).await; +} + +#[test] +fn manifest_requires_pinned_hash_date_custody_and_canonical_encoding() { + let document = trusted_document(); + let bytes = serde_json::to_vec(&document).unwrap(); + let hash = crate::encryption_network::canonical::hash_hex(&bytes); + assert!(VerifiedManifest::verify(&bytes, &hash).is_ok()); + assert!(VerifiedManifest::verify(&bytes, &"00".repeat(32)).is_err()); + let pretty = serde_json::to_vec_pretty(&document).unwrap(); + assert!(VerifiedManifest::verify( + &pretty, + &crate::encryption_network::canonical::hash_hex(&pretty) + ) + .is_err()); + for variant in 0..10 { + let mut doc = document.clone(); + match variant { + 0 => doc.provenance.custody_record.clear(), + 1 => doc.provenance.exported_by.clear(), + 2 => doc.provenance.snapshot_timestamp = "not-a-date".into(), + 3 => doc.provenance.snapshot_timestamp = "9999-01-01T00:00:00Z".into(), + 4 => doc.provenance.snapshot_sha256 = "wrong".into(), + 5 => doc.provenance.backup_id.clear(), + 6 => doc.provenance.storage_location.clear(), + 7 => { + doc.rows.push(doc.rows[0].clone()); + doc.rows.sort(); + } + 8 => doc.rows.reverse(), + _ => doc.provenance.source_database = "other".into(), + } + let bytes = serde_json::to_vec(&doc).unwrap(); + assert!(VerifiedManifest::verify( + &bytes, + &crate::encryption_network::canonical::hash_hex(&bytes) + ) + .is_err()); + } +} + +fn manifest_test_dir() -> std::path::PathBuf { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "tc2713-manifest-{}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + std::fs::create_dir(&path).unwrap(); + path +} + +async fn manifest_runner_case(db: &DatabaseConnection) { + fixtures(db).await; + let dir = manifest_test_dir(); + let path = dir.join("manifest.json"); + let bytes = serde_json::to_vec(&trusted_document()).unwrap(); + let pin = crate::encryption_network::canonical::hash_hex(&bytes); + std::fs::write(&path, &bytes).unwrap(); + assert!(matches!( + manifest::run_offline(db, &rings(), &path, &"00".repeat(32), 1).await, + Err(Error::Manifest) + )); + let target = || { + bound( + db.get_database_backend(), + "SELECT target FROM storage_rekey WHERE id=1", + vec![], + ) + }; + assert!(matches!( + manifest::run_offline(db, &rings(), &path, &pin, 0).await, + Err(Error::Checkpoint) + )); + assert_eq!( + db.query_one(target()) + .await + .unwrap() + .unwrap() + .try_get::("", "target") + .unwrap(), + 0 + ); + let mut absent = trusted_document(); + absent.rows.clear(); + let bytes = serde_json::to_vec(&absent).unwrap(); + std::fs::write(&path, &bytes).unwrap(); + match manifest::run_offline( + db, + &rings(), + &path, + &crate::encryption_network::canonical::hash_hex(&bytes), + 1, + ) + .await + { + Err(Error::ManifestCoverage(c)) => { + assert_eq!(c.missing_networks, 2); + assert_eq!(c.missing_seeds, 4); + } + _ => panic!("missing identities must be a blocking category"), + } + assert_eq!( + db.query_one(target()) + .await + .unwrap() + .unwrap() + .try_get::("", "target") + .unwrap(), + 0 + ); + let bytes = serde_json::to_vec(&trusted_document()).unwrap(); + std::fs::write(&path, &bytes).unwrap(); + let network = NetworkId::new(OWNER, "alpha").unwrap(); + let new_path = "docs/after-snapshot"; + let public = LocalOneOfOneBackend::new(rings().seals) + .derive_named_public_key( + &StaticSecret::from([31; 32]), + &canonical_key_urn(&KeyLocation::new(&network, new_path).unwrap()), + None, + ) + .unwrap(); + exec(db,"INSERT INTO encryption_network_key (network_id,key_path,kind,state,public_key,sealed_seed,key_version,created_at,updated_at) VALUES (?,?,'deterministic','active',?,NULL,1,'now','now')",vec![network.to_string().into(),new_path.into(),public.into()]).await; + match manifest::run_offline(db, &rings(), &path, &pin, 1).await { + Err(Error::ManifestCoverage(c)) => assert_eq!(c.missing_seeds, 1), + _ => panic!("post-snapshot identities must block, including keys with no seed"), + } + assert_eq!( + db.query_one(target()) + .await + .unwrap() + .unwrap() + .try_get::("", "target") + .unwrap(), + 0 + ); + exec( + db, + "DELETE FROM encryption_network_key WHERE network_id=? AND key_path=?", + vec![network.to_string().into(), new_path.into()], + ) + .await; + let beta = NetworkId::new(OWNER, "beta").unwrap().to_string(); + exec(db,"UPDATE encryption_network SET public_key=?,sealed_private_key=NULL,state='pending' WHERE network_id=?",vec![Vec::::new().into(),beta.clone().into()]).await; + match manifest::run_offline(db, &rings(), &path, &pin, 1).await { + Err(Error::ManifestCoverage(c)) => assert_eq!(c.mismatched_networks, 1), + _ => panic!("an allocated snapshot identity cannot become an ignored pending row"), + } + let secret = StaticSecret::from([32; 32]); + exec(db,"UPDATE encryption_network SET public_key=?,sealed_private_key=?,state='active' WHERE network_id=?",vec![PublicKey::from(&secret).as_bytes().to_vec().into(),ColumnEncryption::new([9;32]).encrypt_legacy(secret.as_bytes()).into(),beta.into()]).await; + assert!( + manifest::run_offline(db, &rings(), &path, &pin, 1) + .await + .unwrap() + .complete + ); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[tokio::test] +async fn sqlite_manifest_runner_checks_proof_before_fencing_and_completes() { + manifest_runner_case(&database().await).await; +} + +#[tokio::test] +async fn postgres_manifest_runner_checks_proof_before_fencing_and_completes() { + let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_manifest_runner").await + else { + return; + }; + manifest_runner_case(&f.conn).await; +} + +async fn exporter_case(db: &DatabaseConnection, postgres: bool) { + let dir = manifest_test_dir(); + let archive = dir.join("snapshot.dump"); + let output = dir.join("manifest.json"); + let file_db = if postgres { + None + } else { + let db = Database::connect(format!("sqlite:{}?mode=rwc", archive.display())) + .await + .unwrap(); + crate::migrations::Migrator::up(&db, None).await.unwrap(); + Some(db) + }; + let db = file_db.as_ref().unwrap_or(db); + fixtures(db).await; + let mut command = std::process::Command::new("python3"); + command.arg(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../tools/export-rekey-manifest.py" + )); + if postgres { + let url = crate::test_support::postgres_test_url("manifest_exporter").unwrap(); + let schema: String = db + .query_one(bound( + db.get_database_backend(), + "SELECT current_schema() AS schema", + vec![], + )) + .await + .unwrap() + .unwrap() + .try_get("", "schema") + .unwrap(); + let result = std::process::Command::new("pg_dump") + .args([ + "--no-password", + "--dbname", + &url, + "--schema", + &schema, + "--file", + ]) + .arg(&archive) + .env("PGPASSFILE", "/dev/null") + .env_remove("PGHOSTADDR") + .env_remove("PGSERVICE") + .env_remove("PGSERVICEFILE") + .env("PGOPTIONS", "-c default_transaction_read_only=on") + .output() + .unwrap(); + assert!(result.status.success(), "test snapshot export failed"); + command.args(["--restored-postgres-url", &url, "--schema", &schema]); + } else { + db.query_one(bound( + db.get_database_backend(), + "PRAGMA wal_checkpoint(TRUNCATE)", + vec![], + )) + .await + .unwrap(); + command.arg("--sqlite-snapshot").arg(&archive); + } + let result = command + .arg("--snapshot-file") + .arg(&archive) + .args([ + "--backup-id", + "test-backup", + "--snapshot-timestamp", + "2026-01-01T00:00:00Z", + "--exported-by", + "test-custodian", + "--storage-location", + "test-snapshot://archive", + "--custody-record", + "test-custody://record", + "--output", + ]) + .arg(&output) + .output() + .unwrap(); + assert!(result.status.success(), "read-only manifest export failed"); + let report: serde_json::Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(report.as_object().unwrap().len(), 3); + let proof = + VerifiedManifest::load(&output, report["manifest_sha256"].as_str().unwrap()).unwrap(); + assert_eq!(proof.counts(), (2, 4)); + assert!(!manifest::coverage(db, &proof).await.unwrap().blocked()); + assert_eq!(census(db).await.unwrap()[0].legacy, 2); + let bytes = std::fs::read(&output).unwrap(); + let doc: manifest::Document = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + doc.provenance.snapshot_sha256, + crate::encryption_network::canonical::hash_hex(&std::fs::read(&archive).unwrap()) + ); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[tokio::test] +async fn sqlite_read_only_snapshot_export_is_canonical_and_loadable() { + exporter_case(&database().await, false).await; +} + +#[tokio::test] +async fn postgres_read_only_snapshot_export_is_canonical_and_loadable() { + let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_manifest_export").await + else { + return; + }; + exporter_case(&f.conn, true).await; +} diff --git a/tinycloud-node-server/src/routes/encryption.rs b/tinycloud-node-server/src/routes/encryption.rs index 7ec7bac1..c8a78022 100644 --- a/tinycloud-node-server/src/routes/encryption.rs +++ b/tinycloud-node-server/src/routes/encryption.rs @@ -783,6 +783,7 @@ fn map_key_service_err(err: EncryptionServiceError) -> (Status, String) { pub(crate) fn map_service_err(err: EncryptionServiceError) -> (Status, String) { let status = match err { + EncryptionServiceError::MigrationRequired => Status::ServiceUnavailable, EncryptionServiceError::NetworkNotFound => Status::NotFound, EncryptionServiceError::NetworkAlreadyExists => Status::Conflict, EncryptionServiceError::Db(_) => Status::InternalServerError, @@ -823,3 +824,28 @@ pub(crate) fn map_service_err(err: EncryptionServiceError) -> (Status, String) { #[cfg(feature = "dstack")] pub fn _docs() {} + +#[cfg(test)] +mod migration_required_tests { + use super::*; + use tinycloud_core::encryption::ColumnEncryption; + use tinycloud_core::encryption_network::backend::{KeyBackend, LocalOneOfOneBackend}; + + #[test] + fn legacy_seals_have_a_clear_unavailable_response() { + let ring = ColumnEncryption::new([7; 32]); + let legacy = ring.encrypt_legacy(&[42; 32]); + let backend = LocalOneOfOneBackend::new(ring); + for err in [ + backend + .open_network_secret(&legacy, "network", &[0; 32]) + .err() + .unwrap(), + backend.open_seed(&legacy, "network", "path").err().unwrap(), + ] { + let (status, message) = map_service_err(EncryptionServiceError::from(err)); + assert_eq!(status, Status::ServiceUnavailable); + assert!(message.starts_with("migration_required:")); + } + } +} diff --git a/tools/export-rekey-manifest.py b/tools/export-rekey-manifest.py new file mode 100644 index 00000000..9db61899 --- /dev/null +++ b/tools/export-rekey-manifest.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Export public identities from a locally restored, custody-recorded backup. + +No production endpoint is accepted. Output is canonical manifest bytes; terminal +output contains only its SHA-256 and network/seed counts. +""" +import argparse +import datetime +import hashlib +import ipaddress +import json +import os +from pathlib import Path +import re +import sqlite3 +import subprocess +import sys +from urllib.parse import unquote, urlsplit + + +class Parser(argparse.ArgumentParser): + def error(self, message): + raise ValueError("invalid exporter arguments") + + +def canonical_rows(records): + rows, seen = [], set() + for record in records: + kind, network, path, version, public = record + if kind == "network" and not public: + continue # Pending network with no allocated public key. + if isinstance(public, bytes): + public = public.hex() + if (kind not in ("network", "seed") or not isinstance(network, str) + or not network.startswith("urn:tinycloud:encryption:") + or type(version) is not int or not 1 <= version < 2**63 + or not re.fullmatch("[0-9a-f]{64}", public) + or (kind == "network" and path is not None) + or (kind == "seed" and (not isinstance(path, str) or not path))): + raise ValueError("invalid snapshot identity") + identity = (kind, network, path, version) + if identity in seen: + raise ValueError("duplicate snapshot identity") + seen.add(identity) + rows.append(dict(kind=kind, network_id=network, key_path=path, + key_version=version, public_key=public)) + rows.sort(key=lambda r: (r["kind"], r["network_id"], r["key_path"] or "", r["key_version"])) + return rows + + +def read_sqlite(path): + for suffix in ("-wal", "-journal"): + sidecar = Path(str(path) + suffix) + if sidecar.exists() and sidecar.stat().st_size: + raise ValueError("snapshot must be self-contained") + with sqlite3.connect(Path(path).resolve().as_uri() + "?mode=ro", uri=True) as db: + db.execute("PRAGMA query_only=ON") + db.execute("BEGIN") + networks = [("network", n, None, v, p) for n, v, p in db.execute( + "SELECT network_id,key_version,public_key FROM encryption_network")] + seeds = [("seed", n, k, v, p) for n, k, v, p in db.execute( + "SELECT network_id,key_path,key_version,public_key FROM encryption_network_key")] + return canonical_rows(networks + seeds) + + +def read_postgres(url, schema): + parsed = urlsplit(url) + host = parsed.hostname + if (parsed.scheme not in ("postgres", "postgresql") or parsed.query or parsed.fragment + or unquote(parsed.path).rstrip("/").endswith("/tinycloud-node-production-usw")): + raise ValueError("only an explicit restored loopback database is accepted") + if host != "localhost" and (not host or not ipaddress.ip_address(host).is_loopback): + raise ValueError("only loopback is accepted") + if not re.fullmatch("[a-zA-Z_][a-zA-Z0-9_]*", schema): + raise ValueError("invalid restored schema") + env = {k: v for k, v in os.environ.items() if not k.startswith("PG")} + env.update(PGOPTIONS="-c default_transaction_read_only=on", PGPASSFILE=os.devnull) + query = '''BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY; +SET LOCAL search_path TO "SCHEMA"; +SELECT json_build_array('network',network_id,NULL,key_version,encode(public_key,'hex')) FROM encryption_network; +SELECT json_build_array('seed',network_id,key_path,key_version,encode(public_key,'hex')) FROM encryption_network_key; +COMMIT;'''.replace("SCHEMA", schema) + result = subprocess.run(["psql", "--no-psqlrc", "--no-password", "--quiet", + "--tuples-only", "--no-align", "--dbname", url, + "--set", "ON_ERROR_STOP=1", "--file", "-"], + input=query, capture_output=True, text=True, env=env, check=True) + return canonical_rows([json.loads(line) for line in result.stdout.splitlines() if line]) + + +def main(argv=None): + parser = Parser(description=__doc__) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--sqlite-snapshot") + source.add_argument("--restored-postgres-url") + parser.add_argument("--schema", default="public") + for flag in ("snapshot-file", "backup-id", "snapshot-timestamp", "exported-by", + "storage-location", "custody-record", "output"): + parser.add_argument("--" + flag, required=True) + args = parser.parse_args(argv) + metadata = [args.backup_id, args.exported_by, args.storage_location, args.custody_record] + if any(not value.strip() for value in metadata): + raise ValueError("missing custody metadata") + date = datetime.datetime.fromisoformat(args.snapshot_timestamp.replace("Z", "+00:00")) + if (not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})", args.snapshot_timestamp) + or date.tzinfo is None or date > datetime.datetime.now(datetime.timezone.utc)): + raise ValueError("invalid snapshot date") + if args.sqlite_snapshot and Path(args.sqlite_snapshot).resolve() != Path(args.snapshot_file).resolve(): + raise ValueError("SQLite snapshot must be the checksummed archive itself") + archive_hash = hashlib.sha256() + with open(args.snapshot_file, "rb") as archive: + for block in iter(lambda: archive.read(1024 * 1024), b""): + archive_hash.update(block) + rows = (read_sqlite(args.sqlite_snapshot) if args.sqlite_snapshot else + read_postgres(args.restored_postgres_url, args.schema)) + provenance = dict(source_database="tinycloud-node-production-usw", backup_id=args.backup_id, + snapshot_timestamp=args.snapshot_timestamp, snapshot_sha256=archive_hash.hexdigest(), + exported_by=args.exported_by, storage_location=args.storage_location, + custody_record=args.custody_record) + document = dict(version=1, provenance=provenance, rows=rows) + data = json.dumps(document, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + with open(args.output, "xb") as output: + output.write(data) + print(json.dumps(dict(manifest_sha256=hashlib.sha256(data).hexdigest(), + networks=sum(r["kind"] == "network" for r in rows), + seeds=sum(r["kind"] == "seed" for r in rows)), separators=(",", ":"))) + + +if __name__ == "__main__": + try: + main() + except Exception: + print("manifest export failed", file=sys.stderr) + sys.exit(1) diff --git a/tools/test_export_rekey_manifest.py b/tools/test_export_rekey_manifest.py new file mode 100644 index 00000000..15115b62 --- /dev/null +++ b/tools/test_export_rekey_manifest.py @@ -0,0 +1,38 @@ +import importlib.util +from pathlib import Path +import unittest +from unittest import mock + +spec = importlib.util.spec_from_file_location("rekey_export", Path(__file__).with_name("export-rekey-manifest.py")) +exporter = importlib.util.module_from_spec(spec) +spec.loader.exec_module(exporter) + + +class ExportGuards(unittest.TestCase): + def test_nonlocal_live_database_and_override_urls_are_refused_before_connecting(self): + with mock.patch.object(exporter.subprocess, "run") as connect: + for url in ( + "postgres://example.invalid/restored", + "postgres://127.0.0.1/tinycloud-node-production-usw", + "postgres://127.0.0.1/%74inycloud-node-production-usw", + "postgres://127.0.0.1/restored?host=example.invalid", + "postgres:///restored", + ): + with self.assertRaises(ValueError): + exporter.read_postgres(url, "public") + connect.assert_not_called() + + def test_schema_injection_is_refused_before_connecting(self): + with mock.patch.object(exporter.subprocess, "run") as connect: + with self.assertRaises(ValueError): + exporter.read_postgres("postgres://127.0.0.1/restored", 'public"; COMMIT;') + connect.assert_not_called() + + def test_duplicate_identities_are_not_collapsed(self): + row = ("network", "urn:tinycloud:encryption:did:key:z6Mk:default", None, 1, "11" * 32) + with self.assertRaises(ValueError): + exporter.canonical_rows([row, row]) + + +if __name__ == "__main__": + unittest.main() From de92d462ea25e3a2ff22ea968b8703c635c02d86 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:32:49 +0000 Subject: [PATCH 5/6] fix: verify snapshot coverage before storage migration Details: TC-2713 --- docs/rekey-manifest.md | 44 ++++++++--- tinycloud-core/src/reencrypt.rs | 4 +- tinycloud-core/src/reencrypt_manifest.rs | 25 ++++++- tinycloud-core/src/reencrypt_tests.rs | 93 ++++++++++++++++++++---- tools/export-rekey-manifest.py | 6 +- tools/test_export_rekey_manifest.py | 17 +++++ 6 files changed, 158 insertions(+), 31 deletions(-) diff --git a/docs/rekey-manifest.md b/docs/rekey-manifest.md index 6b2edfd9..13635a3d 100644 --- a/docs/rekey-manifest.md +++ b/docs/rekey-manifest.md @@ -9,22 +9,42 @@ record, not a hash generated from the target database during migration. The source is a dated, trusted PlanetScale backup snapshot of `tinycloud-node-production-usw`. The custodian records the backup ID, RFC3339 snapshot timestamp, archive SHA-256, exporter identity, storage location and -custody record reference. Restore the checked archive into an isolated local -database with no other writers. For PostgreSQL, use a different local database -name; the exporter refuses the live database name and non-loopback endpoints. -No production endpoint is queried by the exporter. - -The custodian must verify that the restored source corresponds to the recorded -archive, authenticate the export, and record the manifest SHA-256 in the separate -access-controlled custody record before the offline window. A checksum supplied -beside an untrusted file without that custody process is not an authenticated pin. -The runner takes the file and the independently obtained pin; it never chooses -the pin by hashing whichever file it receives. +custody record reference. Naomi designates the rotation custodian, who holds the +separately pinned manifest digest. Before the offline window, that custodian must +record the archive and manifest SHA-256 values, backup ID, snapshot timestamp, +exporter, storage location and custody reference in an authenticated private +TC-2713 Linear comment, and record the same manifest digest in a TinyCloud +Secrets entry named `TC2713_REKEY_MANIFEST_SHA256`. These records are operational +prerequisites; the exporter does not create them. + +The custodian verifies the archive digest against that custody record, then +restores **that exact archive** into a newly created, isolated local database or +private PostgreSQL cluster with no other writers. Record the restore command, +local cluster/database identity and checked archive digest in the custody record. +For PostgreSQL, use a different local database name. Tunnels, proxies, port +forwards, `pscale connect` and SSH forwarding are prohibited during export: +loopback alone does not prove the source is a local restore. The exporter rejects +remote and multi-host destinations, query overrides and the live database name, +but cannot establish that a supplied archive produced the connected PostgreSQL +database. The custodian's checked restore and custody record provide that binding; +hashing an unrelated `--snapshot-file` is insufficient. No production endpoint +may be queried by the exporter. + +The runner operator independently authenticates to Linear and TinyCloud Secrets, +retrieves the digest from both custody records, and compares them before supplying +the expected digest to the runner. Stop if either record is unavailable or they +differ. Do not obtain the expected digest from the target database, the manifest +file, a sidecar delivered with it, or a fresh hash of the received file. The runner +takes the file and this independently obtained pin; it never chooses its own pin. Networks or seeds created after the snapshot are not covered. Take a fresh trusted snapshot immediately before the migration window. Missing identities are blocking categories, never exemptions. Client-held authenticated key pins are a possible later second source, not an implemented fallback. +Coverage also checks the reverse direction: every snapshot network must still +exist at its pinned key version with the same public key and a stored seal. +Deleting it, changing its version or clearing it to pending blocks migration +before the writer fence. ## Canonical version 1 encoding @@ -86,6 +106,8 @@ file is converted into private network/seed maps inside `VerifiedManifest`; ther is no raw-map conversion into that type. Coverage runs in bounded pages and reports only missing/mismatched network and seed counts. Any nonzero category blocks activation. Per-row migration checks remain mandatory after coverage. +The activation and page-step helpers are crate-private; integrations use +`run_offline` so they cannot bypass the coverage preflight. This entry point does not provide a production root source or stop serving. Do not deploy #541 standalone on nodes with v1 rows. It ships only within the diff --git a/tinycloud-core/src/reencrypt.rs b/tinycloud-core/src/reencrypt.rs index 341693e1..a1544c98 100644 --- a/tinycloud-core/src/reencrypt.rs +++ b/tinycloud-core/src/reencrypt.rs @@ -862,7 +862,7 @@ pub(crate) async fn pre_activation_guards( /// All serving instances first install overlap readers with old writers. /// Only after the fence commits does this return rings selecting the new writer. /// Stale writers fail at the database boundary, including inserts behind cursors. -pub async fn activate( +pub(crate) async fn activate( db: &C, rings: &Rings, ) -> Result { @@ -995,7 +995,7 @@ async fn check_alias( /// One transaction, one bounded page. Dropping this future rolls the whole page /// back; committed cursors and counters advance with the verified ciphertext. -pub async fn step( +pub(crate) async fn step( db: &C, rings: &Rings, batch: u64, diff --git a/tinycloud-core/src/reencrypt_manifest.rs b/tinycloud-core/src/reencrypt_manifest.rs index d7339516..d9cdb0ec 100644 --- a/tinycloud-core/src/reencrypt_manifest.rs +++ b/tinycloud-core/src/reencrypt_manifest.rs @@ -3,7 +3,10 @@ use super::{select_batch, Error, Rings, COLUMNS}; use crate::encryption_network::{key_id::KeyLocation, NetworkId}; use sea_orm::{ConnectionTrait, TransactionTrait}; use serde::{Deserialize, Serialize}; -use std::{collections::BTreeMap, path::Path}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, +}; #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -182,6 +185,10 @@ pub async fn coverage( proof: &VerifiedManifest, ) -> Result { let mut counts = Coverage::default(); + // A DB row cannot erase a snapshot allocation by changing its version, + // clearing it to pending, or disappearing entirely. No extra DB scan or + // unbounded transaction is needed: remove exact identities as pages arrive. + let mut unseen_networks: BTreeSet<_> = proof.networks.keys().cloned().collect(); for (index, spec) in COLUMNS[..2].iter().enumerate() { let mut after = None; loop { @@ -201,7 +208,10 @@ pub async fn coverage( { let version = row.key_version.ok_or(Error::Verify)?; let expected = match &row.key { - super::Key::Text(id) => proof.network(id, version), + super::Key::Text(id) => { + unseen_networks.remove(&(id.clone(), version)); + proof.network(id, version) + } super::Key::Pair(id, path) => proof.seed(id, path, version), _ => return Err(Error::Verify), }; @@ -215,6 +225,16 @@ pub async fn coverage( counts.mismatched_seeds += 1; } } + Some(_) + if index == 0 + && (row.blob.as_ref().is_none_or(|b| b.is_empty()) + || !matches!( + row.state.as_deref(), + Some("active" | "rotating" | "revoked") + )) => + { + counts.missing_networks += 1; + } _ => {} } } @@ -222,6 +242,7 @@ pub async fn coverage( } } } + counts.missing_networks += unseen_networks.len() as u64; Ok(counts) } diff --git a/tinycloud-core/src/reencrypt_tests.rs b/tinycloud-core/src/reencrypt_tests.rs index 6502d85e..3c233220 100644 --- a/tinycloud-core/src/reencrypt_tests.rs +++ b/tinycloud-core/src/reencrypt_tests.rs @@ -1230,21 +1230,17 @@ fn manifest_requires_pinned_hash_date_custody_and_canonical_encoding() { } } -fn manifest_test_dir() -> std::path::PathBuf { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let path = std::env::temp_dir().join(format!( - "tc2713-manifest-{}-{}", - std::process::id(), - NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - )); - std::fs::create_dir(&path).unwrap(); - path +fn manifest_test_dir() -> tempfile::TempDir { + tempfile::Builder::new() + .prefix("tc2713-manifest-") + .tempdir() + .unwrap() } async fn manifest_runner_case(db: &DatabaseConnection) { fixtures(db).await; let dir = manifest_test_dir(); - let path = dir.join("manifest.json"); + let path = dir.path().join("manifest.json"); let bytes = serde_json::to_vec(&trusted_document()).unwrap(); let pin = crate::encryption_network::canonical::hash_hex(&bytes); std::fs::write(&path, &bytes).unwrap(); @@ -1345,7 +1341,6 @@ async fn manifest_runner_case(db: &DatabaseConnection) { .unwrap() .complete ); - std::fs::remove_dir_all(dir).unwrap(); } #[tokio::test] @@ -1353,6 +1348,77 @@ async fn sqlite_manifest_runner_checks_proof_before_fencing_and_completes() { manifest_runner_case(&database().await).await; } +async fn manifest_snapshot_network_case(db: &DatabaseConnection, version_changed: bool) { + fixtures(db).await; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("manifest.json"); + let bytes = serde_json::to_vec(&trusted_document()).unwrap(); + let pin = crate::encryption_network::canonical::hash_hex(&bytes); + std::fs::write(&path, bytes).unwrap(); + let beta = NetworkId::new(OWNER, "beta").unwrap().to_string(); + exec( + db, + "DELETE FROM encryption_network_key WHERE network_id=?", + vec![beta.clone().into()], + ) + .await; + if version_changed { + exec(db,"UPDATE encryption_network SET key_version=7,public_key=?,sealed_private_key=NULL,state='pending' WHERE network_id=?",vec![Vec::::new().into(),beta.into()]).await; + } else { + exec(db,"UPDATE encryption_network SET sealed_private_key=NULL,state='pending' WHERE network_id=?",vec![beta.into()]).await; + } + let counts = manifest::coverage(db, &trusted_seed_identities()) + .await + .unwrap(); + assert!( + counts.blocked(), + "snapshot network identity and seal must remain present at the pinned version" + ); + assert!(matches!( + manifest::run_offline(db, &rings(), &path, &pin, 1).await, + Err(Error::ManifestCoverage(_)) + )); + let row = db + .query_one(bound( + db.get_database_backend(), + "SELECT target,rewritten FROM storage_rekey WHERE id=1", + vec![], + )) + .await + .unwrap() + .unwrap(); + assert_eq!(row.try_get::("", "target").unwrap(), 0); + assert_eq!(row.try_get::("", "rewritten").unwrap(), 0); +} + +#[tokio::test] +async fn sqlite_manifest_snapshot_version_mutation_blocks_before_fence() { + manifest_snapshot_network_case(&database().await, true).await; +} + +#[tokio::test] +async fn postgres_manifest_snapshot_version_mutation_blocks_before_fence() { + let Some(f) = crate::test_support::PostgresTestSchema::new("manifest_snapshot_version").await + else { + return; + }; + manifest_snapshot_network_case(&f.conn, true).await; +} + +#[tokio::test] +async fn sqlite_manifest_snapshot_missing_seal_blocks_before_fence() { + manifest_snapshot_network_case(&database().await, false).await; +} + +#[tokio::test] +async fn postgres_manifest_snapshot_missing_seal_blocks_before_fence() { + let Some(f) = crate::test_support::PostgresTestSchema::new("manifest_snapshot_seal").await + else { + return; + }; + manifest_snapshot_network_case(&f.conn, false).await; +} + #[tokio::test] async fn postgres_manifest_runner_checks_proof_before_fencing_and_completes() { let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_manifest_runner").await @@ -1364,8 +1430,8 @@ async fn postgres_manifest_runner_checks_proof_before_fencing_and_completes() { async fn exporter_case(db: &DatabaseConnection, postgres: bool) { let dir = manifest_test_dir(); - let archive = dir.join("snapshot.dump"); - let output = dir.join("manifest.json"); + let archive = dir.path().join("snapshot.dump"); + let output = dir.path().join("manifest.json"); let file_db = if postgres { None } else { @@ -1457,7 +1523,6 @@ async fn exporter_case(db: &DatabaseConnection, postgres: bool) { doc.provenance.snapshot_sha256, crate::encryption_network::canonical::hash_hex(&std::fs::read(&archive).unwrap()) ); - std::fs::remove_dir_all(dir).unwrap(); } #[tokio::test] diff --git a/tools/export-rekey-manifest.py b/tools/export-rekey-manifest.py index 9db61899..3f9551b5 100644 --- a/tools/export-rekey-manifest.py +++ b/tools/export-rekey-manifest.py @@ -7,7 +7,6 @@ import argparse import datetime import hashlib -import ipaddress import json import os from pathlib import Path @@ -67,9 +66,12 @@ def read_postgres(url, schema): parsed = urlsplit(url) host = parsed.hostname if (parsed.scheme not in ("postgres", "postgresql") or parsed.query or parsed.fragment + or "," in parsed.netloc or unquote(parsed.path).rstrip("/").endswith("/tinycloud-node-production-usw")): raise ValueError("only an explicit restored loopback database is accepted") - if host != "localhost" and (not host or not ipaddress.ip_address(host).is_loopback): + # Accessing port validates the integer and range before libpq sees the URI. + port = parsed.port + if host not in ("localhost", "127.0.0.1", "::1") or (port is not None and port < 1): raise ValueError("only loopback is accepted") if not re.fullmatch("[a-zA-Z_][a-zA-Z0-9_]*", schema): raise ValueError("invalid restored schema") diff --git a/tools/test_export_rekey_manifest.py b/tools/test_export_rekey_manifest.py index 15115b62..34123397 100644 --- a/tools/test_export_rekey_manifest.py +++ b/tools/test_export_rekey_manifest.py @@ -9,6 +9,23 @@ class ExportGuards(unittest.TestCase): + def test_multihost_and_ambiguous_ports_are_refused_before_connecting(self): + for url in ( + "postgres://localhost:5432,db.prod.example:5432/restored", + "postgres://127.0.0.1:1,remote/restored", + "postgres://[::1]:5432,remote:5432/restored", + "postgres://user,name@localhost:5432/restored", + "postgres://localhost:not-a-port/restored", + "postgres://localhost:65536/restored", + "postgres://127.0.0.2/restored", + "postgres://localhost/restored?hostaddr=192.0.2.1", + "postgres://localhost/restored?service=remote", + ): + with self.subTest(url=url), mock.patch.object(exporter.subprocess, "run") as connect: + with self.assertRaises(ValueError): + exporter.read_postgres(url, "public") + connect.assert_not_called() + def test_nonlocal_live_database_and_override_urls_are_refused_before_connecting(self): with mock.patch.object(exporter.subprocess, "run") as connect: for url in ( From 18696d02bfa49a26ba24dd949e38a2e14b27a44e Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:49:04 +0000 Subject: [PATCH 6/6] fix: retain snapshot identities in migration preflight Details: TC-2713 --- docs/rekey-manifest.md | 7 ++- tinycloud-core/src/reencrypt_manifest.rs | 7 ++- tinycloud-core/src/reencrypt_tests.rs | 54 ++++++++++++++++++++++++ tools/export-rekey-manifest.py | 2 + tools/test_export_rekey_manifest.py | 7 +++ 5 files changed, 75 insertions(+), 2 deletions(-) diff --git a/docs/rekey-manifest.md b/docs/rekey-manifest.md index 13635a3d..f48ff54f 100644 --- a/docs/rekey-manifest.md +++ b/docs/rekey-manifest.md @@ -45,6 +45,11 @@ Coverage also checks the reverse direction: every snapshot network must still exist at its pinned key version with the same public key and a stored seal. Deleting it, changing its version or clearing it to pending blocks migration before the writer fence. +Every snapshot seed identity must also remain present at its pinned network, +key path and key version with the same public key. Removing the row blocks +coverage even if its parent network is intact. Existing deterministic or deleted +key rows may retain their schema-valid NULL seed; their public identity is still +checked. ## Canonical version 1 encoding @@ -81,7 +86,7 @@ and, for a restored PostgreSQL snapshot, `psql`. Supply these explicit arguments | Argument | Meaning | | --- | --- | -| `--restored-postgres-url` | Loopback-only restored PostgreSQL database; URL query options are refused. | +| `--restored-postgres-url` | Loopback-only restored PostgreSQL database; lowercase `postgres://` or `postgresql://` prefix required; URL query options are refused. | | `--schema` | Restored schema, default `public`. | | `--sqlite-snapshot` | Alternative read-only SQLite image; must also be `--snapshot-file`. | | `--snapshot-file` | Actual checked backup archive whose bytes the exporter hashes. | diff --git a/tinycloud-core/src/reencrypt_manifest.rs b/tinycloud-core/src/reencrypt_manifest.rs index d9cdb0ec..28f71866 100644 --- a/tinycloud-core/src/reencrypt_manifest.rs +++ b/tinycloud-core/src/reencrypt_manifest.rs @@ -189,6 +189,7 @@ pub async fn coverage( // clearing it to pending, or disappearing entirely. No extra DB scan or // unbounded transaction is needed: remove exact identities as pages arrive. let mut unseen_networks: BTreeSet<_> = proof.networks.keys().cloned().collect(); + let mut unseen_seeds: BTreeSet<_> = proof.seeds.keys().cloned().collect(); for (index, spec) in COLUMNS[..2].iter().enumerate() { let mut after = None; loop { @@ -212,7 +213,10 @@ pub async fn coverage( unseen_networks.remove(&(id.clone(), version)); proof.network(id, version) } - super::Key::Pair(id, path) => proof.seed(id, path, version), + super::Key::Pair(id, path) => { + unseen_seeds.remove(&(id.clone(), path.clone(), version)); + proof.seed(id, path, version) + } _ => return Err(Error::Verify), }; match expected { @@ -243,6 +247,7 @@ pub async fn coverage( } } counts.missing_networks += unseen_networks.len() as u64; + counts.missing_seeds += unseen_seeds.len() as u64; Ok(counts) } diff --git a/tinycloud-core/src/reencrypt_tests.rs b/tinycloud-core/src/reencrypt_tests.rs index 3c233220..5de4a1f4 100644 --- a/tinycloud-core/src/reencrypt_tests.rs +++ b/tinycloud-core/src/reencrypt_tests.rs @@ -1419,6 +1419,60 @@ async fn postgres_manifest_snapshot_missing_seal_blocks_before_fence() { manifest_snapshot_network_case(&f.conn, false).await; } +async fn manifest_deleted_seed_case(db: &DatabaseConnection) { + fixtures(db).await; + let dir = manifest_test_dir(); + let path = dir.path().join("manifest.json"); + let bytes = serde_json::to_vec(&trusted_document()).unwrap(); + let pin = crate::encryption_network::canonical::hash_hex(&bytes); + std::fs::write(&path, bytes).unwrap(); + let network = NetworkId::new(OWNER, "beta").unwrap().to_string(); + exec( + db, + "DELETE FROM encryption_network_key WHERE network_id=? AND key_path=?", + vec![network.into(), "apps/app/a".into()], + ) + .await; + let counts = manifest::coverage(db, &trusted_seed_identities()) + .await + .unwrap(); + assert_eq!( + counts.missing_seeds, 1, + "a removed snapshot seed identity must block coverage" + ); + assert_eq!(counts.missing_networks, 0); + assert_eq!(counts.mismatched_networks, 0); + assert_eq!(counts.mismatched_seeds, 0); + assert!( + matches!(manifest::run_offline(db, &rings(), &path, &pin, 1).await, Err(Error::ManifestCoverage(c)) if c.missing_seeds == 1) + ); + let row = db + .query_one(bound( + db.get_database_backend(), + "SELECT target,rewritten FROM storage_rekey WHERE id=1", + vec![], + )) + .await + .unwrap() + .unwrap(); + assert_eq!(row.try_get::("", "target").unwrap(), 0); + assert_eq!(row.try_get::("", "rewritten").unwrap(), 0); +} + +#[tokio::test] +async fn sqlite_manifest_deleted_seed_blocks_before_fence() { + manifest_deleted_seed_case(&database().await).await; +} + +#[tokio::test] +async fn postgres_manifest_deleted_seed_blocks_before_fence() { + let Some(f) = crate::test_support::PostgresTestSchema::new("manifest_deleted_seed").await + else { + return; + }; + manifest_deleted_seed_case(&f.conn).await; +} + #[tokio::test] async fn postgres_manifest_runner_checks_proof_before_fencing_and_completes() { let Some(f) = crate::test_support::PostgresTestSchema::new("postgres_manifest_runner").await diff --git a/tools/export-rekey-manifest.py b/tools/export-rekey-manifest.py index 3f9551b5..2cdd05c6 100644 --- a/tools/export-rekey-manifest.py +++ b/tools/export-rekey-manifest.py @@ -63,6 +63,8 @@ def read_sqlite(path): def read_postgres(url, schema): + if not url.startswith(("postgres://", "postgresql://")): + raise ValueError("only lowercase PostgreSQL URLs are accepted") parsed = urlsplit(url) host = parsed.hostname if (parsed.scheme not in ("postgres", "postgresql") or parsed.query or parsed.fragment diff --git a/tools/test_export_rekey_manifest.py b/tools/test_export_rekey_manifest.py index 34123397..feca43dc 100644 --- a/tools/test_export_rekey_manifest.py +++ b/tools/test_export_rekey_manifest.py @@ -9,6 +9,13 @@ class ExportGuards(unittest.TestCase): + def test_postgres_scheme_must_be_lowercase_before_connecting(self): + for scheme in ("POSTGRES", "Postgres", "POSTGRESQL", "PostgreSQL"): + with self.subTest(scheme=scheme), mock.patch.object(exporter.subprocess, "run") as connect: + with self.assertRaises(ValueError): + exporter.read_postgres(f"{scheme}://localhost:55478/restored", "public") + connect.assert_not_called() + def test_multihost_and_ambiguous_ports_are_refused_before_connecting(self): for url in ( "postgres://localhost:5432,db.prod.example:5432/restored",