diff --git a/crates/walletkit-core/src/registration/mod.rs b/crates/walletkit-core/src/registration/mod.rs index 882c114d..b5d6ecfe 100644 --- a/crates/walletkit-core/src/registration/mod.rs +++ b/crates/walletkit-core/src/registration/mod.rs @@ -47,7 +47,9 @@ pub use world_id_core::registration::{ TransportError, MAX_NAME_LEN, MAX_VAULT_SIZE, }; -/// The format of the shared vault: a `CredentialStore::export_vault_for_backup` export. +/// The format of the shared vault: a `CredentialStore::export_vault_for_backup` or +/// `export_vault_for_transfer` export. The latter adds a `pcp_records` table that a +/// receiver merging with `merge_vault_from_backup` ignores, so one format covers both. const PLAINTEXT_VAULT_FORMAT: &str = "walletkit_plaintext_v1"; /// The message-bridge session lifetime, from which both sides estimate the session expiry diff --git a/crates/walletkit-core/src/registration/types.rs b/crates/walletkit-core/src/registration/types.rs index b10a3ad8..2d9352e4 100644 --- a/crates/walletkit-core/src/registration/types.rs +++ b/crates/walletkit-core/src/registration/types.rs @@ -107,8 +107,9 @@ pub enum RequesterStatus { pub struct Registered { /// The new authenticator. Its storage is not initialized yet. pub authenticator: Arc, - /// The shared `CredentialStore::export_vault_for_backup` output, to import with - /// `CredentialStore::merge_vault_from_backup` once storage is initialized. + /// The shared `CredentialStore::export_vault_for_backup` or `export_vault_for_transfer` + /// output, to import with `CredentialStore::merge_vault_from_transfer` once storage is + /// initialized (`merge_vault_from_backup` takes the credentials only). pub vault_backup: Option>, } diff --git a/crates/walletkit-core/src/storage/credential_storage.rs b/crates/walletkit-core/src/storage/credential_storage.rs index 0dbceb69..51b3bf39 100644 --- a/crates/walletkit-core/src/storage/credential_storage.rs +++ b/crates/walletkit-core/src/storage/credential_storage.rs @@ -13,7 +13,10 @@ use super::traits::StorageProvider; #[cfg(not(target_arch = "wasm32"))] use super::traits::{ActivityChangedListener, VaultChangedListener}; use super::traits::{AtomicBlobStore, DeviceKeystore}; -use super::types::{ActivityEntry, ActivityMetadata, ActivityQuery, CredentialRecord}; +use super::types::{ + ActivityEntry, ActivityMetadata, ActivityQuery, CredentialRecord, + VaultTransferMerge, +}; use super::ACCOUNT_KEYS_FILENAME; use super::{CacheDb, CredentialVault}; use super::{StorageLock, StorageLockGuard}; @@ -414,6 +417,40 @@ impl CredentialStore { Ok(added) } + /// Adds the credentials and sealed PCPs of an authenticator transfer (WIP-109) to an + /// initialized vault, atomically and idempotently. Credentials merge as in + /// [`Self::merge_vault_from_backup`]; a PCP is added only when its id is absent, so + /// this device's own packages are never rewound. PCP additions do not notify the + /// vault-changed listener. + /// + /// The host must verify the registration before calling, and must only transfer PCPs + /// the receiving account can unseal. + /// + /// # Errors + /// Returns an error on an invalid export, uninitialized store, or database failure. + pub fn merge_vault_from_transfer( + &self, + transfer_bytes: &[u8], + ) -> StorageResult { + let outcome = { + let inner = self.lock_inner()?; + let _guard = inner.guard()?; + #[cfg(not(target_arch = "wasm32"))] + inner.cleanup_stale_backup_files(); + inner + .state()? + .vault + .merge_plaintext_bytes_for_transfer(transfer_bytes)? + }; + if outcome.credentials != 0 { + self.notify_vault_changed(); + } + Ok(VaultTransferMerge { + credentials_added: outcome.credentials, + pcps_added: outcome.pcps, + }) + } + /// Permanently destroys all credential storage data, including sealed PCPs. /// /// Intended for use when the user logs out or deletes their account. @@ -468,6 +505,40 @@ impl CredentialStore { }) } + /// Exports the vault for an authenticator transfer (WIP-109): everything + /// [`Self::export_vault_for_backup`] exports plus the sealed PCPs of `pcp_kinds`. + /// + /// The transfer response is bounded in size, so the host names the kinds that fit + /// (an Orb package alone can exceed the limit). Backups never include PCPs. + /// + /// # Errors + /// + /// Returns an error if the store is not initialized, a kind is invalid, or the + /// export fails. + #[expect( + clippy::significant_drop_tightening, + reason = "lock held intentionally for the full operation to prevent concurrent cleanup from deleting in-use temp files" + )] + #[expect( + clippy::needless_pass_by_value, + reason = "UniFFI passes sequences by value" + )] + pub fn export_vault_for_transfer( + &self, + pcp_kinds: Vec, + ) -> StorageResult> { + let inner = self.lock_inner()?; + let _guard = inner.guard()?; + inner.cleanup_stale_backup_files(); + let kinds = pcp_kinds.iter().map(String::as_str).collect::>(); + let path = inner.export_vault_for_transfer_to_file(&kinds)?; + let _cleanup = CleanupFile(path.clone()); + + std::fs::read(&path).map_err(|e| { + StorageError::VaultDb(format!("failed to read exported vault: {e}")) + }) + } + /// Imports credentials from an in-memory plaintext vault backup. /// /// The store must already be initialized via [`init`](Self::init). @@ -981,6 +1052,21 @@ impl CredentialStoreInner { Ok(dest.to_string_lossy().to_string()) } + /// Same contract as [`Self::export_vault_for_backup_to_file`], with the PCPs of + /// `pcp_kinds` included. + #[cfg(not(target_arch = "wasm32"))] + fn export_vault_for_transfer_to_file( + &self, + pcp_kinds: &[&str], + ) -> StorageResult { + let state = self.state()?; + let dest = self.temp_backup_path(); + state + .vault + .export_plaintext_for_transfer(&dest, pcp_kinds)?; + Ok(dest.to_string_lossy().to_string()) + } + /// Writes raw bytes to a temporary file in the worldid directory. /// Returns the path. The caller is responsible for cleanup. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/walletkit-core/src/storage/credential_vault/merge.rs b/crates/walletkit-core/src/storage/credential_vault/merge.rs index 90a33a95..fb10c3ef 100644 --- a/crates/walletkit-core/src/storage/credential_vault/merge.rs +++ b/crates/walletkit-core/src/storage/credential_vault/merge.rs @@ -1,6 +1,7 @@ //! Additive backup recovery. Row IDs are local database keys, not credential identities. //! Absence from a snapshot is not a deletion instruction: preserve receiver-only credentials. +use super::pcp::{validate_kind, PCP_TABLE}; use super::{map_db_err, CredentialVault, CREDENTIAL_VERSION_ORDER}; use crate::storage::error::{StorageError, StorageResult}; use walletkit_db::blobs::compute_content_id; @@ -8,6 +9,15 @@ use walletkit_sqlite::{ cipher::ensure_temp_store_memory, params, Connection, StepResult, }; +/// Rows a merge added to the vault. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct MergeOutcome { + /// Credential versions added. + pub credentials: u64, + /// Sealed PCPs added. + pub pcps: u64, +} + impl CredentialVault { /// Atomically merges a plaintext backup, returning the number of added records. /// Existing IDs and credentials are preserved; replaying the same snapshot is a no-op. @@ -29,18 +39,48 @@ impl CredentialVault { /// # Errors /// Returns an error for malformed backup contents, unavailable storage, or a failed transaction. pub(crate) fn merge_plaintext_bytes(&self, bytes: &[u8]) -> StorageResult { + Ok(self.merge_plaintext(bytes, false)?.credentials) + } + + /// Like [`Self::merge_plaintext_bytes`], and also adds the sealed PCPs of a transfer + /// export whose ids are absent locally. A local entry is never replaced: it is this + /// device's own copy, and a transfer must not rewind it. Exports without a + /// `pcp_records` table merge their credentials as before. + /// + /// # Errors + /// Returns an error for malformed contents, unavailable storage, or a failed transaction. + pub(crate) fn merge_plaintext_bytes_for_transfer( + &self, + bytes: &[u8], + ) -> StorageResult { + self.merge_plaintext(bytes, true) + } + + fn merge_plaintext( + &self, + bytes: &[u8], + include_pcps: bool, + ) -> StorageResult { let incoming = Connection::open(":memory:", false).map_err(|e| map_db_err(&e))?; ensure_temp_store_memory(&incoming).map_err(|e| map_db_err(&e))?; incoming .deserialize_readonly("main", bytes) .map_err(|e| map_db_err(&e))?; - merge_from(self.vault.connection(), &incoming) + merge_from(self.vault.connection(), &incoming, include_pcps) } } -fn merge_from(destination: &Connection, incoming: &Connection) -> StorageResult { +fn merge_from( + destination: &Connection, + incoming: &Connection, + include_pcps: bool, +) -> StorageResult { validate_incoming(incoming)?; + let merge_pcps = include_pcps && has_pcp_table(incoming)?; + if merge_pcps { + validate_incoming_pcps(incoming)?; + } let tx = destination .transaction_immediate() .map_err(|e| map_db_err(&e))?; @@ -90,8 +130,78 @@ fn merge_from(destination: &Connection, incoming: &Connection) -> StorageResult< added_records += inserted as u64; } } + + let mut added_pcps = 0; + if merge_pcps { + let mut pcps = incoming + .prepare(&format!( + "SELECT id, kind, created_at, updated_at, data FROM {PCP_TABLE} ORDER BY id" + )) + .map_err(|e| map_db_err(&e))?; + while let StepResult::Row(row) = pcps.step().map_err(|e| map_db_err(&e))? { + let inserted = tx + .execute( + &format!( + "INSERT INTO {PCP_TABLE}(id, kind, created_at, updated_at, data) + VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(id) DO NOTHING;" + ), + params![ + row.column_text(0), + row.column_text(1), + row.column_i64(2), + row.column_i64(3), + row.column_blob(4) + ], + ) + .map_err(|e| map_db_err(&e))?; + added_pcps += inserted as u64; + } + } tx.commit().map_err(|e| map_db_err(&e))?; - Ok(added_records) + Ok(MergeOutcome { + credentials: added_records, + pcps: added_pcps, + }) +} + +fn has_pcp_table(incoming: &Connection) -> StorageResult { + incoming + .query_row_optional( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + params![PCP_TABLE], + |_| Ok(()), + ) + .map(|row| row.is_some()) + .map_err(|e| map_db_err(&e)) +} + +fn validate_incoming_pcps(incoming: &Connection) -> StorageResult<()> { + // Same reasoning as `validate_incoming`: the export carries no constraints. An id + // with an embedded NUL would be truncated on read and could shadow another entry. + let has_invalid_rows = incoming + .query_row( + &format!( + "SELECT EXISTS(SELECT 1 FROM {PCP_TABLE} WHERE + typeof(id) != 'text' OR length(id) = 0 OR instr(CAST(id AS BLOB), x'00') > 0 OR + typeof(kind) != 'text' OR + typeof(created_at) != 'integer' OR created_at < 0 OR + typeof(updated_at) != 'integer' OR updated_at < 0 OR + typeof(data) != 'blob')" + ), + &[], + |row| Ok(row.column_i64(0) != 0), + ) + .map_err(|e| map_db_err(&e))?; + if has_invalid_rows { + return Err(invalid_backup()); + } + let mut kinds = incoming + .prepare(&format!("SELECT DISTINCT kind FROM {PCP_TABLE}")) + .map_err(|e| map_db_err(&e))?; + while let StepResult::Row(row) = kinds.step().map_err(|e| map_db_err(&e))? { + validate_kind(&row.column_text(0)).map_err(|_| invalid_backup())?; + } + Ok(()) } fn validate_incoming(incoming: &Connection) -> StorageResult<()> { @@ -636,4 +746,121 @@ mod tests { .unwrap(); assert_eq!(f.merge().unwrap(), 1); } + + impl Fixture { + fn export_for_transfer(&self, kinds: &[&str]) { + self.source + .export_plaintext_for_transfer(&self.backup, kinds) + .unwrap(); + } + + fn merge_transfer(&self) -> StorageResult { + self.receiver.merge_plaintext_bytes_for_transfer( + &std::fs::read(&self.backup).unwrap(), + ) + } + } + + fn put_pcp(vault: &CredentialVault, id: &str, kind: &str, bytes: &[u8], now: u64) { + vault.pcps().put(id, kind, bytes, now).unwrap(); + } + + fn pcp_bytes(vault: &CredentialVault, id: &str) -> Option> { + vault.pcps().get(id).unwrap().map(|record| record.data) + } + + #[test] + fn transfer_adds_only_the_exported_kinds_and_replays_as_a_no_op() { + let f = Fixture::new(); + store(&f.source, 100, b"remote", 1000); + put_pcp(&f.source, "doc/package", "document_pkg", b"sealed-doc", 500); + put_pcp(&f.source, "orb/package", "orb_pkg", b"sealed-orb", 600); + f.export_for_transfer(&["document_pkg"]); + + assert_eq!( + f.merge_transfer().unwrap(), + MergeOutcome { + credentials: 1, + pcps: 1 + } + ); + assert_eq!( + pcp_bytes(&f.receiver, "doc/package").as_deref(), + Some(b"sealed-doc".as_slice()) + ); + assert_eq!(pcp_bytes(&f.receiver, "orb/package"), None); + let listed = f.receiver.pcps().list(Some("document_pkg")).unwrap(); + assert_eq!((listed[0].created_at, listed[0].updated_at), (500, 500)); + + assert_eq!(f.merge_transfer().unwrap(), MergeOutcome::default()); + } + + #[test] + fn transfer_never_replaces_a_local_pcp() { + let f = Fixture::new(); + put_pcp(&f.receiver, "doc/package", "document_pkg", b"local", 700); + put_pcp(&f.source, "doc/package", "document_pkg", b"remote", 900); + f.export_for_transfer(&["document_pkg"]); + + assert_eq!(f.merge_transfer().unwrap().pcps, 0); + assert_eq!( + pcp_bytes(&f.receiver, "doc/package").as_deref(), + Some(b"local".as_slice()) + ); + } + + #[test] + fn backup_merge_ignores_pcps_and_backup_exports_carry_none() { + let f = Fixture::new(); + store(&f.source, 100, b"remote", 1000); + put_pcp(&f.source, "doc/package", "document_pkg", b"sealed-doc", 500); + + f.export_for_transfer(&["document_pkg"]); + assert_eq!(f.merge().unwrap(), 1); + assert_eq!(pcp_bytes(&f.receiver, "doc/package"), None); + + f.export(); + assert_eq!(f.merge_transfer().unwrap(), MergeOutcome::default()); + assert_eq!(pcp_bytes(&f.receiver, "doc/package"), None); + } + + #[test] + fn transfer_export_rejects_invalid_kinds() { + let f = Fixture::new(); + assert!(f + .source + .export_plaintext_for_transfer(&f.backup, &["Orb Pkg"]) + .is_err()); + } + + #[test] + fn invalid_pcp_rows_roll_back_the_whole_transfer() { + let f = Fixture::new(); + store(&f.source, 100, b"remote", 1000); + put_pcp(&f.source, "doc/package", "document_pkg", b"sealed-doc", 500); + for corruption in [ + "UPDATE pcp_records SET kind = 'Not A Kind';", + "UPDATE pcp_records SET id = CAST(x'646f6300' AS TEXT);", + "UPDATE pcp_records SET data = 'text';", + "UPDATE pcp_records SET created_at = -1;", + ] { + f.export_for_transfer(&["document_pkg"]); + Connection::open(&f.backup, false) + .unwrap() + .execute_batch(corruption) + .unwrap(); + assert!(f.merge_transfer().is_err(), "{corruption}"); + assert!(f.receiver.list_credentials(None, 1000).unwrap().is_empty()); + assert_eq!(pcp_bytes(&f.receiver, "doc/package"), None); + } + + f.export_for_transfer(&["document_pkg"]); + assert_eq!( + f.merge_transfer().unwrap(), + MergeOutcome { + credentials: 1, + pcps: 1 + } + ); + } } diff --git a/crates/walletkit-core/src/storage/credential_vault/mod.rs b/crates/walletkit-core/src/storage/credential_vault/mod.rs index 5445a1d1..70f8e58d 100644 --- a/crates/walletkit-core/src/storage/credential_vault/mod.rs +++ b/crates/walletkit-core/src/storage/credential_vault/mod.rs @@ -399,6 +399,24 @@ impl CredentialVault { .map_err(|e| map_db_err(&e)) } + /// Exports the vault for an authenticator transfer (WIP-109): the backup tables + /// plus the sealed PCPs of `pcp_kinds`. + /// + /// Backups keep excluding PCPs, which have their own backup path. Same locking + /// and cleanup contract as [`Self::export_plaintext`]. + /// + /// # Errors + /// + /// Returns an error if a kind is invalid or the export fails. + pub fn export_plaintext_for_transfer( + &self, + dest: &Path, + pcp_kinds: &[&str], + ) -> StorageResult<()> { + self.export_plaintext(dest)?; + self.pcps().copy_to_plaintext(dest, pcp_kinds) + } + /// Imports credentials from a plaintext (unencrypted) vault backup into /// an empty vault. Intended for restore on a fresh install. /// diff --git a/crates/walletkit-core/src/storage/credential_vault/pcp.rs b/crates/walletkit-core/src/storage/credential_vault/pcp.rs index 151217f8..87ca7604 100644 --- a/crates/walletkit-core/src/storage/credential_vault/pcp.rs +++ b/crates/walletkit-core/src/storage/credential_vault/pcp.rs @@ -5,9 +5,12 @@ use super::{map_db_err, to_i64, to_u64, CredentialVault}; use crate::storage::{PcpMetadata, PcpRecord, StorageError, StorageResult}; +use std::path::Path; use walletkit_sqlite::{params, Connection, StepResult, Value}; const MAX_KIND_LEN: usize = 32; +/// The table a transfer export carries and `merge_plaintext_bytes_for_transfer` reads. +pub(super) const PCP_TABLE: &str = "pcp_records"; impl CredentialVault { pub(crate) const fn pcps(&self) -> PcpTable<'_> { @@ -103,11 +106,53 @@ impl PcpTable<'_> { .map(|count| count != 0) .map_err(|err| map_db_err(&err)) } + + /// Adds the entries of `kinds` to the plaintext export already written at `dest`. + /// + /// A transfer is bounded in size, so the host names the kinds that fit instead + /// of shipping every package. The table is created even when no entry matches. + pub(crate) fn copy_to_plaintext( + &self, + dest: &Path, + kinds: &[&str], + ) -> StorageResult<()> { + for kind in kinds { + validate_kind(kind)?; + } + let attach = format!( + "ATTACH DATABASE '{}' AS transfer KEY '';", + dest.to_string_lossy().replace('\'', "''") + ); + self.conn + .execute_batch(&attach) + .map_err(|err| map_db_err(&err))?; + let placeholders = (1..=kinds.len()) + .map(|i| format!("?{i}")) + .collect::>() + .join(", "); + let values = kinds + .iter() + .map(|kind| Value::Text((*kind).to_owned())) + .collect::>(); + let copied = self.conn.execute( + &format!( + "CREATE TABLE transfer.{PCP_TABLE} AS + SELECT id, kind, created_at, updated_at, data FROM {PCP_TABLE} + WHERE kind IN ({placeholders})" + ), + &values, + ); + // Always detach, even if the copy failed. + let detached = self.conn.execute_batch("DETACH DATABASE transfer;"); + copied.map_err(|err| map_db_err(&err))?; + detached.map_err(|err| map_db_err(&err))?; + Ok(()) + } } /// Accepts short lowercase labels such as `orb_pkg`, so kinds stay comparable /// across hosts and safe to log. -fn validate_kind(kind: &str) -> StorageResult<()> { +pub(super) fn validate_kind(kind: &str) -> StorageResult<()> { let valid = !kind.is_empty() && kind.len() <= MAX_KIND_LEN && kind diff --git a/crates/walletkit-core/src/storage/mod.rs b/crates/walletkit-core/src/storage/mod.rs index bb9a6810..ebad3a4f 100644 --- a/crates/walletkit-core/src/storage/mod.rs +++ b/crates/walletkit-core/src/storage/mod.rs @@ -71,7 +71,7 @@ pub use types::{ ActivityQuery, BlobKind, ContentId, CredentialRecord, Nullifier, ProtocolVersion, ReplayGuardKind, ReplayGuardResult, RequestId, }; -pub use types::{PcpMetadata, PcpRecord}; +pub use types::{PcpMetadata, PcpRecord, VaultTransferMerge}; 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 0f9c9c99..e43df307 100644 --- a/crates/walletkit-core/src/storage/types.rs +++ b/crates/walletkit-core/src/storage/types.rs @@ -221,6 +221,15 @@ pub struct PcpRecord { pub data: Vec, } +/// Rows added by `CredentialStore::merge_vault_from_transfer`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Record)] +pub struct VaultTransferMerge { + /// Credentials newly added to the vault. + pub credentials_added: u64, + /// Sealed PCPs newly added to the vault. + pub pcps_added: u64, +} + /// PCP metadata returned without loading package data. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct PcpMetadata { diff --git a/docs/accounts.md b/docs/accounts.md index 35989d05..422c7f04 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -30,6 +30,14 @@ 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. +An authenticator transfer (WIP-109) can carry PCPs: `export_vault_for_transfer` +takes the kinds to include, since the response is bounded in size and an Orb +package alone can exceed it, and `merge_vault_from_transfer` adds the transferred +credentials and any PCP whose id is absent locally, reporting both counts. A local +PCP is never replaced. `merge_vault_from_backup` ignores the PCPs of such an +export, so one vault format serves backups and transfers. Only transfer PCPs the +receiving account can unseal: WalletKit stores them as opaque bytes. + `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