diff --git a/crates/walletkit-core/src/storage/credential_storage.rs b/crates/walletkit-core/src/storage/credential_storage.rs index df9351db..0dbceb69 100644 --- a/crates/walletkit-core/src/storage/credential_storage.rs +++ b/crates/walletkit-core/src/storage/credential_storage.rs @@ -414,7 +414,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. /// @@ -486,8 +486,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, + /// 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 @@ -659,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. @@ -1159,6 +1168,63 @@ mod tests { use std::sync::atomic::{AtomicU32, Ordering}; + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn pcp_vault_requires_initialization_and_never_notifies_credential_backup_listener() + { + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new( + CredentialStore::from_provider(&InMemoryStorageProvider::new(dir.path())) + .unwrap(), + ); + let pcps = Arc::clone(&store).pcp_vault(); + assert!(matches!( + pcps.put("package", "orb_pkg", b"sealed", 100), + Err(StorageError::NotInitialized) + )); + assert!(matches!( + pcps.get("package"), + Err(StorageError::NotInitialized) + )); + assert!(matches!(pcps.list(None), Err(StorageError::NotInitialized))); + assert!(matches!( + 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(); + 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!( + 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!(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) + .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!( + pcps.get("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 84881e2a..5445a1d1 100644 --- a/crates/walletkit-core/src/storage/credential_vault/mod.rs +++ b/crates/walletkit-core/src/storage/credential_vault/mod.rs @@ -6,6 +6,7 @@ //! [`walletkit_db`]. mod merge; +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 00000000..151217f8 --- /dev/null +++ b/crates/walletkit-core/src/storage/credential_vault/pcp.rs @@ -0,0 +1,534 @@ +//! 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::{PcpMetadata, PcpRecord, StorageError, StorageResult}; +use walletkit_sqlite::{params, Connection, StepResult, Value}; + +const MAX_KIND_LEN: usize = 32; + +impl CredentialVault { + 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, + data: &[u8], + now: u64, + ) -> StorageResult<()> { + // 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); + } + validate_kind(kind)?; + let now = to_i64(now, "now")?; + // A kind mismatch skips the update, so no row changes. + let changed = self + .conn + .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, updated_at = excluded.updated_at + WHERE pcp_records.kind = excluded.kind", + params![id, kind, data, now], + ) + .map_err(|err| map_db_err(&err))?; + if changed == 0 { + return Err(StorageError::PcpKindMismatch); + } + Ok(()) + } + + pub(crate) fn get(&self, id: &str) -> StorageResult> { + let row = self + .conn + .query_row_optional( + "SELECT id, kind, data FROM pcp_records WHERE id = ?1", + params![id], + |row| { + Ok(PcpRecord { + id: row.column_text(0), + kind: row.column_text(1), + data: row.column_blob(2), + }) + }, + ) + .map_err(|err| map_db_err(&err))?; + Ok(row) + } + + pub(crate) fn list(&self, kind: Option<&str>) -> StorageResult> { + let mut stmt = self + .conn + .prepare( + "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))?; + stmt.bind_values(params![ + kind.map_or(Value::Null, |kind| Value::Text(kind.to_owned())) + ]) + .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))? { + entries.push(PcpMetadata { + id: row.column_text(0), + kind: row.column_text(1), + created_at: to_u64(row.column_i64(2), "PCP created_at")?, + updated_at: to_u64(row.column_i64(3), "PCP updated_at")?, + }); + } + Ok(entries) + } + + 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)) + } +} + +/// 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; + + fn new_vault(dir: &std::path::Path, name: &str) -> CredentialVault { + CredentialVault::new(&dir.join(name), &SecretBox::init_with(|| [42; 32])) + .unwrap() + } + + #[test] + 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/🦉"; + 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.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.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] + 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 row is written. + vault + .vault + .connection() + .execute_batch( + "CREATE TABLE commit_guard ( + 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 + .pcps() + .put("package", "orb_pkg", b"data", 100) + .is_err()); + assert_eq!(vault.pcps().get("package").unwrap(), None); + } + + #[test] + 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.pcps().put("a\0b", "orb_pkg", b"data", 100), + Err(StorageError::InvalidPcpId) + )); + for kind in [ + "", + "Orb_pkg", + "orb-pkg", + "orb pkg", + "orb\0pkg", + &"k".repeat(33), + ] { + assert!(matches!( + vault.pcps().put("package", kind, b"data", 100), + Err(StorageError::InvalidPcpKind) + )); + } + vault + .pcps() + .put("package", &"k".repeat(32), b"data", 100) + .unwrap(); + 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 + .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 { + id: "package".to_owned(), + kind: "face_pkg".to_owned(), + created_at: 100, + updated_at: 300, + }] + ); + let error = vault + .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.pcps().list(None).unwrap(), before); + assert_eq!(vault.pcps().get("package").unwrap().unwrap().data, b"two"); + } + + #[test] + 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")] { + 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.pcps().list(Some("unknown_pkg")).unwrap().is_empty()); + assert_eq!(vault.pcps().list(None).unwrap().len(), 3); + } + + #[test] + fn list_rejects_negative_timestamps() { + let dir = tempfile::tempdir().unwrap(); + let vault = new_vault(dir.path(), "vault.db"); + vault + .pcps() + .put("package", "face_pkg", b"sealed", 100) + .unwrap(); + let conn = vault.vault.connection(); + conn.execute_batch( + "PRAGMA ignore_check_constraints = ON; UPDATE pcp_records SET created_at = -1", + ) + .unwrap(); + assert!(matches!( + vault.pcps().list(None), + Err(StorageError::VaultDb(_)) + )); + conn.execute_batch("UPDATE pcp_records SET created_at = 100, updated_at = -1") + .unwrap(); + assert!(matches!( + vault.pcps().list(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 + .pcps() + .put("package", "orb_pkg", b"original", 100) + .unwrap(); + assert!(matches!( + vault.pcps().put("package", "orb_pkg", 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.pcps().put("another", "face_pkg", 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.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.pcps().delete("package"), + Err(StorageError::VaultDb(_)) + )); + 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 + .pcps() + .put("package", "orb_pkg", b"retry", 200) + .unwrap(); + assert!(vault.pcps().delete("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.pcps().put("package", "orb_pkg", b"data", 100), + Err(StorageError::VaultDb(_)) + )); + assert!(matches!( + vault.pcps().get("package"), + Err(StorageError::VaultDb(_)) + )); + assert!(matches!( + vault.pcps().list(None), + Err(StorageError::VaultDb(_)) + )); + assert!(matches!( + vault.pcps().delete("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 + .pcps() + .put("package", "orb_pkg", 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 (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 + .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 + .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.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) + .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 + .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.pcps().get("package").unwrap(), before); + assert!(local + .merge_plaintext_bytes(&std::fs::read(&backup).unwrap()) + .is_err()); + assert_eq!(local.pcps().get("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.pcps().get("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("INSERT INTO vault_meta VALUES (1, NULL, 100, 100)") + }) + .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 + .pcps() + .put("package", "orb_pkg", b"local", 100) + .unwrap(); + let receiver = new_vault(dir.path(), "receiver.db"); + receiver + .pcps() + .put("package", "document_pkg", b"keep", 100) + .unwrap(); + receiver.import_plaintext(&backup).unwrap(); + 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.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 978cce9b..f75e43b7 100644 --- a/crates/walletkit-core/src/storage/credential_vault/schema.rs +++ b/crates/walletkit-core/src/storage/credential_vault/schema.rs @@ -8,7 +8,8 @@ use walletkit_sqlite::{ /// The vault migrations, in order. Append new versions; never change shipped migrations. /// `vault_meta.vault_version` counts the applied migrations. /// Migrations must not start transactions or modify the shared blob schema. -const MIGRATIONS: &[fn(&Connection) -> DbResult<()>] = &[migration_v1, migration_v2]; +const MIGRATIONS: &[fn(&Connection) -> DbResult<()>] = + &[migration_v1, migration_v2, migration_v3]; #[expect( clippy::cast_possible_wrap, @@ -183,11 +184,21 @@ fn read_legacy_version(conn: &Connection) -> DbResult> { /// Validates the tables and metadata layout required by the stored version. fn validate_schema(conn: &Connection, version: i64) -> DbResult<()> { - for table in ["vault_meta", "credential_records", "blob_objects"] { + let required_tables: &[&str] = if version >= 3 { + &[ + "vault_meta", + "credential_records", + "blob_objects", + "pcp_records", + ] + } else { + &["vault_meta", "credential_records", "blob_objects"] + }; + for table in required_tables { let present = conn .query_row_optional( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", - params![table], + params![*table], |_| Ok(()), )? .is_some(); @@ -327,6 +338,21 @@ fn migration_v2(conn: &Connection) -> DbResult<()> { Ok(()) } +/// 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 ( + id TEXT PRIMARY KEY NOT NULL, + kind TEXT NOT NULL CHECK (length(kind) BETWEEN 1 AND 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. + data BLOB NOT NULL + );", + ) +} + #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; @@ -382,6 +408,47 @@ 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 v2_vault_upgrades_to_v3_without_changing_the_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, migration_v2]) + }) + .unwrap(), + }; + let conn = vault.vault.connection(); + vault.init_leaf_index(7, 100).unwrap(); + assert_eq!(version(conn), 2); + assert!(conn.prepare("SELECT * FROM pcp_records").is_err()); + ensure_schema(conn).unwrap(); + assert_eq!(version(conn), 3); + assert!(vault.pcps().list(None).unwrap().is_empty()); + vault.init_leaf_index(7, 200).unwrap(); + assert!(matches!( + vault.init_leaf_index(8, 300), + Err(StorageError::InvalidLeafIndex { .. }) + )); + } + #[test] fn version_reader_only_reads_metadata() { let conn = Connection::open(":memory:", false).unwrap(); @@ -649,7 +716,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 = diff --git a/crates/walletkit-core/src/storage/error.rs b/crates/walletkit-core/src/storage/error.rs index e16729b2..06ae2432 100644 --- a/crates/walletkit-core/src/storage/error.rs +++ b/crates/walletkit-core/src/storage/error.rs @@ -84,6 +84,18 @@ pub enum StorageError { credential_id: u64, }, + /// An existing PCP id cannot be reassigned to another kind. + #[error("PCP kind does not match the existing entry")] + PcpKindMismatch, + + /// 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, + /// 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 7162b73c..bb9a6810 100644 --- a/crates/walletkit-core/src/storage/mod.rs +++ b/crates/walletkit-core/src/storage/mod.rs @@ -13,8 +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. 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 @@ -49,6 +51,7 @@ pub mod credential_vault; pub mod error; pub mod keys; pub mod paths; +pub mod pcp_vault; pub mod traits; pub mod types; @@ -58,6 +61,7 @@ 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, @@ -67,6 +71,7 @@ pub use types::{ ActivityQuery, BlobKind, ContentId, CredentialRecord, Nullifier, ProtocolVersion, ReplayGuardKind, ReplayGuardResult, RequestId, }; +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/pcp_vault.rs b/crates/walletkit-core/src/storage/pcp_vault.rs new file mode 100644 index 00000000..ed619040 --- /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)) + } +} diff --git a/crates/walletkit-core/src/storage/traits.rs b/crates/walletkit-core/src/storage/traits.rs index 26658ac5..8dd6d734 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 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 21533f00..0f9c9c99 100644 --- a/crates/walletkit-core/src/storage/types.rs +++ b/crates/walletkit-core/src/storage/types.rs @@ -209,3 +209,27 @@ impl ActivityQuery { next } } + +/// 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. + pub id: String, + /// Host-defined kind label, such as `orb_pkg`. + pub kind: String, + /// Opaque sealed package bytes. + pub data: Vec, +} + +/// PCP metadata returned without loading package data. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct PcpMetadata { + /// Exact identifier supplied by the host. + pub id: String, + /// Host-defined kind label, such as `orb_pkg`. + pub kind: String, + /// Creation time in Unix seconds. + pub created_at: u64, + /// Last update time in Unix seconds. + pub updated_at: u64, +} diff --git a/docs/accounts.md b/docs/accounts.md index 19599c86..35989d05 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -22,6 +22,20 @@ 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::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 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 cad4e143..f6d21a56 100644 --- a/web/walletkit/README.md +++ b/web/walletkit/README.md @@ -243,6 +243,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. | | Flamingo | Browser WebSockets can't send its signed handshake headers, and it relies on device attestation. | | `DeviceKeystore`, `AtomicBlobStore`, and `StorageProvider` | They need synchronous host callbacks. Use `StorageKeys.fromBytes`. |