lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 09838bdc2b55123a15f1bdea18b284b341c73d0e
parent 706f459593179d0ac1906f26eca47450d66d7e20
Author: triesap <tyson@radroots.org>
Date:   Wed,  5 Aug 2026 19:12:36 +0000

feat(storage-sqlite): migrate private envelopes to v2

Diffstat:
MCargo.lock | 1+
Mcontracts/storage/private_schema_v1.toml | 35++++++++++++++++++++++++++++++++---
Mcrates/storage_sqlite/Cargo.toml | 1+
Mcrates/storage_sqlite/src/migration.rs | 33+++++++++++++++++++++++++++++++++
Acrates/storage_sqlite/src/migration/private/0004_context_bound_envelopes.up.sql | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/storage_sqlite/src/migration/private/mod.rs | 94++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/storage_sqlite/src/private_artifact/mod.rs | 627++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
7 files changed, 926 insertions(+), 60 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2804,6 +2804,7 @@ name = "radroots_storage_sqlite" version = "0.1.0-alpha" dependencies = [ "fs2", + "hex", "radroots_event", "radroots_event_codec", "radroots_secrets", diff --git a/contracts/storage/private_schema_v1.toml b/contracts/storage/private_schema_v1.toml @@ -2,9 +2,9 @@ schema_version = 1 database = "private.sqlite" application_id = 1380208722 # ASCII "RDPR" minimum_version = 1 -current_version = 3 -migration_name = "legacy_import_commits" -migration_sha256 = "9377f0af8f070d977a5237e2a1294e6977f5b704e7a8434d97a3dc5f4ae75e86" +current_version = 4 +migration_name = "context_bound_envelopes" +migration_sha256 = "dd6bb42471db47fcd9c62f8d59b66ddba381776d879cfbffd94993f7ef7409de" forward_only = true raw_sql_public = false encrypted_envelopes = true @@ -16,6 +16,7 @@ authorities = [ "permitted_nip46_sessions", "legacy_private_import_staging", "legacy_import_commits", + "context_bound_envelopes", ] forbidden_tables = [ @@ -77,3 +78,31 @@ owned_objects = [ "radroots_private_legacy_import_staging_parent_idx", "radroots_private_legacy_import_staging_update_guard", ] + +[[migrations]] +version = 4 +name = "context_bound_envelopes" +sha256 = "dd6bb42471db47fcd9c62f8d59b66ddba381776d879cfbffd94993f7ef7409de" +owned_objects = [ + "radroots_private_artifacts", + "radroots_private_artifacts_delete_guard", + "radroots_private_artifacts_envelope_guard", + "radroots_private_artifacts_expiry_idx", + "radroots_private_artifacts_identity_guard", + "radroots_private_artifacts_insert_envelope_guard", + "radroots_private_artifacts_key_version_idx", + "radroots_private_artifacts_kind_idx", + "radroots_private_artifacts_reseal_audit", + "radroots_private_envelope_reseals", + "radroots_private_envelope_reseals_artifact_idx", + "radroots_private_envelope_reseals_delete_guard", + "radroots_private_envelope_reseals_update_guard", + "radroots_private_legacy_import_commit_delete_guard", + "radroots_private_legacy_import_commit_update_guard", + "radroots_private_legacy_import_commits", + "radroots_private_legacy_import_staging", + "radroots_private_legacy_import_staging_delete_guard", + "radroots_private_legacy_import_staging_insert_guard", + "radroots_private_legacy_import_staging_parent_idx", + "radroots_private_legacy_import_staging_update_guard", +] diff --git a/crates/storage_sqlite/Cargo.toml b/crates/storage_sqlite/Cargo.toml @@ -34,6 +34,7 @@ sha2 = { workspace = true, features = ["std"] } sqlx = { workspace = true, features = ["runtime-tokio", "sqlite-bundled"] } [dev-dependencies] +hex = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } toml = { workspace = true } diff --git a/crates/storage_sqlite/src/migration.rs b/crates/storage_sqlite/src/migration.rs @@ -540,6 +540,25 @@ mod tests { .expect("runtime user version"); } + async fn establish_private_version(connection: &mut SqliteConnection, version: u32) { + for migration_version in 1..=version { + sqlx::raw_sql( + private::migration_sql(migration_version).expect("registered private SQL"), + ) + .execute(&mut *connection) + .await + .expect("private migration"); + } + sqlx::raw_sql(SET_PRIVATE_APPLICATION_ID) + .execute(&mut *connection) + .await + .expect("private application id"); + sqlx::raw_sql(set_user_version_sql(version).expect("version pragma")) + .execute(&mut *connection) + .await + .expect("private user version"); + } + #[tokio::test] async fn fresh_runtime_and_private_schemas_migrate_to_exact_current_versions() { let mut runtime_connection = connection().await; @@ -661,6 +680,20 @@ mod tests { } #[tokio::test] + async fn every_recognized_private_version_applies_exactly_the_pending_suffix() { + for initial_version in 1..=private::CURRENT_VERSION { + let mut connection = connection().await; + establish_private_version(&mut connection, initial_version).await; + let report = migrate_private(&mut connection, OpenMode::ReadWriteExisting) + .await + .expect("recognized private forward migration"); + assert_eq!(report.initial_version(), initial_version); + assert_eq!(report.final_version(), private::CURRENT_VERSION); + assert_eq!(report.applied(), private::CURRENT_VERSION - initial_version); + } + } + + #[tokio::test] async fn read_only_old_schema_requires_migration_without_mutating() { let mut connection = connection().await; establish_runtime_version(&mut connection, 1).await; diff --git a/crates/storage_sqlite/src/migration/private/0004_context_bound_envelopes.up.sql b/crates/storage_sqlite/src/migration/private/0004_context_bound_envelopes.up.sql @@ -0,0 +1,195 @@ +CREATE TABLE radroots_private_envelope_v2_preflight ( + unsupported_v2_rows INTEGER NOT NULL CHECK(unsupported_v2_rows = 0) +) STRICT; + +INSERT INTO radroots_private_envelope_v2_preflight(unsupported_v2_rows) +SELECT COUNT(*) FROM radroots_private_artifacts WHERE envelope_version = 2; + +DROP TABLE radroots_private_envelope_v2_preflight; + +ALTER TABLE radroots_private_artifacts +ADD COLUMN context_fingerprint BLOB +CHECK(context_fingerprint IS NULL OR length(context_fingerprint) = 32); + +ALTER TABLE radroots_private_artifacts +ADD COLUMN last_reseal_id BLOB +CHECK(last_reseal_id IS NULL OR length(last_reseal_id) = 16); + +ALTER TABLE radroots_private_artifacts +ADD COLUMN last_reseal_fingerprint BLOB +CHECK(last_reseal_fingerprint IS NULL OR length(last_reseal_fingerprint) = 32); + +CREATE TABLE radroots_private_envelope_reseals ( + reseal_id BLOB PRIMARY KEY NOT NULL CHECK(length(reseal_id) = 16), + artifact_id BLOB NOT NULL CHECK(length(artifact_id) = 16), + request_fingerprint BLOB NOT NULL CHECK(length(request_fingerprint) = 32), + expected_revision INTEGER NOT NULL CHECK(expected_revision > 0), + expected_commitment BLOB NOT NULL CHECK(length(expected_commitment) = 32), + committed_revision INTEGER NOT NULL CHECK(committed_revision = expected_revision + 1), + next_commitment BLOB NOT NULL CHECK(length(next_commitment) = 32), + committed_at_unix_ms INTEGER NOT NULL CHECK(committed_at_unix_ms > 0), + FOREIGN KEY(artifact_id) REFERENCES radroots_private_artifacts(artifact_id), + UNIQUE(artifact_id, committed_revision), + UNIQUE(artifact_id, request_fingerprint) +) STRICT, WITHOUT ROWID; + +CREATE INDEX radroots_private_envelope_reseals_artifact_idx +ON radroots_private_envelope_reseals(artifact_id, committed_revision); + +CREATE TRIGGER radroots_private_envelope_reseals_update_guard +BEFORE UPDATE ON radroots_private_envelope_reseals +BEGIN + SELECT RAISE(ABORT, 'private envelope reseal evidence is immutable'); +END; + +CREATE TRIGGER radroots_private_envelope_reseals_delete_guard +BEFORE DELETE ON radroots_private_envelope_reseals +BEGIN + SELECT RAISE(ABORT, 'private envelope reseal evidence is retained'); +END; + +DROP TRIGGER radroots_private_artifacts_identity_guard; +DROP TRIGGER radroots_private_artifacts_envelope_guard; + +CREATE TRIGGER radroots_private_artifacts_identity_guard +BEFORE UPDATE OF artifact_id, artifact_kind, schema_id, created_at_unix_ms +ON radroots_private_artifacts +BEGIN + SELECT RAISE(ABORT, 'private artifact identity is immutable'); +END; + +CREATE TRIGGER radroots_private_artifacts_insert_envelope_guard +BEFORE INSERT ON radroots_private_artifacts +WHEN NOT ( + (NEW.encrypted_envelope IS NULL + AND NEW.envelope_version IS NULL + AND NEW.context_fingerprint IS NULL + AND NEW.last_reseal_id IS NULL + AND NEW.last_reseal_fingerprint IS NULL) + OR + (NEW.encrypted_envelope IS NOT NULL + AND NEW.envelope_version = 2 + AND NEW.context_fingerprint IS NOT NULL + AND NEW.last_reseal_id IS NULL + AND NEW.last_reseal_fingerprint IS NULL) +) +BEGIN + SELECT RAISE(ABORT, 'new private envelopes must be context-bound v2'); +END; + +CREATE TRIGGER radroots_private_artifacts_envelope_guard +BEFORE UPDATE OF + commitment, + protected_size_bytes, + secret_provider, + secret_reference, + key_version, + envelope_version, + encrypted_envelope, + context_fingerprint, + last_reseal_id, + last_reseal_fingerprint +ON radroots_private_artifacts +WHEN NOT ( + ( + OLD.encrypted_envelope IS NULL + AND OLD.envelope_version IS NULL + AND OLD.context_fingerprint IS NULL + AND NEW.encrypted_envelope IS NOT NULL + AND NEW.envelope_version = 2 + AND NEW.context_fingerprint IS NOT NULL + AND NEW.last_reseal_id IS NULL + AND NEW.last_reseal_fingerprint IS NULL + AND NEW.commitment = OLD.commitment + AND NEW.protected_size_bytes = OLD.protected_size_bytes + AND NEW.secret_provider = OLD.secret_provider + AND NEW.secret_reference = OLD.secret_reference + AND NEW.key_version = OLD.key_version + AND NEW.revision = OLD.revision + AND NEW.stage = OLD.stage + AND NEW.updated_at_unix_ms = OLD.updated_at_unix_ms + ) + OR + ( + OLD.encrypted_envelope IS NULL + AND OLD.envelope_version IS NULL + AND OLD.context_fingerprint IS NULL + AND NEW.encrypted_envelope IS NULL + AND NEW.envelope_version IS NULL + AND NEW.context_fingerprint IS NULL + AND NEW.last_reseal_id IS NOT NULL + AND NEW.last_reseal_id IS NOT OLD.last_reseal_id + AND NEW.last_reseal_fingerprint IS NOT NULL + AND NEW.revision = OLD.revision + 1 + AND NEW.stage = 'active' + AND NEW.updated_at_unix_ms > OLD.updated_at_unix_ms + AND NEW.deleted_at_unix_ms IS NULL + AND NEW.deletion_reason IS NULL + AND NEW.tombstone_commitment IS NULL + ) + OR + ( + OLD.encrypted_envelope IS NOT NULL + AND OLD.envelope_version = 1 + AND OLD.context_fingerprint IS NULL + AND OLD.last_reseal_id IS NULL + AND OLD.last_reseal_fingerprint IS NULL + AND OLD.stage = 'active' + AND NEW.encrypted_envelope IS NOT NULL + AND NEW.envelope_version = 2 + AND NEW.context_fingerprint IS NOT NULL + AND NEW.last_reseal_id IS NOT NULL + AND NEW.last_reseal_fingerprint IS NOT NULL + AND NEW.revision = OLD.revision + 1 + AND NEW.stage = 'active' + AND NEW.updated_at_unix_ms > OLD.updated_at_unix_ms + AND NEW.deleted_at_unix_ms IS NULL + AND NEW.deletion_reason IS NULL + AND NEW.tombstone_commitment IS NULL + ) + OR + ( + OLD.encrypted_envelope IS NOT NULL + AND NEW.encrypted_envelope IS NULL + AND NEW.envelope_version IS NULL + AND NEW.context_fingerprint = OLD.context_fingerprint + AND NEW.last_reseal_id IS OLD.last_reseal_id + AND NEW.last_reseal_fingerprint IS OLD.last_reseal_fingerprint + AND NEW.commitment = OLD.commitment + AND NEW.protected_size_bytes = OLD.protected_size_bytes + AND NEW.secret_provider = OLD.secret_provider + AND NEW.secret_reference = OLD.secret_reference + AND NEW.key_version = OLD.key_version + AND NEW.revision = OLD.revision + 1 + AND NEW.stage = 'tombstoned' + AND NEW.updated_at_unix_ms > OLD.updated_at_unix_ms + ) +) +BEGIN + SELECT RAISE(ABORT, 'private envelope mutation is not an authorized transition'); +END; + +CREATE TRIGGER radroots_private_artifacts_reseal_audit +AFTER UPDATE OF last_reseal_id, last_reseal_fingerprint ON radroots_private_artifacts +WHEN NEW.last_reseal_id IS NOT NULL AND NEW.last_reseal_id IS NOT OLD.last_reseal_id +BEGIN + INSERT INTO radroots_private_envelope_reseals ( + reseal_id, + artifact_id, + request_fingerprint, + expected_revision, + expected_commitment, + committed_revision, + next_commitment, + committed_at_unix_ms + ) VALUES ( + NEW.last_reseal_id, + NEW.artifact_id, + NEW.last_reseal_fingerprint, + OLD.revision, + OLD.commitment, + NEW.revision, + NEW.commitment, + NEW.updated_at_unix_ms + ); +END; diff --git a/crates/storage_sqlite/src/migration/private/mod.rs b/crates/storage_sqlite/src/migration/private/mod.rs @@ -6,11 +6,12 @@ /// Lowest private schema version this package can recognize. pub const MINIMUM_VERSION: u32 = 1; /// Current private schema version created by this package. -pub const CURRENT_VERSION: u32 = 3; +pub const CURRENT_VERSION: u32 = 4; const PRIVATE_V1_SQL: &str = include_str!("0001_private.up.sql"); const LEGACY_PRIVATE_STAGING_V2_SQL: &str = include_str!("0002_legacy_private_staging.up.sql"); const LEGACY_IMPORT_COMMITS_V3_SQL: &str = include_str!("0003_legacy_import_commits.up.sql"); +const CONTEXT_BOUND_ENVELOPES_V4_SQL: &str = include_str!("0004_context_bound_envelopes.up.sql"); /// Stable, non-SQL description of one forward private migration. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -82,6 +83,30 @@ const PRIVATE_V3_OBJECTS: &[&str] = &[ "radroots_private_legacy_import_staging_update_guard", ]; +const PRIVATE_V4_OBJECTS: &[&str] = &[ + "radroots_private_artifacts", + "radroots_private_artifacts_delete_guard", + "radroots_private_artifacts_envelope_guard", + "radroots_private_artifacts_expiry_idx", + "radroots_private_artifacts_identity_guard", + "radroots_private_artifacts_insert_envelope_guard", + "radroots_private_artifacts_key_version_idx", + "radroots_private_artifacts_kind_idx", + "radroots_private_artifacts_reseal_audit", + "radroots_private_envelope_reseals", + "radroots_private_envelope_reseals_artifact_idx", + "radroots_private_envelope_reseals_delete_guard", + "radroots_private_envelope_reseals_update_guard", + "radroots_private_legacy_import_commit_delete_guard", + "radroots_private_legacy_import_commit_update_guard", + "radroots_private_legacy_import_commits", + "radroots_private_legacy_import_staging", + "radroots_private_legacy_import_staging_delete_guard", + "radroots_private_legacy_import_staging_insert_guard", + "radroots_private_legacy_import_staging_parent_idx", + "radroots_private_legacy_import_staging_update_guard", +]; + /// Ordered, immutable private migration plan. pub const MIGRATIONS: &[MigrationDescriptor] = &[ MigrationDescriptor { @@ -102,6 +127,12 @@ pub const MIGRATIONS: &[MigrationDescriptor] = &[ up_sha256: "9377f0af8f070d977a5237e2a1294e6977f5b704e7a8434d97a3dc5f4ae75e86", owned_objects: PRIVATE_V3_OBJECTS, }, + MigrationDescriptor { + version: 4, + name: "context_bound_envelopes", + up_sha256: "dd6bb42471db47fcd9c62f8d59b66ddba381776d879cfbffd94993f7ef7409de", + owned_objects: PRIVATE_V4_OBJECTS, + }, ]; pub(crate) const fn migration_sql(version: u32) -> Option<&'static str> { @@ -109,6 +140,7 @@ pub(crate) const fn migration_sql(version: u32) -> Option<&'static str> { 1 => Some(PRIVATE_V1_SQL), 2 => Some(LEGACY_PRIVATE_STAGING_V2_SQL), 3 => Some(LEGACY_IMPORT_COMMITS_V3_SQL), + 4 => Some(CONTEXT_BOUND_ENVELOPES_V4_SQL), _ => None, } } @@ -152,7 +184,7 @@ mod tests { #[test] fn migration_plan_matches_governed_snapshot() { let snapshot = toml::from_str::<PlanSnapshot>(PLAN_SNAPSHOT).expect("valid snapshot"); - let migration = MIGRATIONS[2]; + let migration = MIGRATIONS[3]; assert_eq!(snapshot.schema_version, 1); assert_eq!(snapshot.database, "private.sqlite"); assert_eq!(snapshot.application_id, 1_380_208_722); @@ -163,7 +195,7 @@ mod tests { assert!(snapshot.forward_only); assert!(!snapshot.raw_sql_public); assert!(snapshot.encrypted_envelopes); - assert_eq!(snapshot.authorities.len(), 6); + assert_eq!(snapshot.authorities.len(), 7); assert_eq!(snapshot.forbidden_tables, ["studio", "ui_state"]); assert_eq!(snapshot.migrations.len(), MIGRATIONS.len()); for (expected, actual) in snapshot.migrations.iter().zip(MIGRATIONS) { @@ -180,7 +212,7 @@ mod tests { let sql = migration_sql(migration.version()).expect("registered SQL"); assert_eq!(format!("{:x}", Sha256::digest(sql)), migration.up_sha256()); } - assert_eq!(migration_sql(4), None); + assert_eq!(migration_sql(5), None); } #[tokio::test] @@ -206,7 +238,7 @@ mod tests { .iter() .map(|row| row.get::<String, _>("name")) .collect::<Vec<_>>(); - assert_eq!(actual, MIGRATIONS[2].owned_objects()); + assert_eq!(actual, MIGRATIONS[3].owned_objects()); let forbidden = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM sqlite_schema WHERE lower(name) LIKE '%studio%' OR lower(name) LIKE '%ui_state%'", @@ -227,4 +259,56 @@ mod tests { .expect("inspect integrity"); assert_eq!(integrity, "ok"); } + + #[tokio::test] + async fn v4_preflight_rejects_unfingerprinted_v2_without_partial_schema() { + let mut connection = SqliteConnection::connect("sqlite::memory:") + .await + .expect("open memory SQLite"); + for migration in &MIGRATIONS[..3] { + sqlx::raw_sql(migration_sql(migration.version()).expect("registered SQL")) + .execute(&mut connection) + .await + .expect("apply private schema"); + } + sqlx::query( + "INSERT INTO radroots_private_artifacts ( + artifact_id, artifact_kind, schema_id, commitment, protected_size_bytes, + secret_provider, secret_reference, key_version, envelope_version, + encrypted_envelope, revision, stage, created_at_unix_ms, updated_at_unix_ms + ) VALUES (?, 'trade.private_terms', 'trade.private_terms.v1', ?, 1, + 'memory', 'key', 1, 2, ?, 1, 'active', 1, 1)", + ) + .bind([1_u8; 16].as_slice()) + .bind([2_u8; 32].as_slice()) + .bind([3_u8].as_slice()) + .execute(&mut connection) + .await + .expect("unfingerprinted v2 fixture"); + + let mut transaction = connection.begin().await.expect("migration transaction"); + assert!( + sqlx::raw_sql(migration_sql(4).expect("v4 SQL")) + .execute(&mut *transaction) + .await + .is_err() + ); + transaction.rollback().await.expect("rollback preflight"); + let context_column = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM pragma_table_info('radroots_private_artifacts') + WHERE name = 'context_fingerprint'", + ) + .fetch_one(&mut connection) + .await + .expect("inspect columns"); + let preflight_table = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_schema + WHERE name = 'radroots_private_envelope_v2_preflight'", + ) + .fetch_one(&mut connection) + .await + .expect("inspect catalog"); + assert_eq!(context_column, 0); + assert_eq!(preflight_table, 0); + } } diff --git a/crates/storage_sqlite/src/private_artifact/mod.rs b/crates/storage_sqlite/src/private_artifact/mod.rs @@ -1,13 +1,17 @@ use crate::SqliteStorage; -use radroots_secrets::EncryptedEnvelope; +use radroots_secrets::{ + EncryptedEnvelope, + context::{EnvelopeContext, EnvelopePurpose, EnvelopeSubject, PayloadSchemaId}, +}; use radroots_storage::{ Error, event::BoxFuture, private_artifact::{ ArtifactCommitment, ArtifactKind, ArtifactSchemaId, DeletionReason, DurableSecretReference, - EXPIRED_ARTIFACT_QUERY_LIMIT_MAX, PrivateArtifactId, PrivateArtifactMetadata, - PrivateArtifactRevision, PrivateArtifactStage, PrivateArtifactStatus, PrivateArtifactStore, - RetentionPolicy, + EXPIRED_ARTIFACT_QUERY_LIMIT_MAX, PrivateArtifactEnvelopeMigrationStatus, + PrivateArtifactId, PrivateArtifactMetadata, PrivateArtifactResealReceipt, + PrivateArtifactResealRequest, PrivateArtifactRevision, PrivateArtifactStage, + PrivateArtifactStatus, PrivateArtifactStore, RetentionPolicy, }, }; use sha2::{Digest, Sha256}; @@ -48,6 +52,59 @@ impl PrivateArtifactStore for SqliteStorage { }) } + fn reseal_metadata( + &self, + request: PrivateArtifactResealRequest, + ) -> BoxFuture<'_, Result<PrivateArtifactResealReceipt, Error>> { + Box::pin(async move { + self.require_private_writer()?; + let mut transaction = self + .private_pool() + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(map_backend)?; + if let Some(receipt) = load_reseal_receipt(&mut transaction, &request).await? { + transaction.commit().await.map_err(map_backend)?; + return receipt.replay(&request); + } + let current = load_metadata(&mut transaction, request.artifact_id()) + .await? + .ok_or(Error::PrivateArtifactNotFound)?; + let next = current.resealed(&request)?; + let result = sqlx::query( + "UPDATE radroots_private_artifacts SET + commitment = ?, protected_size_bytes = ?, secret_provider = ?, + secret_reference = ?, key_version = ?, revision = ?, + updated_at_unix_ms = ?, last_reseal_id = ?, last_reseal_fingerprint = ? + WHERE artifact_id = ? AND revision = ? AND commitment = ? + AND encrypted_envelope IS NULL AND envelope_version IS NULL", + ) + .bind(next.commitment().as_bytes().as_slice()) + .bind(i64_from_u64(next.protected_size_bytes())?) + .bind(next.secret_reference().provider()) + .bind(next.secret_reference().opaque_reference()) + .bind(i64::from(next.secret_reference().key_version())) + .bind(i64_from_u64(next.revision().get())?) + .bind(i64_from_u64(next.updated_at_unix_ms())?) + .bind(request.reseal_id().as_bytes().as_slice()) + .bind(request.fingerprint().as_slice()) + .bind(request.artifact_id().as_bytes().as_slice()) + .bind(i64_from_u64(request.expected_revision().get())?) + .bind(request.expected_commitment().as_bytes().as_slice()) + .execute(&mut *transaction) + .await + .map_err(map_reseal)?; + if result.rows_affected() != 1 { + return Err(Error::PrivateArtifactResealConflict); + } + let receipt = load_reseal_receipt(&mut transaction, &request) + .await? + .ok_or(Error::PrivateArtifactPersistenceIndeterminate)?; + transaction.commit().await.map_err(map_indeterminate)?; + Ok(receipt) + }) + } + fn mark_expired( &self, artifact_id: PrivateArtifactId, @@ -150,7 +207,8 @@ impl SqliteStorage { envelope: &EncryptedEnvelope, ) -> Result<PrivateArtifactMetadata, Error> { self.require_private_writer()?; - let encoded = validate_envelope(&metadata, envelope)?; + let encoded = validate_new_envelope(&metadata, envelope)?; + let context_fingerprint = metadata.envelope_context().fingerprint(); let mut transaction = self .private_pool() .begin_with("BEGIN IMMEDIATE") @@ -159,7 +217,11 @@ impl SqliteStorage { let stored = put_metadata_transaction( &mut transaction, metadata, - Some((envelope.version(), encoded.as_slice())), + Some(( + envelope.version(), + encoded.as_slice(), + context_fingerprint.as_slice(), + )), ) .await?; transaction.commit().await.map_err(map_backend)?; @@ -196,7 +258,7 @@ impl SqliteStorage { if u64_from_i64(version)? != u64::from(envelope.version()) { return Err(Error::CorruptPrivateArtifactMetadata); } - validate_envelope(&metadata, &envelope) + validate_stored_envelope(&metadata, &envelope, &row) .map_err(|_| Error::CorruptPrivateArtifactMetadata)?; Ok(Some(envelope)) } @@ -204,6 +266,96 @@ impl SqliteStorage { } } + /// Returns an identity-free inventory of private-envelope migration state. + #[cfg_attr(coverage_nightly, coverage(off))] + pub async fn private_artifact_envelope_migration_status( + &self, + ) -> Result<PrivateArtifactEnvelopeMigrationStatus, Error> { + let row = sqlx::query( + "SELECT + COALESCE(SUM(CASE WHEN envelope_version = 1 + AND context_fingerprint IS NULL THEN 1 ELSE 0 END), 0) AS v1_pending, + COALESCE(SUM(CASE WHEN envelope_version = 2 + AND context_fingerprint IS NOT NULL THEN 1 ELSE 0 END), 0) AS v2_current, + COALESCE(SUM(CASE WHEN envelope_version IS NOT NULL AND ( + envelope_version NOT IN (1, 2) + OR (envelope_version = 1 AND context_fingerprint IS NOT NULL) + OR (envelope_version = 2 AND context_fingerprint IS NULL) + ) THEN 1 ELSE 0 END), 0) AS corrupt + FROM radroots_private_artifacts", + ) + .fetch_one(self.private_pool()) + .await + .map_err(map_backend)?; + Ok(PrivateArtifactEnvelopeMigrationStatus { + v1_pending: count(&row, "v1_pending")?, + v2_current: count(&row, "v2_current")?, + corrupt: count(&row, "corrupt")?, + blocked_provider: 0, + conflicted: 0, + }) + } + + /// Atomically replaces one authenticated v1 envelope with an independently + /// produced context-bound v2 envelope. + #[cfg_attr(coverage_nightly, coverage(off))] + pub async fn commit_private_artifact_reseal( + &self, + request: PrivateArtifactResealRequest, + envelope: &EncryptedEnvelope, + ) -> Result<PrivateArtifactResealReceipt, Error> { + self.require_private_writer()?; + let mut transaction = self + .private_pool() + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(map_backend)?; + if let Some(receipt) = load_reseal_receipt(&mut transaction, &request).await? { + transaction.commit().await.map_err(map_backend)?; + return receipt.replay(&request); + } + let current = load_metadata(&mut transaction, request.artifact_id()) + .await? + .ok_or(Error::PrivateArtifactNotFound)?; + let next = current.resealed(&request)?; + let encoded = validate_new_envelope(&next, envelope)?; + let context_fingerprint = next.envelope_context().fingerprint(); + let result = sqlx::query( + "UPDATE radroots_private_artifacts SET + commitment = ?, protected_size_bytes = ?, secret_provider = ?, + secret_reference = ?, key_version = ?, envelope_version = 2, + encrypted_envelope = ?, context_fingerprint = ?, revision = ?, + updated_at_unix_ms = ?, last_reseal_id = ?, last_reseal_fingerprint = ? + WHERE artifact_id = ? AND revision = ? AND commitment = ? + AND envelope_version = 1 AND context_fingerprint IS NULL", + ) + .bind(next.commitment().as_bytes().as_slice()) + .bind(i64_from_u64(next.protected_size_bytes())?) + .bind(next.secret_reference().provider()) + .bind(next.secret_reference().opaque_reference()) + .bind(i64::from(next.secret_reference().key_version())) + .bind(encoded.as_slice()) + .bind(context_fingerprint.as_slice()) + .bind(i64_from_u64(next.revision().get())?) + .bind(i64_from_u64(next.updated_at_unix_ms())?) + .bind(request.reseal_id().as_bytes().as_slice()) + .bind(request.fingerprint().as_slice()) + .bind(request.artifact_id().as_bytes().as_slice()) + .bind(i64_from_u64(request.expected_revision().get())?) + .bind(request.expected_commitment().as_bytes().as_slice()) + .execute(&mut *transaction) + .await + .map_err(map_reseal)?; + if result.rows_affected() != 1 { + return Err(Error::PrivateArtifactResealConflict); + } + let receipt = load_reseal_receipt(&mut transaction, &request) + .await? + .ok_or(Error::PrivateArtifactPersistenceIndeterminate)?; + transaction.commit().await.map_err(map_indeterminate)?; + Ok(receipt) + } + fn require_private_writer(&self) -> Result<(), Error> { if self.event_mode() == radroots_storage::status::EventStoreMode::ReadOnly { return Err(Error::BackendUnavailable); @@ -216,7 +368,7 @@ impl SqliteStorage { async fn put_metadata_transaction( transaction: &mut sqlx::Transaction<'_, Sqlite>, metadata: PrivateArtifactMetadata, - envelope: Option<(u16, &[u8])>, + envelope: Option<(u16, &[u8], &[u8])>, ) -> Result<PrivateArtifactMetadata, Error> { if metadata.stage() != PrivateArtifactStage::Active || metadata.revision() != PrivateArtifactRevision::INITIAL @@ -237,14 +389,15 @@ async fn put_metadata_transaction( .try_get::<Option<Vec<u8>>, _>("encrypted_envelope") .map_err(map_corrupt)?; return match (stored_envelope, envelope) { - (None, Some((version, encoded))) => { + (None, Some((version, encoded, context_fingerprint))) => { let result = sqlx::query( "UPDATE radroots_private_artifacts - SET envelope_version = ?, encrypted_envelope = ? + SET envelope_version = ?, encrypted_envelope = ?, context_fingerprint = ? WHERE artifact_id = ? AND encrypted_envelope IS NULL", ) .bind(i64::from(version)) .bind(encoded) + .bind(context_fingerprint) .bind(metadata.artifact_id().as_bytes().as_slice()) .execute(&mut **transaction) .await @@ -254,7 +407,7 @@ async fn put_metadata_transaction( } Ok(metadata) } - (Some(stored), Some((_, encoded))) if stored.as_slice() == encoded => Ok(metadata), + (Some(stored), Some((_, encoded, _))) if stored.as_slice() == encoded => Ok(metadata), (Some(_), Some(_)) => Err(Error::PrivateArtifactConflict), (_, None) => Ok(metadata), }; @@ -267,17 +420,17 @@ async fn put_metadata_transaction( async fn insert_metadata( transaction: &mut sqlx::Transaction<'_, Sqlite>, metadata: &PrivateArtifactMetadata, - envelope: Option<(u16, &[u8])>, + envelope: Option<(u16, &[u8], &[u8])>, ) -> Result<(), Error> { let tombstone = metadata.tombstone_record(); sqlx::query( "INSERT INTO radroots_private_artifacts ( artifact_id, artifact_kind, schema_id, commitment, protected_size_bytes, secret_provider, secret_reference, key_version, envelope_version, - encrypted_envelope, delete_not_before_unix_ms, expires_at_unix_ms, + encrypted_envelope, context_fingerprint, delete_not_before_unix_ms, expires_at_unix_ms, revision, stage, created_at_unix_ms, updated_at_unix_ms, deleted_at_unix_ms, deletion_reason, tombstone_commitment - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(metadata.artifact_id().as_bytes().as_slice()) .bind(metadata.kind().as_str()) @@ -287,8 +440,9 @@ async fn insert_metadata( .bind(metadata.secret_reference().provider()) .bind(metadata.secret_reference().opaque_reference()) .bind(i64::from(metadata.secret_reference().key_version())) - .bind(envelope.map(|(version, _)| i64::from(version))) - .bind(envelope.map(|(_, encoded)| encoded)) + .bind(envelope.map(|(version, _, _)| i64::from(version))) + .bind(envelope.map(|(_, encoded, _)| encoded)) + .bind(envelope.map(|(_, _, context_fingerprint)| context_fingerprint)) .bind( metadata .retention() @@ -453,11 +607,14 @@ fn decode_metadata(row: &sqlx::sqlite::SqliteRow) -> Result<PrivateArtifactMetad .map_err(|_| Error::CorruptPrivateArtifactMetadata) } -fn validate_envelope( +fn validate_new_envelope( metadata: &PrivateArtifactMetadata, envelope: &EncryptedEnvelope, ) -> Result<Vec<u8>, Error> { - if metadata.stage() != PrivateArtifactStage::Active + let expected_context = secrets_context(metadata)?; + if envelope.version() != 2 + || envelope.context() != Some(&expected_context) + || metadata.stage() != PrivateArtifactStage::Active || metadata.secret_reference().opaque_reference() != envelope.reference().id().as_str() || metadata.secret_reference().key_version() != envelope.reference().key_version().get() { @@ -475,6 +632,91 @@ fn validate_envelope( Ok(encoded) } +fn validate_stored_envelope( + metadata: &PrivateArtifactMetadata, + envelope: &EncryptedEnvelope, + row: &sqlx::sqlite::SqliteRow, +) -> Result<(), Error> { + let encoded = envelope + .encode() + .map_err(|_| Error::CorruptPrivateArtifactMetadata)?; + if metadata.secret_reference().opaque_reference() != envelope.reference().id().as_str() + || metadata.secret_reference().key_version() != envelope.reference().key_version().get() + || metadata.protected_size_bytes() + != u64::try_from(encoded.len()).map_err(|_| Error::CorruptPrivateArtifactMetadata)? + || metadata.commitment().as_bytes() != Sha256::digest(encoded.as_slice()).as_slice() + { + return Err(Error::CorruptPrivateArtifactMetadata); + } + let stored_fingerprint = row + .try_get::<Option<Vec<u8>>, _>("context_fingerprint") + .map_err(map_corrupt)?; + match envelope.version() { + 1 if envelope.context().is_none() && stored_fingerprint.is_none() => Ok(()), + 2 => { + let expected = secrets_context(metadata)?; + let expected_fingerprint = metadata.envelope_context().fingerprint(); + if envelope.context() == Some(&expected) + && stored_fingerprint.as_deref() == Some(expected_fingerprint.as_slice()) + { + Ok(()) + } else { + Err(Error::CorruptPrivateArtifactMetadata) + } + } + _ => Err(Error::CorruptPrivateArtifactMetadata), + } +} + +fn secrets_context(metadata: &PrivateArtifactMetadata) -> Result<EnvelopeContext, Error> { + let derived = metadata.envelope_context(); + Ok(EnvelopeContext::new( + EnvelopePurpose::parse(derived.purpose()) + .map_err(|_| Error::CorruptPrivateArtifactMetadata)?, + EnvelopeSubject::parse(derived.subject_type(), derived.subject()) + .map_err(|_| Error::CorruptPrivateArtifactMetadata)?, + PayloadSchemaId::parse(derived.payload_schema()) + .map_err(|_| Error::CorruptPrivateArtifactMetadata)?, + )) +} + +#[cfg_attr(coverage_nightly, coverage(off))] +async fn load_reseal_receipt( + transaction: &mut sqlx::Transaction<'_, Sqlite>, + request: &PrivateArtifactResealRequest, +) -> Result<Option<PrivateArtifactResealReceipt>, Error> { + let Some(row) = sqlx::query( + "SELECT artifact_id, request_fingerprint, committed_revision + FROM radroots_private_envelope_reseals WHERE reseal_id = ?", + ) + .bind(request.reseal_id().as_bytes().as_slice()) + .fetch_optional(&mut **transaction) + .await + .map_err(map_backend)? + else { + return Ok(None); + }; + let artifact_id = row + .try_get::<Vec<u8>, _>("artifact_id") + .map_err(map_corrupt)?; + let fingerprint = row + .try_get::<Vec<u8>, _>("request_fingerprint") + .map_err(map_corrupt)?; + if artifact_id.as_slice() != request.artifact_id().as_bytes() + || fingerprint.as_slice() != request.fingerprint() + { + return Err(Error::PrivateArtifactResealConflict); + } + let revision = PrivateArtifactRevision::new(u64_from_i64( + row.try_get::<i64, _>("committed_revision") + .map_err(map_corrupt)?, + )?) + .map_err(|_| Error::CorruptPrivateArtifactMetadata)?; + Ok(Some(PrivateArtifactResealReceipt::committed( + request, revision, + ))) +} + fn optional_u64(row: &sqlx::sqlite::SqliteRow, column: &str) -> Result<Option<u64>, Error> { row.try_get::<Option<i64>, _>(column) .map_err(map_corrupt)? @@ -542,6 +784,14 @@ fn map_backend(_: sqlx::Error) -> Error { Error::BackendUnavailable } +fn map_reseal(_: sqlx::Error) -> Error { + Error::PrivateArtifactResealConflict +} + +fn map_indeterminate(_: sqlx::Error) -> Error { + Error::PrivateArtifactPersistenceIndeterminate +} + fn map_corrupt(_: sqlx::Error) -> Error { Error::CorruptPrivateArtifactMetadata } @@ -556,13 +806,17 @@ mod tests { }; use radroots_secrets::{ Error as SecretError, KeyWrapping, SecretId, SecretRef, - envelope::{Nonce, SealMaterial, SealRequest}, + envelope::{LegacyV1ResealAuthority, Nonce, SealMaterial, SealRequest}, error::Operation, id::{BackendKind, KeyVersion}, wrapping::{ - BoxFuture as SecretFuture, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret, + BoxFuture as SecretFuture, LegacyV1UnwrapRequest, SecretMaterial, UnwrapRequest, + WrapRequest, WrappedSecret, }, }; + use radroots_storage::private_artifact::{ + PrivateArtifactResealDisposition, PrivateArtifactResealId, + }; use radroots_storage::status::EventStoreMode; use sqlx::sqlite::SqlitePoolOptions; @@ -574,7 +828,10 @@ mod tests { request: WrapRequest<'a>, ) -> SecretFuture<'a, Result<WrappedSecret, SecretError>> { Box::pin(async move { - if request.reference().id().as_str() != "private-artifact-key" { + if !matches!( + request.reference().id().as_str(), + "private-artifact-key" | "envelope-key" + ) { return Err(SecretError::BackendFailure { backend: BackendKind::Memory, operation: Operation::Wrap, @@ -591,21 +848,45 @@ mod tests { request: UnwrapRequest<'a>, ) -> SecretFuture<'a, Result<SecretMaterial, SecretError>> { Box::pin(async move { - if request.reference().id().as_str() != "private-artifact-key" { + if !matches!( + request.reference().id().as_str(), + "private-artifact-key" | "envelope-key" + ) { return Err(SecretError::BackendFailure { backend: BackendKind::Memory, operation: Operation::Unwrap, }); } - let plaintext = request - .wrapped() - .as_bytes() - .iter() - .map(|byte| byte ^ 0xA5) - .collect::<Vec<_>>(); + let plaintext = if request.wrapped().as_bytes() == [0x4b; 32] { + vec![0x11; 32] + } else { + request + .wrapped() + .as_bytes() + .iter() + .map(|byte| byte ^ 0xA5) + .collect::<Vec<_>>() + }; SecretMaterial::from_slice(plaintext.as_slice()) }) } + + fn unwrap_legacy_v1<'a>( + &'a self, + request: LegacyV1UnwrapRequest<'a>, + ) -> SecretFuture<'a, Result<SecretMaterial, SecretError>> { + Box::pin(async move { + if request.reference().id().as_str() != "envelope-key" + || request.wrapped().as_bytes() != [0x4b; 32] + { + return Err(SecretError::BackendFailure { + backend: BackendKind::Memory, + operation: Operation::Unwrap, + }); + } + SecretMaterial::from_slice(&[0x11; 32]) + }) + } } async fn store(mode: EventStoreMode) -> SqliteStorage { @@ -647,13 +928,31 @@ mod tests { ) } - async fn sealed_envelope(plaintext: &[u8], version: u32) -> EncryptedEnvelope { + fn test_context(id: u8, kind: &str) -> EnvelopeContext { + let subject = [id; 16] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::<String>(); + EnvelopeContext::new( + EnvelopePurpose::parse(format!("radroots.private_artifact.{kind}")).expect("purpose"), + EnvelopeSubject::parse("private_artifact", subject).expect("subject"), + PayloadSchemaId::parse(format!("{kind}.v1")).expect("schema"), + ) + } + + async fn sealed_envelope( + plaintext: &[u8], + version: u32, + id: u8, + kind: &str, + ) -> EncryptedEnvelope { let plaintext = SecretMaterial::from_slice(plaintext).expect("plaintext"); let data_key = SecretMaterial::from_slice(&[0x31; 32]).expect("data key"); EncryptedEnvelope::seal( &VectorWrapping, SealRequest::new( reference(version), + test_context(id, kind), &plaintext, SealMaterial::new(data_key, Nonce::new([0x42; 24])), ), @@ -687,11 +986,79 @@ mod tests { .expect("metadata") } + async fn migrated_legacy_store() -> (SqliteStorage, PrivateArtifactMetadata, EncryptedEnvelope) + { + const V1_ENVELOPE_HEX: &str = "52525331000101010100000007000c656e76656c6f70652d6b6579222222222222222222222222222222222222222222222222000000204b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b00000028f106837e33d690e7c5287abdd815ce9257b7b5b176ea9596abf3b7fe745aec5a8c2487a553d4659d"; + let runtime_pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("runtime SQLite"); + for migration in RUNTIME_MIGRATIONS { + sqlx::raw_sql(runtime_migration_sql(migration.version()).expect("runtime SQL")) + .execute(&runtime_pool) + .await + .expect("runtime migration"); + } + let private_pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("private SQLite"); + for migration in &PRIVATE_MIGRATIONS[..3] { + sqlx::raw_sql(private_migration_sql(migration.version()).expect("private SQL")) + .execute(&private_pool) + .await + .expect("private migration"); + } + let encoded = hex::decode(V1_ENVELOPE_HEX).expect("legacy vector"); + let envelope = EncryptedEnvelope::decode(encoded.as_slice()).expect("legacy envelope"); + let metadata = metadata( + 1, + "trade.private_terms", + &envelope, + RetentionPolicy::indefinite(), + ); + sqlx::query( + "INSERT INTO radroots_private_artifacts ( + artifact_id, artifact_kind, schema_id, commitment, protected_size_bytes, + secret_provider, secret_reference, key_version, envelope_version, + encrypted_envelope, revision, stage, created_at_unix_ms, updated_at_unix_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, 1, 'active', 100, 100)", + ) + .bind(metadata.artifact_id().as_bytes().as_slice()) + .bind(metadata.kind().as_str()) + .bind(metadata.schema_id().as_str()) + .bind(metadata.commitment().as_bytes().as_slice()) + .bind(i64_from_u64(metadata.protected_size_bytes()).unwrap()) + .bind(metadata.secret_reference().provider()) + .bind(metadata.secret_reference().opaque_reference()) + .bind(i64::from(metadata.secret_reference().key_version())) + .bind(encoded) + .execute(&private_pool) + .await + .expect("legacy row"); + sqlx::raw_sql(private_migration_sql(4).expect("v4 SQL")) + .execute(&private_pool) + .await + .expect("v4 migration"); + ( + SqliteStorage::with_private_pool( + runtime_pool, + private_pool, + radroots_storage::event::SourceGeneration::new([91; 32]).expect("generation"), + EventStoreMode::ReadWrite, + ), + metadata, + envelope, + ) + } + #[tokio::test] async fn encrypted_envelopes_round_trip_with_exact_commitment_and_key_version() { let store = store(EventStoreMode::ReadWrite).await; - let envelope = sealed_envelope(b"private farm coordinates", 7).await; - let metadata = metadata(1, "farm_location", &envelope, RetentionPolicy::indefinite()); + let envelope = sealed_envelope(b"private farm coordinates", 7, 1, "farm.location").await; + let metadata = metadata(1, "farm.location", &envelope, RetentionPolicy::indefinite()); let stored = store .put_encrypted_private_artifact(metadata.clone(), &envelope) .await @@ -710,7 +1077,10 @@ mod tests { loaded.encode().expect("loaded bytes"), envelope.encode().expect("expected bytes") ); - let opened = loaded.open(&VectorWrapping).await.expect("open envelope"); + let opened = loaded + .open(&VectorWrapping, &test_context(1, "farm.location")) + .await + .expect("open envelope"); opened.expose_secret(|bytes| assert_eq!(bytes, b"private farm coordinates")); let row = sqlx::query( @@ -722,7 +1092,7 @@ mod tests { .await .expect("private row"); assert_eq!(row.get::<i64, _>("key_version"), 7); - assert_eq!(row.get::<i64, _>("envelope_version"), 1); + assert_eq!(row.get::<i64, _>("envelope_version"), 2); let encrypted = row.get::<Vec<u8>, _>("encrypted_envelope"); assert!( !encrypted @@ -730,7 +1100,8 @@ mod tests { .any(|bytes| bytes == b"private farm coordinates") ); - let wrong_key_envelope = sealed_envelope(b"private farm coordinates", 8).await; + let wrong_key_envelope = + sealed_envelope(b"private farm coordinates", 8, 1, "farm.location").await; assert_eq!( store .put_encrypted_private_artifact(metadata, &wrong_key_envelope) @@ -738,19 +1109,19 @@ mod tests { Err(Error::InvalidPrivateArtifactMetadata) ); - let envelope = sealed_envelope(b"validation matrix", 9).await; + let envelope = sealed_envelope(b"validation matrix", 9, 9, "test.validation_matrix").await; let valid = self::metadata( 9, - "validation_matrix", + "test.validation_matrix", &envelope, RetentionPolicy::new(Some(100), Some(100)).expect("retention"), ); - assert!(validate_envelope(&valid, &envelope).is_ok()); + assert!(validate_new_envelope(&valid, &envelope).is_ok()); let expired = valid .mark_expired(valid.revision(), 100) .expect("expired metadata"); assert_eq!( - validate_envelope(&expired, &envelope), + validate_new_envelope(&expired, &envelope), Err(Error::InvalidPrivateArtifactMetadata) ); for (commitment, protected_size, secret_reference) in [ @@ -787,7 +1158,7 @@ mod tests { ) .expect("structurally valid metadata"); assert_eq!( - validate_envelope(&invalid, &envelope), + validate_new_envelope(&invalid, &envelope), Err(Error::InvalidPrivateArtifactMetadata) ); } @@ -796,10 +1167,10 @@ mod tests { #[tokio::test] async fn expiry_and_tombstone_delete_envelope_but_preserve_commitment() { let store = store(EventStoreMode::ReadWrite).await; - let envelope = sealed_envelope(b"private trade artifact", 3).await; + let envelope = sealed_envelope(b"private trade artifact", 3, 2, "trade.artifact").await; let metadata = metadata( 2, - "trade_artifact", + "trade.artifact", &envelope, RetentionPolicy::new(Some(400), Some(300)).expect("retention"), ); @@ -873,12 +1244,12 @@ mod tests { async fn all_private_authorities_are_metadata_only_without_an_envelope() { let store = store(EventStoreMode::ReadWrite).await; for (id, kind) in [ - (10, "signing_reference"), - (11, "farm_location"), - (12, "trade_artifact"), - (13, "nip46_session"), + (10, "signing.reference"), + (11, "farm.location"), + (12, "trade.artifact"), + (13, "nip46.session"), ] { - let envelope = sealed_envelope(kind.as_bytes(), 1).await; + let envelope = sealed_envelope(kind.as_bytes(), 1, id, kind).await; let metadata = metadata(id, kind, &envelope, RetentionPolicy::indefinite()); store .put_metadata(metadata.clone()) @@ -917,10 +1288,10 @@ mod tests { #[tokio::test] async fn conflicts_corruption_and_read_only_mode_fail_closed() { let writable_store = store(EventStoreMode::ReadWrite).await; - let envelope = sealed_envelope(b"signing reference", 5).await; + let envelope = sealed_envelope(b"signing reference", 5, 20, "signing.reference").await; let stored_metadata = metadata( 20, - "signing_reference", + "signing.reference", &envelope, RetentionPolicy::indefinite(), ); @@ -928,10 +1299,11 @@ mod tests { .put_encrypted_private_artifact(stored_metadata.clone(), &envelope) .await .expect("store artifact"); - let other_envelope = sealed_envelope(b"another signing reference", 5).await; + let other_envelope = + sealed_envelope(b"another signing reference", 5, 20, "signing.reference").await; let conflicting = metadata( 20, - "signing_reference", + "signing.reference", &other_envelope, RetentionPolicy::indefinite(), ); @@ -953,10 +1325,10 @@ mod tests { ); let read_only = store(EventStoreMode::ReadOnly).await; - let envelope = sealed_envelope(b"read only", 1).await; + let envelope = sealed_envelope(b"read only", 1, 21, "nip46.session").await; let metadata = metadata( 21, - "nip46_session", + "nip46.session", &envelope, RetentionPolicy::indefinite(), ); @@ -967,4 +1339,155 @@ mod tests { Err(Error::BackendUnavailable) ); } + + #[tokio::test] + async fn legacy_reseal_is_atomic_idempotent_and_context_bound() { + let (store, metadata, legacy) = migrated_legacy_store().await; + assert_eq!(legacy.version(), 1); + assert_eq!( + store + .private_artifact_envelope_migration_status() + .await + .expect("migration status"), + PrivateArtifactEnvelopeMigrationStatus { + v1_pending: 1, + v2_current: 0, + corrupt: 0, + blocked_provider: 0, + conflicted: 0, + } + ); + + let context = test_context(1, "trade.private_terms"); + let authority = LegacyV1ResealAuthority::new(); + assert!(matches!( + legacy + .reseal_legacy_v1( + &VectorWrapping, + &authority, + legacy.reference(), + reference(8), + context.clone(), + &|_| false, + SealMaterial::new( + SecretMaterial::from_slice(&[0x33; 32]).expect("fresh key"), + Nonce::new([0x44; 24]), + ), + ) + .await, + Err(SecretError::LegacyPayloadValidationFailed) + )); + assert_eq!( + store + .encrypted_private_artifact(metadata.artifact_id()) + .await + .expect("unchanged legacy") + .expect("legacy envelope") + .version(), + 1 + ); + + let resealed = legacy + .reseal_legacy_v1( + &VectorWrapping, + &authority, + legacy.reference(), + reference(8), + context.clone(), + &|plaintext| plaintext == b"radroots envelope vector", + SealMaterial::new( + SecretMaterial::from_slice(&[0x33; 32]).expect("fresh key"), + Nonce::new([0x44; 24]), + ), + ) + .await + .expect("authorized reseal"); + let encoded = resealed.envelope().encode().expect("v2 bytes"); + let request = PrivateArtifactResealRequest::new( + PrivateArtifactResealId::new([0x55; 16]).expect("reseal id"), + metadata.artifact_id(), + metadata.revision(), + metadata.commitment(), + ArtifactCommitment::new(Sha256::digest(encoded.as_slice()).into()), + u64::try_from(encoded.len()).expect("v2 length"), + DurableSecretReference::new("memory", "private-artifact-key", 8) + .expect("next reference"), + 200, + ) + .expect("reseal request"); + let committed = store + .commit_private_artifact_reseal(request.clone(), resealed.envelope()) + .await + .expect("atomic commit"); + assert_eq!( + committed.disposition(), + PrivateArtifactResealDisposition::Committed + ); + let replayed = store + .commit_private_artifact_reseal(request.clone(), resealed.envelope()) + .await + .expect("lost-response replay"); + assert_eq!( + replayed.disposition(), + PrivateArtifactResealDisposition::Replayed + ); + let current = store + .encrypted_private_artifact(metadata.artifact_id()) + .await + .expect("current envelope") + .expect("v2 envelope"); + assert_eq!(current.version(), 2); + current + .open(&VectorWrapping, &context) + .await + .expect("context-bound open") + .expose_secret(|plaintext| assert_eq!(plaintext, b"radroots envelope vector")); + assert_eq!( + store + .private_artifact_envelope_migration_status() + .await + .expect("migration status"), + PrivateArtifactEnvelopeMigrationStatus { + v1_pending: 0, + v2_current: 1, + corrupt: 0, + blocked_provider: 0, + conflicted: 0, + } + ); + + let conflict = PrivateArtifactResealRequest::new( + request.reseal_id(), + request.artifact_id(), + request.expected_revision(), + request.expected_commitment(), + ArtifactCommitment::new([0x66; 32]), + request.next_protected_size_bytes(), + request.next_secret_reference().clone(), + request.committed_at_unix_ms(), + ) + .expect("conflicting request"); + assert_eq!( + store + .commit_private_artifact_reseal(conflict, resealed.envelope()) + .await, + Err(Error::PrivateArtifactResealConflict) + ); + assert!( + sqlx::query( + "UPDATE radroots_private_artifacts SET encrypted_envelope = encrypted_envelope + WHERE artifact_id = ?", + ) + .bind(metadata.artifact_id().as_bytes().as_slice()) + .execute(store.private_pool()) + .await + .is_err() + ); + assert!( + sqlx::query("DELETE FROM radroots_private_envelope_reseals") + .execute(store.private_pool()) + .await + .is_err() + ); + } }