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:
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()
+ );
+ }
}