From ef054a7acd9dc72f09c6ed49067d3e066a930069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Tue, 27 Oct 2026 11:54:13 +0000 Subject: [PATCH 01/19] feat(storage): add transactional vault schema migrations --- .../storage/credential_vault/fixtures/v1.sql | 35 ++ .../src/storage/credential_vault/mod.rs | 12 +- .../src/storage/credential_vault/schema.rs | 345 +++++++++++++++++- 3 files changed, 372 insertions(+), 20 deletions(-) create mode 100644 crates/walletkit-core/src/storage/credential_vault/fixtures/v1.sql diff --git a/crates/walletkit-core/src/storage/credential_vault/fixtures/v1.sql b/crates/walletkit-core/src/storage/credential_vault/fixtures/v1.sql new file mode 100644 index 000000000..dd397b2c6 --- /dev/null +++ b/crates/walletkit-core/src/storage/credential_vault/fixtures/v1.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS vault_meta ( + schema_version INTEGER NOT NULL, + leaf_index INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_meta_schema_version + ON vault_meta (schema_version); + + CREATE TRIGGER IF NOT EXISTS vault_meta_set_updated_at + AFTER UPDATE ON vault_meta + FOR EACH ROW + BEGIN + UPDATE vault_meta + SET updated_at = CAST(strftime('%s','now') AS INTEGER) + WHERE schema_version = NEW.schema_version; + END; + + CREATE TABLE IF NOT EXISTS credential_records ( + credential_id INTEGER NOT NULL PRIMARY KEY, + issuer_schema_id INTEGER NOT NULL, + subject_blinding_factor BLOB NOT NULL, + genesis_issued_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + credential_blob_cid BLOB NOT NULL, + associated_data_cid BLOB + ); + + CREATE INDEX IF NOT EXISTS idx_cred_by_issuer_schema + ON credential_records (issuer_schema_id, updated_at DESC); + + CREATE INDEX IF NOT EXISTS idx_cred_by_expiry + ON credential_records (expires_at); diff --git a/crates/walletkit-core/src/storage/credential_vault/mod.rs b/crates/walletkit-core/src/storage/credential_vault/mod.rs index 21c2acbf4..5d6c8171b 100644 --- a/crates/walletkit-core/src/storage/credential_vault/mod.rs +++ b/crates/walletkit-core/src/storage/credential_vault/mod.rs @@ -14,7 +14,7 @@ use std::path::Path; use crate::storage::error::{StorageError, StorageResult}; use crate::storage::types::{BlobKind, CredentialRecord}; -use schema::{ensure_schema, VAULT_SCHEMA_VERSION}; +use schema::{ensure_schema, VAULT_META_KEY}; use secrecy::SecretBox; use walletkit_db::{blobs, Vault}; use walletkit_sqlite::{cipher, params, Error as DbError, Row, StepResult, Value}; @@ -26,7 +26,8 @@ use walletkit_sqlite::{cipher, params, Error as DbError, Row, StepResult, Value} /// `init_leaf_index`) with the authoritative `leaf_index` from the /// authenticator. /// -/// **Note:** New tables added to the vault schema must be added here too. +/// Only credential backup tables belong here. Local-only tables must remain excluded. +/// The database schema version (`PRAGMA user_version`) is local as well. pub(crate) const BACKUP_TABLES: &[&str] = &["credential_records", "blob_objects"]; // A version's content, never a device-local row ID, breaks equal-timestamp ties. @@ -52,10 +53,7 @@ impl CredentialVault { path: &Path, k_intermediate: &SecretBox<[u8; 32]>, ) -> StorageResult { - let vault = Vault::open(path, k_intermediate, |conn| { - blobs::ensure_schema(conn)?; - ensure_schema(conn) - })?; + let vault = Vault::open(path, k_intermediate, ensure_schema)?; Ok(Self { vault }) } @@ -84,7 +82,7 @@ impl CredentialVault { ELSE vault_meta.leaf_index END RETURNING leaf_index", - params![VAULT_SCHEMA_VERSION, leaf_index_i64, now_i64], + params![VAULT_META_KEY, leaf_index_i64, now_i64], |stmt| Ok(stmt.column_i64(0)), ) .map_err(|err| map_db_err(&err))?; diff --git a/crates/walletkit-core/src/storage/credential_vault/schema.rs b/crates/walletkit-core/src/storage/credential_vault/schema.rs index f08e9e0b1..6ac229a6b 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -1,21 +1,78 @@ -//! Credential-vault schema (vault metadata + credential records). -//! -//! The shared `blob_objects` table comes from -//! [`walletkit_db::Blobs::ensure_schema`]; this module owns only the -//! credential-specific tables. +//! Versioned credential-vault schema. The shared blob schema is owned by `walletkit-db`. -use walletkit_sqlite::{Connection, DbResult}; +use walletkit_db::blobs; +use walletkit_sqlite::{error::ErrorCode, Connection, DbResult, Error}; -pub(super) const VAULT_SCHEMA_VERSION: i64 = 1; +// This column is a fixed metadata key, independent of the database schema version. +pub(super) const VAULT_META_KEY: i64 = 1; -/// Creates the credential-vault tables, indexes, and triggers. +/// Append migrations here; `PRAGMA user_version` counts the applied migrations. +/// Migrations must not start transactions or modify the shared blob schema. +const MIGRATIONS: &[fn(&Connection) -> DbResult<()>] = &[migration_v1]; + +/// Applies pending migrations atomically, without ever resetting vault data. +/// +/// The immediate transaction serializes concurrent openers before reading the +/// version. DDL and the version marker commit together; interrupted migrations +/// are rolled back and retried on the next open. /// -/// **Backup sensitivity:** Schema changes here affect plaintext vault -/// backups. -/// - New tables must be added to [`super::BACKUP_TABLES`]. -/// - Column changes (especially new `NOT NULL` columns without defaults) can -/// break restoring older backups into a newer schema. +/// Plaintext backups contain only [`super::BACKUP_TABLES`], not `user_version`. +/// New credential columns require backward-compatible import and merge queries. pub(super) fn ensure_schema(conn: &Connection) -> DbResult<()> { + migrate(conn, MIGRATIONS) +} + +fn migrate( + conn: &Connection, + migrations: &[fn(&Connection) -> DbResult<()>], +) -> DbResult<()> { + let tx = conn.transaction_immediate()?; + let version = + tx.query_row("PRAGMA user_version", &[], |row| Ok(row.column_i64(0)))?; + let applied = usize::try_from(version) + .map_err(|_| schema_error("invalid vault schema version"))?; + if applied > migrations.len() { + return Err(schema_error(format!( + "vault schema version {version} is newer than supported version {}; upgrade WalletKit", + migrations.len() + ))); + } + + if version == 0 { + let has_meta = tx.query_row_optional( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'vault_meta'", + &[], |_| Ok(()), + )?.is_some(); + if has_meta { + let invalid = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM vault_meta WHERE schema_version != 1 OR typeof(schema_version) != 'integer')", + &[], |row| Ok(row.column_i64(0) != 0), + )?; + if invalid { + return Err(schema_error( + "unsupported legacy vault schema version; upgrade WalletKit", + )); + } + } + } + + blobs::ensure_schema(conn)?; + for migration in migrations.iter().skip(applied) { + migration(conn)?; + } + tx.execute_batch(&format!("PRAGMA user_version = {}", migrations.len()))?; + tx.commit() +} + +fn schema_error(message: impl Into) -> Error { + Error { + code: ErrorCode(1), + message: message.into(), + } +} + +/// Version 1: the credential tables and fixed-key account metadata. +fn migration_v1(conn: &Connection) -> DbResult<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS vault_meta ( schema_version INTEGER NOT NULL, @@ -55,3 +112,265 @@ pub(super) fn ensure_schema(conn: &Connection) -> DbResult<()> { ", ) } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + use crate::storage::{CredentialVault, StorageError}; + use secrecy::SecretBox; + use walletkit_db::Vault; + use walletkit_sqlite::cipher; + + fn version(conn: &Connection) -> i64 { + conn.query_row("PRAGMA user_version", &[], |row| Ok(row.column_i64(0))) + .unwrap() + } + + fn migration_v2(conn: &Connection) -> DbResult<()> { + conn.execute_batch("CREATE TABLE local_only (value INTEGER);") + } + + #[test] + fn fresh_vault_records_version_and_reopens_idempotently() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.db"); + let key = SecretBox::init_with(|| [42; 32]); + let vault = CredentialVault::new(&path, &key).unwrap(); + assert_eq!(version(vault.vault.connection()), 1); + vault.init_leaf_index(7, 100).unwrap(); + drop(vault); + let vault = CredentialVault::new(&path, &key).unwrap(); + vault.init_leaf_index(7, 200).unwrap(); + let count = vault + .vault + .connection() + .query_row("SELECT COUNT(*) FROM vault_meta", &[], |row| { + Ok(row.column_i64(0)) + }) + .unwrap(); + assert_eq!(count, 1); + assert!(matches!( + vault.init_leaf_index(8, 300), + Err(StorageError::InvalidLeafIndex { .. }) + )); + } + + #[test] + fn legacy_v1_is_adopted_without_changing_data_or_blob_schema() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.db"); + let key = SecretBox::init_with(|| [42; 32]); + let old = Vault::open(&path, &key, |conn| { + blobs::ensure_schema(conn)?; + conn.execute_batch(include_str!("fixtures/v1.sql"))?; + conn.execute_batch("INSERT INTO vault_meta VALUES (1, 7, 100, 100)") + }) + .unwrap(); + let cid = blobs::put(old.connection(), 1, b"credential", 100).unwrap(); + old.connection().execute( + "INSERT INTO credential_records VALUES (1, 10, ?1, 100, 1000, 100, ?2, NULL)", + walletkit_sqlite::params![vec![7_u8; 32], cid.as_slice()], + ).unwrap(); + let blob_schema = old + .connection() + .query_row( + "SELECT sql FROM sqlite_master WHERE name = 'blob_objects'", + &[], + |row| Ok(row.column_text(0)), + ) + .unwrap(); + assert_eq!(version(old.connection()), 0); + drop(old); + let vault = CredentialVault::new(&path, &key).unwrap(); + let conn = vault.vault.connection(); + assert_eq!(version(conn), 1); + assert_eq!(blobs::get(conn, &cid).unwrap().unwrap(), b"credential"); + assert_eq!( + vault + .fetch_credential_and_blinding_factor(10, 100) + .unwrap() + .unwrap(), + (b"credential".to_vec(), vec![7; 32]) + ); + assert_eq!(conn.query_row( + "SELECT leaf_index = 7 AND created_at = 100 AND updated_at = 100 FROM vault_meta", + &[], |row| Ok(row.column_i64(0)), + ).unwrap(), 1); + assert_eq!( + conn.query_row( + "SELECT sql FROM sqlite_master WHERE name = 'blob_objects'", + &[], + |row| Ok(row.column_text(0)), + ) + .unwrap(), + blob_schema + ); + } + + #[test] + fn unsupported_versions_fail_without_modifying_the_vault() { + for marker in [ + "PRAGMA user_version = 2", + "PRAGMA user_version = -1", + "UPDATE vault_meta SET schema_version = 2; PRAGMA user_version = 0", + ] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.db"); + let key = SecretBox::init_with(|| [42; 32]); + let vault = CredentialVault::new(&path, &key).unwrap(); + vault.init_leaf_index(7, 100).unwrap(); + let conn = vault.vault.connection(); + conn.execute_batch(marker).unwrap(); + let before = version(conn); + conn.execute_batch("DROP TABLE blob_objects").unwrap(); + drop(vault); + let err = CredentialVault::new(&path, &key).unwrap_err(); + assert!(matches!(err, StorageError::VaultDb(_))); + assert!(err.to_string().contains("vault schema version")); + let conn = cipher::open_encrypted(&path, &key).unwrap(); + assert_eq!(version(&conn), before); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM vault_meta WHERE leaf_index = 7", + &[], + |row| Ok(row.column_i64(0)), + ) + .unwrap(), + 1 + ); + assert!(conn.prepare("SELECT * FROM blob_objects").is_err()); + } + } + + #[test] + fn failed_migration_rolls_back_ddl_and_version_then_retries() { + fn fail(conn: &Connection) -> DbResult<()> { + migration_v2(conn)?; + conn.execute_batch("INSERT INTO missing_table VALUES (1)") + } + let conn = Connection::open(":memory:", false).unwrap(); + ensure_schema(&conn).unwrap(); + assert!(migrate(&conn, &[migration_v1, fail]).is_err()); + assert_eq!(version(&conn), 1); + assert!(conn.prepare("SELECT * FROM local_only").is_err()); + migrate(&conn, &[migration_v1, migration_v2]).unwrap(); + assert_eq!(version(&conn), 2); + migrate(&conn, &[migration_v1, migration_v2]).unwrap(); + } + + #[test] + fn concurrent_migrator_fails_busy_then_rechecks_committed_version() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.db"); + let key = SecretBox::init_with(|| [42; 32]); + let first = cipher::open_encrypted(&path, &key).unwrap(); + ensure_schema(&first).unwrap(); + let second = cipher::open_encrypted(&path, &key).unwrap(); + let tx = first.transaction_immediate().unwrap(); + migration_v2(&first).unwrap(); + tx.execute_batch("PRAGMA user_version = 2").unwrap(); + let error = migrate(&second, &[migration_v1, migration_v2]).unwrap_err(); + assert_eq!(error.code.0 & 0xff, 5); // SQLITE_BUSY + tx.commit().unwrap(); + migrate(&second, &[migration_v1, migration_v2]).unwrap(); + assert_eq!(version(&second), 2); + } + + #[test] + fn crash_during_migration_is_retried_on_next_open() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.db"); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "storage::credential_vault::schema::tests::crash_child", + ]) + .env("WALLETKIT_MIGRATION_CRASH_DB", &path) + .status() + .unwrap(); + assert!(status.success()); + assert!(path.exists()); + let key = SecretBox::init_with(|| [42; 32]); + let conn = cipher::open_encrypted(&path, &key).unwrap(); + assert_eq!(version(&conn), 0); + assert!(conn.prepare("SELECT * FROM vault_meta").is_err()); + drop(conn); + let vault = CredentialVault::new(&path, &key).unwrap(); + assert_eq!(version(vault.vault.connection()), 1); + vault.init_leaf_index(8, 200).unwrap(); + } + + #[test] + fn crash_child() { + let Some(path) = std::env::var_os("WALLETKIT_MIGRATION_CRASH_DB") else { + return; + }; + let key = SecretBox::init_with(|| [42; 32]); + let conn = cipher::open_encrypted(std::path::Path::new(&path), &key).unwrap(); + let _tx = conn.transaction_immediate().unwrap(); + blobs::ensure_schema(&conn).unwrap(); + migration_v1(&conn).unwrap(); + conn.execute_batch( + "INSERT INTO vault_meta VALUES (1, 7, 100, 100); PRAGMA user_version = 1", + ) + .unwrap(); + std::process::exit(0); + } + + #[test] + fn old_backup_import_and_merge_preserve_newer_destination_version() { + let dir = tempfile::tempdir().unwrap(); + let key = SecretBox::init_with(|| [42; 32]); + let source = CredentialVault { + vault: Vault::open(&dir.path().join("old.db"), &key, |conn| { + blobs::ensure_schema(conn)?; + conn.execute_batch(include_str!("fixtures/v1.sql")) + }) + .unwrap(), + }; + source + .store_credential( + 10, + vec![7; 32], + 100, + 1000, + b"credential".to_vec(), + None, + 100, + ) + .unwrap(); + let backup = dir.path().join("backup.db"); + source.export_plaintext(&backup).unwrap(); + let bytes = std::fs::read(&backup).unwrap(); + for merge in [false, true] { + let destination = + CredentialVault::new(&dir.path().join(format!("new-{merge}.db")), &key) + .unwrap(); + migrate( + destination.vault.connection(), + &[migration_v1, migration_v2], + ) + .unwrap(); + destination.init_leaf_index(7, 100).unwrap(); + if merge { + assert_eq!(destination.merge_plaintext_bytes(&bytes).unwrap(), 1); + } else { + destination.import_plaintext(&backup).unwrap(); + } + assert_eq!(version(destination.vault.connection()), 2); + assert_eq!( + destination + .fetch_credential_and_blinding_factor(10, 100) + .unwrap() + .unwrap() + .0, + b"credential" + ); + destination.init_leaf_index(7, 200).unwrap(); + assert!(matches!( + destination.init_leaf_index(8, 200), + Err(StorageError::InvalidLeafIndex { .. }) + )); + } + } +} From b27028ec11abe3e309125b9a420a0d4c0254246f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Tue, 27 Oct 2026 12:05:00 +0000 Subject: [PATCH 02/19] feat(storage): store PCPs in a dedicated vault table --- Cargo.lock | 1 + Cargo.toml | 1 + crates/walletkit-core/Cargo.toml | 1 + .../src/storage/credential_storage.rs | 124 ++++- .../src/storage/credential_vault/mod.rs | 2 + .../src/storage/credential_vault/pcp.rs | 481 ++++++++++++++++++ .../src/storage/credential_vault/schema.rs | 48 +- crates/walletkit-core/src/storage/error.rs | 8 + crates/walletkit-core/src/storage/mod.rs | 5 +- crates/walletkit-core/src/storage/traits.rs | 3 +- crates/walletkit-core/src/storage/types.rs | 57 +++ docs/accounts.md | 8 + 12 files changed, 716 insertions(+), 23 deletions(-) create mode 100644 crates/walletkit-core/src/storage/credential_vault/pcp.rs diff --git a/Cargo.lock b/Cargo.lock index 6e71976f6..73337acca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9175,6 +9175,7 @@ dependencies = [ "axum", "backon", "base64 0.22.1", + "blake3", "chacha20poly1305 0.10.1", "chrono", "ciborium", diff --git a/Cargo.toml b/Cargo.toml index 6912b655a..9881034fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ categories = ["api-bindings", "cryptography::cryptocurrencies"] [workspace.dependencies] +blake3 = "1.8.3" alloy = { version = "2", default-features = false } alloy-core = { version = "1", default-features = false, features = [ "sol-types", diff --git a/crates/walletkit-core/Cargo.toml b/crates/walletkit-core/Cargo.toml index f684656a3..da8bacf44 100644 --- a/crates/walletkit-core/Cargo.toml +++ b/crates/walletkit-core/Cargo.toml @@ -60,6 +60,7 @@ getrandom = { workspace = true, features = ["wasm_js"] } # Native-only dependencies (not available on wasm32) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] async-trait = { workspace = true } +blake3 = { workspace = true } attested-request = { workspace = true } ctor = { workspace = true } flamingo-verifier-api-types = { workspace = true } diff --git a/crates/walletkit-core/src/storage/credential_storage.rs b/crates/walletkit-core/src/storage/credential_storage.rs index df9351db6..d8ecbe310 100644 --- a/crates/walletkit-core/src/storage/credential_storage.rs +++ b/crates/walletkit-core/src/storage/credential_storage.rs @@ -16,6 +16,8 @@ use super::traits::{AtomicBlobStore, DeviceKeystore}; use super::types::{ActivityEntry, ActivityMetadata, ActivityQuery, CredentialRecord}; use super::ACCOUNT_KEYS_FILENAME; use super::{CacheDb, CredentialVault}; +#[cfg(not(target_arch = "wasm32"))] +use super::{PcpKind, PcpMetadata, PcpRecord}; use super::{StorageLock, StorageLockGuard}; use crate::{Credential, FieldElement}; use world_id_core::primitives::merkle::AccountInclusionProof; @@ -440,6 +442,61 @@ impl CredentialStore { } } +#[cfg(not(target_arch = "wasm32"))] +#[uniffi::export] +impl CredentialStore { + /// Stores sealed PCP bytes under the host's exact logical path and returns + /// their 32-byte BLAKE3 checksum. Replaces data at an existing path only when + /// the kind matches, preserving its creation time. + /// + /// Paths are opaque identifiers; hosts own normalization. PCP changes do + /// not notify the credential vault-change listener or affect vault backups. + /// + /// # Errors + /// Returns an error if storage is uninitialized, the kind conflicts with an + /// existing entry, the timestamp is out of range, or the database write fails. + pub fn put_pcp( + &self, + path: &str, + kind: PcpKind, + data: &[u8], + now: u64, + ) -> StorageResult> { + self.lock_inner()? + .state()? + .vault + .put_pcp(path, kind, data, now) + } + + /// Retrieves a PCP, verifying its BLAKE3 checksum. Returns `None` if absent. + /// + /// # Errors + /// Returns an error if storage is uninitialized, stored data is corrupt, + /// or the database read fails. Errors never include the logical path. + pub fn get_pcp(&self, path: &str) -> StorageResult> { + self.lock_inner()?.state()?.vault.get_pcp(path) + } + + /// Lists PCP metadata in path order, optionally restricted to one kind. + /// Does not load package data; use [`Self::get_pcp`] to verify its checksum. + /// + /// # Errors + /// Returns an error if storage is uninitialized, stored metadata is corrupt, + /// or the database query fails. + pub fn list_pcps(&self, kind: Option) -> StorageResult> { + self.lock_inner()?.state()?.vault.list_pcps(kind) + } + + /// Deletes a PCP, returning whether it existed. Does not notify the + /// credential vault-change listener or affect credential backups. + /// + /// # Errors + /// Returns an error if storage is uninitialized or the database write fails. + pub fn delete_pcp(&self, path: &str) -> StorageResult { + self.lock_inner()?.state()?.vault.delete_pcp(path) + } +} + #[cfg(not(target_arch = "wasm32"))] #[uniffi::export] impl CredentialStore { @@ -486,8 +543,9 @@ impl CredentialStore { inner.import_vault_from_file(&path) } - /// Registers a listener that is called after every successful vault - /// mutation (store, delete, purge). + /// Registers a listener for successful credential mutations (store, delete, + /// purge, merge). PCP changes do not affect credential backups and do not + /// notify this listener. /// /// Only one listener can be active at a time — calling this replaces any /// previously registered listener. The previous delivery thread shuts down @@ -1159,6 +1217,68 @@ mod tests { use std::sync::atomic::{AtomicU32, Ordering}; + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn pcp_api_requires_initialization_and_never_notifies_credential_backup_listener() { + let dir = tempfile::tempdir().unwrap(); + let store = + CredentialStore::from_provider(&InMemoryStorageProvider::new(dir.path())) + .unwrap(); + assert!(matches!( + store.put_pcp("package", PcpKind::Orb, b"sealed", 100), + Err(StorageError::NotInitialized) + )); + assert!(matches!( + store.get_pcp("package"), + Err(StorageError::NotInitialized) + )); + assert!(matches!( + store.list_pcps(None), + Err(StorageError::NotInitialized) + )); + assert!(matches!( + store.delete_pcp("package"), + Err(StorageError::NotInitialized) + )); + store.init(42, 100).unwrap(); + let (tx, rx) = mpsc::sync_channel(1); + *store.vault_changed_tx.lock().unwrap() = Some(tx); + let before = store.export_vault_for_backup().unwrap(); + assert_eq!( + store + .put_pcp("package", PcpKind::Orb, b"sealed", 100) + .unwrap(), + blake3::hash(b"sealed").as_bytes() + ); + assert!(store.get_pcp("missing").unwrap().is_none()); + assert_eq!(store.get_pcp("package").unwrap().unwrap().data, b"sealed"); + assert_eq!(store.list_pcps(Some(PcpKind::Orb)).unwrap().len(), 1); + assert!(matches!( + store.put_pcp("package", PcpKind::Face, b"wrong", 100), + Err(StorageError::PcpKindMismatch) + )); + assert_eq!(store.export_vault_for_backup().unwrap(), before); + store.import_vault_from_backup(&before).unwrap(); + assert_eq!(store.merge_vault_from_backup(&before).unwrap(), 0); + assert_eq!(store.get_pcp("package").unwrap().unwrap().data, b"sealed"); + assert!(store.delete_pcp("package").unwrap()); + assert!(!store.delete_pcp("package").unwrap()); + assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty))); + let credential: Credential = world_id_core::Credential::new() + .issuer_schema_id(10) + .genesis_issued_at(100) + .into(); + store + .store_credential(&credential, &FieldElement::from(7_u64), 1000, None, 100) + .unwrap(); + rx.try_recv().unwrap(); + store.destroy_storage().unwrap(); + assert!(matches!( + store.get_pcp("package"), + Err(StorageError::NotInitialized) + )); + } + fn supplied_keys(byte: u8) -> Arc { Arc::new(StorageKeys::from_bytes(vec![byte; 32]).expect("32-byte key")) } diff --git a/crates/walletkit-core/src/storage/credential_vault/mod.rs b/crates/walletkit-core/src/storage/credential_vault/mod.rs index 5d6c8171b..4a4585da9 100644 --- a/crates/walletkit-core/src/storage/credential_vault/mod.rs +++ b/crates/walletkit-core/src/storage/credential_vault/mod.rs @@ -6,6 +6,8 @@ //! [`walletkit_db`]. mod merge; +#[cfg(not(target_arch = "wasm32"))] +mod pcp; mod schema; #[cfg(test)] mod tests; diff --git a/crates/walletkit-core/src/storage/credential_vault/pcp.rs b/crates/walletkit-core/src/storage/credential_vault/pcp.rs new file mode 100644 index 000000000..480cf3ea9 --- /dev/null +++ b/crates/walletkit-core/src/storage/credential_vault/pcp.rs @@ -0,0 +1,481 @@ +//! Local PCP storage, independent of credential backup tables and notifications. + +use super::{map_db_err, to_i64, to_u64, CredentialVault}; +use crate::storage::{PcpKind, PcpMetadata, PcpRecord, StorageError, StorageResult}; +use walletkit_sqlite::{params, StepResult, Value}; + +impl CredentialVault { + pub(crate) fn put_pcp( + &self, + path: &str, + kind: PcpKind, + data: &[u8], + now: u64, + ) -> StorageResult> { + let now = to_i64(now, "now")?; + let checksum = blake3::hash(data); + self.vault.connection().query_row_optional( + "INSERT INTO pcp_records (path, kind, data, checksum, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?5) + ON CONFLICT(path) DO UPDATE SET + data = excluded.data, checksum = excluded.checksum, updated_at = excluded.updated_at + WHERE pcp_records.kind = excluded.kind + RETURNING checksum", + params![path, kind as i64, data, checksum.as_bytes().as_slice(), now], + |row| Ok(row.column_blob(0)), + ).map_err(|err| map_db_err(&err))?.ok_or(StorageError::PcpKindMismatch) + } + + pub(crate) fn get_pcp(&self, path: &str) -> StorageResult> { + let row = self + .vault + .connection() + .query_row_optional( + "SELECT path, kind, data, checksum FROM pcp_records WHERE path = ?1", + params![path], + |row| { + Ok(( + row.column_text(0), + row.column_i64(1), + row.column_blob(2), + row.column_blob(3), + )) + }, + ) + .map_err(|err| map_db_err(&err))?; + let Some((path, kind, data, checksum)) = row else { + return Ok(None); + }; + let kind = PcpKind::try_from(kind)?; + if blake3::hash(&data).as_bytes().as_slice() != checksum { + return Err(StorageError::CorruptedPcp("checksum mismatch".to_owned())); + } + Ok(Some(PcpRecord { + path, + kind, + data, + checksum, + })) + } + + pub(crate) fn list_pcps( + &self, + kind: Option, + ) -> StorageResult> { + let mut stmt = self + .vault + .connection() + .prepare( + "SELECT path, kind, checksum, created_at, updated_at FROM pcp_records + WHERE (?1 IS NULL OR kind = ?1) ORDER BY path", + ) + .map_err(|err| map_db_err(&err))?; + stmt.bind_values(params![ + kind.map_or(Value::Null, |kind| Value::Integer(kind as i64)) + ]) + .map_err(|err| map_db_err(&err))?; + let mut entries = Vec::new(); + while let StepResult::Row(row) = stmt.step().map_err(|err| map_db_err(&err))? { + let checksum = row.column_blob(2); + if checksum.len() != 32 { + return Err(StorageError::CorruptedPcp( + "invalid checksum length".to_owned(), + )); + } + entries.push(PcpMetadata { + path: row.column_text(0), + kind: PcpKind::try_from(row.column_i64(1))?, + checksum, + created_at: to_u64(row.column_i64(3), "PCP created_at")?, + updated_at: to_u64(row.column_i64(4), "PCP updated_at")?, + }); + } + Ok(entries) + } + + pub(crate) fn delete_pcp(&self, path: &str) -> StorageResult { + self.vault + .connection() + .execute("DELETE FROM pcp_records WHERE path = ?1", params![path]) + .map(|count| count != 0) + .map_err(|err| map_db_err(&err)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use secrecy::SecretBox; + use walletkit_sqlite::Connection; + + fn new_vault(dir: &std::path::Path, name: &str) -> CredentialVault { + CredentialVault::new(&dir.join(name), &SecretBox::init_with(|| [42; 32])) + .unwrap() + } + + #[test] + fn opaque_paths_and_bytes_round_trip_across_reopen_with_stable_checksums() { + let dir = tempfile::tempdir().unwrap(); + let vault = new_vault(dir.path(), "vault.db"); + let data = vec![0xA5; 5 * 1024 * 1024]; + let path = "./opaque/../identifier/🦉"; + let checksum = vault.put_pcp(path, PcpKind::Orb, &data, 100).unwrap(); + assert_eq!(checksum, blake3::hash(&data).as_bytes()); + let expected = PcpRecord { + path: path.to_owned(), + kind: PcpKind::Orb, + data, + checksum, + }; + assert_eq!(vault.get_pcp(path).unwrap(), Some(expected.clone())); + assert_eq!(vault.get_pcp("opaque/../identifier/🦉").unwrap(), None); + drop(vault); + let vault = new_vault(dir.path(), "vault.db"); + assert_eq!(vault.get_pcp(path).unwrap(), Some(expected)); + assert!(vault.delete_pcp(path).unwrap()); + assert!(!vault.delete_pcp(path).unwrap()); + assert_eq!(vault.get_pcp(path).unwrap(), None); + assert!(vault.list_pcps(None).unwrap().is_empty()); + } + + #[test] + fn put_updates_bytes_and_timestamp_but_preserves_kind_and_creation_time() { + let dir = tempfile::tempdir().unwrap(); + let vault = new_vault(dir.path(), "vault.db"); + let first = vault + .put_pcp("package", PcpKind::Face, b"one", 100) + .unwrap(); + assert_eq!( + vault + .put_pcp("package", PcpKind::Face, b"one", 200) + .unwrap(), + first + ); + let next = vault + .put_pcp("package", PcpKind::Face, b"two", 300) + .unwrap(); + assert_ne!(first, next); + let before = vault.list_pcps(None).unwrap(); + assert_eq!( + before, + vec![PcpMetadata { + path: "package".to_owned(), + kind: PcpKind::Face, + checksum: next, + created_at: 100, + updated_at: 300, + }] + ); + let error = vault + .put_pcp("package", PcpKind::Document, b"wrong kind", 400) + .unwrap_err(); + assert!(matches!(error, StorageError::PcpKindMismatch)); + assert!(!error.to_string().contains("package")); + assert_eq!(vault.list_pcps(None).unwrap(), before); + assert_eq!(vault.get_pcp("package").unwrap().unwrap().data, b"two"); + } + + #[test] + fn list_filters_each_kind_and_returns_metadata_for_empty_or_corrupt_data() { + let dir = tempfile::tempdir().unwrap(); + let vault = new_vault(dir.path(), "vault.db"); + for (kind, path) in [ + (PcpKind::Orb, "a"), + (PcpKind::Face, "b"), + (PcpKind::Document, "c"), + ] { + vault.put_pcp(path, kind, b"", 100).unwrap(); + assert_eq!(vault.get_pcp(path).unwrap().unwrap().data, b""); + assert_eq!(vault.list_pcps(Some(kind)).unwrap()[0].path, path); + } + assert_eq!(vault.list_pcps(None).unwrap().len(), 3); + vault + .vault + .connection() + .execute_batch("UPDATE pcp_records SET data = X'FF'") + .unwrap(); + assert_eq!(vault.list_pcps(None).unwrap().len(), 3); + assert!(matches!( + vault.get_pcp("a"), + Err(StorageError::CorruptedPcp(_)) + )); + } + + #[test] + fn corrupt_checksums_and_kinds_fail_without_exposing_paths() { + let dir = tempfile::tempdir().unwrap(); + let vault = new_vault(dir.path(), "vault.db"); + let path = "private-face-identifier"; + vault.put_pcp(path, PcpKind::Face, b"sealed", 100).unwrap(); + let conn = vault.vault.connection(); + for sql in [ + "UPDATE pcp_records SET checksum = zeroblob(32)", + "PRAGMA ignore_check_constraints = ON; UPDATE pcp_records SET checksum = X'FF'", + "UPDATE pcp_records SET kind = 4", + ] { + conn.execute_batch(sql).unwrap(); + let error = vault.get_pcp(path).unwrap_err(); + assert!(matches!(error, StorageError::CorruptedPcp(_))); + assert!(!error.to_string().contains(path)); + } + assert!(matches!( + vault.list_pcps(None), + Err(StorageError::CorruptedPcp(_)) + )); + conn.execute_batch("UPDATE pcp_records SET checksum = zeroblob(32)") + .unwrap(); + assert!(matches!( + vault.list_pcps(None), + Err(StorageError::CorruptedPcp(_)) + )); + conn.execute_batch("UPDATE pcp_records SET kind = 2, created_at = -1") + .unwrap(); + assert!(matches!( + vault.list_pcps(None), + Err(StorageError::VaultDb(_)) + )); + conn.execute_batch("UPDATE pcp_records SET created_at = 100, updated_at = -1") + .unwrap(); + assert!(matches!( + vault.list_pcps(None), + Err(StorageError::VaultDb(_)) + )); + } + + #[test] + fn invalid_time_and_failed_writes_leave_existing_data_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let vault = new_vault(dir.path(), "vault.db"); + vault + .put_pcp("package", PcpKind::Orb, b"original", 100) + .unwrap(); + assert!(matches!( + vault.put_pcp("package", PcpKind::Orb, b"new", u64::MAX), + Err(StorageError::VaultDb(_)) + )); + let conn = vault.vault.connection(); + conn.execute_batch( + "CREATE TRIGGER reject_pcp_insert BEFORE INSERT ON pcp_records BEGIN SELECT RAISE(ABORT, 'injected'); END;", + ).unwrap(); + assert!(matches!( + vault.put_pcp("another", PcpKind::Face, b"new", 200), + Err(StorageError::VaultDb(_)) + )); + conn.execute_batch( + "DROP TRIGGER reject_pcp_insert; + CREATE TRIGGER reject_pcp_update BEFORE UPDATE ON pcp_records BEGIN SELECT RAISE(ABORT, 'injected'); END;", + ).unwrap(); + assert!(matches!( + vault.put_pcp("package", PcpKind::Orb, b"new", 200), + Err(StorageError::VaultDb(_)) + )); + conn.execute_batch( + "CREATE TRIGGER reject_pcp_delete BEFORE DELETE ON pcp_records BEGIN SELECT RAISE(ABORT, 'injected'); END;", + ).unwrap(); + assert!(matches!( + vault.delete_pcp("package"), + Err(StorageError::VaultDb(_)) + )); + assert_eq!(vault.get_pcp("package").unwrap().unwrap().data, b"original"); + assert_eq!(vault.list_pcps(None).unwrap().len(), 1); + conn.execute_batch( + "DROP TRIGGER reject_pcp_update; DROP TRIGGER reject_pcp_delete", + ) + .unwrap(); + vault + .put_pcp("package", PcpKind::Orb, b"retry", 200) + .unwrap(); + assert!(vault.delete_pcp("package").unwrap()); + } + + #[test] + fn unavailable_table_errors_are_propagated_by_every_operation() { + let dir = tempfile::tempdir().unwrap(); + let vault = new_vault(dir.path(), "vault.db"); + vault + .vault + .connection() + .execute_batch("DROP TABLE pcp_records") + .unwrap(); + assert!(matches!( + vault.put_pcp("package", PcpKind::Orb, b"data", 100), + Err(StorageError::VaultDb(_)) + )); + assert!(matches!( + vault.get_pcp("package"), + Err(StorageError::VaultDb(_)) + )); + assert!(matches!( + vault.list_pcps(None), + Err(StorageError::VaultDb(_)) + )); + assert!(matches!( + vault.delete_pcp("package"), + Err(StorageError::VaultDb(_)) + )); + } + + #[test] + fn credential_backups_exclude_pcps_and_ignore_incoming_pcp_tables() { + let dir = tempfile::tempdir().unwrap(); + let source = new_vault(dir.path(), "source.db"); + source + .store_credential( + 10, + vec![7; 32], + 100, + 1000, + b"credential".to_vec(), + None, + 100, + ) + .unwrap(); + let backup = dir.path().join("backup.db"); + source.export_plaintext(&backup).unwrap(); + let before = std::fs::read(&backup).unwrap(); + source + .put_pcp("package", PcpKind::Orb, b"remote", 100) + .unwrap(); + source.export_plaintext(&backup).unwrap(); + assert_eq!(std::fs::read(&backup).unwrap(), before); + assert!(!super::super::BACKUP_TABLES.contains(&"pcp_records")); + let incoming = Connection::open(&backup, false).unwrap(); + assert!(incoming.prepare("SELECT * FROM pcp_records").is_err()); + incoming + .execute_batch( + "CREATE TABLE pcp_records (path TEXT, kind INTEGER, data BLOB); + INSERT INTO pcp_records VALUES ('package', 3, X'FF'); + INSERT INTO pcp_records VALUES ('incoming-only', 3, X'FF');", + ) + .unwrap(); + drop(incoming); + for merge in [false, true] { + let local = new_vault(dir.path(), &format!("local-{merge}.db")); + local + .put_pcp("package", PcpKind::Face, b"local", 50) + .unwrap(); + let expected = local.get_pcp("package").unwrap(); + let metadata = local.list_pcps(None).unwrap(); + if merge { + assert_eq!( + local + .merge_plaintext_bytes(&std::fs::read(&backup).unwrap()) + .unwrap(), + 1 + ); + assert_eq!( + local + .merge_plaintext_bytes(&std::fs::read(&backup).unwrap()) + .unwrap(), + 0 + ); + } else { + local.import_plaintext(&backup).unwrap(); + } + assert_eq!(local.get_pcp("package").unwrap(), expected); + assert_eq!(local.list_pcps(None).unwrap(), metadata); + assert!(local.get_pcp("incoming-only").unwrap().is_none()); + assert_eq!( + local + .fetch_credential_and_blinding_factor(10, 100) + .unwrap() + .unwrap() + .0, + b"credential" + ); + } + } + + #[test] + fn failed_credential_import_and_merge_preserve_local_pcps() { + let dir = tempfile::tempdir().unwrap(); + let source = new_vault(dir.path(), "source.db"); + source + .store_credential( + 10, + vec![7; 32], + 100, + 1000, + b"credential".to_vec(), + None, + 100, + ) + .unwrap(); + let backup = dir.path().join("backup.db"); + source.export_plaintext(&backup).unwrap(); + let local = new_vault(dir.path(), "local.db"); + local + .put_pcp("package", PcpKind::Face, b"keep", 50) + .unwrap(); + let before = local.get_pcp("package").unwrap(); + local.vault.connection().execute_batch( + "CREATE TRIGGER reject_credential BEFORE INSERT ON credential_records BEGIN SELECT RAISE(ABORT, 'injected'); END;", + ).unwrap(); + assert!(local.import_plaintext(&backup).is_err()); + assert_eq!(local.get_pcp("package").unwrap(), before); + assert!(local + .merge_plaintext_bytes(&std::fs::read(&backup).unwrap()) + .is_err()); + assert_eq!(local.get_pcp("package").unwrap(), before); + assert!(local.list_credentials(None, 100).unwrap().is_empty()); + local + .vault + .connection() + .execute_batch("DROP TRIGGER reject_credential") + .unwrap(); + local.import_plaintext(&backup).unwrap(); + assert_eq!(local.get_pcp("package").unwrap(), before); + } + + #[test] + fn recorded_v1_vault_upgrades_and_old_backup_preserves_local_pcps() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.db"); + let key = SecretBox::init_with(|| [42; 32]); + let old = walletkit_db::Vault::open(&path, &key, |conn| { + walletkit_db::blobs::ensure_schema(conn)?; + conn.execute_batch(include_str!("fixtures/v1.sql"))?; + conn.execute_batch("PRAGMA user_version = 1") + }) + .unwrap(); + let source = CredentialVault { vault: old }; + source.init_leaf_index(7, 100).unwrap(); + source + .store_credential( + 10, + vec![7; 32], + 100, + 1000, + b"credential".to_vec(), + None, + 100, + ) + .unwrap(); + let backup = dir.path().join("old-backup.db"); + source.export_plaintext(&backup).unwrap(); + drop(source); + let upgraded = new_vault(dir.path(), "vault.db"); + upgraded.init_leaf_index(7, 200).unwrap(); + assert!(matches!( + upgraded.init_leaf_index(8, 200), + Err(StorageError::InvalidLeafIndex { .. }) + )); + assert_eq!(upgraded.list_credentials(None, 100).unwrap().len(), 1); + upgraded + .put_pcp("package", PcpKind::Orb, b"local", 100) + .unwrap(); + let receiver = new_vault(dir.path(), "receiver.db"); + receiver + .put_pcp("package", PcpKind::Document, b"keep", 100) + .unwrap(); + receiver.import_plaintext(&backup).unwrap(); + assert_eq!(receiver.get_pcp("package").unwrap().unwrap().data, b"keep"); + assert_eq!( + receiver + .merge_plaintext_bytes(&std::fs::read(&backup).unwrap()) + .unwrap(), + 0 + ); + assert_eq!(receiver.get_pcp("package").unwrap().unwrap().data, b"keep"); + } +} diff --git a/crates/walletkit-core/src/storage/credential_vault/schema.rs b/crates/walletkit-core/src/storage/credential_vault/schema.rs index 6ac229a6b..c56ecc3aa 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -8,7 +8,7 @@ pub(super) const VAULT_META_KEY: i64 = 1; /// Append migrations here; `PRAGMA user_version` counts the applied migrations. /// Migrations must not start transactions or modify the shared blob schema. -const MIGRATIONS: &[fn(&Connection) -> DbResult<()>] = &[migration_v1]; +const MIGRATIONS: &[fn(&Connection) -> DbResult<()>] = &[migration_v1, migration_v2]; /// Applies pending migrations atomically, without ever resetting vault data. /// @@ -113,6 +113,20 @@ fn migration_v1(conn: &Connection) -> DbResult<()> { ) } +/// Version 2: local PCP storage, deliberately excluded from credential backups. +fn migration_v2(conn: &Connection) -> DbResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS pcp_records ( + path TEXT PRIMARY KEY NOT NULL, + kind INTEGER NOT NULL CHECK (kind IN (1, 2, 3)), + data BLOB NOT NULL, + checksum BLOB NOT NULL CHECK (typeof(checksum) = 'blob' AND length(checksum) = 32), + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= 0) + );", + ) +} + #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; @@ -126,7 +140,7 @@ mod tests { .unwrap() } - fn migration_v2(conn: &Connection) -> DbResult<()> { + fn migration_test_local(conn: &Connection) -> DbResult<()> { conn.execute_batch("CREATE TABLE local_only (value INTEGER);") } @@ -136,7 +150,7 @@ mod tests { let path = dir.path().join("vault.db"); let key = SecretBox::init_with(|| [42; 32]); let vault = CredentialVault::new(&path, &key).unwrap(); - assert_eq!(version(vault.vault.connection()), 1); + assert_eq!(version(vault.vault.connection()), 2); vault.init_leaf_index(7, 100).unwrap(); drop(vault); let vault = CredentialVault::new(&path, &key).unwrap(); @@ -183,7 +197,7 @@ mod tests { drop(old); let vault = CredentialVault::new(&path, &key).unwrap(); let conn = vault.vault.connection(); - assert_eq!(version(conn), 1); + assert_eq!(version(conn), 2); assert_eq!(blobs::get(conn, &cid).unwrap().unwrap(), b"credential"); assert_eq!( vault @@ -210,7 +224,7 @@ mod tests { #[test] fn unsupported_versions_fail_without_modifying_the_vault() { for marker in [ - "PRAGMA user_version = 2", + "PRAGMA user_version = 2147483647", "PRAGMA user_version = -1", "UPDATE vault_meta SET schema_version = 2; PRAGMA user_version = 0", ] { @@ -245,17 +259,17 @@ mod tests { #[test] fn failed_migration_rolls_back_ddl_and_version_then_retries() { fn fail(conn: &Connection) -> DbResult<()> { - migration_v2(conn)?; + migration_test_local(conn)?; conn.execute_batch("INSERT INTO missing_table VALUES (1)") } let conn = Connection::open(":memory:", false).unwrap(); - ensure_schema(&conn).unwrap(); + migrate(&conn, &[migration_v1]).unwrap(); assert!(migrate(&conn, &[migration_v1, fail]).is_err()); assert_eq!(version(&conn), 1); assert!(conn.prepare("SELECT * FROM local_only").is_err()); - migrate(&conn, &[migration_v1, migration_v2]).unwrap(); + migrate(&conn, &[migration_v1, migration_test_local]).unwrap(); assert_eq!(version(&conn), 2); - migrate(&conn, &[migration_v1, migration_v2]).unwrap(); + migrate(&conn, &[migration_v1, migration_test_local]).unwrap(); } #[test] @@ -264,15 +278,16 @@ mod tests { let path = dir.path().join("vault.db"); let key = SecretBox::init_with(|| [42; 32]); let first = cipher::open_encrypted(&path, &key).unwrap(); - ensure_schema(&first).unwrap(); + migrate(&first, &[migration_v1]).unwrap(); let second = cipher::open_encrypted(&path, &key).unwrap(); let tx = first.transaction_immediate().unwrap(); - migration_v2(&first).unwrap(); + migration_test_local(&first).unwrap(); tx.execute_batch("PRAGMA user_version = 2").unwrap(); - let error = migrate(&second, &[migration_v1, migration_v2]).unwrap_err(); + let error = + migrate(&second, &[migration_v1, migration_test_local]).unwrap_err(); assert_eq!(error.code.0 & 0xff, 5); // SQLITE_BUSY tx.commit().unwrap(); - migrate(&second, &[migration_v1, migration_v2]).unwrap(); + migrate(&second, &[migration_v1, migration_test_local]).unwrap(); assert_eq!(version(&second), 2); } @@ -296,7 +311,7 @@ mod tests { assert!(conn.prepare("SELECT * FROM vault_meta").is_err()); drop(conn); let vault = CredentialVault::new(&path, &key).unwrap(); - assert_eq!(version(vault.vault.connection()), 1); + assert_eq!(version(vault.vault.connection()), 2); vault.init_leaf_index(8, 200).unwrap(); } @@ -346,11 +361,6 @@ mod tests { let destination = CredentialVault::new(&dir.path().join(format!("new-{merge}.db")), &key) .unwrap(); - migrate( - destination.vault.connection(), - &[migration_v1, migration_v2], - ) - .unwrap(); destination.init_leaf_index(7, 100).unwrap(); if merge { assert_eq!(destination.merge_plaintext_bytes(&bytes).unwrap(), 1); diff --git a/crates/walletkit-core/src/storage/error.rs b/crates/walletkit-core/src/storage/error.rs index e16729b20..797b73eb5 100644 --- a/crates/walletkit-core/src/storage/error.rs +++ b/crates/walletkit-core/src/storage/error.rs @@ -84,6 +84,14 @@ pub enum StorageError { credential_id: u64, }, + /// An existing PCP path cannot be reassigned to another kind. + #[error("PCP kind does not match the existing entry")] + PcpKindMismatch, + + /// A stored PCP has invalid metadata or does not match its checksum. + #[error("corrupted PCP: {0}")] + CorruptedPcp(String), + /// Corrupted cache entry #[error("corrupted cache entry at {key_prefix}")] CorruptedCacheEntry { diff --git a/crates/walletkit-core/src/storage/mod.rs b/crates/walletkit-core/src/storage/mod.rs index 7162b73ca..0b9294cc4 100644 --- a/crates/walletkit-core/src/storage/mod.rs +++ b/crates/walletkit-core/src/storage/mod.rs @@ -14,7 +14,8 @@ //! //! 1. **Vault database (`account.vault.sqlite`)** — authoritative storage for //! credentials, associated data blobs, issuer subject blinding factors, and the account -//! leaf index. Corruption is a hard failure. See [`crate::storage::CredentialVault`]. +//! leaf index, plus sealed PCPs on native platforms. PCPs are excluded from +//! credential backups and their change notifications. Corruption is a hard failure. See [`crate::storage::CredentialVault`]. //! 2. **Cache database (`account.cache.sqlite`)** — non-authoritative, regenerable //! entries: Merkle inclusion proof cache, per-account session seed, and nullifier //! replay guards. Subject to TTL pruning and can be rebuilt at any time without @@ -67,6 +68,8 @@ pub use types::{ ActivityQuery, BlobKind, ContentId, CredentialRecord, Nullifier, ProtocolVersion, ReplayGuardKind, ReplayGuardResult, RequestId, }; +#[cfg(not(target_arch = "wasm32"))] +pub use types::{PcpKind, PcpMetadata, PcpRecord}; pub use walletkit_db::{Lock as StorageLock, LockGuard as StorageLockGuard}; /// Deletes a closed `SQLite` database and its journal sidecars. diff --git a/crates/walletkit-core/src/storage/traits.rs b/crates/walletkit-core/src/storage/traits.rs index 26658ac54..c9abf9486 100644 --- a/crates/walletkit-core/src/storage/traits.rs +++ b/crates/walletkit-core/src/storage/traits.rs @@ -97,7 +97,8 @@ pub trait StorageProvider: Send + Sync { /// callback is delivered on a dedicated background thread to avoid re-entering /// the `UniFFI` call stack (see `logger.rs` for rationale). /// -/// This is only called when individual credentials are added or removed. +/// This is only called when individual credentials are added or removed. PCP +/// changes do not affect credential backups and do not trigger this listener. /// /// # Expected usage /// diff --git a/crates/walletkit-core/src/storage/types.rs b/crates/walletkit-core/src/storage/types.rs index 21533f005..89baba6d5 100644 --- a/crates/walletkit-core/src/storage/types.rs +++ b/crates/walletkit-core/src/storage/types.rs @@ -209,3 +209,60 @@ impl ActivityQuery { next } } + +/// Kind of sealed Personal Custody Package. Paths are unique across all kinds. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +#[repr(i64)] +pub enum PcpKind { + /// Orb package (`orb_pkg` in Bedrock). + Orb = 1, + /// Face package (`face_pkg` in Bedrock). + Face = 2, + /// Document package (`document_pkg` in Bedrock). + Document = 3, +} + +#[cfg(not(target_arch = "wasm32"))] +impl TryFrom for PcpKind { + type Error = StorageError; + + fn try_from(value: i64) -> StorageResult { + match value { + 1 => Ok(Self::Orb), + 2 => Ok(Self::Face), + 3 => Ok(Self::Document), + _ => Err(StorageError::CorruptedPcp("invalid stored kind".to_owned())), + } + } +} + +/// Sealed PCP bytes and their verified BLAKE3 checksum. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct PcpRecord { + /// Exact logical identifier supplied by the host; never interpreted as a filesystem path. + pub path: String, + /// Package kind. + pub kind: PcpKind, + /// Opaque sealed package bytes, unchanged by `WalletKit`. + pub data: Vec, + /// BLAKE3 of `data`, exactly 32 bytes. + pub checksum: Vec, +} + +/// PCP metadata returned without loading package data. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct PcpMetadata { + /// Exact logical identifier supplied by the host. + pub path: String, + /// Package kind. + pub kind: PcpKind, + /// Stored BLAKE3 checksum, exactly 32 bytes. Verified against data by `get_pcp`. + pub checksum: Vec, + /// First insertion time in Unix seconds. + pub created_at: u64, + /// Most recent put time in Unix seconds. + pub updated_at: u64, +} diff --git a/docs/accounts.md b/docs/accounts.md index 19599c865..814940331 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -22,6 +22,14 @@ database key in the device keystore; for the types involved, see the In the browser, the app supplies the key itself; see [Protect the database key](web/package.md#protect-the-database-key). +On native platforms, `CredentialStore` also stores sealed Personal Custody Packages +with `put_pcp`, `get_pcp`, `list_pcps`, and `delete_pcp`. Paths are opaque logical +identifiers shared across Orb, Face, and Document packages; the host supplies the +normalized path. Reads verify BLAKE3 checksums, and an existing path cannot change +kind. PCPs are excluded from credential vault backups and remain intact during +credential import or merge. PCP writes do not trigger the credential vault-change +listener; hosts must schedule their PCP backups separately. + ## From registration to stored credential The following diagram shows the calls between the app, WalletKit, and the World ID From 4e20143a0d90ca80bad811c9cf7f604f677b5e89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Tue, 27 Oct 2026 13:09:26 +0000 Subject: [PATCH 03/19] fix(storage): commit PCP upserts explicitly An autocommit INSERT ... RETURNING commits when the statement is finalized, and walletkit-sqlite does not observe that result, so a commit-time IO failure was reported as success. Run the upsert in a transaction and propagate the commit error. Co-Authored-By: Claude Opus 5.5 --- .../src/storage/credential_vault/pcp.rs | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/crates/walletkit-core/src/storage/credential_vault/pcp.rs b/crates/walletkit-core/src/storage/credential_vault/pcp.rs index 480cf3ea9..2c8ae52c5 100644 --- a/crates/walletkit-core/src/storage/credential_vault/pcp.rs +++ b/crates/walletkit-core/src/storage/credential_vault/pcp.rs @@ -14,16 +14,25 @@ impl CredentialVault { ) -> StorageResult> { let now = to_i64(now, "now")?; let checksum = blake3::hash(data); - self.vault.connection().query_row_optional( - "INSERT INTO pcp_records (path, kind, data, checksum, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?5) - ON CONFLICT(path) DO UPDATE SET - data = excluded.data, checksum = excluded.checksum, updated_at = excluded.updated_at - WHERE pcp_records.kind = excluded.kind - RETURNING checksum", - params![path, kind as i64, data, checksum.as_bytes().as_slice(), now], - |row| Ok(row.column_blob(0)), - ).map_err(|err| map_db_err(&err))?.ok_or(StorageError::PcpKindMismatch) + // An autocommit `RETURNING` commits when the statement is finalized, and that + // result is not observable. The explicit commit surfaces write failures. + let conn = self.vault.connection(); + let tx = conn.transaction().map_err(|err| map_db_err(&err))?; + let stored = tx + .query_row_optional( + "INSERT INTO pcp_records (path, kind, data, checksum, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?5) + ON CONFLICT(path) DO UPDATE SET + data = excluded.data, checksum = excluded.checksum, updated_at = excluded.updated_at + WHERE pcp_records.kind = excluded.kind + RETURNING checksum", + params![path, kind as i64, data, checksum.as_bytes().as_slice(), now], + |row| Ok(row.column_blob(0)), + ) + .map_err(|err| map_db_err(&err))? + .ok_or(StorageError::PcpKindMismatch)?; + tx.commit().map_err(|err| map_db_err(&err))?; + Ok(stored) } pub(crate) fn get_pcp(&self, path: &str) -> StorageResult> { @@ -138,6 +147,29 @@ mod tests { assert!(vault.list_pcps(None).unwrap().is_empty()); } + #[test] + fn put_reports_failures_that_only_surface_at_commit() { + let dir = tempfile::tempdir().unwrap(); + let vault = new_vault(dir.path(), "vault.db"); + // A deferred foreign key is checked at commit, after the `RETURNING` row. + vault + .vault + .connection() + .execute_batch( + "CREATE TABLE commit_guard ( + missing TEXT REFERENCES pcp_records (path) DEFERRABLE INITIALLY DEFERRED + ); + CREATE TRIGGER fail_at_commit AFTER INSERT ON pcp_records BEGIN + INSERT INTO commit_guard VALUES ('not-stored'); + END;", + ) + .unwrap(); + assert!(vault + .put_pcp("package", PcpKind::Orb, b"data", 100) + .is_err()); + assert_eq!(vault.get_pcp("package").unwrap(), None); + } + #[test] fn put_updates_bytes_and_timestamp_but_preserves_kind_and_creation_time() { let dir = tempfile::tempdir().unwrap(); From 50745556d1d82d0aa02cbbe8464e3117747b5513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Tue, 27 Oct 2026 13:15:15 +0000 Subject: [PATCH 04/19] fix(storage): reject PCP paths containing NUL Text columns are read back up to the first NUL, so such a path could be stored but not listed or fetched again. Co-Authored-By: Claude Opus 5.5 --- .../src/storage/credential_storage.rs | 5 +++-- .../src/storage/credential_vault/pcp.rs | 16 ++++++++++++++++ crates/walletkit-core/src/storage/error.rs | 4 ++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/walletkit-core/src/storage/credential_storage.rs b/crates/walletkit-core/src/storage/credential_storage.rs index d8ecbe310..a98be2145 100644 --- a/crates/walletkit-core/src/storage/credential_storage.rs +++ b/crates/walletkit-core/src/storage/credential_storage.rs @@ -453,8 +453,9 @@ impl CredentialStore { /// not notify the credential vault-change listener or affect vault backups. /// /// # Errors - /// Returns an error if storage is uninitialized, the kind conflicts with an - /// existing entry, the timestamp is out of range, or the database write fails. + /// Returns an error if storage is uninitialized, the path contains a NUL + /// character, the kind conflicts with an existing entry, the timestamp is out + /// of range, or the database write fails. pub fn put_pcp( &self, path: &str, diff --git a/crates/walletkit-core/src/storage/credential_vault/pcp.rs b/crates/walletkit-core/src/storage/credential_vault/pcp.rs index 2c8ae52c5..de6d4ee52 100644 --- a/crates/walletkit-core/src/storage/credential_vault/pcp.rs +++ b/crates/walletkit-core/src/storage/credential_vault/pcp.rs @@ -12,6 +12,11 @@ impl CredentialVault { data: &[u8], now: u64, ) -> StorageResult> { + // Text columns are read back up to the first NUL, so such a path could not be + // listed or fetched again. + if path.contains('\0') { + return Err(StorageError::InvalidPcpPath); + } let now = to_i64(now, "now")?; let checksum = blake3::hash(data); // An autocommit `RETURNING` commits when the statement is finalized, and that @@ -170,6 +175,17 @@ mod tests { assert_eq!(vault.get_pcp("package").unwrap(), None); } + #[test] + fn put_rejects_paths_that_cannot_be_read_back() { + let dir = tempfile::tempdir().unwrap(); + let vault = new_vault(dir.path(), "vault.db"); + assert!(matches!( + vault.put_pcp("a\0b", PcpKind::Orb, b"data", 100), + Err(StorageError::InvalidPcpPath) + )); + assert!(vault.list_pcps(None).unwrap().is_empty()); + } + #[test] fn put_updates_bytes_and_timestamp_but_preserves_kind_and_creation_time() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/walletkit-core/src/storage/error.rs b/crates/walletkit-core/src/storage/error.rs index 797b73eb5..d1c3c9d2e 100644 --- a/crates/walletkit-core/src/storage/error.rs +++ b/crates/walletkit-core/src/storage/error.rs @@ -88,6 +88,10 @@ pub enum StorageError { #[error("PCP kind does not match the existing entry")] PcpKindMismatch, + /// A PCP path contains a NUL character, which stored text cannot round-trip. + #[error("PCP path contains a NUL character")] + InvalidPcpPath, + /// A stored PCP has invalid metadata or does not match its checksum. #[error("corrupted PCP: {0}")] CorruptedPcp(String), From 23773a2f1b9496e61ebba7ec05c540bad6876db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Tue, 27 Oct 2026 13:18:27 +0000 Subject: [PATCH 05/19] perf(storage): store PCP data after its metadata SQLite reaches columns after a large blob only by walking its overflow pages, so listing PCP metadata read every package. Migration v2 is unreleased, so the column order can still change. Co-Authored-By: Claude Opus 5.5 --- .../src/storage/credential_vault/schema.rs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/walletkit-core/src/storage/credential_vault/schema.rs b/crates/walletkit-core/src/storage/credential_vault/schema.rs index c56ecc3aa..926332b3b 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -119,10 +119,11 @@ fn migration_v2(conn: &Connection) -> DbResult<()> { "CREATE TABLE IF NOT EXISTS pcp_records ( path TEXT PRIMARY KEY NOT NULL, kind INTEGER NOT NULL CHECK (kind IN (1, 2, 3)), - data BLOB NOT NULL, checksum BLOB NOT NULL CHECK (typeof(checksum) = 'blob' AND length(checksum) = 32), created_at INTEGER NOT NULL CHECK (created_at >= 0), - updated_at INTEGER NOT NULL CHECK (updated_at >= 0) + updated_at INTEGER NOT NULL CHECK (updated_at >= 0), + -- Last, so reading metadata does not walk the package's overflow pages. + data BLOB NOT NULL );", ) } @@ -144,6 +145,23 @@ mod tests { conn.execute_batch("CREATE TABLE local_only (value INTEGER);") } + #[test] + fn pcp_data_is_stored_after_its_metadata() { + let dir = tempfile::tempdir().unwrap(); + let key = SecretBox::init_with(|| [42; 32]); + let vault = CredentialVault::new(&dir.path().join("vault.db"), &key).unwrap(); + let last_column = vault + .vault + .connection() + .query_row( + "SELECT name FROM pragma_table_info('pcp_records') ORDER BY cid DESC LIMIT 1", + &[], + |row| Ok(row.column_text(0)), + ) + .unwrap(); + assert_eq!(last_column, "data"); + } + #[test] fn fresh_vault_records_version_and_reopens_idempotently() { let dir = tempfile::tempdir().unwrap(); From 58881af0d23d64d18bd5c53308ebc3397f0d6b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Tue, 27 Oct 2026 13:56:51 +0000 Subject: [PATCH 06/19] test(storage): format the v1 schema fixture as plain SQL Co-Authored-By: Claude Opus 5.5 --- .../storage/credential_vault/fixtures/v1.sql | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/crates/walletkit-core/src/storage/credential_vault/fixtures/v1.sql b/crates/walletkit-core/src/storage/credential_vault/fixtures/v1.sql index dd397b2c6..06737ef7e 100644 --- a/crates/walletkit-core/src/storage/credential_vault/fixtures/v1.sql +++ b/crates/walletkit-core/src/storage/credential_vault/fixtures/v1.sql @@ -1,35 +1,35 @@ CREATE TABLE IF NOT EXISTS vault_meta ( - schema_version INTEGER NOT NULL, - leaf_index INTEGER, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); + schema_version INTEGER NOT NULL, + leaf_index INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); - CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_meta_schema_version - ON vault_meta (schema_version); +CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_meta_schema_version +ON vault_meta (schema_version); - CREATE TRIGGER IF NOT EXISTS vault_meta_set_updated_at - AFTER UPDATE ON vault_meta - FOR EACH ROW - BEGIN - UPDATE vault_meta - SET updated_at = CAST(strftime('%s','now') AS INTEGER) - WHERE schema_version = NEW.schema_version; - END; +CREATE TRIGGER IF NOT EXISTS vault_meta_set_updated_at +AFTER UPDATE ON vault_meta +FOR EACH ROW +BEGIN + UPDATE vault_meta + SET updated_at = CAST(strftime('%s','now') AS INTEGER) + WHERE schema_version = NEW.schema_version; +END; - CREATE TABLE IF NOT EXISTS credential_records ( - credential_id INTEGER NOT NULL PRIMARY KEY, - issuer_schema_id INTEGER NOT NULL, - subject_blinding_factor BLOB NOT NULL, - genesis_issued_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - credential_blob_cid BLOB NOT NULL, - associated_data_cid BLOB - ); +CREATE TABLE IF NOT EXISTS credential_records ( + credential_id INTEGER NOT NULL PRIMARY KEY, + issuer_schema_id INTEGER NOT NULL, + subject_blinding_factor BLOB NOT NULL, + genesis_issued_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + credential_blob_cid BLOB NOT NULL, + associated_data_cid BLOB +); - CREATE INDEX IF NOT EXISTS idx_cred_by_issuer_schema - ON credential_records (issuer_schema_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_cred_by_issuer_schema +ON credential_records (issuer_schema_id, updated_at DESC); - CREATE INDEX IF NOT EXISTS idx_cred_by_expiry - ON credential_records (expires_at); +CREATE INDEX IF NOT EXISTS idx_cred_by_expiry +ON credential_records (expires_at); From a00e43fd239e226845a7b4c995ec4fc21a9ca00e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Tue, 27 Oct 2026 14:11:35 +0000 Subject: [PATCH 07/19] refactor(storage): make PCP ids and kinds opaque to WalletKit WalletKit should not know about issuers. PCPs are now keyed by an opaque id, and the kind is a short host-chosen label (for example the backup designator orb_pkg) instead of an Orb/Face/Document enum. The API is no longer native-only: it only touches SQLite. Co-Authored-By: Claude Opus 5.5 --- crates/walletkit-core/Cargo.toml | 2 +- .../src/storage/credential_storage.rs | 61 ++--- .../src/storage/credential_vault/mod.rs | 1 - .../src/storage/credential_vault/pcp.rs | 211 ++++++++++-------- .../src/storage/credential_vault/schema.rs | 4 +- crates/walletkit-core/src/storage/error.rs | 12 +- crates/walletkit-core/src/storage/mod.rs | 3 +- crates/walletkit-core/src/storage/types.rs | 45 +--- docs/accounts.md | 8 +- 9 files changed, 170 insertions(+), 177 deletions(-) diff --git a/crates/walletkit-core/Cargo.toml b/crates/walletkit-core/Cargo.toml index da8bacf44..f49e985f8 100644 --- a/crates/walletkit-core/Cargo.toml +++ b/crates/walletkit-core/Cargo.toml @@ -23,6 +23,7 @@ name = "walletkit_core" [dependencies] alloy-core = { workspace = true } backon = { workspace = true } +blake3 = { workspace = true } base64 = { workspace = true } ciborium = { workspace = true } futures-util = { workspace = true } @@ -60,7 +61,6 @@ getrandom = { workspace = true, features = ["wasm_js"] } # Native-only dependencies (not available on wasm32) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] async-trait = { workspace = true } -blake3 = { workspace = true } attested-request = { workspace = true } ctor = { workspace = true } flamingo-verifier-api-types = { workspace = true } diff --git a/crates/walletkit-core/src/storage/credential_storage.rs b/crates/walletkit-core/src/storage/credential_storage.rs index a98be2145..6e2e6c72e 100644 --- a/crates/walletkit-core/src/storage/credential_storage.rs +++ b/crates/walletkit-core/src/storage/credential_storage.rs @@ -16,8 +16,7 @@ use super::traits::{AtomicBlobStore, DeviceKeystore}; use super::types::{ActivityEntry, ActivityMetadata, ActivityQuery, CredentialRecord}; use super::ACCOUNT_KEYS_FILENAME; use super::{CacheDb, CredentialVault}; -#[cfg(not(target_arch = "wasm32"))] -use super::{PcpKind, PcpMetadata, PcpRecord}; +use super::{PcpMetadata, PcpRecord}; use super::{StorageLock, StorageLockGuard}; use crate::{Credential, FieldElement}; use world_id_core::primitives::merkle::AccountInclusionProof; @@ -442,50 +441,55 @@ impl CredentialStore { } } -#[cfg(not(target_arch = "wasm32"))] #[uniffi::export] impl CredentialStore { - /// Stores sealed PCP bytes under the host's exact logical path and returns - /// their 32-byte BLAKE3 checksum. Replaces data at an existing path only when - /// the kind matches, preserving its creation time. + /// Stores sealed PCP bytes under the host's exact id and returns their + /// 32-byte BLAKE3 checksum. Replaces data at an existing id only when the + /// kind matches, preserving its creation time. /// - /// Paths are opaque identifiers; hosts own normalization. PCP changes do - /// not notify the credential vault-change listener or affect vault backups. + /// The id and kind are opaque to `WalletKit`. Hosts own id normalization and + /// typically use the backup designator, such as `orb_pkg`, as the kind. PCP + /// changes do not notify the credential vault-change listener or affect vault + /// backups. /// /// # Errors - /// Returns an error if storage is uninitialized, the path contains a NUL - /// character, the kind conflicts with an existing entry, the timestamp is out - /// of range, or the database write fails. + /// Returns an error if storage is uninitialized, the id contains a NUL + /// character, the kind is not a short lowercase label, the kind conflicts with + /// an existing entry, the timestamp is out of range, or the database write fails. pub fn put_pcp( &self, - path: &str, - kind: PcpKind, + id: &str, + kind: &str, data: &[u8], now: u64, ) -> StorageResult> { self.lock_inner()? .state()? .vault - .put_pcp(path, kind, data, now) + .put_pcp(id, kind, data, now) } /// Retrieves a PCP, verifying its BLAKE3 checksum. Returns `None` if absent. /// /// # Errors /// Returns an error if storage is uninitialized, stored data is corrupt, - /// or the database read fails. Errors never include the logical path. - pub fn get_pcp(&self, path: &str) -> StorageResult> { - self.lock_inner()?.state()?.vault.get_pcp(path) + /// or the database read fails. Errors never include the id. + pub fn get_pcp(&self, id: &str) -> StorageResult> { + self.lock_inner()?.state()?.vault.get_pcp(id) } - /// Lists PCP metadata in path order, optionally restricted to one kind. + /// Lists PCP metadata in id order, optionally restricted to one kind. /// Does not load package data; use [`Self::get_pcp`] to verify its checksum. /// /// # Errors /// Returns an error if storage is uninitialized, stored metadata is corrupt, /// or the database query fails. - pub fn list_pcps(&self, kind: Option) -> StorageResult> { - self.lock_inner()?.state()?.vault.list_pcps(kind) + #[expect( + clippy::needless_pass_by_value, + reason = "UniFFI passes optional strings by value" + )] + pub fn list_pcps(&self, kind: Option) -> StorageResult> { + self.lock_inner()?.state()?.vault.list_pcps(kind.as_deref()) } /// Deletes a PCP, returning whether it existed. Does not notify the @@ -493,8 +497,8 @@ impl CredentialStore { /// /// # Errors /// Returns an error if storage is uninitialized or the database write fails. - pub fn delete_pcp(&self, path: &str) -> StorageResult { - self.lock_inner()?.state()?.vault.delete_pcp(path) + pub fn delete_pcp(&self, id: &str) -> StorageResult { + self.lock_inner()?.state()?.vault.delete_pcp(id) } } @@ -1226,7 +1230,7 @@ mod tests { CredentialStore::from_provider(&InMemoryStorageProvider::new(dir.path())) .unwrap(); assert!(matches!( - store.put_pcp("package", PcpKind::Orb, b"sealed", 100), + store.put_pcp("package", "orb_pkg", b"sealed", 100), Err(StorageError::NotInitialized) )); assert!(matches!( @@ -1246,16 +1250,17 @@ mod tests { *store.vault_changed_tx.lock().unwrap() = Some(tx); let before = store.export_vault_for_backup().unwrap(); assert_eq!( - store - .put_pcp("package", PcpKind::Orb, b"sealed", 100) - .unwrap(), + store.put_pcp("package", "orb_pkg", b"sealed", 100).unwrap(), blake3::hash(b"sealed").as_bytes() ); assert!(store.get_pcp("missing").unwrap().is_none()); assert_eq!(store.get_pcp("package").unwrap().unwrap().data, b"sealed"); - assert_eq!(store.list_pcps(Some(PcpKind::Orb)).unwrap().len(), 1); + assert_eq!( + store.list_pcps(Some("orb_pkg".to_owned())).unwrap().len(), + 1 + ); assert!(matches!( - store.put_pcp("package", PcpKind::Face, b"wrong", 100), + store.put_pcp("package", "face_pkg", b"wrong", 100), Err(StorageError::PcpKindMismatch) )); assert_eq!(store.export_vault_for_backup().unwrap(), before); diff --git a/crates/walletkit-core/src/storage/credential_vault/mod.rs b/crates/walletkit-core/src/storage/credential_vault/mod.rs index 4a4585da9..fb2a3c534 100644 --- a/crates/walletkit-core/src/storage/credential_vault/mod.rs +++ b/crates/walletkit-core/src/storage/credential_vault/mod.rs @@ -6,7 +6,6 @@ //! [`walletkit_db`]. mod merge; -#[cfg(not(target_arch = "wasm32"))] mod pcp; mod schema; #[cfg(test)] diff --git a/crates/walletkit-core/src/storage/credential_vault/pcp.rs b/crates/walletkit-core/src/storage/credential_vault/pcp.rs index de6d4ee52..3c80fa278 100644 --- a/crates/walletkit-core/src/storage/credential_vault/pcp.rs +++ b/crates/walletkit-core/src/storage/credential_vault/pcp.rs @@ -1,22 +1,29 @@ //! Local PCP storage, independent of credential backup tables and notifications. +//! +//! `WalletKit` treats PCPs as opaque: the host chooses each entry's id and kind, and +//! neither is interpreted beyond validation. use super::{map_db_err, to_i64, to_u64, CredentialVault}; -use crate::storage::{PcpKind, PcpMetadata, PcpRecord, StorageError, StorageResult}; +use crate::storage::{PcpMetadata, PcpRecord, StorageError, StorageResult}; use walletkit_sqlite::{params, StepResult, Value}; +/// Longest accepted kind, in bytes. Kinds are short labels such as `orb_pkg`. +const MAX_KIND_LEN: usize = 32; + impl CredentialVault { pub(crate) fn put_pcp( &self, - path: &str, - kind: PcpKind, + id: &str, + kind: &str, data: &[u8], now: u64, ) -> StorageResult> { - // Text columns are read back up to the first NUL, so such a path could not be + // Text columns are read back up to the first NUL, so such an id could not be // listed or fetched again. - if path.contains('\0') { - return Err(StorageError::InvalidPcpPath); + if id.contains('\0') { + return Err(StorageError::InvalidPcpId); } + validate_kind(kind)?; let now = to_i64(now, "now")?; let checksum = blake3::hash(data); // An autocommit `RETURNING` commits when the statement is finalized, and that @@ -25,13 +32,13 @@ impl CredentialVault { let tx = conn.transaction().map_err(|err| map_db_err(&err))?; let stored = tx .query_row_optional( - "INSERT INTO pcp_records (path, kind, data, checksum, created_at, updated_at) + "INSERT INTO pcp_records (id, kind, data, checksum, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?5) - ON CONFLICT(path) DO UPDATE SET + ON CONFLICT(id) DO UPDATE SET data = excluded.data, checksum = excluded.checksum, updated_at = excluded.updated_at WHERE pcp_records.kind = excluded.kind RETURNING checksum", - params![path, kind as i64, data, checksum.as_bytes().as_slice(), now], + params![id, kind, data, checksum.as_bytes().as_slice(), now], |row| Ok(row.column_blob(0)), ) .map_err(|err| map_db_err(&err))? @@ -40,32 +47,31 @@ impl CredentialVault { Ok(stored) } - pub(crate) fn get_pcp(&self, path: &str) -> StorageResult> { + pub(crate) fn get_pcp(&self, id: &str) -> StorageResult> { let row = self .vault .connection() .query_row_optional( - "SELECT path, kind, data, checksum FROM pcp_records WHERE path = ?1", - params![path], + "SELECT id, kind, data, checksum FROM pcp_records WHERE id = ?1", + params![id], |row| { Ok(( row.column_text(0), - row.column_i64(1), + row.column_text(1), row.column_blob(2), row.column_blob(3), )) }, ) .map_err(|err| map_db_err(&err))?; - let Some((path, kind, data, checksum)) = row else { + let Some((id, kind, data, checksum)) = row else { return Ok(None); }; - let kind = PcpKind::try_from(kind)?; if blake3::hash(&data).as_bytes().as_slice() != checksum { return Err(StorageError::CorruptedPcp("checksum mismatch".to_owned())); } Ok(Some(PcpRecord { - path, + id, kind, data, checksum, @@ -74,18 +80,18 @@ impl CredentialVault { pub(crate) fn list_pcps( &self, - kind: Option, + kind: Option<&str>, ) -> StorageResult> { let mut stmt = self .vault .connection() .prepare( - "SELECT path, kind, checksum, created_at, updated_at FROM pcp_records - WHERE (?1 IS NULL OR kind = ?1) ORDER BY path", + "SELECT id, kind, checksum, created_at, updated_at FROM pcp_records + WHERE (?1 IS NULL OR kind = ?1) ORDER BY id", ) .map_err(|err| map_db_err(&err))?; stmt.bind_values(params![ - kind.map_or(Value::Null, |kind| Value::Integer(kind as i64)) + kind.map_or(Value::Null, |kind| Value::Text(kind.to_owned())) ]) .map_err(|err| map_db_err(&err))?; let mut entries = Vec::new(); @@ -97,8 +103,8 @@ impl CredentialVault { )); } entries.push(PcpMetadata { - path: row.column_text(0), - kind: PcpKind::try_from(row.column_i64(1))?, + id: row.column_text(0), + kind: row.column_text(1), checksum, created_at: to_u64(row.column_i64(3), "PCP created_at")?, updated_at: to_u64(row.column_i64(4), "PCP updated_at")?, @@ -107,16 +113,31 @@ impl CredentialVault { Ok(entries) } - pub(crate) fn delete_pcp(&self, path: &str) -> StorageResult { + pub(crate) fn delete_pcp(&self, id: &str) -> StorageResult { self.vault .connection() - .execute("DELETE FROM pcp_records WHERE path = ?1", params![path]) + .execute("DELETE FROM pcp_records WHERE id = ?1", params![id]) .map(|count| count != 0) .map_err(|err| map_db_err(&err)) } } -#[cfg(test)] +/// Accepts short lowercase labels such as `orb_pkg`, so kinds stay comparable +/// across hosts and safe to log. +fn validate_kind(kind: &str) -> StorageResult<()> { + let valid = !kind.is_empty() + && kind.len() <= MAX_KIND_LEN + && kind + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'); + if valid { + Ok(()) + } else { + Err(StorageError::InvalidPcpKind) + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; use secrecy::SecretBox; @@ -128,27 +149,27 @@ mod tests { } #[test] - fn opaque_paths_and_bytes_round_trip_across_reopen_with_stable_checksums() { + fn opaque_ids_and_bytes_round_trip_across_reopen_with_stable_checksums() { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); let data = vec![0xA5; 5 * 1024 * 1024]; - let path = "./opaque/../identifier/🦉"; - let checksum = vault.put_pcp(path, PcpKind::Orb, &data, 100).unwrap(); + let id = "./opaque/../identifier/🦉"; + let checksum = vault.put_pcp(id, "orb_pkg", &data, 100).unwrap(); assert_eq!(checksum, blake3::hash(&data).as_bytes()); let expected = PcpRecord { - path: path.to_owned(), - kind: PcpKind::Orb, + id: id.to_owned(), + kind: "orb_pkg".to_owned(), data, checksum, }; - assert_eq!(vault.get_pcp(path).unwrap(), Some(expected.clone())); + assert_eq!(vault.get_pcp(id).unwrap(), Some(expected.clone())); assert_eq!(vault.get_pcp("opaque/../identifier/🦉").unwrap(), None); drop(vault); let vault = new_vault(dir.path(), "vault.db"); - assert_eq!(vault.get_pcp(path).unwrap(), Some(expected)); - assert!(vault.delete_pcp(path).unwrap()); - assert!(!vault.delete_pcp(path).unwrap()); - assert_eq!(vault.get_pcp(path).unwrap(), None); + assert_eq!(vault.get_pcp(id).unwrap(), Some(expected)); + assert!(vault.delete_pcp(id).unwrap()); + assert!(!vault.delete_pcp(id).unwrap()); + assert_eq!(vault.get_pcp(id).unwrap(), None); assert!(vault.list_pcps(None).unwrap().is_empty()); } @@ -162,60 +183,68 @@ mod tests { .connection() .execute_batch( "CREATE TABLE commit_guard ( - missing TEXT REFERENCES pcp_records (path) DEFERRABLE INITIALLY DEFERRED + missing TEXT REFERENCES pcp_records (id) DEFERRABLE INITIALLY DEFERRED ); CREATE TRIGGER fail_at_commit AFTER INSERT ON pcp_records BEGIN INSERT INTO commit_guard VALUES ('not-stored'); END;", ) .unwrap(); - assert!(vault - .put_pcp("package", PcpKind::Orb, b"data", 100) - .is_err()); + assert!(vault.put_pcp("package", "orb_pkg", b"data", 100).is_err()); assert_eq!(vault.get_pcp("package").unwrap(), None); } #[test] - fn put_rejects_paths_that_cannot_be_read_back() { + fn put_rejects_ids_and_kinds_it_cannot_store_faithfully() { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); assert!(matches!( - vault.put_pcp("a\0b", PcpKind::Orb, b"data", 100), - Err(StorageError::InvalidPcpPath) + vault.put_pcp("a\0b", "orb_pkg", b"data", 100), + Err(StorageError::InvalidPcpId) )); - assert!(vault.list_pcps(None).unwrap().is_empty()); + for kind in [ + "", + "Orb_pkg", + "orb-pkg", + "orb pkg", + "orb\0pkg", + &"k".repeat(33), + ] { + assert!(matches!( + vault.put_pcp("package", kind, b"data", 100), + Err(StorageError::InvalidPcpKind) + )); + } + vault + .put_pcp("package", &"k".repeat(32), b"data", 100) + .unwrap(); + assert_eq!(vault.list_pcps(None).unwrap().len(), 1); } #[test] fn put_updates_bytes_and_timestamp_but_preserves_kind_and_creation_time() { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); - let first = vault - .put_pcp("package", PcpKind::Face, b"one", 100) - .unwrap(); + let first = vault.put_pcp("package", "face_pkg", b"one", 100).unwrap(); assert_eq!( - vault - .put_pcp("package", PcpKind::Face, b"one", 200) - .unwrap(), + vault.put_pcp("package", "face_pkg", b"one", 200).unwrap(), first ); - let next = vault - .put_pcp("package", PcpKind::Face, b"two", 300) - .unwrap(); + let next = vault.put_pcp("package", "face_pkg", b"two", 300).unwrap(); assert_ne!(first, next); let before = vault.list_pcps(None).unwrap(); assert_eq!( before, vec![PcpMetadata { - path: "package".to_owned(), - kind: PcpKind::Face, + id: "package".to_owned(), + kind: "face_pkg".to_owned(), checksum: next, created_at: 100, updated_at: 300, }] ); let error = vault - .put_pcp("package", PcpKind::Document, b"wrong kind", 400) + .put_pcp("package", "document_pkg", b"wrong kind", 400) .unwrap_err(); assert!(matches!(error, StorageError::PcpKindMismatch)); assert!(!error.to_string().contains("package")); @@ -224,18 +253,15 @@ mod tests { } #[test] - fn list_filters_each_kind_and_returns_metadata_for_empty_or_corrupt_data() { + fn list_filters_by_kind_and_returns_metadata_for_empty_or_corrupt_data() { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); - for (kind, path) in [ - (PcpKind::Orb, "a"), - (PcpKind::Face, "b"), - (PcpKind::Document, "c"), - ] { - vault.put_pcp(path, kind, b"", 100).unwrap(); - assert_eq!(vault.get_pcp(path).unwrap().unwrap().data, b""); - assert_eq!(vault.list_pcps(Some(kind)).unwrap()[0].path, path); + for (kind, id) in [("orb_pkg", "a"), ("face_pkg", "b"), ("document_pkg", "c")] { + vault.put_pcp(id, kind, b"", 100).unwrap(); + assert_eq!(vault.get_pcp(id).unwrap().unwrap().data, b""); + assert_eq!(vault.list_pcps(Some(kind)).unwrap()[0].id, id); } + assert!(vault.list_pcps(Some("unknown_pkg")).unwrap().is_empty()); assert_eq!(vault.list_pcps(None).unwrap().len(), 3); vault .vault @@ -250,34 +276,29 @@ mod tests { } #[test] - fn corrupt_checksums_and_kinds_fail_without_exposing_paths() { + fn corrupt_checksums_fail_without_exposing_ids() { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); - let path = "private-face-identifier"; - vault.put_pcp(path, PcpKind::Face, b"sealed", 100).unwrap(); + let id = "private-face-identifier"; + vault.put_pcp(id, "face_pkg", b"sealed", 100).unwrap(); let conn = vault.vault.connection(); for sql in [ "UPDATE pcp_records SET checksum = zeroblob(32)", "PRAGMA ignore_check_constraints = ON; UPDATE pcp_records SET checksum = X'FF'", - "UPDATE pcp_records SET kind = 4", ] { conn.execute_batch(sql).unwrap(); - let error = vault.get_pcp(path).unwrap_err(); + let error = vault.get_pcp(id).unwrap_err(); assert!(matches!(error, StorageError::CorruptedPcp(_))); - assert!(!error.to_string().contains(path)); + assert!(!error.to_string().contains(id)); } assert!(matches!( vault.list_pcps(None), Err(StorageError::CorruptedPcp(_)) )); - conn.execute_batch("UPDATE pcp_records SET checksum = zeroblob(32)") - .unwrap(); - assert!(matches!( - vault.list_pcps(None), - Err(StorageError::CorruptedPcp(_)) - )); - conn.execute_batch("UPDATE pcp_records SET kind = 2, created_at = -1") - .unwrap(); + conn.execute_batch( + "UPDATE pcp_records SET checksum = zeroblob(32), created_at = -1", + ) + .unwrap(); assert!(matches!( vault.list_pcps(None), Err(StorageError::VaultDb(_)) @@ -295,10 +316,10 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); vault - .put_pcp("package", PcpKind::Orb, b"original", 100) + .put_pcp("package", "orb_pkg", b"original", 100) .unwrap(); assert!(matches!( - vault.put_pcp("package", PcpKind::Orb, b"new", u64::MAX), + vault.put_pcp("package", "orb_pkg", b"new", u64::MAX), Err(StorageError::VaultDb(_)) )); let conn = vault.vault.connection(); @@ -306,7 +327,7 @@ mod tests { "CREATE TRIGGER reject_pcp_insert BEFORE INSERT ON pcp_records BEGIN SELECT RAISE(ABORT, 'injected'); END;", ).unwrap(); assert!(matches!( - vault.put_pcp("another", PcpKind::Face, b"new", 200), + vault.put_pcp("another", "face_pkg", b"new", 200), Err(StorageError::VaultDb(_)) )); conn.execute_batch( @@ -314,7 +335,7 @@ mod tests { CREATE TRIGGER reject_pcp_update BEFORE UPDATE ON pcp_records BEGIN SELECT RAISE(ABORT, 'injected'); END;", ).unwrap(); assert!(matches!( - vault.put_pcp("package", PcpKind::Orb, b"new", 200), + vault.put_pcp("package", "orb_pkg", b"new", 200), Err(StorageError::VaultDb(_)) )); conn.execute_batch( @@ -330,9 +351,7 @@ mod tests { "DROP TRIGGER reject_pcp_update; DROP TRIGGER reject_pcp_delete", ) .unwrap(); - vault - .put_pcp("package", PcpKind::Orb, b"retry", 200) - .unwrap(); + vault.put_pcp("package", "orb_pkg", b"retry", 200).unwrap(); assert!(vault.delete_pcp("package").unwrap()); } @@ -346,7 +365,7 @@ mod tests { .execute_batch("DROP TABLE pcp_records") .unwrap(); assert!(matches!( - vault.put_pcp("package", PcpKind::Orb, b"data", 100), + vault.put_pcp("package", "orb_pkg", b"data", 100), Err(StorageError::VaultDb(_)) )); assert!(matches!( @@ -382,7 +401,7 @@ mod tests { source.export_plaintext(&backup).unwrap(); let before = std::fs::read(&backup).unwrap(); source - .put_pcp("package", PcpKind::Orb, b"remote", 100) + .put_pcp("package", "orb_pkg", b"remote", 100) .unwrap(); source.export_plaintext(&backup).unwrap(); assert_eq!(std::fs::read(&backup).unwrap(), before); @@ -391,17 +410,15 @@ mod tests { assert!(incoming.prepare("SELECT * FROM pcp_records").is_err()); incoming .execute_batch( - "CREATE TABLE pcp_records (path TEXT, kind INTEGER, data BLOB); - INSERT INTO pcp_records VALUES ('package', 3, X'FF'); - INSERT INTO pcp_records VALUES ('incoming-only', 3, X'FF');", + "CREATE TABLE pcp_records (id TEXT, kind TEXT, data BLOB); + INSERT INTO pcp_records VALUES ('package', 'document_pkg', X'FF'); + INSERT INTO pcp_records VALUES ('incoming-only', 'document_pkg', X'FF');", ) .unwrap(); drop(incoming); for merge in [false, true] { let local = new_vault(dir.path(), &format!("local-{merge}.db")); - local - .put_pcp("package", PcpKind::Face, b"local", 50) - .unwrap(); + local.put_pcp("package", "face_pkg", b"local", 50).unwrap(); let expected = local.get_pcp("package").unwrap(); let metadata = local.list_pcps(None).unwrap(); if merge { @@ -452,9 +469,7 @@ mod tests { let backup = dir.path().join("backup.db"); source.export_plaintext(&backup).unwrap(); let local = new_vault(dir.path(), "local.db"); - local - .put_pcp("package", PcpKind::Face, b"keep", 50) - .unwrap(); + local.put_pcp("package", "face_pkg", b"keep", 50).unwrap(); let before = local.get_pcp("package").unwrap(); local.vault.connection().execute_batch( "CREATE TRIGGER reject_credential BEFORE INSERT ON credential_records BEGIN SELECT RAISE(ABORT, 'injected'); END;", @@ -510,11 +525,11 @@ mod tests { )); assert_eq!(upgraded.list_credentials(None, 100).unwrap().len(), 1); upgraded - .put_pcp("package", PcpKind::Orb, b"local", 100) + .put_pcp("package", "orb_pkg", b"local", 100) .unwrap(); let receiver = new_vault(dir.path(), "receiver.db"); receiver - .put_pcp("package", PcpKind::Document, b"keep", 100) + .put_pcp("package", "document_pkg", b"keep", 100) .unwrap(); receiver.import_plaintext(&backup).unwrap(); assert_eq!(receiver.get_pcp("package").unwrap().unwrap().data, b"keep"); diff --git a/crates/walletkit-core/src/storage/credential_vault/schema.rs b/crates/walletkit-core/src/storage/credential_vault/schema.rs index 926332b3b..6a0e514b0 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -117,8 +117,8 @@ fn migration_v1(conn: &Connection) -> DbResult<()> { fn migration_v2(conn: &Connection) -> DbResult<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS pcp_records ( - path TEXT PRIMARY KEY NOT NULL, - kind INTEGER NOT NULL CHECK (kind IN (1, 2, 3)), + id TEXT PRIMARY KEY NOT NULL, + kind TEXT NOT NULL CHECK (length(kind) BETWEEN 1 AND 32), checksum BLOB NOT NULL CHECK (typeof(checksum) = 'blob' AND length(checksum) = 32), created_at INTEGER NOT NULL CHECK (created_at >= 0), updated_at INTEGER NOT NULL CHECK (updated_at >= 0), diff --git a/crates/walletkit-core/src/storage/error.rs b/crates/walletkit-core/src/storage/error.rs index d1c3c9d2e..b2bf61618 100644 --- a/crates/walletkit-core/src/storage/error.rs +++ b/crates/walletkit-core/src/storage/error.rs @@ -84,13 +84,17 @@ pub enum StorageError { credential_id: u64, }, - /// An existing PCP path cannot be reassigned to another kind. + /// An existing PCP id cannot be reassigned to another kind. #[error("PCP kind does not match the existing entry")] PcpKindMismatch, - /// A PCP path contains a NUL character, which stored text cannot round-trip. - #[error("PCP path contains a NUL character")] - InvalidPcpPath, + /// A PCP id contains a NUL character, which stored text cannot round-trip. + #[error("PCP id contains a NUL character")] + InvalidPcpId, + + /// A PCP kind is not 1 to 32 bytes of lowercase ASCII letters, digits or `_`. + #[error("PCP kind must be 1 to 32 lowercase ASCII letters, digits or underscores")] + InvalidPcpKind, /// A stored PCP has invalid metadata or does not match its checksum. #[error("corrupted PCP: {0}")] diff --git a/crates/walletkit-core/src/storage/mod.rs b/crates/walletkit-core/src/storage/mod.rs index 0b9294cc4..b698ef88f 100644 --- a/crates/walletkit-core/src/storage/mod.rs +++ b/crates/walletkit-core/src/storage/mod.rs @@ -68,8 +68,7 @@ pub use types::{ ActivityQuery, BlobKind, ContentId, CredentialRecord, Nullifier, ProtocolVersion, ReplayGuardKind, ReplayGuardResult, RequestId, }; -#[cfg(not(target_arch = "wasm32"))] -pub use types::{PcpKind, PcpMetadata, PcpRecord}; +pub use types::{PcpMetadata, PcpRecord}; pub use walletkit_db::{Lock as StorageLock, LockGuard as StorageLockGuard}; /// Deletes a closed `SQLite` database and its journal sidecars. diff --git a/crates/walletkit-core/src/storage/types.rs b/crates/walletkit-core/src/storage/types.rs index 89baba6d5..ed5c08146 100644 --- a/crates/walletkit-core/src/storage/types.rs +++ b/crates/walletkit-core/src/storage/types.rs @@ -210,41 +210,13 @@ impl ActivityQuery { } } -/// Kind of sealed Personal Custody Package. Paths are unique across all kinds. -#[cfg(not(target_arch = "wasm32"))] -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] -#[repr(i64)] -pub enum PcpKind { - /// Orb package (`orb_pkg` in Bedrock). - Orb = 1, - /// Face package (`face_pkg` in Bedrock). - Face = 2, - /// Document package (`document_pkg` in Bedrock). - Document = 3, -} - -#[cfg(not(target_arch = "wasm32"))] -impl TryFrom for PcpKind { - type Error = StorageError; - - fn try_from(value: i64) -> StorageResult { - match value { - 1 => Ok(Self::Orb), - 2 => Ok(Self::Face), - 3 => Ok(Self::Document), - _ => Err(StorageError::CorruptedPcp("invalid stored kind".to_owned())), - } - } -} - /// Sealed PCP bytes and their verified BLAKE3 checksum. -#[cfg(not(target_arch = "wasm32"))] #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct PcpRecord { - /// Exact logical identifier supplied by the host; never interpreted as a filesystem path. - pub path: String, - /// Package kind. - pub kind: PcpKind, + /// Exact identifier supplied by the host; never interpreted. + pub id: String, + /// Host-defined kind label, such as `orb_pkg`. + pub kind: String, /// Opaque sealed package bytes, unchanged by `WalletKit`. pub data: Vec, /// BLAKE3 of `data`, exactly 32 bytes. @@ -252,13 +224,12 @@ pub struct PcpRecord { } /// PCP metadata returned without loading package data. -#[cfg(not(target_arch = "wasm32"))] #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct PcpMetadata { - /// Exact logical identifier supplied by the host. - pub path: String, - /// Package kind. - pub kind: PcpKind, + /// Exact identifier supplied by the host. + pub id: String, + /// Host-defined kind label, such as `orb_pkg`. + pub kind: String, /// Stored BLAKE3 checksum, exactly 32 bytes. Verified against data by `get_pcp`. pub checksum: Vec, /// First insertion time in Unix seconds. diff --git a/docs/accounts.md b/docs/accounts.md index 814940331..e6812909a 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -22,10 +22,10 @@ database key in the device keystore; for the types involved, see the In the browser, the app supplies the key itself; see [Protect the database key](web/package.md#protect-the-database-key). -On native platforms, `CredentialStore` also stores sealed Personal Custody Packages -with `put_pcp`, `get_pcp`, `list_pcps`, and `delete_pcp`. Paths are opaque logical -identifiers shared across Orb, Face, and Document packages; the host supplies the -normalized path. Reads verify BLAKE3 checksums, and an existing path cannot change +`CredentialStore` also stores sealed Personal Custody Packages with `put_pcp`, +`get_pcp`, `list_pcps`, and `delete_pcp`. Each entry has an opaque id and a short +kind label chosen by the host, such as the backup designator `orb_pkg`; WalletKit +interprets neither. Reads verify BLAKE3 checksums, and an existing id cannot change kind. PCPs are excluded from credential vault backups and remain intact during credential import or merge. PCP writes do not trigger the credential vault-change listener; hosts must schedule their PCP backups separately. From 3b243b04febbc4ec9f148461cee932566f2380de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Tue, 27 Oct 2026 14:59:16 +0000 Subject: [PATCH 08/19] docs: clarify PCP API availability and storage lifecycle --- crates/walletkit-core/src/storage/credential_storage.rs | 5 ++++- crates/walletkit-core/src/storage/mod.rs | 2 +- docs/accounts.md | 6 ++++++ web/walletkit/README.md | 1 + 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/walletkit-core/src/storage/credential_storage.rs b/crates/walletkit-core/src/storage/credential_storage.rs index 6e2e6c72e..61a4a0fa6 100644 --- a/crates/walletkit-core/src/storage/credential_storage.rs +++ b/crates/walletkit-core/src/storage/credential_storage.rs @@ -415,7 +415,7 @@ impl CredentialStore { Ok(added) } - /// Permanently destroys all credential storage data. + /// Permanently destroys all credential storage data, including sealed PCPs. /// /// Intended for use when the user logs out or deletes their account. /// @@ -452,6 +452,9 @@ impl CredentialStore { /// changes do not notify the credential vault-change listener or affect vault /// backups. /// + /// Requires [`Self::init`] with the account's leaf index. Like credentials, + /// PCPs are permanently removed by [`Self::destroy_storage`]. + /// /// # Errors /// Returns an error if storage is uninitialized, the id contains a NUL /// character, the kind is not a short lowercase label, the kind conflicts with diff --git a/crates/walletkit-core/src/storage/mod.rs b/crates/walletkit-core/src/storage/mod.rs index b698ef88f..f7f4c130e 100644 --- a/crates/walletkit-core/src/storage/mod.rs +++ b/crates/walletkit-core/src/storage/mod.rs @@ -14,7 +14,7 @@ //! //! 1. **Vault database (`account.vault.sqlite`)** — authoritative storage for //! credentials, associated data blobs, issuer subject blinding factors, and the account -//! leaf index, plus sealed PCPs on native platforms. PCPs are excluded from +//! leaf index, plus sealed PCPs. PCPs are excluded from //! credential backups and their change notifications. Corruption is a hard failure. See [`crate::storage::CredentialVault`]. //! 2. **Cache database (`account.cache.sqlite`)** — non-authoritative, regenerable //! entries: Merkle inclusion proof cache, per-account session seed, and nullifier diff --git a/docs/accounts.md b/docs/accounts.md index e6812909a..f96016c34 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -30,6 +30,12 @@ kind. PCPs are excluded from credential vault backups and remain intact during credential import or merge. PCP writes do not trigger the credential vault-change listener; hosts must schedule their PCP backups separately. +These PCP methods are available to Rust callers, including on wasm, and through +the Swift and Kotlin bindings. The browser JavaScript package does not expose +them. PCP operations require `CredentialStore::init` with the account's leaf +index; hosts must initialize storage before migrating or restoring PCPs. +`destroy_storage` permanently destroys PCPs along with the account's credentials. + ## From registration to stored credential The following diagram shows the calls between the app, WalletKit, and the World ID diff --git a/web/walletkit/README.md b/web/walletkit/README.md index 0cff1dfc7..0d6ae18ea 100644 --- a/web/walletkit/README.md +++ b/web/walletkit/README.md @@ -201,6 +201,7 @@ These parts of the Swift and Kotlin bindings aren't available in the browser: | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | `Logger` and `initLogging` | Not implemented for the browser. | | Vault and activity change listeners | Not implemented for the browser. | +| Sealed PCP storage | Available in Rust on wasm and in Swift/Kotlin; no browser bindings. | | Vault backup export and replacement import | Supported only on iOS and Android. Use `mergeVaultFromBackup` to import. | | `proveCredentialSub` | Credential ownership proofs aren't supported on WASM. | | Flamingo | Browser WebSockets can't send its signed handshake headers, and it relies on device attestation. | From ef5cd351d0e5d1ab218e1be853ba49018c7649aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Wed, 28 Oct 2026 10:25:11 +0000 Subject: [PATCH 09/19] fix(storage): align vault schema checks with cache migrations --- .../src/storage/credential_vault/schema.rs | 150 ++++++++++++++---- 1 file changed, 122 insertions(+), 28 deletions(-) diff --git a/crates/walletkit-core/src/storage/credential_vault/schema.rs b/crates/walletkit-core/src/storage/credential_vault/schema.rs index 6ac229a6b..fc646ef30 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -6,64 +6,127 @@ use walletkit_sqlite::{error::ErrorCode, Connection, DbResult, Error}; // This column is a fixed metadata key, independent of the database schema version. pub(super) const VAULT_META_KEY: i64 = 1; -/// Append migrations here; `PRAGMA user_version` counts the applied migrations. +/// The vault migrations, in order. Append new versions; never change shipped migrations. +/// `PRAGMA user_version` counts the applied migrations. /// Migrations must not start transactions or modify the shared blob schema. const MIGRATIONS: &[fn(&Connection) -> DbResult<()>] = &[migration_v1]; -/// Applies pending migrations atomically, without ever resetting vault data. +#[allow( + clippy::cast_possible_wrap, + reason = "the migration list cannot approach i64::MAX" +)] +const SCHEMA_VERSION: i64 = MIGRATIONS.len() as i64; + +/// Ensures the vault schema is present and at the expected version. /// -/// The immediate transaction serializes concurrent openers before reading the -/// version. DDL and the version marker commit together; interrupted migrations -/// are rolled back and retried on the next open. +/// Current vaults are checked without a write transaction. Pending migrations +/// and their version marker commit together; interrupted migrations roll back. +/// A newer or incomplete vault returns an error without resetting its data. /// /// Plaintext backups contain only [`super::BACKUP_TABLES`], not `user_version`. /// New credential columns require backward-compatible import and merge queries. pub(super) fn ensure_schema(conn: &Connection) -> DbResult<()> { - migrate(conn, MIGRATIONS) + match read_version(conn)? { + version if version == SCHEMA_VERSION => { + if !schema_is_intact(conn)? { + return Err(schema_error("vault schema is incomplete")); + } + } + version if version < SCHEMA_VERSION => { + migrate(conn, MIGRATIONS)?; + } + version => { + return Err(newer_schema_error(version, MIGRATIONS.len())); + } + } + Ok(()) } +/// Applies pending migrations and records the version in the same transaction. fn migrate( conn: &Connection, migrations: &[fn(&Connection) -> DbResult<()>], ) -> DbResult<()> { let tx = conn.transaction_immediate()?; - let version = - tx.query_row("PRAGMA user_version", &[], |row| Ok(row.column_i64(0)))?; + // Recheck after taking the write lock: another opener may have migrated first. + let version = read_version(conn)?; let applied = usize::try_from(version) .map_err(|_| schema_error("invalid vault schema version"))?; if applied > migrations.len() { - return Err(schema_error(format!( - "vault schema version {version} is newer than supported version {}; upgrade WalletKit", - migrations.len() - ))); + return Err(newer_schema_error(version, migrations.len())); + } + if applied == migrations.len() { + return tx.commit(); } if version == 0 { - let has_meta = tx.query_row_optional( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'vault_meta'", - &[], |_| Ok(()), - )?.is_some(); - if has_meta { - let invalid = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM vault_meta WHERE schema_version != 1 OR typeof(schema_version) != 'integer')", - &[], |row| Ok(row.column_i64(0) != 0), - )?; - if invalid { - return Err(schema_error( - "unsupported legacy vault schema version; upgrade WalletKit", - )); - } - } + validate_legacy_schema(conn)?; } blobs::ensure_schema(conn)?; for migration in migrations.iter().skip(applied) { migration(conn)?; } - tx.execute_batch(&format!("PRAGMA user_version = {}", migrations.len()))?; + record_version(conn, migrations.len())?; tx.commit() } +fn read_version(conn: &Connection) -> DbResult { + let version = + conn.query_row("PRAGMA user_version", &[], |row| Ok(row.column_i64(0)))?; + if version < 0 { + return Err(schema_error("invalid vault schema version")); + } + Ok(version) +} + +/// Checks that the tables a current vault must have are present. +fn schema_is_intact(conn: &Connection) -> DbResult { + let present = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' + AND name IN ('vault_meta', 'credential_records', 'blob_objects');", + &[], + |row| Ok(row.column_i64(0)), + )?; + Ok(present == 3) +} + +fn validate_legacy_schema(conn: &Connection) -> DbResult<()> { + let has_meta = conn + .query_row_optional( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'vault_meta'", + &[], + |_| Ok(()), + )? + .is_some(); + if !has_meta { + return Ok(()); + } + let invalid = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM vault_meta + WHERE schema_version != 1 OR typeof(schema_version) != 'integer')", + &[], + |row| Ok(row.column_i64(0) != 0), + )?; + if invalid { + return Err(schema_error( + "unsupported legacy vault schema version; upgrade WalletKit", + )); + } + Ok(()) +} + +fn record_version(conn: &Connection, version: usize) -> DbResult<()> { + conn.execute_batch(&format!("PRAGMA user_version = {version}")) +} + +fn newer_schema_error(version: i64, supported: usize) -> Error { + schema_error(format!( + "vault schema version {version} is newer than supported version {supported}; upgrade WalletKit" + )) +} + fn schema_error(message: impl Into) -> Error { Error { code: ErrorCode(1), @@ -155,6 +218,37 @@ mod tests { )); } + #[test] + fn current_schema_needs_no_write_lock() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.db"); + let key = SecretBox::init_with(|| [42; 32]); + let vault = CredentialVault::new(&path, &key).unwrap(); + let second = cipher::open_encrypted(&path, &key).unwrap(); + let _writer = vault.vault.connection().transaction_immediate().unwrap(); + + ensure_schema(&second).unwrap(); + second.execute_batch("PRAGMA query_only = ON").unwrap(); + ensure_schema(&second).unwrap(); + assert_eq!(version(&second), SCHEMA_VERSION); + } + + #[test] + fn current_schema_with_missing_tables_fails_without_rebuilding() { + for table in ["vault_meta", "credential_records", "blob_objects"] { + let dir = tempfile::tempdir().unwrap(); + let key = SecretBox::init_with(|| [42; 32]); + let vault = + CredentialVault::new(&dir.path().join("vault.db"), &key).unwrap(); + let conn = vault.vault.connection(); + conn.execute_batch(&format!("DROP TABLE {table}")).unwrap(); + + assert!(ensure_schema(conn).is_err(), "missing {table}"); + assert_eq!(version(conn), SCHEMA_VERSION); + assert!(conn.prepare(&format!("SELECT * FROM {table}")).is_err()); + } + } + #[test] fn legacy_v1_is_adopted_without_changing_data_or_blob_schema() { let dir = tempfile::tempdir().unwrap(); From 4ba4918df64511ec8601fb3659ab1419d6fa415b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Wed, 28 Oct 2026 10:30:16 +0000 Subject: [PATCH 10/19] fix(storage): check PCP table in current vault schemas --- .../src/storage/credential_vault/schema.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/walletkit-core/src/storage/credential_vault/schema.rs b/crates/walletkit-core/src/storage/credential_vault/schema.rs index ed4845496..9c48554e0 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -85,11 +85,11 @@ fn schema_is_intact(conn: &Connection) -> DbResult { let present = conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' - AND name IN ('vault_meta', 'credential_records', 'blob_objects');", + AND name IN ('vault_meta', 'credential_records', 'blob_objects', 'pcp_records');", &[], |row| Ok(row.column_i64(0)), )?; - Ok(present == 3) + Ok(present == 4) } fn validate_legacy_schema(conn: &Connection) -> DbResult<()> { @@ -267,7 +267,12 @@ mod tests { #[test] fn current_schema_with_missing_tables_fails_without_rebuilding() { - for table in ["vault_meta", "credential_records", "blob_objects"] { + for table in [ + "vault_meta", + "credential_records", + "blob_objects", + "pcp_records", + ] { let dir = tempfile::tempdir().unwrap(); let key = SecretBox::init_with(|| [42; 32]); let vault = From cf8773e278db518087743edb64c504166767fba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Wed, 28 Oct 2026 12:13:51 +0000 Subject: [PATCH 11/19] refactor(storage): version vault schema in singleton metadata --- .../src/storage/credential_vault/mod.rs | 63 ++-- .../src/storage/credential_vault/schema.rs | 320 ++++++++++++++---- 2 files changed, 289 insertions(+), 94 deletions(-) diff --git a/crates/walletkit-core/src/storage/credential_vault/mod.rs b/crates/walletkit-core/src/storage/credential_vault/mod.rs index 5d6c8171b..cf5580b1a 100644 --- a/crates/walletkit-core/src/storage/credential_vault/mod.rs +++ b/crates/walletkit-core/src/storage/credential_vault/mod.rs @@ -14,7 +14,7 @@ use std::path::Path; use crate::storage::error::{StorageError, StorageResult}; use crate::storage::types::{BlobKind, CredentialRecord}; -use schema::{ensure_schema, VAULT_META_KEY}; +use schema::{ensure_schema, VAULT_SCHEMA_VERSION}; use secrecy::SecretBox; use walletkit_db::{blobs, Vault}; use walletkit_sqlite::{cipher, params, Error as DbError, Row, StepResult, Value}; @@ -27,7 +27,7 @@ use walletkit_sqlite::{cipher, params, Error as DbError, Row, StepResult, Value} /// authenticator. /// /// Only credential backup tables belong here. Local-only tables must remain excluded. -/// The database schema version (`PRAGMA user_version`) is local as well. +/// The database schema version in `vault_meta` is local as well. pub(crate) const BACKUP_TABLES: &[&str] = &["credential_records", "blob_objects"]; // A version's content, never a device-local row ID, breaks equal-timestamp ties. @@ -70,28 +70,49 @@ impl CredentialVault { let leaf_index_i64 = to_i64(leaf_index, "leaf_index")?; let now_i64 = to_i64(now, "now")?; let conn = self.vault.connection(); - let tx = conn.transaction().map_err(|err| map_db_err(&err))?; - let stored = tx + let tx = conn + .transaction_immediate() + .map_err(|err| map_db_err(&err))?; + let (count, stored) = tx .query_row( - "INSERT INTO vault_meta (schema_version, leaf_index, created_at, updated_at) - VALUES (?1, ?2, ?3, ?3) - ON CONFLICT(schema_version) DO UPDATE SET - leaf_index = CASE - WHEN vault_meta.leaf_index IS NULL - THEN excluded.leaf_index - ELSE vault_meta.leaf_index - END - RETURNING leaf_index", - params![VAULT_META_KEY, leaf_index_i64, now_i64], - |stmt| Ok(stmt.column_i64(0)), + "SELECT COUNT(*), MAX(leaf_index) FROM vault_meta", + &[], + |row| { + Ok(( + row.column_i64(0), + (!row.is_column_null(1)).then(|| row.column_i64(1)), + )) + }, ) .map_err(|err| map_db_err(&err))?; - if stored != leaf_index_i64 { - let expected = to_u64(stored, "leaf_index")?; - return Err(StorageError::InvalidLeafIndex { - expected, - provided: leaf_index, - }); + match (count, stored) { + (0, _) => { + tx.execute( + "INSERT INTO vault_meta (schema_version, leaf_index, created_at, updated_at) + VALUES (?1, ?2, ?3, ?3)", + params![VAULT_SCHEMA_VERSION, leaf_index_i64, now_i64], + ).map_err(|err| map_db_err(&err))?; + } + (1, None) => { + tx.execute( + "UPDATE vault_meta SET leaf_index = ?1, updated_at = ?2", + params![leaf_index_i64, now_i64], + ) + .map_err(|err| map_db_err(&err))?; + } + (1, Some(stored)) => { + if stored != leaf_index_i64 { + return Err(StorageError::InvalidLeafIndex { + expected: to_u64(stored, "leaf_index")?, + provided: leaf_index, + }); + } + } + _ => { + return Err(StorageError::VaultDb( + "invalid vault metadata row count".into(), + )) + } } tx.commit().map_err(|err| map_db_err(&err))?; Ok(()) diff --git a/crates/walletkit-core/src/storage/credential_vault/schema.rs b/crates/walletkit-core/src/storage/credential_vault/schema.rs index fc646ef30..47aba06b4 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -1,13 +1,10 @@ //! Versioned credential-vault schema. The shared blob schema is owned by `walletkit-db`. use walletkit_db::blobs; -use walletkit_sqlite::{error::ErrorCode, Connection, DbResult, Error}; - -// This column is a fixed metadata key, independent of the database schema version. -pub(super) const VAULT_META_KEY: i64 = 1; +use walletkit_sqlite::{error::ErrorCode, params, Connection, DbResult, Error}; /// The vault migrations, in order. Append new versions; never change shipped migrations. -/// `PRAGMA user_version` counts the applied migrations. +/// `vault_meta.schema_version` counts the applied migrations. /// Migrations must not start transactions or modify the shared blob schema. const MIGRATIONS: &[fn(&Connection) -> DbResult<()>] = &[migration_v1]; @@ -15,24 +12,28 @@ const MIGRATIONS: &[fn(&Connection) -> DbResult<()>] = &[migration_v1]; clippy::cast_possible_wrap, reason = "the migration list cannot approach i64::MAX" )] -const SCHEMA_VERSION: i64 = MIGRATIONS.len() as i64; +pub(super) const VAULT_SCHEMA_VERSION: i64 = MIGRATIONS.len() as i64; /// Ensures the vault schema is present and at the expected version. /// -/// Current vaults are checked without a write transaction. Pending migrations -/// and their version marker commit together; interrupted migrations roll back. +/// Current vaults with singleton metadata are checked without a write transaction. +/// Legacy metadata is adopted once. Pending migrations and their version marker +/// commit together; interrupted migrations roll back. /// A newer or incomplete vault returns an error without resetting its data. /// -/// Plaintext backups contain only [`super::BACKUP_TABLES`], not `user_version`. +/// Plaintext backups contain only [`super::BACKUP_TABLES`], not `vault_meta`. /// New credential columns require backward-compatible import and merge queries. pub(super) fn ensure_schema(conn: &Connection) -> DbResult<()> { match read_version(conn)? { - version if version == SCHEMA_VERSION => { + version if version == VAULT_SCHEMA_VERSION => { if !schema_is_intact(conn)? { return Err(schema_error("vault schema is incomplete")); } + if !has_singleton_constraint(conn)? { + migrate(conn, MIGRATIONS)?; + } } - version if version < SCHEMA_VERSION => { + version if version < VAULT_SCHEMA_VERSION => { migrate(conn, MIGRATIONS)?; } version => { @@ -55,29 +56,63 @@ fn migrate( if applied > migrations.len() { return Err(newer_schema_error(version, migrations.len())); } - if applied == migrations.len() { + if applied == migrations.len() && has_singleton_constraint(conn)? { return tx.commit(); } - if version == 0 { - validate_legacy_schema(conn)?; - } - blobs::ensure_schema(conn)?; for migration in migrations.iter().skip(applied) { migration(conn)?; } - record_version(conn, migrations.len())?; + conn.execute_batch( + "DROP INDEX IF EXISTS idx_vault_meta_schema_version; + CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_meta_singleton + ON vault_meta ((1));", + )?; + if applied != migrations.len() { + record_version(conn, migrations.len())?; + } tx.commit() } fn read_version(conn: &Connection) -> DbResult { - let version = - conn.query_row("PRAGMA user_version", &[], |row| Ok(row.column_i64(0)))?; - if version < 0 { - return Err(schema_error("invalid vault schema version")); + let (has_meta, has_data) = conn.query_row( + "SELECT + EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'vault_meta'), + EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' + AND name IN ('credential_records', 'blob_objects'))", + &[], + |row| Ok((row.column_i64(0) != 0, row.column_i64(1) != 0)), + )?; + if !has_meta { + return if has_data { + Err(schema_error("vault schema is incomplete")) + } else { + Ok(0) + }; + } + let (count, version, is_integer) = conn.query_row( + "SELECT COUNT(*), MIN(schema_version), MIN(typeof(schema_version) = 'integer') + FROM vault_meta", + &[], + |row| Ok((row.column_i64(0), row.column_i64(1), row.column_i64(2) != 0)), + )?; + match count { + 0 if !has_singleton_constraint(conn)? => Ok(0), + 1 if is_integer && version > 0 => Ok(version), + _ => Err(schema_error( + "invalid vault schema version or metadata row count", + )), } - Ok(version) +} + +fn has_singleton_constraint(conn: &Connection) -> DbResult { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master + WHERE type = 'index' AND name = 'idx_vault_meta_singleton')", + &[], + |row| Ok(row.column_i64(0) != 0), + ) } /// Checks that the tables a current vault must have are present. @@ -92,35 +127,24 @@ fn schema_is_intact(conn: &Connection) -> DbResult { Ok(present == 3) } -fn validate_legacy_schema(conn: &Connection) -> DbResult<()> { - let has_meta = conn - .query_row_optional( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'vault_meta'", - &[], - |_| Ok(()), - )? - .is_some(); - if !has_meta { - return Ok(()); - } - let invalid = conn.query_row( - "SELECT EXISTS(SELECT 1 FROM vault_meta - WHERE schema_version != 1 OR typeof(schema_version) != 'integer')", - &[], - |row| Ok(row.column_i64(0) != 0), +/// Records the schema version without replacing account metadata. +fn record_version(conn: &Connection, version: usize) -> DbResult<()> { + let version = i64::try_from(version) + .map_err(|_| schema_error("invalid vault schema version"))?; + let updated = conn.execute( + "UPDATE vault_meta SET schema_version = ?1", + params![version], )?; - if invalid { - return Err(schema_error( - "unsupported legacy vault schema version; upgrade WalletKit", - )); + if updated == 0 { + conn.execute( + "INSERT INTO vault_meta (schema_version, leaf_index, created_at, updated_at) + VALUES (?1, NULL, strftime('%s','now'), strftime('%s','now'))", + params![version], + )?; } Ok(()) } -fn record_version(conn: &Connection, version: usize) -> DbResult<()> { - conn.execute_batch(&format!("PRAGMA user_version = {version}")) -} - fn newer_schema_error(version: i64, supported: usize) -> Error { schema_error(format!( "vault schema version {version} is newer than supported version {supported}; upgrade WalletKit" @@ -134,7 +158,7 @@ fn schema_error(message: impl Into) -> Error { } } -/// Version 1: the credential tables and fixed-key account metadata. +/// Version 1: the credential tables and account metadata. fn migration_v1(conn: &Connection) -> DbResult<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS vault_meta ( @@ -144,9 +168,6 @@ fn migration_v1(conn: &Connection) -> DbResult<()> { updated_at INTEGER NOT NULL ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_meta_schema_version - ON vault_meta (schema_version); - CREATE TRIGGER IF NOT EXISTS vault_meta_set_updated_at AFTER UPDATE ON vault_meta FOR EACH ROW @@ -185,11 +206,10 @@ mod tests { use walletkit_sqlite::cipher; fn version(conn: &Connection) -> i64 { - conn.query_row("PRAGMA user_version", &[], |row| Ok(row.column_i64(0))) - .unwrap() + read_version(conn).unwrap() } - fn migration_v2(conn: &Connection) -> DbResult<()> { + fn migration_test_local(conn: &Connection) -> DbResult<()> { conn.execute_batch("CREATE TABLE local_only (value INTEGER);") } @@ -218,6 +238,120 @@ mod tests { )); } + #[test] + fn account_initialization_preserves_migrated_schema_and_metadata() { + let dir = tempfile::tempdir().unwrap(); + let key = SecretBox::init_with(|| [42; 32]); + let vault = CredentialVault { + vault: Vault::open(&dir.path().join("vault.db"), &key, |conn| { + migrate(conn, &[migration_v1]) + }) + .unwrap(), + }; + let conn = vault.vault.connection(); + vault.init_leaf_index(7, 100).unwrap(); + let created_at = conn + .query_row("SELECT created_at FROM vault_meta", &[], |row| { + Ok(row.column_i64(0)) + }) + .unwrap(); + migrate(conn, &[migration_v1, migration_test_local]).unwrap(); + + vault.init_leaf_index(7, 200).unwrap(); + assert!(matches!( + vault.init_leaf_index(8, 300), + Err(StorageError::InvalidLeafIndex { .. }) + )); + assert_eq!(conn.query_row( + "SELECT COUNT(*), schema_version, leaf_index, created_at FROM vault_meta", &[], + |row| Ok((row.column_i64(0), row.column_i64(1), row.column_i64(2), row.column_i64(3))), + ).unwrap(), (1, 2, 7, created_at)); + } + + #[test] + fn metadata_constraint_rejects_a_second_row_with_a_different_version() { + let conn = Connection::open(":memory:", false).unwrap(); + ensure_schema(&conn).unwrap(); + assert!(conn + .execute( + "INSERT INTO vault_meta VALUES (?1, 8, 100, 100)", + params![VAULT_SCHEMA_VERSION + 1] + ) + .is_err()); + assert_eq!(version(&conn), VAULT_SCHEMA_VERSION); + assert_eq!(conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name = 'idx_vault_meta_schema_version'", + &[], |row| Ok(row.column_i64(0)), + ).unwrap(), 0); + } + + #[test] + fn uninitialized_legacy_metadata_is_adopted_before_account_initialization() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.db"); + let key = SecretBox::init_with(|| [42; 32]); + let old = Vault::open(&path, &key, |conn| { + blobs::ensure_schema(conn)?; + conn.execute_batch(include_str!("fixtures/v1.sql")) + }) + .unwrap(); + drop(old); + let vault = CredentialVault::new(&path, &key).unwrap(); + assert_eq!(version(vault.vault.connection()), VAULT_SCHEMA_VERSION); + vault.init_leaf_index(7, 100).unwrap(); + assert!(matches!( + vault.init_leaf_index(8, 200), + Err(StorageError::InvalidLeafIndex { .. }) + )); + assert!(vault + .vault + .connection() + .execute( + "INSERT INTO vault_meta VALUES (?1, 8, 100, 100)", + params![VAULT_SCHEMA_VERSION + 1] + ) + .is_err()); + } + + #[test] + fn duplicate_legacy_metadata_fails_without_modifying_rows_or_indexes() { + let conn = Connection::open(":memory:", false).unwrap(); + blobs::ensure_schema(&conn).unwrap(); + conn.execute_batch(include_str!("fixtures/v1.sql")).unwrap(); + conn.execute_batch( + "INSERT INTO vault_meta VALUES (1, 7, 100, 100), (2, 8, 200, 200)", + ) + .unwrap(); + assert!(ensure_schema(&conn).is_err()); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM vault_meta", &[], |row| Ok( + row.column_i64(0) + )) + .unwrap(), + 2 + ); + assert!(!has_singleton_constraint(&conn).unwrap()); + assert_eq!(conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name = 'idx_vault_meta_schema_version'", + &[], |row| Ok(row.column_i64(0)), + ).unwrap(), 1); + } + + #[test] + fn missing_metadata_row_fails_without_reinitializing_an_existing_vault() { + let conn = Connection::open(":memory:", false).unwrap(); + ensure_schema(&conn).unwrap(); + conn.execute_batch("DELETE FROM vault_meta").unwrap(); + assert!(ensure_schema(&conn).is_err()); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM vault_meta", &[], |row| Ok( + row.column_i64(0) + )) + .unwrap(), + 0 + ); + } + #[test] fn current_schema_needs_no_write_lock() { let dir = tempfile::tempdir().unwrap(); @@ -230,7 +364,7 @@ mod tests { ensure_schema(&second).unwrap(); second.execute_batch("PRAGMA query_only = ON").unwrap(); ensure_schema(&second).unwrap(); - assert_eq!(version(&second), SCHEMA_VERSION); + assert_eq!(version(&second), VAULT_SCHEMA_VERSION); } #[test] @@ -244,7 +378,9 @@ mod tests { conn.execute_batch(&format!("DROP TABLE {table}")).unwrap(); assert!(ensure_schema(conn).is_err(), "missing {table}"); - assert_eq!(version(conn), SCHEMA_VERSION); + if table != "vault_meta" { + assert_eq!(version(conn), VAULT_SCHEMA_VERSION); + } assert!(conn.prepare(&format!("SELECT * FROM {table}")).is_err()); } } @@ -273,7 +409,7 @@ mod tests { |row| Ok(row.column_text(0)), ) .unwrap(); - assert_eq!(version(old.connection()), 0); + assert_eq!(version(old.connection()), 1); drop(old); let vault = CredentialVault::new(&path, &key).unwrap(); let conn = vault.vault.connection(); @@ -304,9 +440,9 @@ mod tests { #[test] fn unsupported_versions_fail_without_modifying_the_vault() { for marker in [ - "PRAGMA user_version = 2", - "PRAGMA user_version = -1", - "UPDATE vault_meta SET schema_version = 2; PRAGMA user_version = 0", + "UPDATE vault_meta SET schema_version = 2147483647", + "UPDATE vault_meta SET schema_version = -1", + "UPDATE vault_meta SET schema_version = 'invalid'", ] { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("vault.db"); @@ -315,14 +451,24 @@ mod tests { vault.init_leaf_index(7, 100).unwrap(); let conn = vault.vault.connection(); conn.execute_batch(marker).unwrap(); - let before = version(conn); + let before = conn + .query_row("SELECT schema_version FROM vault_meta", &[], |row| { + Ok(row.column_text(0)) + }) + .unwrap(); conn.execute_batch("DROP TABLE blob_objects").unwrap(); drop(vault); let err = CredentialVault::new(&path, &key).unwrap_err(); assert!(matches!(err, StorageError::VaultDb(_))); assert!(err.to_string().contains("vault schema version")); let conn = cipher::open_encrypted(&path, &key).unwrap(); - assert_eq!(version(&conn), before); + assert_eq!( + conn.query_row("SELECT schema_version FROM vault_meta", &[], |row| Ok( + row.column_text(0) + ),) + .unwrap(), + before + ); assert_eq!( conn.query_row( "SELECT COUNT(*) FROM vault_meta WHERE leaf_index = 7", @@ -339,7 +485,7 @@ mod tests { #[test] fn failed_migration_rolls_back_ddl_and_version_then_retries() { fn fail(conn: &Connection) -> DbResult<()> { - migration_v2(conn)?; + migration_test_local(conn)?; conn.execute_batch("INSERT INTO missing_table VALUES (1)") } let conn = Connection::open(":memory:", false).unwrap(); @@ -347,9 +493,38 @@ mod tests { assert!(migrate(&conn, &[migration_v1, fail]).is_err()); assert_eq!(version(&conn), 1); assert!(conn.prepare("SELECT * FROM local_only").is_err()); - migrate(&conn, &[migration_v1, migration_v2]).unwrap(); + migrate(&conn, &[migration_v1, migration_test_local]).unwrap(); assert_eq!(version(&conn), 2); - migrate(&conn, &[migration_v1, migration_v2]).unwrap(); + migrate(&conn, &[migration_v1, migration_test_local]).unwrap(); + } + + #[test] + fn failed_version_update_rolls_back_migration_and_preserves_account() { + let dir = tempfile::tempdir().unwrap(); + let key = SecretBox::init_with(|| [42; 32]); + let vault = CredentialVault { + vault: Vault::open(&dir.path().join("vault.db"), &key, |conn| { + migrate(conn, &[migration_v1]) + }) + .unwrap(), + }; + vault.init_leaf_index(7, 100).unwrap(); + let conn = vault.vault.connection(); + conn.execute_batch( + "CREATE TRIGGER reject_version BEFORE UPDATE OF schema_version ON vault_meta + BEGIN SELECT RAISE(ABORT, 'injected'); END;", + ).unwrap(); + assert!(migrate(conn, &[migration_v1, migration_test_local]).is_err()); + assert_eq!(version(conn), 1); + assert!(conn.prepare("SELECT * FROM local_only").is_err()); + vault.init_leaf_index(7, 200).unwrap(); + conn.execute_batch("DROP TRIGGER reject_version").unwrap(); + migrate(conn, &[migration_v1, migration_test_local]).unwrap(); + assert_eq!(version(conn), 2); + assert!(matches!( + vault.init_leaf_index(8, 300), + Err(StorageError::InvalidLeafIndex { .. }) + )); } #[test] @@ -361,12 +536,13 @@ mod tests { ensure_schema(&first).unwrap(); let second = cipher::open_encrypted(&path, &key).unwrap(); let tx = first.transaction_immediate().unwrap(); - migration_v2(&first).unwrap(); - tx.execute_batch("PRAGMA user_version = 2").unwrap(); - let error = migrate(&second, &[migration_v1, migration_v2]).unwrap_err(); + migration_test_local(&first).unwrap(); + record_version(&first, 2).unwrap(); + let error = + migrate(&second, &[migration_v1, migration_test_local]).unwrap_err(); assert_eq!(error.code.0 & 0xff, 5); // SQLITE_BUSY tx.commit().unwrap(); - migrate(&second, &[migration_v1, migration_v2]).unwrap(); + migrate(&second, &[migration_v1, migration_test_local]).unwrap(); assert_eq!(version(&second), 2); } @@ -404,10 +580,8 @@ mod tests { let _tx = conn.transaction_immediate().unwrap(); blobs::ensure_schema(&conn).unwrap(); migration_v1(&conn).unwrap(); - conn.execute_batch( - "INSERT INTO vault_meta VALUES (1, 7, 100, 100); PRAGMA user_version = 1", - ) - .unwrap(); + conn.execute_batch("INSERT INTO vault_meta VALUES (1, 7, 100, 100)") + .unwrap(); std::process::exit(0); } @@ -442,7 +616,7 @@ mod tests { .unwrap(); migrate( destination.vault.connection(), - &[migration_v1, migration_v2], + &[migration_v1, migration_test_local], ) .unwrap(); destination.init_leaf_index(7, 100).unwrap(); From f37fcbcbb0dde18ad006f351cb5831e23a019313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Wed, 28 Oct 2026 12:59:26 +0000 Subject: [PATCH 12/19] style(storage): expect the migration count cast lint --- crates/walletkit-core/src/storage/credential_vault/schema.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/walletkit-core/src/storage/credential_vault/schema.rs b/crates/walletkit-core/src/storage/credential_vault/schema.rs index 47aba06b4..3281d8c6e 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -8,7 +8,7 @@ use walletkit_sqlite::{error::ErrorCode, params, Connection, DbResult, Error}; /// Migrations must not start transactions or modify the shared blob schema. const MIGRATIONS: &[fn(&Connection) -> DbResult<()>] = &[migration_v1]; -#[allow( +#[expect( clippy::cast_possible_wrap, reason = "the migration list cannot approach i64::MAX" )] From 97285048abb4f8c5d810583983aa7d70b45cc91f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Wed, 28 Oct 2026 13:09:43 +0000 Subject: [PATCH 13/19] docs(storage): clarify vault schema initialization --- .../src/storage/credential_vault/schema.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/walletkit-core/src/storage/credential_vault/schema.rs b/crates/walletkit-core/src/storage/credential_vault/schema.rs index 3281d8c6e..5295cc6fe 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -14,15 +14,14 @@ const MIGRATIONS: &[fn(&Connection) -> DbResult<()>] = &[migration_v1]; )] pub(super) const VAULT_SCHEMA_VERSION: i64 = MIGRATIONS.len() as i64; -/// Ensures the vault schema is present and at the expected version. +/// Initializes the vault schema or applies pending migrations. /// -/// Current vaults with singleton metadata are checked without a write transaction. -/// Legacy metadata is adopted once. Pending migrations and their version marker -/// commit together; interrupted migrations roll back. -/// A newer or incomplete vault returns an error without resetting its data. +/// Existing vaults need no writes unless a schema or metadata upgrade is needed. +/// Upgrades run in one transaction and roll back if interrupted or unsuccessful. +/// Newer or incomplete schemas return an error without resetting the vault. /// -/// Plaintext backups contain only [`super::BACKUP_TABLES`], not `vault_meta`. -/// New credential columns require backward-compatible import and merge queries. +/// Plaintext backups contain only [`super::BACKUP_TABLES`], excluding `vault_meta`. +/// Credential schema changes must preserve compatibility with older backups. pub(super) fn ensure_schema(conn: &Connection) -> DbResult<()> { match read_version(conn)? { version if version == VAULT_SCHEMA_VERSION => { From 9ba89f52c32a8596e51a363c2b0ab6a5837600b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Wed, 28 Oct 2026 13:44:58 +0000 Subject: [PATCH 14/19] refactor(storage): separate vault initialization from version reads --- .../src/storage/credential_vault/schema.rs | 342 +++++++++++++----- 1 file changed, 242 insertions(+), 100 deletions(-) diff --git a/crates/walletkit-core/src/storage/credential_vault/schema.rs b/crates/walletkit-core/src/storage/credential_vault/schema.rs index 5295cc6fe..c56abc381 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -1,12 +1,14 @@ //! Versioned credential-vault schema. The shared blob schema is owned by `walletkit-db`. use walletkit_db::blobs; -use walletkit_sqlite::{error::ErrorCode, params, Connection, DbResult, Error}; +use walletkit_sqlite::{ + error::ErrorCode, params, Connection, DbResult, Error, StepResult, +}; /// The vault migrations, in order. Append new versions; never change shipped migrations. /// `vault_meta.schema_version` counts the applied migrations. /// Migrations must not start transactions or modify the shared blob schema. -const MIGRATIONS: &[fn(&Connection) -> DbResult<()>] = &[migration_v1]; +const MIGRATIONS: &[fn(&Connection) -> DbResult<()>] = &[migration_v1, migration_v2]; #[expect( clippy::cast_possible_wrap, @@ -23,21 +25,18 @@ pub(super) const VAULT_SCHEMA_VERSION: i64 = MIGRATIONS.len() as i64; /// Plaintext backups contain only [`super::BACKUP_TABLES`], excluding `vault_meta`. /// Credential schema changes must preserve compatibility with older backups. pub(super) fn ensure_schema(conn: &Connection) -> DbResult<()> { - match read_version(conn)? { - version if version == VAULT_SCHEMA_VERSION => { - if !schema_is_intact(conn)? { - return Err(schema_error("vault schema is incomplete")); - } - if !has_singleton_constraint(conn)? { - migrate(conn, MIGRATIONS)?; - } - } - version if version < VAULT_SCHEMA_VERSION => { - migrate(conn, MIGRATIONS)?; - } - version => { - return Err(newer_schema_error(version, MIGRATIONS.len())); - } + if schema_is_empty(conn)? { + return migrate(conn, MIGRATIONS); + } + let Some(version) = read_version(conn)? else { + return migrate(conn, MIGRATIONS); + }; + if version > VAULT_SCHEMA_VERSION { + return Err(newer_schema_error(version, MIGRATIONS.len())); + } + validate_schema(conn, version)?; + if version < VAULT_SCHEMA_VERSION { + migrate(conn, MIGRATIONS)?; } Ok(()) } @@ -48,14 +47,24 @@ fn migrate( migrations: &[fn(&Connection) -> DbResult<()>], ) -> DbResult<()> { let tx = conn.transaction_immediate()?; - // Recheck after taking the write lock: another opener may have migrated first. - let version = read_version(conn)?; + // Recheck under the write lock: another opener may have initialized or migrated first. + let (version, needs_metadata) = if schema_is_empty(conn)? { + (0, true) + } else if let Some(version) = read_version(conn)? { + (version, false) + } else { + validate_legacy_schema(conn)?; + (1, true) + }; let applied = usize::try_from(version) .map_err(|_| schema_error("invalid vault schema version"))?; if applied > migrations.len() { return Err(newer_schema_error(version, migrations.len())); } - if applied == migrations.len() && has_singleton_constraint(conn)? { + if !needs_metadata { + validate_schema(conn, version)?; + } + if applied == migrations.len() && !needs_metadata { return tx.commit(); } @@ -63,67 +72,81 @@ fn migrate( for migration in migrations.iter().skip(applied) { migration(conn)?; } - conn.execute_batch( - "DROP INDEX IF EXISTS idx_vault_meta_schema_version; - CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_meta_singleton - ON vault_meta ((1));", - )?; - if applied != migrations.len() { - record_version(conn, migrations.len())?; - } + record_version(conn, migrations.len())?; tx.commit() } -fn read_version(conn: &Connection) -> DbResult { - let (has_meta, has_data) = conn.query_row( - "SELECT - EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'vault_meta'), - EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' - AND name IN ('credential_records', 'blob_objects'))", - &[], - |row| Ok((row.column_i64(0) != 0, row.column_i64(1) != 0)), - )?; - if !has_meta { - return if has_data { - Err(schema_error("vault schema is incomplete")) - } else { - Ok(0) - }; +/// Reads the stored version, or `None` if the metadata table has no row. +/// Missing tables, duplicate rows and invalid versions return an error. +fn read_version(conn: &Connection) -> DbResult> { + let mut stmt = + conn.prepare("SELECT schema_version, typeof(schema_version) FROM vault_meta")?; + let (version, storage_type) = match stmt.step()? { + StepResult::Done => return Ok(None), + StepResult::Row(row) => (row.column_i64(0), row.column_text(1)), + }; + if matches!(stmt.step()?, StepResult::Row(_)) { + return Err(schema_error("multiple vault metadata rows")); } - let (count, version, is_integer) = conn.query_row( - "SELECT COUNT(*), MIN(schema_version), MIN(typeof(schema_version) = 'integer') - FROM vault_meta", - &[], - |row| Ok((row.column_i64(0), row.column_i64(1), row.column_i64(2) != 0)), - )?; - match count { - 0 if !has_singleton_constraint(conn)? => Ok(0), - 1 if is_integer && version > 0 => Ok(version), - _ => Err(schema_error( - "invalid vault schema version or metadata row count", - )), + if storage_type != "integer" || version <= 0 { + return Err(schema_error("invalid vault schema version")); } + Ok(Some(version)) } -fn has_singleton_constraint(conn: &Connection) -> DbResult { +fn schema_is_empty(conn: &Connection) -> DbResult { conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master - WHERE type = 'index' AND name = 'idx_vault_meta_singleton')", + "SELECT NOT EXISTS(SELECT 1 FROM sqlite_master)", &[], |row| Ok(row.column_i64(0) != 0), ) } -/// Checks that the tables a current vault must have are present. -fn schema_is_intact(conn: &Connection) -> DbResult { - let present = conn.query_row( - "SELECT COUNT(*) FROM sqlite_master - WHERE type = 'table' - AND name IN ('vault_meta', 'credential_records', 'blob_objects');", +/// Validates the tables and metadata constraint required by the stored version. +fn validate_schema(conn: &Connection, version: i64) -> DbResult<()> { + for table in ["vault_meta", "credential_records", "blob_objects"] { + let present = conn + .query_row_optional( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + params![table], + |_| Ok(()), + )? + .is_some(); + if !present { + return Err(schema_error("vault schema is incomplete")); + } + } + if version >= 2 && !has_singleton_constraint(conn)? { + return Err(schema_error("vault metadata constraint is missing")); + } + Ok(()) +} + +/// Recognizes v1 vaults whose account metadata has not been initialized yet. +fn validate_legacy_schema(conn: &Connection) -> DbResult<()> { + validate_schema(conn, 1)?; + let has_legacy_index = conn + .query_row_optional( + "SELECT 1 FROM sqlite_master WHERE type = 'index' + AND tbl_name = 'vault_meta' AND name = 'idx_vault_meta_schema_version'", + &[], + |_| Ok(()), + )? + .is_some(); + if !has_legacy_index || has_singleton_constraint(conn)? { + return Err(schema_error("vault metadata row is missing")); + } + Ok(()) +} + +fn has_singleton_constraint(conn: &Connection) -> DbResult { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master + WHERE type = 'index' AND tbl_name = 'vault_meta' + AND name = 'idx_vault_meta_singleton')", &[], - |row| Ok(row.column_i64(0)), - )?; - Ok(present == 3) + |row| Ok(row.column_i64(0) != 0), + ) } /// Records the schema version without replacing account metadata. @@ -196,6 +219,15 @@ fn migration_v1(conn: &Connection) -> DbResult<()> { ) } +/// Version 2: enforce one metadata row independently of its schema version. +fn migration_v2(conn: &Connection) -> DbResult<()> { + conn.execute_batch( + "DROP INDEX IF EXISTS idx_vault_meta_schema_version; + CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_meta_singleton + ON vault_meta ((1));", + ) +} + #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; @@ -205,20 +237,119 @@ mod tests { use walletkit_sqlite::cipher; fn version(conn: &Connection) -> i64 { - read_version(conn).unwrap() + read_version(conn).unwrap().unwrap() } fn migration_test_local(conn: &Connection) -> DbResult<()> { conn.execute_batch("CREATE TABLE local_only (value INTEGER);") } + #[test] + fn version_reader_only_reads_metadata() { + let conn = Connection::open(":memory:", false).unwrap(); + assert!(read_version(&conn).is_err()); + conn.execute_batch("CREATE TABLE vault_meta (schema_version)") + .unwrap(); + assert_eq!(read_version(&conn).unwrap(), None); + conn.execute_batch("INSERT INTO vault_meta VALUES (1)") + .unwrap(); + assert_eq!(read_version(&conn).unwrap(), Some(1)); + conn.execute_batch("INSERT INTO vault_meta VALUES (1)") + .unwrap(); + assert!(read_version(&conn).is_err()); + } + + #[test] + fn version_reader_rejects_invalid_sqlite_values() { + for value in ["NULL", "0", "-1", "1.5", "'invalid'", "'1'", "x'31'"] { + let conn = Connection::open(":memory:", false).unwrap(); + conn.execute_batch("CREATE TABLE vault_meta (schema_version)") + .unwrap(); + conn.execute_batch(&format!("INSERT INTO vault_meta VALUES ({value})")) + .unwrap(); + assert!(read_version(&conn).is_err(), "{value}"); + } + } + + #[test] + fn incomplete_older_schemas_fail_before_migrations() { + for initialized in [false, true] { + for table in ["credential_records", "blob_objects"] { + let conn = Connection::open(":memory:", false).unwrap(); + blobs::ensure_schema(&conn).unwrap(); + conn.execute_batch(include_str!("fixtures/v1.sql")).unwrap(); + if initialized { + conn.execute_batch( + "INSERT INTO vault_meta VALUES (1, 7, 100, 100)", + ) + .unwrap(); + } + conn.execute_batch(&format!("DROP TABLE {table}")).unwrap(); + assert!(ensure_schema(&conn).is_err()); + assert_eq!(read_version(&conn).unwrap(), initialized.then_some(1)); + assert!(!has_singleton_constraint(&conn).unwrap()); + assert!(conn.prepare(&format!("SELECT * FROM {table}")).is_err()); + } + } + } + + #[test] + fn initialization_rechecks_after_another_opener_creates_the_vault() { + let dir = tempfile::tempdir().unwrap(); + let key = SecretBox::init_with(|| [42; 32]); + let path = dir.path().join("vault.db"); + let first = cipher::open_encrypted(&path, &key).unwrap(); + let second = cipher::open_encrypted(&path, &key).unwrap(); + assert!(schema_is_empty(&second).unwrap()); + ensure_schema(&first).unwrap(); + migrate(&second, MIGRATIONS).unwrap(); + assert_eq!(version(&second), VAULT_SCHEMA_VERSION); + assert_eq!( + second + .query_row("SELECT total_changes()", &[], |row| { + Ok(row.column_i64(0)) + }) + .unwrap(), + 0 + ); + } + + #[test] + fn missing_current_metadata_constraint_fails_without_repair() { + let conn = Connection::open(":memory:", false).unwrap(); + ensure_schema(&conn).unwrap(); + conn.execute_batch("DROP INDEX idx_vault_meta_singleton") + .unwrap(); + assert!(ensure_schema(&conn).is_err()); + assert_eq!(version(&conn), VAULT_SCHEMA_VERSION); + assert!(!has_singleton_constraint(&conn).unwrap()); + } + + #[test] + fn populated_database_without_metadata_is_not_initialized() { + let conn = Connection::open(":memory:", false).unwrap(); + conn.execute_batch( + "CREATE TABLE unrelated (value); INSERT INTO unrelated VALUES (7)", + ) + .unwrap(); + assert!(ensure_schema(&conn).is_err()); + assert!(conn.prepare("SELECT * FROM vault_meta").is_err()); + assert_eq!( + conn.query_row("SELECT value FROM unrelated", &[], |row| Ok( + row.column_i64(0) + )) + .unwrap(), + 7 + ); + } + #[test] fn fresh_vault_records_version_and_reopens_idempotently() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("vault.db"); let key = SecretBox::init_with(|| [42; 32]); let vault = CredentialVault::new(&path, &key).unwrap(); - assert_eq!(version(vault.vault.connection()), 1); + assert_eq!(version(vault.vault.connection()), VAULT_SCHEMA_VERSION); vault.init_leaf_index(7, 100).unwrap(); drop(vault); let vault = CredentialVault::new(&path, &key).unwrap(); @@ -254,7 +385,7 @@ mod tests { Ok(row.column_i64(0)) }) .unwrap(); - migrate(conn, &[migration_v1, migration_test_local]).unwrap(); + migrate(conn, MIGRATIONS).unwrap(); vault.init_leaf_index(7, 200).unwrap(); assert!(matches!( @@ -264,7 +395,7 @@ mod tests { assert_eq!(conn.query_row( "SELECT COUNT(*), schema_version, leaf_index, created_at FROM vault_meta", &[], |row| Ok((row.column_i64(0), row.column_i64(1), row.column_i64(2), row.column_i64(3))), - ).unwrap(), (1, 2, 7, created_at)); + ).unwrap(), (1, VAULT_SCHEMA_VERSION, 7, created_at)); } #[test] @@ -412,7 +543,7 @@ mod tests { drop(old); let vault = CredentialVault::new(&path, &key).unwrap(); let conn = vault.vault.connection(); - assert_eq!(version(conn), 1); + assert_eq!(version(conn), VAULT_SCHEMA_VERSION); assert_eq!(blobs::get(conn, &cid).unwrap().unwrap(), b"credential"); assert_eq!( vault @@ -421,10 +552,15 @@ mod tests { .unwrap(), (b"credential".to_vec(), vec![7; 32]) ); - assert_eq!(conn.query_row( - "SELECT leaf_index = 7 AND created_at = 100 AND updated_at = 100 FROM vault_meta", - &[], |row| Ok(row.column_i64(0)), - ).unwrap(), 1); + assert_eq!( + conn.query_row( + "SELECT leaf_index = 7 AND created_at = 100 FROM vault_meta", + &[], + |row| Ok(row.column_i64(0)), + ) + .unwrap(), + 1 + ); assert_eq!( conn.query_row( "SELECT sql FROM sqlite_master WHERE name = 'blob_objects'", @@ -489,12 +625,15 @@ mod tests { } let conn = Connection::open(":memory:", false).unwrap(); ensure_schema(&conn).unwrap(); - assert!(migrate(&conn, &[migration_v1, fail]).is_err()); - assert_eq!(version(&conn), 1); + let mut migrations = MIGRATIONS.to_vec(); + migrations.push(fail); + assert!(migrate(&conn, &migrations).is_err()); + assert_eq!(version(&conn), VAULT_SCHEMA_VERSION); assert!(conn.prepare("SELECT * FROM local_only").is_err()); - migrate(&conn, &[migration_v1, migration_test_local]).unwrap(); - assert_eq!(version(&conn), 2); - migrate(&conn, &[migration_v1, migration_test_local]).unwrap(); + *migrations.last_mut().unwrap() = migration_test_local; + migrate(&conn, &migrations).unwrap(); + assert_eq!(version(&conn), VAULT_SCHEMA_VERSION + 1); + migrate(&conn, &migrations).unwrap(); } #[test] @@ -503,23 +642,27 @@ mod tests { let key = SecretBox::init_with(|| [42; 32]); let vault = CredentialVault { vault: Vault::open(&dir.path().join("vault.db"), &key, |conn| { - migrate(conn, &[migration_v1]) + blobs::ensure_schema(conn)?; + conn.execute_batch(include_str!("fixtures/v1.sql"))?; + conn.execute_batch("INSERT INTO vault_meta VALUES (1, 7, 100, 100)") }) .unwrap(), }; - vault.init_leaf_index(7, 100).unwrap(); let conn = vault.vault.connection(); conn.execute_batch( "CREATE TRIGGER reject_version BEFORE UPDATE OF schema_version ON vault_meta BEGIN SELECT RAISE(ABORT, 'injected'); END;", ).unwrap(); - assert!(migrate(conn, &[migration_v1, migration_test_local]).is_err()); + assert!(ensure_schema(conn).is_err()); assert_eq!(version(conn), 1); - assert!(conn.prepare("SELECT * FROM local_only").is_err()); + assert!(!has_singleton_constraint(conn).unwrap()); + assert!(conn.query_row_optional( + "SELECT 1 FROM sqlite_master WHERE name = 'idx_vault_meta_schema_version'", &[], |_| Ok(()), + ).unwrap().is_some()); vault.init_leaf_index(7, 200).unwrap(); conn.execute_batch("DROP TRIGGER reject_version").unwrap(); - migrate(conn, &[migration_v1, migration_test_local]).unwrap(); - assert_eq!(version(conn), 2); + ensure_schema(conn).unwrap(); + assert_eq!(version(conn), VAULT_SCHEMA_VERSION); assert!(matches!( vault.init_leaf_index(8, 300), Err(StorageError::InvalidLeafIndex { .. }) @@ -536,13 +679,14 @@ mod tests { let second = cipher::open_encrypted(&path, &key).unwrap(); let tx = first.transaction_immediate().unwrap(); migration_test_local(&first).unwrap(); - record_version(&first, 2).unwrap(); - let error = - migrate(&second, &[migration_v1, migration_test_local]).unwrap_err(); + let mut migrations = MIGRATIONS.to_vec(); + migrations.push(migration_test_local); + record_version(&first, migrations.len()).unwrap(); + let error = migrate(&second, &migrations).unwrap_err(); assert_eq!(error.code.0 & 0xff, 5); // SQLITE_BUSY tx.commit().unwrap(); - migrate(&second, &[migration_v1, migration_test_local]).unwrap(); - assert_eq!(version(&second), 2); + migrate(&second, &migrations).unwrap(); + assert_eq!(version(&second), VAULT_SCHEMA_VERSION + 1); } #[test] @@ -561,11 +705,11 @@ mod tests { assert!(path.exists()); let key = SecretBox::init_with(|| [42; 32]); let conn = cipher::open_encrypted(&path, &key).unwrap(); - assert_eq!(version(&conn), 0); + assert!(schema_is_empty(&conn).unwrap()); assert!(conn.prepare("SELECT * FROM vault_meta").is_err()); drop(conn); let vault = CredentialVault::new(&path, &key).unwrap(); - assert_eq!(version(vault.vault.connection()), 1); + assert_eq!(version(vault.vault.connection()), VAULT_SCHEMA_VERSION); vault.init_leaf_index(8, 200).unwrap(); } @@ -613,18 +757,16 @@ mod tests { let destination = CredentialVault::new(&dir.path().join(format!("new-{merge}.db")), &key) .unwrap(); - migrate( - destination.vault.connection(), - &[migration_v1, migration_test_local], - ) - .unwrap(); destination.init_leaf_index(7, 100).unwrap(); if merge { assert_eq!(destination.merge_plaintext_bytes(&bytes).unwrap(), 1); } else { destination.import_plaintext(&backup).unwrap(); } - assert_eq!(version(destination.vault.connection()), 2); + assert_eq!( + version(destination.vault.connection()), + VAULT_SCHEMA_VERSION + ); assert_eq!( destination .fetch_credential_and_blinding_factor(10, 100) From f927c14d732fdd44a63662b1995aeb91a34d46a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Wed, 28 Oct 2026 14:16:12 +0000 Subject: [PATCH 15/19] docs(storage): explain why PCPs are excluded from credential backups Co-Authored-By: Claude Opus 5.5 --- crates/walletkit-core/src/storage/credential_vault/schema.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/walletkit-core/src/storage/credential_vault/schema.rs b/crates/walletkit-core/src/storage/credential_vault/schema.rs index d0f648d93..c0b6ff113 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -239,7 +239,8 @@ fn migration_v2(conn: &Connection) -> DbResult<()> { ) } -/// Version 3: local PCP storage, deliberately excluded from credential backups. +/// Version 3: local PCP storage. PCPs are backed up separately under their own +/// designators, such as `orb_pkg`, so credential backups exclude them. fn migration_v3(conn: &Connection) -> DbResult<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS pcp_records ( From 724b86ff8251353964755b8cd540565ca2108460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Wed, 28 Oct 2026 14:28:15 +0000 Subject: [PATCH 16/19] refactor(storage): drop PCP checksums Vault pages are already authenticated by sqlite3mc and PCPs are sealed, so a stored BLAKE3 adds no integrity protection. No caller uses it. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 - Cargo.toml | 1 - crates/walletkit-core/Cargo.toml | 1 - .../src/storage/credential_storage.rs | 20 ++- .../src/storage/credential_vault/pcp.rs | 127 ++++++------------ .../src/storage/credential_vault/schema.rs | 1 - crates/walletkit-core/src/storage/error.rs | 4 - crates/walletkit-core/src/storage/types.rs | 6 +- docs/accounts.md | 3 +- 9 files changed, 48 insertions(+), 116 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73337acca..6e71976f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9175,7 +9175,6 @@ dependencies = [ "axum", "backon", "base64 0.22.1", - "blake3", "chacha20poly1305 0.10.1", "chrono", "ciborium", diff --git a/Cargo.toml b/Cargo.toml index 9881034fc..6912b655a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,6 @@ categories = ["api-bindings", "cryptography::cryptocurrencies"] [workspace.dependencies] -blake3 = "1.8.3" alloy = { version = "2", default-features = false } alloy-core = { version = "1", default-features = false, features = [ "sol-types", diff --git a/crates/walletkit-core/Cargo.toml b/crates/walletkit-core/Cargo.toml index f49e985f8..f684656a3 100644 --- a/crates/walletkit-core/Cargo.toml +++ b/crates/walletkit-core/Cargo.toml @@ -23,7 +23,6 @@ name = "walletkit_core" [dependencies] alloy-core = { workspace = true } backon = { workspace = true } -blake3 = { workspace = true } base64 = { workspace = true } ciborium = { workspace = true } futures-util = { workspace = true } diff --git a/crates/walletkit-core/src/storage/credential_storage.rs b/crates/walletkit-core/src/storage/credential_storage.rs index 61a4a0fa6..7ff559a6c 100644 --- a/crates/walletkit-core/src/storage/credential_storage.rs +++ b/crates/walletkit-core/src/storage/credential_storage.rs @@ -443,9 +443,8 @@ impl CredentialStore { #[uniffi::export] impl CredentialStore { - /// Stores sealed PCP bytes under the host's exact id and returns their - /// 32-byte BLAKE3 checksum. Replaces data at an existing id only when the - /// kind matches, preserving its creation time. + /// Stores sealed PCP bytes under the host's exact id. Replaces data at an + /// existing id only when the kind matches, preserving its creation time. /// /// The id and kind are opaque to `WalletKit`. Hosts own id normalization and /// typically use the backup designator, such as `orb_pkg`, as the kind. PCP @@ -465,24 +464,24 @@ impl CredentialStore { kind: &str, data: &[u8], now: u64, - ) -> StorageResult> { + ) -> StorageResult<()> { self.lock_inner()? .state()? .vault .put_pcp(id, kind, data, now) } - /// Retrieves a PCP, verifying its BLAKE3 checksum. Returns `None` if absent. + /// Retrieves a PCP. Returns `None` if absent. /// /// # Errors - /// Returns an error if storage is uninitialized, stored data is corrupt, - /// or the database read fails. Errors never include the id. + /// Returns an error if storage is uninitialized or the database read fails. + /// Errors never include the id. pub fn get_pcp(&self, id: &str) -> StorageResult> { self.lock_inner()?.state()?.vault.get_pcp(id) } /// Lists PCP metadata in id order, optionally restricted to one kind. - /// Does not load package data; use [`Self::get_pcp`] to verify its checksum. + /// Does not load package data; use [`Self::get_pcp`] to read it. /// /// # Errors /// Returns an error if storage is uninitialized, stored metadata is corrupt, @@ -1252,10 +1251,7 @@ mod tests { let (tx, rx) = mpsc::sync_channel(1); *store.vault_changed_tx.lock().unwrap() = Some(tx); let before = store.export_vault_for_backup().unwrap(); - assert_eq!( - store.put_pcp("package", "orb_pkg", b"sealed", 100).unwrap(), - blake3::hash(b"sealed").as_bytes() - ); + store.put_pcp("package", "orb_pkg", b"sealed", 100).unwrap(); assert!(store.get_pcp("missing").unwrap().is_none()); assert_eq!(store.get_pcp("package").unwrap().unwrap().data, b"sealed"); assert_eq!( diff --git a/crates/walletkit-core/src/storage/credential_vault/pcp.rs b/crates/walletkit-core/src/storage/credential_vault/pcp.rs index b0945f450..f30fbc9b1 100644 --- a/crates/walletkit-core/src/storage/credential_vault/pcp.rs +++ b/crates/walletkit-core/src/storage/credential_vault/pcp.rs @@ -17,7 +17,7 @@ impl CredentialVault { kind: &str, data: &[u8], now: u64, - ) -> StorageResult> { + ) -> StorageResult<()> { // Text columns are read back up to the first NUL, so such an id could not be // listed or fetched again. if id.contains('\0') { @@ -25,26 +25,23 @@ impl CredentialVault { } validate_kind(kind)?; let now = to_i64(now, "now")?; - let checksum = blake3::hash(data); - // An autocommit `RETURNING` commits when the statement is finalized, and that - // result is not observable. The explicit commit surfaces write failures. - let conn = self.vault.connection(); - let tx = conn.transaction().map_err(|err| map_db_err(&err))?; - let stored = tx - .query_row_optional( - "INSERT INTO pcp_records (id, kind, data, checksum, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?5) + // A kind mismatch skips the update, so no row changes. + let changed = self + .vault + .connection() + .execute( + "INSERT INTO pcp_records (id, kind, data, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?4) ON CONFLICT(id) DO UPDATE SET - data = excluded.data, checksum = excluded.checksum, updated_at = excluded.updated_at - WHERE pcp_records.kind = excluded.kind - RETURNING checksum", - params![id, kind, data, checksum.as_bytes().as_slice(), now], - |row| Ok(row.column_blob(0)), + data = excluded.data, updated_at = excluded.updated_at + WHERE pcp_records.kind = excluded.kind", + params![id, kind, data, now], ) - .map_err(|err| map_db_err(&err))? - .ok_or(StorageError::PcpKindMismatch)?; - tx.commit().map_err(|err| map_db_err(&err))?; - Ok(stored) + .map_err(|err| map_db_err(&err))?; + if changed == 0 { + return Err(StorageError::PcpKindMismatch); + } + Ok(()) } pub(crate) fn get_pcp(&self, id: &str) -> StorageResult> { @@ -52,30 +49,18 @@ impl CredentialVault { .vault .connection() .query_row_optional( - "SELECT id, kind, data, checksum FROM pcp_records WHERE id = ?1", + "SELECT id, kind, data FROM pcp_records WHERE id = ?1", params![id], |row| { - Ok(( - row.column_text(0), - row.column_text(1), - row.column_blob(2), - row.column_blob(3), - )) + Ok(PcpRecord { + id: row.column_text(0), + kind: row.column_text(1), + data: row.column_blob(2), + }) }, ) .map_err(|err| map_db_err(&err))?; - let Some((id, kind, data, checksum)) = row else { - return Ok(None); - }; - if blake3::hash(&data).as_bytes().as_slice() != checksum { - return Err(StorageError::CorruptedPcp("checksum mismatch".to_owned())); - } - Ok(Some(PcpRecord { - id, - kind, - data, - checksum, - })) + Ok(row) } pub(crate) fn list_pcps( @@ -86,7 +71,7 @@ impl CredentialVault { .vault .connection() .prepare( - "SELECT id, kind, checksum, created_at, updated_at FROM pcp_records + "SELECT id, kind, created_at, updated_at FROM pcp_records WHERE (?1 IS NULL OR kind = ?1) ORDER BY id", ) .map_err(|err| map_db_err(&err))?; @@ -96,18 +81,11 @@ impl CredentialVault { .map_err(|err| map_db_err(&err))?; let mut entries = Vec::new(); while let StepResult::Row(row) = stmt.step().map_err(|err| map_db_err(&err))? { - let checksum = row.column_blob(2); - if checksum.len() != 32 { - return Err(StorageError::CorruptedPcp( - "invalid checksum length".to_owned(), - )); - } entries.push(PcpMetadata { id: row.column_text(0), kind: row.column_text(1), - checksum, - created_at: to_u64(row.column_i64(3), "PCP created_at")?, - updated_at: to_u64(row.column_i64(4), "PCP updated_at")?, + created_at: to_u64(row.column_i64(2), "PCP created_at")?, + updated_at: to_u64(row.column_i64(3), "PCP updated_at")?, }); } Ok(entries) @@ -149,18 +127,16 @@ mod tests { } #[test] - fn opaque_ids_and_bytes_round_trip_across_reopen_with_stable_checksums() { + fn opaque_ids_and_bytes_round_trip_across_reopen() { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); let data = vec![0xA5; 5 * 1024 * 1024]; let id = "./opaque/../identifier/🦉"; - let checksum = vault.put_pcp(id, "orb_pkg", &data, 100).unwrap(); - assert_eq!(checksum, blake3::hash(&data).as_bytes()); + vault.put_pcp(id, "orb_pkg", &data, 100).unwrap(); let expected = PcpRecord { id: id.to_owned(), kind: "orb_pkg".to_owned(), data, - checksum, }; assert_eq!(vault.get_pcp(id).unwrap(), Some(expected.clone())); assert_eq!(vault.get_pcp("opaque/../identifier/🦉").unwrap(), None); @@ -177,7 +153,7 @@ mod tests { fn put_reports_failures_that_only_surface_at_commit() { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); - // A deferred foreign key is checked at commit, after the `RETURNING` row. + // A deferred foreign key is checked at commit, after the row is written. vault .vault .connection() @@ -225,20 +201,15 @@ mod tests { fn put_updates_bytes_and_timestamp_but_preserves_kind_and_creation_time() { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); - let first = vault.put_pcp("package", "face_pkg", b"one", 100).unwrap(); - assert_eq!( - vault.put_pcp("package", "face_pkg", b"one", 200).unwrap(), - first - ); - let next = vault.put_pcp("package", "face_pkg", b"two", 300).unwrap(); - assert_ne!(first, next); + vault.put_pcp("package", "face_pkg", b"one", 100).unwrap(); + vault.put_pcp("package", "face_pkg", b"one", 200).unwrap(); + vault.put_pcp("package", "face_pkg", b"two", 300).unwrap(); let before = vault.list_pcps(None).unwrap(); assert_eq!( before, vec![PcpMetadata { id: "package".to_owned(), kind: "face_pkg".to_owned(), - checksum: next, created_at: 100, updated_at: 300, }] @@ -253,7 +224,7 @@ mod tests { } #[test] - fn list_filters_by_kind_and_returns_metadata_for_empty_or_corrupt_data() { + fn list_filters_by_kind_and_returns_metadata_for_empty_data() { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); for (kind, id) in [("orb_pkg", "a"), ("face_pkg", "b"), ("document_pkg", "c")] { @@ -263,40 +234,18 @@ mod tests { } assert!(vault.list_pcps(Some("unknown_pkg")).unwrap().is_empty()); assert_eq!(vault.list_pcps(None).unwrap().len(), 3); - vault - .vault - .connection() - .execute_batch("UPDATE pcp_records SET data = X'FF'") - .unwrap(); - assert_eq!(vault.list_pcps(None).unwrap().len(), 3); - assert!(matches!( - vault.get_pcp("a"), - Err(StorageError::CorruptedPcp(_)) - )); } #[test] - fn corrupt_checksums_fail_without_exposing_ids() { + fn list_rejects_negative_timestamps() { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); - let id = "private-face-identifier"; - vault.put_pcp(id, "face_pkg", b"sealed", 100).unwrap(); + vault + .put_pcp("package", "face_pkg", b"sealed", 100) + .unwrap(); let conn = vault.vault.connection(); - for sql in [ - "UPDATE pcp_records SET checksum = zeroblob(32)", - "PRAGMA ignore_check_constraints = ON; UPDATE pcp_records SET checksum = X'FF'", - ] { - conn.execute_batch(sql).unwrap(); - let error = vault.get_pcp(id).unwrap_err(); - assert!(matches!(error, StorageError::CorruptedPcp(_))); - assert!(!error.to_string().contains(id)); - } - assert!(matches!( - vault.list_pcps(None), - Err(StorageError::CorruptedPcp(_)) - )); conn.execute_batch( - "UPDATE pcp_records SET checksum = zeroblob(32), created_at = -1", + "PRAGMA ignore_check_constraints = ON; UPDATE pcp_records SET created_at = -1", ) .unwrap(); assert!(matches!( diff --git a/crates/walletkit-core/src/storage/credential_vault/schema.rs b/crates/walletkit-core/src/storage/credential_vault/schema.rs index c0b6ff113..b5fc355bd 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -246,7 +246,6 @@ fn migration_v3(conn: &Connection) -> DbResult<()> { "CREATE TABLE IF NOT EXISTS pcp_records ( id TEXT PRIMARY KEY NOT NULL, kind TEXT NOT NULL CHECK (length(kind) BETWEEN 1 AND 32), - checksum BLOB NOT NULL CHECK (typeof(checksum) = 'blob' AND length(checksum) = 32), created_at INTEGER NOT NULL CHECK (created_at >= 0), updated_at INTEGER NOT NULL CHECK (updated_at >= 0), -- Last, so reading metadata does not walk the package's overflow pages. diff --git a/crates/walletkit-core/src/storage/error.rs b/crates/walletkit-core/src/storage/error.rs index b2bf61618..06ae24328 100644 --- a/crates/walletkit-core/src/storage/error.rs +++ b/crates/walletkit-core/src/storage/error.rs @@ -96,10 +96,6 @@ pub enum StorageError { #[error("PCP kind must be 1 to 32 lowercase ASCII letters, digits or underscores")] InvalidPcpKind, - /// A stored PCP has invalid metadata or does not match its checksum. - #[error("corrupted PCP: {0}")] - CorruptedPcp(String), - /// Corrupted cache entry #[error("corrupted cache entry at {key_prefix}")] CorruptedCacheEntry { diff --git a/crates/walletkit-core/src/storage/types.rs b/crates/walletkit-core/src/storage/types.rs index ed5c08146..57ff4bf3d 100644 --- a/crates/walletkit-core/src/storage/types.rs +++ b/crates/walletkit-core/src/storage/types.rs @@ -210,7 +210,7 @@ impl ActivityQuery { } } -/// Sealed PCP bytes and their verified BLAKE3 checksum. +/// Sealed PCP bytes stored under a host-chosen id. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct PcpRecord { /// Exact identifier supplied by the host; never interpreted. @@ -219,8 +219,6 @@ pub struct PcpRecord { pub kind: String, /// Opaque sealed package bytes, unchanged by `WalletKit`. pub data: Vec, - /// BLAKE3 of `data`, exactly 32 bytes. - pub checksum: Vec, } /// PCP metadata returned without loading package data. @@ -230,8 +228,6 @@ pub struct PcpMetadata { pub id: String, /// Host-defined kind label, such as `orb_pkg`. pub kind: String, - /// Stored BLAKE3 checksum, exactly 32 bytes. Verified against data by `get_pcp`. - pub checksum: Vec, /// First insertion time in Unix seconds. pub created_at: u64, /// Most recent put time in Unix seconds. diff --git a/docs/accounts.md b/docs/accounts.md index f96016c34..394970a60 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -25,8 +25,7 @@ In the browser, the app supplies the key itself; see `CredentialStore` also stores sealed Personal Custody Packages with `put_pcp`, `get_pcp`, `list_pcps`, and `delete_pcp`. Each entry has an opaque id and a short kind label chosen by the host, such as the backup designator `orb_pkg`; WalletKit -interprets neither. Reads verify BLAKE3 checksums, and an existing id cannot change -kind. PCPs are excluded from credential vault backups and remain intact during +interprets neither. An existing id cannot change kind. PCPs are excluded from credential vault backups and remain intact during credential import or merge. PCP writes do not trigger the credential vault-change listener; hosts must schedule their PCP backups separately. From 4e32d2eaf3461a9996a3b866f5b30db771be8aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Wed, 28 Oct 2026 14:47:20 +0000 Subject: [PATCH 17/19] refactor(storage): expose PCPs through PcpVault CredentialStore::pcp_vault returns a handle limited to put/get/list/delete, matching the host PCP store shape. Also tightens the PR's doc comments and corrects the vault-changed listener's mutation list. Co-Authored-By: Claude Opus 5.5 --- .../src/storage/credential_storage.rs | 111 ++++++++++-------- .../src/storage/credential_vault/pcp.rs | 5 +- crates/walletkit-core/src/storage/mod.rs | 9 +- crates/walletkit-core/src/storage/traits.rs | 2 +- crates/walletkit-core/src/storage/types.rs | 8 +- docs/accounts.md | 25 ++-- 6 files changed, 88 insertions(+), 72 deletions(-) diff --git a/crates/walletkit-core/src/storage/credential_storage.rs b/crates/walletkit-core/src/storage/credential_storage.rs index 7ff559a6c..579c4d934 100644 --- a/crates/walletkit-core/src/storage/credential_storage.rs +++ b/crates/walletkit-core/src/storage/credential_storage.rs @@ -443,29 +443,44 @@ impl CredentialStore { #[uniffi::export] impl CredentialStore { - /// Stores sealed PCP bytes under the host's exact id. Replaces data at an - /// existing id only when the kind matches, preserving its creation time. - /// - /// The id and kind are opaque to `WalletKit`. Hosts own id normalization and - /// typically use the backup designator, such as `orb_pkg`, as the kind. PCP - /// changes do not notify the credential vault-change listener or affect vault - /// backups. - /// - /// Requires [`Self::init`] with the account's leaf index. Like credentials, - /// PCPs are permanently removed by [`Self::destroy_storage`]. + /// Returns a handle to this store's sealed PCPs. + #[must_use] + pub fn pcp_vault(self: Arc) -> Arc { + Arc::new(PcpVault { store: self }) + } +} + +/// Sealed PCP storage in the account vault. +/// +/// Ids and kinds are opaque to `WalletKit`; hosts own id normalization and +/// typically use the backup designator (such as `orb_pkg`) as the kind. PCPs are +/// excluded from credential backups and do not notify the vault-changed listener. +/// +/// Operations require [`CredentialStore::init`] with the account's leaf index. +/// [`CredentialStore::destroy_storage`] permanently removes PCPs. +#[derive(Debug, uniffi::Object)] +pub struct PcpVault { + store: Arc, +} + +#[uniffi::export] +impl PcpVault { + /// Stores sealed PCP bytes under the host's exact id. Replaces an existing + /// entry only when `kind` matches, preserving its creation time. /// /// # Errors - /// Returns an error if storage is uninitialized, the id contains a NUL - /// character, the kind is not a short lowercase label, the kind conflicts with - /// an existing entry, the timestamp is out of range, or the database write fails. - pub fn put_pcp( + /// Returns an error if storage is uninitialized, `id` contains a NUL + /// character, `kind` is invalid or conflicts with an existing entry, `now` is + /// out of range, or the database write fails. + pub fn put( &self, id: &str, kind: &str, data: &[u8], now: u64, ) -> StorageResult<()> { - self.lock_inner()? + self.store + .lock_inner()? .state()? .vault .put_pcp(id, kind, data, now) @@ -476,12 +491,12 @@ impl CredentialStore { /// # Errors /// Returns an error if storage is uninitialized or the database read fails. /// Errors never include the id. - pub fn get_pcp(&self, id: &str) -> StorageResult> { - self.lock_inner()?.state()?.vault.get_pcp(id) + pub fn get(&self, id: &str) -> StorageResult> { + self.store.lock_inner()?.state()?.vault.get_pcp(id) } /// Lists PCP metadata in id order, optionally restricted to one kind. - /// Does not load package data; use [`Self::get_pcp`] to read it. + /// Does not load package data; use [`Self::get`] to read it. /// /// # Errors /// Returns an error if storage is uninitialized, stored metadata is corrupt, @@ -490,17 +505,20 @@ impl CredentialStore { clippy::needless_pass_by_value, reason = "UniFFI passes optional strings by value" )] - pub fn list_pcps(&self, kind: Option) -> StorageResult> { - self.lock_inner()?.state()?.vault.list_pcps(kind.as_deref()) + pub fn list(&self, kind: Option) -> StorageResult> { + self.store + .lock_inner()? + .state()? + .vault + .list_pcps(kind.as_deref()) } - /// Deletes a PCP, returning whether it existed. Does not notify the - /// credential vault-change listener or affect credential backups. + /// Deletes a PCP, returning whether it existed. /// /// # Errors /// Returns an error if storage is uninitialized or the database write fails. - pub fn delete_pcp(&self, id: &str) -> StorageResult { - self.lock_inner()?.state()?.vault.delete_pcp(id) + pub fn delete(&self, id: &str) -> StorageResult { + self.store.lock_inner()?.state()?.vault.delete_pcp(id) } } @@ -551,8 +569,8 @@ impl CredentialStore { } /// Registers a listener for successful credential mutations (store, delete, - /// purge, merge). PCP changes do not affect credential backups and do not - /// notify this listener. + /// merge). PCP changes do not affect credential backups or notify this + /// listener. /// /// Only one listener can be active at a time — calling this replaces any /// previously registered listener. The previous delivery thread shuts down @@ -1226,48 +1244,45 @@ mod tests { #[test] #[cfg(not(target_arch = "wasm32"))] - fn pcp_api_requires_initialization_and_never_notifies_credential_backup_listener() { + fn pcp_vault_requires_initialization_and_never_notifies_credential_backup_listener() + { let dir = tempfile::tempdir().unwrap(); - let store = + let store = Arc::new( CredentialStore::from_provider(&InMemoryStorageProvider::new(dir.path())) - .unwrap(); - assert!(matches!( - store.put_pcp("package", "orb_pkg", b"sealed", 100), - Err(StorageError::NotInitialized) - )); + .unwrap(), + ); + let pcps = Arc::clone(&store).pcp_vault(); assert!(matches!( - store.get_pcp("package"), + pcps.put("package", "orb_pkg", b"sealed", 100), Err(StorageError::NotInitialized) )); assert!(matches!( - store.list_pcps(None), + pcps.get("package"), Err(StorageError::NotInitialized) )); + assert!(matches!(pcps.list(None), Err(StorageError::NotInitialized))); assert!(matches!( - store.delete_pcp("package"), + pcps.delete("package"), Err(StorageError::NotInitialized) )); store.init(42, 100).unwrap(); let (tx, rx) = mpsc::sync_channel(1); *store.vault_changed_tx.lock().unwrap() = Some(tx); let before = store.export_vault_for_backup().unwrap(); - store.put_pcp("package", "orb_pkg", b"sealed", 100).unwrap(); - assert!(store.get_pcp("missing").unwrap().is_none()); - assert_eq!(store.get_pcp("package").unwrap().unwrap().data, b"sealed"); - assert_eq!( - store.list_pcps(Some("orb_pkg".to_owned())).unwrap().len(), - 1 - ); + pcps.put("package", "orb_pkg", b"sealed", 100).unwrap(); + assert!(pcps.get("missing").unwrap().is_none()); + assert_eq!(pcps.get("package").unwrap().unwrap().data, b"sealed"); + assert_eq!(pcps.list(Some("orb_pkg".to_owned())).unwrap().len(), 1); assert!(matches!( - store.put_pcp("package", "face_pkg", b"wrong", 100), + pcps.put("package", "face_pkg", b"wrong", 100), Err(StorageError::PcpKindMismatch) )); assert_eq!(store.export_vault_for_backup().unwrap(), before); store.import_vault_from_backup(&before).unwrap(); assert_eq!(store.merge_vault_from_backup(&before).unwrap(), 0); - assert_eq!(store.get_pcp("package").unwrap().unwrap().data, b"sealed"); - assert!(store.delete_pcp("package").unwrap()); - assert!(!store.delete_pcp("package").unwrap()); + assert_eq!(pcps.get("package").unwrap().unwrap().data, b"sealed"); + assert!(pcps.delete("package").unwrap()); + assert!(!pcps.delete("package").unwrap()); assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty))); let credential: Credential = world_id_core::Credential::new() .issuer_schema_id(10) @@ -1279,7 +1294,7 @@ mod tests { rx.try_recv().unwrap(); store.destroy_storage().unwrap(); assert!(matches!( - store.get_pcp("package"), + pcps.get("package"), Err(StorageError::NotInitialized) )); } diff --git a/crates/walletkit-core/src/storage/credential_vault/pcp.rs b/crates/walletkit-core/src/storage/credential_vault/pcp.rs index f30fbc9b1..c05fff704 100644 --- a/crates/walletkit-core/src/storage/credential_vault/pcp.rs +++ b/crates/walletkit-core/src/storage/credential_vault/pcp.rs @@ -7,7 +7,6 @@ use super::{map_db_err, to_i64, to_u64, CredentialVault}; use crate::storage::{PcpMetadata, PcpRecord, StorageError, StorageResult}; use walletkit_sqlite::{params, StepResult, Value}; -/// Longest accepted kind, in bytes. Kinds are short labels such as `orb_pkg`. const MAX_KIND_LEN: usize = 32; impl CredentialVault { @@ -18,8 +17,8 @@ impl CredentialVault { data: &[u8], now: u64, ) -> StorageResult<()> { - // Text columns are read back up to the first NUL, so such an id could not be - // listed or fetched again. + // Text columns are read back up to the first NUL, so the id would not + // round-trip. if id.contains('\0') { return Err(StorageError::InvalidPcpId); } diff --git a/crates/walletkit-core/src/storage/mod.rs b/crates/walletkit-core/src/storage/mod.rs index f7f4c130e..9faeba319 100644 --- a/crates/walletkit-core/src/storage/mod.rs +++ b/crates/walletkit-core/src/storage/mod.rs @@ -13,9 +13,10 @@ //! It owns the account key envelope and two databases: //! //! 1. **Vault database (`account.vault.sqlite`)** — authoritative storage for -//! credentials, associated data blobs, issuer subject blinding factors, and the account -//! leaf index, plus sealed PCPs. PCPs are excluded from -//! credential backups and their change notifications. Corruption is a hard failure. See [`crate::storage::CredentialVault`]. +//! credentials, associated data blobs, issuer subject blinding factors, the +//! account leaf index, and sealed PCPs. PCPs are excluded from credential +//! backups and change notifications. Corruption is a hard failure. See +//! [`crate::storage::CredentialVault`]. //! 2. **Cache database (`account.cache.sqlite`)** — non-authoritative, regenerable //! entries: Merkle inclusion proof cache, per-account session seed, and nullifier //! replay guards. Subject to TTL pruning and can be rebuilt at any time without @@ -54,7 +55,7 @@ pub mod traits; pub mod types; pub use cache::CacheDb; -pub use credential_storage::CredentialStore; +pub use credential_storage::{CredentialStore, PcpVault}; pub use credential_vault::CredentialVault; pub use error::{StorageError, StorageResult}; pub use keys::StorageKeys; diff --git a/crates/walletkit-core/src/storage/traits.rs b/crates/walletkit-core/src/storage/traits.rs index c9abf9486..8dd6d734a 100644 --- a/crates/walletkit-core/src/storage/traits.rs +++ b/crates/walletkit-core/src/storage/traits.rs @@ -98,7 +98,7 @@ pub trait StorageProvider: Send + Sync { /// the `UniFFI` call stack (see `logger.rs` for rationale). /// /// This is only called when individual credentials are added or removed. PCP -/// changes do not affect credential backups and do not trigger this listener. +/// changes do not affect credential backups or trigger this listener. /// /// # Expected usage /// diff --git a/crates/walletkit-core/src/storage/types.rs b/crates/walletkit-core/src/storage/types.rs index 57ff4bf3d..0f9c9c99a 100644 --- a/crates/walletkit-core/src/storage/types.rs +++ b/crates/walletkit-core/src/storage/types.rs @@ -213,11 +213,11 @@ impl ActivityQuery { /// Sealed PCP bytes stored under a host-chosen id. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct PcpRecord { - /// Exact identifier supplied by the host; never interpreted. + /// Exact identifier supplied by the host. pub id: String, /// Host-defined kind label, such as `orb_pkg`. pub kind: String, - /// Opaque sealed package bytes, unchanged by `WalletKit`. + /// Opaque sealed package bytes. pub data: Vec, } @@ -228,8 +228,8 @@ pub struct PcpMetadata { pub id: String, /// Host-defined kind label, such as `orb_pkg`. pub kind: String, - /// First insertion time in Unix seconds. + /// Creation time in Unix seconds. pub created_at: u64, - /// Most recent put time in Unix seconds. + /// Last update time in Unix seconds. pub updated_at: u64, } diff --git a/docs/accounts.md b/docs/accounts.md index 394970a60..35989d058 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -22,18 +22,19 @@ database key in the device keystore; for the types involved, see the In the browser, the app supplies the key itself; see [Protect the database key](web/package.md#protect-the-database-key). -`CredentialStore` also stores sealed Personal Custody Packages with `put_pcp`, -`get_pcp`, `list_pcps`, and `delete_pcp`. Each entry has an opaque id and a short -kind label chosen by the host, such as the backup designator `orb_pkg`; WalletKit -interprets neither. An existing id cannot change kind. PCPs are excluded from credential vault backups and remain intact during -credential import or merge. PCP writes do not trigger the credential vault-change -listener; hosts must schedule their PCP backups separately. - -These PCP methods are available to Rust callers, including on wasm, and through -the Swift and Kotlin bindings. The browser JavaScript package does not expose -them. PCP operations require `CredentialStore::init` with the account's leaf -index; hosts must initialize storage before migrating or restoring PCPs. -`destroy_storage` permanently destroys PCPs along with the account's credentials. +`CredentialStore::pcp_vault` returns a `PcpVault` that stores sealed Personal +Custody Packages with `put`, `get`, `list`, and `delete`. Each entry has an opaque +id and a short kind label chosen by the host, such as the backup designator +`orb_pkg`; WalletKit interprets neither. An existing id cannot change kind. PCPs +are excluded from credential vault backups and remain intact during credential +import or merge. PCP writes do not trigger the credential vault-change listener; +hosts must schedule their PCP backups separately. + +`PcpVault` is available to Rust callers, including on wasm, and through the Swift +and Kotlin bindings. The browser JavaScript package does not expose it. PCP +operations require `CredentialStore::init` with the account's leaf index; hosts +must initialize storage before migrating or restoring PCPs. `destroy_storage` +permanently destroys PCPs along with the account's credentials. ## From registration to stored credential From 2a099eddd97a5bc095b4e4d86335dadb3a9e02f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Wed, 28 Oct 2026 15:04:26 +0000 Subject: [PATCH 18/19] refactor(storage): move PcpVault to its own module, namespace PCP SQL PcpVault lives in storage/pcp_vault.rs and reaches the vault through a narrow CredentialStore::with_vault helper. Internal PCP queries move from CredentialVault methods to a borrowed PcpTable (vault.pcps().put(...)). Co-Authored-By: Claude Opus 5.5 --- .../src/storage/credential_storage.rs | 90 +------- .../src/storage/credential_vault/pcp.rs | 192 +++++++++++------- .../src/storage/credential_vault/schema.rs | 2 +- crates/walletkit-core/src/storage/mod.rs | 4 +- .../walletkit-core/src/storage/pcp_vault.rs | 80 ++++++++ 5 files changed, 209 insertions(+), 159 deletions(-) create mode 100644 crates/walletkit-core/src/storage/pcp_vault.rs diff --git a/crates/walletkit-core/src/storage/credential_storage.rs b/crates/walletkit-core/src/storage/credential_storage.rs index 579c4d934..0dbceb69a 100644 --- a/crates/walletkit-core/src/storage/credential_storage.rs +++ b/crates/walletkit-core/src/storage/credential_storage.rs @@ -16,7 +16,6 @@ use super::traits::{AtomicBlobStore, DeviceKeystore}; use super::types::{ActivityEntry, ActivityMetadata, ActivityQuery, CredentialRecord}; use super::ACCOUNT_KEYS_FILENAME; use super::{CacheDb, CredentialVault}; -use super::{PcpMetadata, PcpRecord}; use super::{StorageLock, StorageLockGuard}; use crate::{Credential, FieldElement}; use world_id_core::primitives::merkle::AccountInclusionProof; @@ -441,87 +440,6 @@ impl CredentialStore { } } -#[uniffi::export] -impl CredentialStore { - /// Returns a handle to this store's sealed PCPs. - #[must_use] - pub fn pcp_vault(self: Arc) -> Arc { - Arc::new(PcpVault { store: self }) - } -} - -/// Sealed PCP storage in the account vault. -/// -/// Ids and kinds are opaque to `WalletKit`; hosts own id normalization and -/// typically use the backup designator (such as `orb_pkg`) as the kind. PCPs are -/// excluded from credential backups and do not notify the vault-changed listener. -/// -/// Operations require [`CredentialStore::init`] with the account's leaf index. -/// [`CredentialStore::destroy_storage`] permanently removes PCPs. -#[derive(Debug, uniffi::Object)] -pub struct PcpVault { - store: Arc, -} - -#[uniffi::export] -impl PcpVault { - /// Stores sealed PCP bytes under the host's exact id. Replaces an existing - /// entry only when `kind` matches, preserving its creation time. - /// - /// # Errors - /// Returns an error if storage is uninitialized, `id` contains a NUL - /// character, `kind` is invalid or conflicts with an existing entry, `now` is - /// out of range, or the database write fails. - pub fn put( - &self, - id: &str, - kind: &str, - data: &[u8], - now: u64, - ) -> StorageResult<()> { - self.store - .lock_inner()? - .state()? - .vault - .put_pcp(id, kind, data, now) - } - - /// Retrieves a PCP. Returns `None` if absent. - /// - /// # Errors - /// Returns an error if storage is uninitialized or the database read fails. - /// Errors never include the id. - pub fn get(&self, id: &str) -> StorageResult> { - self.store.lock_inner()?.state()?.vault.get_pcp(id) - } - - /// Lists PCP metadata in id order, optionally restricted to one kind. - /// Does not load package data; use [`Self::get`] to read it. - /// - /// # Errors - /// Returns an error if storage is uninitialized, stored metadata is corrupt, - /// or the database query fails. - #[expect( - clippy::needless_pass_by_value, - reason = "UniFFI passes optional strings by value" - )] - pub fn list(&self, kind: Option) -> StorageResult> { - self.store - .lock_inner()? - .state()? - .vault - .list_pcps(kind.as_deref()) - } - - /// Deletes a PCP, returning whether it existed. - /// - /// # Errors - /// Returns an error if storage is uninitialized or the database write fails. - pub fn delete(&self, id: &str) -> StorageResult { - self.store.lock_inner()?.state()?.vault.delete_pcp(id) - } -} - #[cfg(not(target_arch = "wasm32"))] #[uniffi::export] impl CredentialStore { @@ -742,6 +660,14 @@ impl CredentialStore { .map_err(|_| StorageError::Lock("storage mutex poisoned".to_string())) } + /// Runs `f` on the initialized vault while holding the storage mutex. + pub(super) fn with_vault( + &self, + f: impl FnOnce(&CredentialVault) -> StorageResult, + ) -> StorageResult { + f(&self.lock_inner()?.state()?.vault) + } + /// Retrieves a full credential including raw bytes by issuer schema ID. /// /// Returns the most recent non-expired credential matching the issuer schema ID. diff --git a/crates/walletkit-core/src/storage/credential_vault/pcp.rs b/crates/walletkit-core/src/storage/credential_vault/pcp.rs index c05fff704..151217f84 100644 --- a/crates/walletkit-core/src/storage/credential_vault/pcp.rs +++ b/crates/walletkit-core/src/storage/credential_vault/pcp.rs @@ -5,12 +5,25 @@ use super::{map_db_err, to_i64, to_u64, CredentialVault}; use crate::storage::{PcpMetadata, PcpRecord, StorageError, StorageResult}; -use walletkit_sqlite::{params, StepResult, Value}; +use walletkit_sqlite::{params, Connection, StepResult, Value}; const MAX_KIND_LEN: usize = 32; impl CredentialVault { - pub(crate) fn put_pcp( + pub(crate) const fn pcps(&self) -> PcpTable<'_> { + PcpTable { + conn: self.vault.connection(), + } + } +} + +/// The vault's `pcp_records` table. +pub struct PcpTable<'a> { + conn: &'a Connection, +} + +impl PcpTable<'_> { + pub(crate) fn put( &self, id: &str, kind: &str, @@ -26,8 +39,7 @@ impl CredentialVault { let now = to_i64(now, "now")?; // A kind mismatch skips the update, so no row changes. let changed = self - .vault - .connection() + .conn .execute( "INSERT INTO pcp_records (id, kind, data, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?4) @@ -43,10 +55,9 @@ impl CredentialVault { Ok(()) } - pub(crate) fn get_pcp(&self, id: &str) -> StorageResult> { + pub(crate) fn get(&self, id: &str) -> StorageResult> { let row = self - .vault - .connection() + .conn .query_row_optional( "SELECT id, kind, data FROM pcp_records WHERE id = ?1", params![id], @@ -62,13 +73,9 @@ impl CredentialVault { Ok(row) } - pub(crate) fn list_pcps( - &self, - kind: Option<&str>, - ) -> StorageResult> { + pub(crate) fn list(&self, kind: Option<&str>) -> StorageResult> { let mut stmt = self - .vault - .connection() + .conn .prepare( "SELECT id, kind, created_at, updated_at FROM pcp_records WHERE (?1 IS NULL OR kind = ?1) ORDER BY id", @@ -90,9 +97,8 @@ impl CredentialVault { Ok(entries) } - pub(crate) fn delete_pcp(&self, id: &str) -> StorageResult { - self.vault - .connection() + pub(crate) fn delete(&self, id: &str) -> StorageResult { + self.conn .execute("DELETE FROM pcp_records WHERE id = ?1", params![id]) .map(|count| count != 0) .map_err(|err| map_db_err(&err)) @@ -118,7 +124,6 @@ fn validate_kind(kind: &str) -> StorageResult<()> { mod tests { use super::*; use secrecy::SecretBox; - use walletkit_sqlite::Connection; fn new_vault(dir: &std::path::Path, name: &str) -> CredentialVault { CredentialVault::new(&dir.join(name), &SecretBox::init_with(|| [42; 32])) @@ -131,21 +136,21 @@ mod tests { let vault = new_vault(dir.path(), "vault.db"); let data = vec![0xA5; 5 * 1024 * 1024]; let id = "./opaque/../identifier/🦉"; - vault.put_pcp(id, "orb_pkg", &data, 100).unwrap(); + vault.pcps().put(id, "orb_pkg", &data, 100).unwrap(); let expected = PcpRecord { id: id.to_owned(), kind: "orb_pkg".to_owned(), data, }; - assert_eq!(vault.get_pcp(id).unwrap(), Some(expected.clone())); - assert_eq!(vault.get_pcp("opaque/../identifier/🦉").unwrap(), None); + assert_eq!(vault.pcps().get(id).unwrap(), Some(expected.clone())); + assert_eq!(vault.pcps().get("opaque/../identifier/🦉").unwrap(), None); drop(vault); let vault = new_vault(dir.path(), "vault.db"); - assert_eq!(vault.get_pcp(id).unwrap(), Some(expected)); - assert!(vault.delete_pcp(id).unwrap()); - assert!(!vault.delete_pcp(id).unwrap()); - assert_eq!(vault.get_pcp(id).unwrap(), None); - assert!(vault.list_pcps(None).unwrap().is_empty()); + assert_eq!(vault.pcps().get(id).unwrap(), Some(expected)); + assert!(vault.pcps().delete(id).unwrap()); + assert!(!vault.pcps().delete(id).unwrap()); + assert_eq!(vault.pcps().get(id).unwrap(), None); + assert!(vault.pcps().list(None).unwrap().is_empty()); } #[test] @@ -165,8 +170,11 @@ mod tests { END;", ) .unwrap(); - assert!(vault.put_pcp("package", "orb_pkg", b"data", 100).is_err()); - assert_eq!(vault.get_pcp("package").unwrap(), None); + assert!(vault + .pcps() + .put("package", "orb_pkg", b"data", 100) + .is_err()); + assert_eq!(vault.pcps().get("package").unwrap(), None); } #[test] @@ -174,7 +182,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); assert!(matches!( - vault.put_pcp("a\0b", "orb_pkg", b"data", 100), + vault.pcps().put("a\0b", "orb_pkg", b"data", 100), Err(StorageError::InvalidPcpId) )); for kind in [ @@ -186,24 +194,34 @@ mod tests { &"k".repeat(33), ] { assert!(matches!( - vault.put_pcp("package", kind, b"data", 100), + vault.pcps().put("package", kind, b"data", 100), Err(StorageError::InvalidPcpKind) )); } vault - .put_pcp("package", &"k".repeat(32), b"data", 100) + .pcps() + .put("package", &"k".repeat(32), b"data", 100) .unwrap(); - assert_eq!(vault.list_pcps(None).unwrap().len(), 1); + assert_eq!(vault.pcps().list(None).unwrap().len(), 1); } #[test] fn put_updates_bytes_and_timestamp_but_preserves_kind_and_creation_time() { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); - vault.put_pcp("package", "face_pkg", b"one", 100).unwrap(); - vault.put_pcp("package", "face_pkg", b"one", 200).unwrap(); - vault.put_pcp("package", "face_pkg", b"two", 300).unwrap(); - let before = vault.list_pcps(None).unwrap(); + vault + .pcps() + .put("package", "face_pkg", b"one", 100) + .unwrap(); + vault + .pcps() + .put("package", "face_pkg", b"one", 200) + .unwrap(); + vault + .pcps() + .put("package", "face_pkg", b"two", 300) + .unwrap(); + let before = vault.pcps().list(None).unwrap(); assert_eq!( before, vec![PcpMetadata { @@ -214,12 +232,13 @@ mod tests { }] ); let error = vault - .put_pcp("package", "document_pkg", b"wrong kind", 400) + .pcps() + .put("package", "document_pkg", b"wrong kind", 400) .unwrap_err(); assert!(matches!(error, StorageError::PcpKindMismatch)); assert!(!error.to_string().contains("package")); - assert_eq!(vault.list_pcps(None).unwrap(), before); - assert_eq!(vault.get_pcp("package").unwrap().unwrap().data, b"two"); + assert_eq!(vault.pcps().list(None).unwrap(), before); + assert_eq!(vault.pcps().get("package").unwrap().unwrap().data, b"two"); } #[test] @@ -227,12 +246,12 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); for (kind, id) in [("orb_pkg", "a"), ("face_pkg", "b"), ("document_pkg", "c")] { - vault.put_pcp(id, kind, b"", 100).unwrap(); - assert_eq!(vault.get_pcp(id).unwrap().unwrap().data, b""); - assert_eq!(vault.list_pcps(Some(kind)).unwrap()[0].id, id); + vault.pcps().put(id, kind, b"", 100).unwrap(); + assert_eq!(vault.pcps().get(id).unwrap().unwrap().data, b""); + assert_eq!(vault.pcps().list(Some(kind)).unwrap()[0].id, id); } - assert!(vault.list_pcps(Some("unknown_pkg")).unwrap().is_empty()); - assert_eq!(vault.list_pcps(None).unwrap().len(), 3); + assert!(vault.pcps().list(Some("unknown_pkg")).unwrap().is_empty()); + assert_eq!(vault.pcps().list(None).unwrap().len(), 3); } #[test] @@ -240,7 +259,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); vault - .put_pcp("package", "face_pkg", b"sealed", 100) + .pcps() + .put("package", "face_pkg", b"sealed", 100) .unwrap(); let conn = vault.vault.connection(); conn.execute_batch( @@ -248,13 +268,13 @@ mod tests { ) .unwrap(); assert!(matches!( - vault.list_pcps(None), + vault.pcps().list(None), Err(StorageError::VaultDb(_)) )); conn.execute_batch("UPDATE pcp_records SET created_at = 100, updated_at = -1") .unwrap(); assert!(matches!( - vault.list_pcps(None), + vault.pcps().list(None), Err(StorageError::VaultDb(_)) )); } @@ -264,10 +284,11 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let vault = new_vault(dir.path(), "vault.db"); vault - .put_pcp("package", "orb_pkg", b"original", 100) + .pcps() + .put("package", "orb_pkg", b"original", 100) .unwrap(); assert!(matches!( - vault.put_pcp("package", "orb_pkg", b"new", u64::MAX), + vault.pcps().put("package", "orb_pkg", b"new", u64::MAX), Err(StorageError::VaultDb(_)) )); let conn = vault.vault.connection(); @@ -275,7 +296,7 @@ mod tests { "CREATE TRIGGER reject_pcp_insert BEFORE INSERT ON pcp_records BEGIN SELECT RAISE(ABORT, 'injected'); END;", ).unwrap(); assert!(matches!( - vault.put_pcp("another", "face_pkg", b"new", 200), + vault.pcps().put("another", "face_pkg", b"new", 200), Err(StorageError::VaultDb(_)) )); conn.execute_batch( @@ -283,24 +304,30 @@ mod tests { CREATE TRIGGER reject_pcp_update BEFORE UPDATE ON pcp_records BEGIN SELECT RAISE(ABORT, 'injected'); END;", ).unwrap(); assert!(matches!( - vault.put_pcp("package", "orb_pkg", b"new", 200), + vault.pcps().put("package", "orb_pkg", b"new", 200), Err(StorageError::VaultDb(_)) )); conn.execute_batch( "CREATE TRIGGER reject_pcp_delete BEFORE DELETE ON pcp_records BEGIN SELECT RAISE(ABORT, 'injected'); END;", ).unwrap(); assert!(matches!( - vault.delete_pcp("package"), + vault.pcps().delete("package"), Err(StorageError::VaultDb(_)) )); - assert_eq!(vault.get_pcp("package").unwrap().unwrap().data, b"original"); - assert_eq!(vault.list_pcps(None).unwrap().len(), 1); + assert_eq!( + vault.pcps().get("package").unwrap().unwrap().data, + b"original" + ); + assert_eq!(vault.pcps().list(None).unwrap().len(), 1); conn.execute_batch( "DROP TRIGGER reject_pcp_update; DROP TRIGGER reject_pcp_delete", ) .unwrap(); - vault.put_pcp("package", "orb_pkg", b"retry", 200).unwrap(); - assert!(vault.delete_pcp("package").unwrap()); + vault + .pcps() + .put("package", "orb_pkg", b"retry", 200) + .unwrap(); + assert!(vault.pcps().delete("package").unwrap()); } #[test] @@ -313,19 +340,19 @@ mod tests { .execute_batch("DROP TABLE pcp_records") .unwrap(); assert!(matches!( - vault.put_pcp("package", "orb_pkg", b"data", 100), + vault.pcps().put("package", "orb_pkg", b"data", 100), Err(StorageError::VaultDb(_)) )); assert!(matches!( - vault.get_pcp("package"), + vault.pcps().get("package"), Err(StorageError::VaultDb(_)) )); assert!(matches!( - vault.list_pcps(None), + vault.pcps().list(None), Err(StorageError::VaultDb(_)) )); assert!(matches!( - vault.delete_pcp("package"), + vault.pcps().delete("package"), Err(StorageError::VaultDb(_)) )); } @@ -349,7 +376,8 @@ mod tests { source.export_plaintext(&backup).unwrap(); let before = std::fs::read(&backup).unwrap(); source - .put_pcp("package", "orb_pkg", b"remote", 100) + .pcps() + .put("package", "orb_pkg", b"remote", 100) .unwrap(); source.export_plaintext(&backup).unwrap(); assert_eq!(std::fs::read(&backup).unwrap(), before); @@ -366,9 +394,12 @@ mod tests { drop(incoming); for merge in [false, true] { let local = new_vault(dir.path(), &format!("local-{merge}.db")); - local.put_pcp("package", "face_pkg", b"local", 50).unwrap(); - let expected = local.get_pcp("package").unwrap(); - let metadata = local.list_pcps(None).unwrap(); + local + .pcps() + .put("package", "face_pkg", b"local", 50) + .unwrap(); + let expected = local.pcps().get("package").unwrap(); + let metadata = local.pcps().list(None).unwrap(); if merge { assert_eq!( local @@ -385,9 +416,9 @@ mod tests { } else { local.import_plaintext(&backup).unwrap(); } - assert_eq!(local.get_pcp("package").unwrap(), expected); - assert_eq!(local.list_pcps(None).unwrap(), metadata); - assert!(local.get_pcp("incoming-only").unwrap().is_none()); + assert_eq!(local.pcps().get("package").unwrap(), expected); + assert_eq!(local.pcps().list(None).unwrap(), metadata); + assert!(local.pcps().get("incoming-only").unwrap().is_none()); assert_eq!( local .fetch_credential_and_blinding_factor(10, 100) @@ -417,17 +448,20 @@ mod tests { let backup = dir.path().join("backup.db"); source.export_plaintext(&backup).unwrap(); let local = new_vault(dir.path(), "local.db"); - local.put_pcp("package", "face_pkg", b"keep", 50).unwrap(); - let before = local.get_pcp("package").unwrap(); + local + .pcps() + .put("package", "face_pkg", b"keep", 50) + .unwrap(); + let before = local.pcps().get("package").unwrap(); local.vault.connection().execute_batch( "CREATE TRIGGER reject_credential BEFORE INSERT ON credential_records BEGIN SELECT RAISE(ABORT, 'injected'); END;", ).unwrap(); assert!(local.import_plaintext(&backup).is_err()); - assert_eq!(local.get_pcp("package").unwrap(), before); + assert_eq!(local.pcps().get("package").unwrap(), before); assert!(local .merge_plaintext_bytes(&std::fs::read(&backup).unwrap()) .is_err()); - assert_eq!(local.get_pcp("package").unwrap(), before); + assert_eq!(local.pcps().get("package").unwrap(), before); assert!(local.list_credentials(None, 100).unwrap().is_empty()); local .vault @@ -435,7 +469,7 @@ mod tests { .execute_batch("DROP TRIGGER reject_credential") .unwrap(); local.import_plaintext(&backup).unwrap(); - assert_eq!(local.get_pcp("package").unwrap(), before); + assert_eq!(local.pcps().get("package").unwrap(), before); } #[test] @@ -473,20 +507,28 @@ mod tests { )); assert_eq!(upgraded.list_credentials(None, 100).unwrap().len(), 1); upgraded - .put_pcp("package", "orb_pkg", b"local", 100) + .pcps() + .put("package", "orb_pkg", b"local", 100) .unwrap(); let receiver = new_vault(dir.path(), "receiver.db"); receiver - .put_pcp("package", "document_pkg", b"keep", 100) + .pcps() + .put("package", "document_pkg", b"keep", 100) .unwrap(); receiver.import_plaintext(&backup).unwrap(); - assert_eq!(receiver.get_pcp("package").unwrap().unwrap().data, b"keep"); + assert_eq!( + receiver.pcps().get("package").unwrap().unwrap().data, + b"keep" + ); assert_eq!( receiver .merge_plaintext_bytes(&std::fs::read(&backup).unwrap()) .unwrap(), 0 ); - assert_eq!(receiver.get_pcp("package").unwrap().unwrap().data, b"keep"); + assert_eq!( + receiver.pcps().get("package").unwrap().unwrap().data, + b"keep" + ); } } diff --git a/crates/walletkit-core/src/storage/credential_vault/schema.rs b/crates/walletkit-core/src/storage/credential_vault/schema.rs index b5fc355bd..cec607836 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -303,7 +303,7 @@ mod tests { assert!(conn.prepare("SELECT * FROM pcp_records").is_err()); ensure_schema(conn).unwrap(); assert_eq!(version(conn), 3); - assert!(vault.list_pcps(None).unwrap().is_empty()); + assert!(vault.pcps().list(None).unwrap().is_empty()); vault.init_leaf_index(7, 200).unwrap(); assert!(matches!( vault.init_leaf_index(8, 300), diff --git a/crates/walletkit-core/src/storage/mod.rs b/crates/walletkit-core/src/storage/mod.rs index 9faeba319..bb9a6810a 100644 --- a/crates/walletkit-core/src/storage/mod.rs +++ b/crates/walletkit-core/src/storage/mod.rs @@ -51,15 +51,17 @@ pub mod credential_vault; pub mod error; pub mod keys; pub mod paths; +pub mod pcp_vault; pub mod traits; pub mod types; pub use cache::CacheDb; -pub use credential_storage::{CredentialStore, PcpVault}; +pub use credential_storage::CredentialStore; pub use credential_vault::CredentialVault; pub use error::{StorageError, StorageResult}; pub use keys::StorageKeys; pub use paths::StoragePaths; +pub use pcp_vault::PcpVault; pub use traits::{ ActivityChangedListener, AtomicBlobStore, DeviceKeystore, StorageProvider, VaultChangedListener, diff --git a/crates/walletkit-core/src/storage/pcp_vault.rs b/crates/walletkit-core/src/storage/pcp_vault.rs new file mode 100644 index 000000000..ed6190408 --- /dev/null +++ b/crates/walletkit-core/src/storage/pcp_vault.rs @@ -0,0 +1,80 @@ +//! Host-facing storage for sealed PCPs. + +use std::sync::Arc; + +use super::{CredentialStore, PcpMetadata, PcpRecord, StorageResult}; + +#[uniffi::export] +impl CredentialStore { + /// Returns a handle to this store's sealed PCPs. + #[must_use] + pub fn pcp_vault(self: Arc) -> Arc { + Arc::new(PcpVault { store: self }) + } +} + +/// Sealed PCP storage in the account vault. +/// +/// Ids and kinds are opaque to `WalletKit`; hosts own id normalization and +/// typically use the backup designator (such as `orb_pkg`) as the kind. PCPs are +/// excluded from credential backups and do not notify the vault-changed listener. +/// +/// Operations require [`CredentialStore::init`] with the account's leaf index. +/// [`CredentialStore::destroy_storage`] permanently removes PCPs. +#[derive(Debug, uniffi::Object)] +pub struct PcpVault { + store: Arc, +} + +#[uniffi::export] +impl PcpVault { + /// Stores sealed PCP bytes under the host's exact id. Replaces an existing + /// entry only when `kind` matches, preserving its creation time. + /// + /// # Errors + /// Returns an error if storage is uninitialized, `id` contains a NUL + /// character, `kind` is invalid or conflicts with an existing entry, `now` is + /// out of range, or the database write fails. + pub fn put( + &self, + id: &str, + kind: &str, + data: &[u8], + now: u64, + ) -> StorageResult<()> { + self.store + .with_vault(|vault| vault.pcps().put(id, kind, data, now)) + } + + /// Retrieves a PCP. Returns `None` if absent. + /// + /// # Errors + /// Returns an error if storage is uninitialized or the database read fails. + /// Errors never include the id. + pub fn get(&self, id: &str) -> StorageResult> { + self.store.with_vault(|vault| vault.pcps().get(id)) + } + + /// Lists PCP metadata in id order, optionally restricted to one kind. + /// Does not load package data; use [`Self::get`] to read it. + /// + /// # Errors + /// Returns an error if storage is uninitialized, stored metadata is corrupt, + /// or the database query fails. + #[expect( + clippy::needless_pass_by_value, + reason = "UniFFI passes optional strings by value" + )] + pub fn list(&self, kind: Option) -> StorageResult> { + self.store + .with_vault(|vault| vault.pcps().list(kind.as_deref())) + } + + /// Deletes a PCP, returning whether it existed. + /// + /// # Errors + /// Returns an error if storage is uninitialized or the database write fails. + pub fn delete(&self, id: &str) -> StorageResult { + self.store.with_vault(|vault| vault.pcps().delete(id)) + } +} From e1f1834e3c7c9cc932d8e0ebf6cc4708f37fd643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C4=85d?= Date: Thu, 29 Oct 2026 16:49:28 +0000 Subject: [PATCH 19/19] test(storage): name the v2 to v3 upgrade test Co-Authored-By: Claude Opus 5.5 --- crates/walletkit-core/src/storage/credential_vault/schema.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/walletkit-core/src/storage/credential_vault/schema.rs b/crates/walletkit-core/src/storage/credential_vault/schema.rs index 7e4a935bb..f75e43b74 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -426,7 +426,7 @@ mod tests { } #[test] - fn v2_metadata_upgrades_without_changing_the_account() { + fn v2_vault_upgrades_to_v3_without_changing_the_account() { let dir = tempfile::tempdir().unwrap(); let key = SecretBox::init_with(|| [42; 32]); let vault = CredentialVault {