diff --git a/Cargo.lock b/Cargo.lock index 7ccf4f5..dcb0f0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1042,9 +1042,13 @@ dependencies = [ "deadcat-rfq-provider", "elements", "hmac", + "libc", "rand 0.8.7", + "redb", + "rustix 1.1.4", "sha2 0.10.9", "subtle", + "tempfile", "thiserror 2.0.18", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index f2c400c..b9a08a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,10 +28,12 @@ elements = { version = "0.25", features = ["serde"] } hex = { version = "0.4", features = ["serde"] } hmac = "0.12" iroh = "1" +libc = "0.2" postcard = { version = "1", default-features = false, features = ["alloc"] } rand = "0.8" redb = "4" reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "charset"] } +rustix = { version = "1", features = ["process"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/README.md b/README.md index be79eb4..79c1767 100644 --- a/README.md +++ b/README.md @@ -52,13 +52,18 @@ one canonical signed PSET before returning it. Exact retries replay that durable winner without re-signing; concurrently in-flight valid signature encodings may both sign, but every caller returns the same stored winner. Its `FirmQuote` is still an internal, unauthenticated artifact, not yet a provider- -signed network quote. The first custom provider-wallet slice adds a versioned -encrypted keystore, in-memory BIP32/SLIP-77 key derivation, fresh confidential -tree-less P2TR destinations, output recovery, exact durable-job signing, and a -provider-side non-last blinding coordinator. It deliberately has no arbitrary -signing API, and Elements Core remains only the intended chain, mempool, policy, -and relay authority. Filesystem/passphrase operations, an authoritative -inventory scanner, daemon and live-regtest integration, backup-recovery tooling, +signed network quote. The custom provider wallet now adds a versioned encrypted +keystore, in-memory BIP32/SLIP-77 key derivation, fresh confidential tree-less +P2TR destinations, output recovery, exact durable-job signing, and a +provider-side non-last blinding coordinator. Its identity-bound `wallet.redb` +catalog durably records each random locator before returning a destination, +publishes new and restored wallets through a same-directory staging file in a +trusted path hierarchy on a lock-supporting local Unix filesystem, and exports +an authenticated logical wallet-only snapshot. The naked cryptographic wallet +deliberately cannot issue production destinations or sign arbitrary data. +Elements Core remains only the intended chain, mempool, policy, and relay +authority. Protected passphrase delivery, an authoritative inventory scanner, +daemon and live-regtest integration, coordinated provider-state recovery, market-data pricing, the authenticated remote protocol, relay reconciliation, and HSM support remain future work. [ADR 0008](docs/adr/0008-rfq-service-owned-wallet.md) records that boundary. diff --git a/crates/deadcat-rfq-wallet/Cargo.toml b/crates/deadcat-rfq-wallet/Cargo.toml index 1d77f95..03216ce 100644 --- a/crates/deadcat-rfq-wallet/Cargo.toml +++ b/crates/deadcat-rfq-wallet/Cargo.toml @@ -16,7 +16,13 @@ deadcat-rfq-provider.workspace = true elements.workspace = true hmac.workspace = true rand.workspace = true +redb.workspace = true sha2.workspace = true subtle.workspace = true +tempfile.workspace = true thiserror.workspace = true zeroize.workspace = true + +[target.'cfg(unix)'.dependencies] +libc.workspace = true +rustix.workspace = true diff --git a/crates/deadcat-rfq-wallet/src/keystore.rs b/crates/deadcat-rfq-wallet/src/keystore.rs index 1951252..387dd17 100644 --- a/crates/deadcat-rfq-wallet/src/keystore.rs +++ b/crates/deadcat-rfq-wallet/src/keystore.rs @@ -144,7 +144,7 @@ impl EncryptedKeystore { Self::seal_entropy_with_rng(identity, passphrase, kdf, &entropy, rng) } - fn seal_entropy_with_rng( + pub(crate) fn seal_entropy_with_rng( identity: ProviderIdentity, passphrase: &[u8], kdf: KdfParams, diff --git a/crates/deadcat-rfq-wallet/src/lib.rs b/crates/deadcat-rfq-wallet/src/lib.rs index 5d159be..b0eb3d3 100644 --- a/crates/deadcat-rfq-wallet/src/lib.rs +++ b/crates/deadcat-rfq-wallet/src/lib.rs @@ -1,11 +1,11 @@ //! Narrow, purpose-built hot-wallet capabilities for an RFQ provider. //! -//! This crate owns no chain index. It provides a versioned encrypted keystore -//! envelope, fresh confidential tree-less P2TR destinations, -//! confidential-output recovery, and exact durable-job signing. The envelope -//! bytes can be copied and reopened, but a production daemon must still supply -//! atomic filesystem persistence, protected passphrase delivery, -//! authoritative chain scanning, and tested backup transport and verification. +//! This crate owns no chain index. It provides a versioned encrypted keystore, +//! an identity-bound durable locator catalog, fresh confidential tree-less +//! P2TR destinations, confidential-output recovery, and exact durable-job +//! signing. A production daemon must still supply protected passphrase +//! delivery, authoritative chain scanning, coordinated service backup, and +//! host-level memory hardening. //! //! Secret buffers owned here are erased on drop where their underlying type //! permits it. This is defense in depth, not a claim that Rust temporaries, @@ -16,7 +16,12 @@ #![forbid(unsafe_code)] mod keystore; +mod persistent; mod wallet; pub use keystore::{DEFAULT_KDF_PARAMS, EncryptedKeystore, KdfParams, KeystoreError, UnlockedSeed}; +pub use persistent::{ + MAX_WALLET_CATALOG_ENTRIES, PersistentRfqWallet, PersistentWalletError, WalletBackup, + WalletCatalogSnapshot, +}; pub use wallet::{RfqWallet, RfqWalletError}; diff --git a/crates/deadcat-rfq-wallet/src/persistent.rs b/crates/deadcat-rfq-wallet/src/persistent.rs new file mode 100644 index 0000000..1a853da --- /dev/null +++ b/crates/deadcat-rfq-wallet/src/persistent.rs @@ -0,0 +1,1430 @@ +use core::fmt; +use std::collections::BTreeSet; +use std::fs::{self, File, OpenOptions, TryLockError}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, MutexGuard}; + +use deadcat_rfq_provider::{ + ConfidentialDestination, DestinationPurpose, DestinationSource, ProviderIdentity, + ProviderOutputRecovery, ProviderSigner, SigningJob, SigningResponse, WalletKeyLocator, + WalletOwnedOutput, +}; +use elements::{AssetId, OutPoint, TxOut}; +use rand::rngs::OsRng; +use rand::{CryptoRng, RngCore}; +use redb::{ + CommitError, Database, DatabaseError, Durability, ReadableDatabase as _, ReadableTable, + ReadableTableMetadata as _, SetDurabilityError, StorageError, TableDefinition, TableError, + TransactionError, WriteTransaction, +}; +use sha2::{Digest as _, Sha256}; +use subtle::ConstantTimeEq as _; +use tempfile::TempPath; +use thiserror::Error; + +use crate::{ + DEFAULT_KDF_PARAMS, EncryptedKeystore, KdfParams, KeystoreError, RfqWallet, RfqWalletError, +}; + +const SCHEMA_VERSION: u32 = 1; +const CATALOG_CHECKPOINT_VERSION: u32 = 1; +const DATABASE_CACHE_BYTES: usize = 16 * 1024 * 1024; +const CATALOG_NONCE_BYTES: usize = 16; +const CATALOG_VALUE_BYTES: usize = 8 + 32; +const CHECKPOINT_BYTES: usize = 32; +const MAX_ISSUANCE_ATTEMPTS: usize = 32; + +const SCHEMA_VERSION_KEY: &str = "schema_version"; +const PROVIDER_IDENTITY_KEY: &str = "provider_identity"; +const WALLET_ID_KEY: &str = "wallet_id"; +const ENCRYPTED_KEYSTORE_KEY: &str = "encrypted_keystore"; +const KEYSTORE_DIGEST_KEY: &str = "keystore_digest"; +const CATALOG_REVISION_KEY: &str = "catalog_revision"; +const CATALOG_CHECKPOINT_KEY: &str = "catalog_checkpoint"; +const META_ENTRY_COUNT: u64 = 7; + +const META: TableDefinition<&str, &[u8]> = TableDefinition::new("wallet_meta"); +// The nonce is the key rather than the complete locator so a repeated random +// namespace is rejected even when it is presented under a different purpose. +const CATALOG: TableDefinition<&[u8], &[u8]> = TableDefinition::new("issued_locators"); + +const CATALOG_ROOT_DOMAIN: &[u8] = b"deadcat/rfq/wallet/catalog-root/v1"; +const CATALOG_ENTRY_DOMAIN: &[u8] = b"deadcat/rfq/wallet/catalog-entry/v1"; + +const BACKUP_MAGIC: &[u8; 8] = b"DCRFQWB\0"; +const BACKUP_VERSION: u16 = 1; +const BACKUP_FLAGS: u16 = 0; +const BACKUP_HEADER_BYTES: usize = 8 + 2 + 2 + 4 + 96 + 16 + 8 + 4 + 4; +const BACKUP_TRAILER_BYTES: usize = CHECKPOINT_BYTES; +const MAX_BACKUP_KEYSTORE_BYTES: usize = 4 * 1024; + +/// Hard bound shared by live issuance and logical backup parsing. +/// +/// The catalog is append-only. A deployment approaching this deliberately +/// generous bound must rotate to a new wallet through an explicit operational +/// procedure rather than producing a backup that this implementation cannot +/// safely parse. +pub const MAX_WALLET_CATALOG_ENTRIES: u64 = 1_000_000; + +/// One coherent, revisioned view of every destination locator ever issued by +/// this wallet. +#[derive(Clone, PartialEq, Eq)] +pub struct WalletCatalogSnapshot { + revision: u64, + checkpoint: [u8; CHECKPOINT_BYTES], + locators: Vec, +} + +impl WalletCatalogSnapshot { + #[must_use] + pub const fn revision(&self) -> u64 { + self.revision + } + + #[must_use] + pub const fn checkpoint(&self) -> [u8; CHECKPOINT_BYTES] { + self.checkpoint + } + + #[must_use] + pub fn locators(&self) -> &[WalletKeyLocator] { + &self.locators + } +} + +impl fmt::Debug for WalletCatalogSnapshot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WalletCatalogSnapshot") + .field("revision", &self.revision) + .field("checkpoint", &"[authenticated]") + .field( + "locators", + &format_args!("[{} opaque entries]", self.locators.len()), + ) + .finish() + } +} + +/// Bounded wallet-only recovery artifact. +/// +/// This contains the encrypted keystore and append-only locator catalog, but +/// no provider reservation state, signing commitments, chain state, plaintext +/// key material, passphrase, or confidential openings. Restoring an authentic +/// stale artifact cannot discover random locators issued after its revision. +/// Bytes accepted through [`Self::from_bytes`] are only structurally checked; +/// the wallet-derived catalog checkpoint is authenticated during restore. +#[derive(Clone, PartialEq, Eq)] +pub struct WalletBackup { + bytes: Vec, +} + +impl WalletBackup { + /// Parse and structurally bound a logical backup. Authentication is + /// completed during [`PersistentRfqWallet::restore`], after the encrypted + /// keystore has been unlocked. + pub fn from_bytes(bytes: Vec) -> Result { + parse_backup(&bytes)?; + Ok(Self { bytes }) + } + + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + #[must_use] + pub fn revision(&self) -> u64 { + // Construction has already validated all fixed offsets. + read_u64(&self.bytes, 128) + } + + #[must_use] + pub fn checkpoint(&self) -> [u8; CHECKPOINT_BYTES] { + self.bytes[self.bytes.len() - CHECKPOINT_BYTES..] + .try_into() + .expect("validated backup trailer has a fixed length") + } +} + +impl fmt::Debug for WalletBackup { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WalletBackup") + .field("bytes", &self.bytes.len()) + .field("declared_revision", &self.revision()) + .field( + "contents", + &"[encrypted keystore; opaque catalog; authentication deferred]", + ) + .finish() + } +} + +/// Identity-bound wallet plus crash-durable destination catalog. +/// +/// This is the only type in this crate that implements [`DestinationSource`]. +/// Every returned destination is committed to redb with immediate durability +/// before it crosses the API boundary. An error after that commit may burn an +/// unused but still discoverable destination; an uncommitted destination is +/// never returned. +/// +/// The current persistent backend is Unix-only and requires an owner-controlled +/// parent directory plus a local filesystem that supports exclusive file +/// locks. It rejects unsupported locking instead of accepting a single-writer +/// promise from the embedding service. The complete path hierarchy must be +/// trusted against rename or replacement; only the immediate parent is +/// validated directly. +/// +/// Once no-clobber publication succeeds, a later inode check, directory sync, +/// or validation error is reported as +/// [`PersistentWalletError::PublishedButUnconfirmed`]. The caller must inspect +/// and reopen the existing target and must never blindly delete or recreate it. +pub struct PersistentRfqWallet { + database: Database, + wallet: RfqWallet, + identity: ProviderIdentity, + operation_lock: Mutex<()>, + poisoned: AtomicBool, +} + +impl PersistentRfqWallet { + /// Create a new wallet database at an absent path using the default KDF. + pub fn create( + path: impl AsRef, + identity: ProviderIdentity, + passphrase: &[u8], + ) -> Result { + Self::create_with_kdf(path, identity, passphrase, DEFAULT_KDF_PARAMS) + } + + /// Create a new wallet using an explicitly selected bounded KDF profile. + pub fn create_with_kdf( + path: impl AsRef, + identity: ProviderIdentity, + passphrase: &[u8], + kdf: KdfParams, + ) -> Result { + let envelope = EncryptedKeystore::generate_with_kdf(identity, passphrase, kdf)?; + Self::create_from_envelope(path.as_ref(), identity, passphrase, &envelope, OsRng) + } + + /// Open an existing wallet. This never creates or initializes a missing or + /// empty database. + pub fn open( + path: impl AsRef, + identity: ProviderIdentity, + passphrase: &[u8], + ) -> Result { + Self::open_with_rng(path.as_ref(), identity, passphrase, OsRng) + } + + /// Restore an authenticated wallet-only backup into an absent path. + /// + /// The caller must reconcile the accompanying provider database and chain + /// state before allowing the restored wallet to quote or sign. Running the + /// restored wallet concurrently with its source clone is unsupported. + pub fn restore( + path: impl AsRef, + identity: ProviderIdentity, + passphrase: &[u8], + backup: &WalletBackup, + ) -> Result { + Self::restore_with_rng(path.as_ref(), identity, passphrase, backup, OsRng) + } +} + +impl PersistentRfqWallet { + fn create_from_envelope( + path: &Path, + identity: ProviderIdentity, + passphrase: &[u8], + envelope: &EncryptedKeystore, + rng: R, + ) -> Result { + let wallet = RfqWallet::with_rng(envelope.unlock(identity, passphrase)?, rng)?; + let entries = Vec::new(); + let checkpoint = catalog_root_checkpoint(&wallet, envelope)?; + let (database, staging, staging_identity) = create_staging_database(path)?; + initialize_database( + &database, + identity, + wallet.wallet_id(), + envelope, + &entries, + checkpoint, + )?; + validate_database(&database, &wallet, identity, envelope)?; + let database = publish_staging_database(database, staging, staging_identity, path)?; + validate_database(&database, &wallet, identity, envelope) + .map_err(PersistentWalletError::published_but_unconfirmed)?; + Ok(Self { + database, + wallet, + identity, + operation_lock: Mutex::new(()), + poisoned: AtomicBool::new(false), + }) + } + + fn open_with_rng( + path: &Path, + identity: ProviderIdentity, + passphrase: &[u8], + rng: R, + ) -> Result { + let database = open_database(path)?; + let envelope = load_envelope(&database, identity)?; + let wallet = RfqWallet::with_rng(envelope.unlock(identity, passphrase)?, rng)?; + validate_database(&database, &wallet, identity, &envelope)?; + Ok(Self { + database, + wallet, + identity, + operation_lock: Mutex::new(()), + poisoned: AtomicBool::new(false), + }) + } + + fn restore_with_rng( + path: &Path, + identity: ProviderIdentity, + passphrase: &[u8], + backup: &WalletBackup, + rng: R, + ) -> Result { + let parsed = parse_backup(backup.as_bytes())?; + if parsed.identity != identity_bytes(identity) { + return Err(PersistentWalletError::IdentityMismatch); + } + let envelope = EncryptedKeystore::from_bytes(parsed.keystore.to_vec())?; + let wallet = RfqWallet::with_rng(envelope.unlock(identity, passphrase)?, rng)?; + if wallet.wallet_id() != parsed.wallet_id { + return Err(PersistentWalletError::WalletBindingMismatch); + } + let entries = validate_backup_catalog(&wallet, &envelope, &parsed)?; + let (database, staging, staging_identity) = create_staging_database(path)?; + initialize_database( + &database, + identity, + wallet.wallet_id(), + &envelope, + &entries, + parsed.checkpoint, + )?; + validate_database(&database, &wallet, identity, &envelope)?; + let database = publish_staging_database(database, staging, staging_identity, path)?; + validate_database(&database, &wallet, identity, &envelope) + .map_err(PersistentWalletError::published_but_unconfirmed)?; + Ok(Self { + database, + wallet, + identity, + operation_lock: Mutex::new(()), + poisoned: AtomicBool::new(false), + }) + } + + #[must_use] + pub const fn identity(&self) -> ProviderIdentity { + self.identity + } + + /// Issue a durable confidential destination for initial or replenishment + /// liquidity supplied by the operator. + pub fn fresh_inventory_destination( + &self, + ) -> Result { + self.issue_destination(IssuancePurpose::InventoryDeposit) + } + + /// Reconstruct one authenticated destination without exporting private key + /// material. + pub fn recover_confidential_destination( + &self, + locator: WalletKeyLocator, + ) -> Result { + let _operation_guard = self.lock_operations()?; + self.ensure_healthy()?; + self.wallet + .recover_confidential_destination(locator) + .map_err(PersistentWalletError::from) + } + + /// Recover a complete wallet-owned output for the future authoritative + /// inventory scanner without exposing its confidential opening. + pub fn recover_owned_output( + &self, + locator: WalletKeyLocator, + outpoint: OutPoint, + txout: TxOut, + ) -> Result { + let _operation_guard = self.lock_operations()?; + self.ensure_healthy()?; + self.wallet + .recover_owned_output(locator, outpoint, txout) + .map_err(PersistentWalletError::from) + } + + /// Read the catalog revision without retaining a database snapshot. A + /// scanner compares this before and after its external chain observation. + pub fn catalog_revision(&self) -> Result { + let _operation_guard = self.lock_operations()?; + self.ensure_healthy()?; + let read = self.database.begin_read()?; + let meta = read.open_table(META)?; + read_metadata_u64(&meta, CATALOG_REVISION_KEY) + } + + /// Read and authenticate one coherent catalog snapshot. + pub fn catalog_snapshot(&self) -> Result { + let _operation_guard = self.lock_operations()?; + self.ensure_healthy()?; + let state = read_and_validate_state(&self.database, &self.wallet, self.identity)?; + Ok(WalletCatalogSnapshot { + revision: state.revision, + checkpoint: state.checkpoint, + locators: state + .entries + .into_iter() + .map(|entry| entry.locator) + .collect(), + }) + } + + /// Export one coherent logical wallet-only snapshot. + pub fn export_backup(&self) -> Result { + let _operation_guard = self.lock_operations()?; + self.ensure_healthy()?; + let state = read_and_validate_state(&self.database, &self.wallet, self.identity)?; + encode_backup(self.identity, self.wallet.wallet_id(), &state) + } + + fn issue_destination( + &self, + purpose: IssuancePurpose, + ) -> Result { + let _operation_guard = self.lock_operations()?; + self.ensure_healthy()?; + for _ in 0..MAX_ISSUANCE_ATTEMPTS { + let destination = match purpose { + IssuancePurpose::InventoryDeposit => { + self.wallet.candidate_inventory_destination()? + } + IssuancePurpose::Settlement(purpose) => { + self.wallet.candidate_settlement_destination(purpose)? + } + }; + #[cfg(test)] + mutation_failpoints::hit(mutation_failpoints::ISSUE_AFTER_DERIVATION)?; + + let locator = destination.wallet_locator(); + let nonce = self.wallet.locator_nonce(locator)?; + let write = self.begin_immediate_write()?; + let (revision, checkpoint) = { + let meta = write.open_table(META)?; + ( + read_metadata_u64(&meta, CATALOG_REVISION_KEY)?, + read_metadata_array::(&meta, CATALOG_CHECKPOINT_KEY)?, + ) + }; + { + let mut catalog = write.open_table(CATALOG)?; + if catalog.get(nonce.as_slice())?.is_some() { + drop(catalog); + drop(write); + continue; + } + if revision >= MAX_WALLET_CATALOG_ENTRIES { + return Err(PersistentWalletError::CatalogFull); + } + let next_revision = revision + .checked_add(1) + .ok_or(PersistentWalletError::CatalogRevisionOverflow)?; + let value = encode_catalog_value(next_revision, locator); + catalog.insert(nonce.as_slice(), value.as_slice())?; + #[cfg(test)] + mutation_failpoints::hit(mutation_failpoints::ISSUE_AFTER_CATALOG_INSERT)?; + drop(catalog); + + let next_checkpoint = + catalog_entry_checkpoint(&self.wallet, checkpoint, next_revision, locator)?; + let mut meta = write.open_table(META)?; + meta.insert(CATALOG_REVISION_KEY, next_revision.to_be_bytes().as_slice())?; + #[cfg(test)] + mutation_failpoints::hit(mutation_failpoints::ISSUE_AFTER_REVISION)?; + meta.insert(CATALOG_CHECKPOINT_KEY, next_checkpoint.as_slice())?; + } + #[cfg(test)] + mutation_failpoints::hit(mutation_failpoints::ISSUE_BEFORE_COMMIT)?; + self.commit_write(write)?; + #[cfg(test)] + mutation_failpoints::hit(mutation_failpoints::ISSUE_AFTER_COMMIT)?; + return Ok(destination); + } + Err(PersistentWalletError::DestinationEntropyExhausted) + } + + fn lock_operations(&self) -> Result, PersistentWalletError> { + self.operation_lock + .lock() + .map_err(|_| PersistentWalletError::OperationLockPoisoned) + } + + fn ensure_healthy(&self) -> Result<(), PersistentWalletError> { + if self.poisoned.load(Ordering::Acquire) { + return Err(PersistentWalletError::Poisoned); + } + Ok(()) + } + + fn begin_immediate_write(&self) -> Result { + self.ensure_healthy()?; + let mut write = self.database.begin_write()?; + write.set_durability(Durability::Immediate)?; + Ok(write) + } + + fn commit_write(&self, write: WriteTransaction) -> Result<(), PersistentWalletError> { + match write.commit() { + Ok(()) => { + #[cfg(test)] + if let Err(error) = + mutation_failpoints::hit(mutation_failpoints::ISSUE_COMMIT_AMBIGUOUS) + { + // Model a backend that reports failure after the commit's + // durability outcome can no longer be distinguished. The + // live handle must remain unusable until a clean reopen. + self.poisoned.store(true, Ordering::Release); + return Err(error); + } + Ok(()) + } + Err(error) => { + self.poisoned.store(true, Ordering::Release); + Err(PersistentWalletError::Commit(error)) + } + } + } +} + +impl fmt::Debug for PersistentRfqWallet { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PersistentRfqWallet") + .field("identity", &self.identity) + .field("wallet", &"[unlocked and redacted]") + .field("catalog", &"[durable and opaque]") + .finish_non_exhaustive() + } +} + +impl DestinationSource for PersistentRfqWallet { + type Error = PersistentWalletError; + + fn fresh_confidential_destination( + &self, + purpose: DestinationPurpose, + ) -> Result { + self.issue_destination(IssuancePurpose::Settlement(purpose)) + } +} + +impl ProviderOutputRecovery for PersistentRfqWallet { + type Error = PersistentWalletError; + + fn validate_confidential_output( + &self, + wallet_locator: WalletKeyLocator, + expected_internal_key: elements::secp256k1_zkp::XOnlyPublicKey, + txout: &TxOut, + expected_asset: AssetId, + expected_amount: u64, + ) -> Result<(), Self::Error> { + let _operation_guard = self.lock_operations()?; + self.ensure_healthy()?; + self.wallet + .validate_confidential_output( + wallet_locator, + expected_internal_key, + txout, + expected_asset, + expected_amount, + ) + .map_err(PersistentWalletError::from) + } +} + +impl ProviderSigner for PersistentRfqWallet { + type Error = PersistentWalletError; + + fn sign(&self, job: &SigningJob) -> Result { + let _operation_guard = self.lock_operations()?; + self.ensure_healthy()?; + // A durable provider job may legitimately contain a later authenticated + // locator that is absent from a stale wallet-only backup. Locator MAC + // validation still binds every target to this exact seed, identity, + // and wallet id; catalog membership is an issuance/scanning concern. + self.wallet.sign(job).map_err(PersistentWalletError::from) + } +} + +#[derive(Clone, Copy)] +enum IssuancePurpose { + InventoryDeposit, + Settlement(DestinationPurpose), +} + +#[derive(Clone, Copy)] +struct CatalogEntry { + revision: u64, + locator: WalletKeyLocator, +} + +struct StoredState { + envelope: EncryptedKeystore, + revision: u64, + checkpoint: [u8; CHECKPOINT_BYTES], + entries: Vec, +} + +fn initialize_database( + database: &Database, + identity: ProviderIdentity, + wallet_id: [u8; 16], + envelope: &EncryptedKeystore, + entries: &[CatalogEntry], + checkpoint: [u8; CHECKPOINT_BYTES], +) -> Result<(), PersistentWalletError> { + if entries.len() as u64 > MAX_WALLET_CATALOG_ENTRIES { + return Err(PersistentWalletError::CatalogFull); + } + let mut write = database.begin_write()?; + write.set_durability(Durability::Immediate)?; + { + let mut meta = write.open_table(META)?; + if !meta.is_empty()? { + return Err(PersistentWalletError::NonemptyNewDatabase); + } + meta.insert(SCHEMA_VERSION_KEY, SCHEMA_VERSION.to_be_bytes().as_slice())?; + meta.insert(PROVIDER_IDENTITY_KEY, identity_bytes(identity).as_slice())?; + meta.insert(WALLET_ID_KEY, wallet_id.as_slice())?; + meta.insert(ENCRYPTED_KEYSTORE_KEY, envelope.as_bytes())?; + meta.insert(KEYSTORE_DIGEST_KEY, keystore_digest(envelope).as_slice())?; + meta.insert( + CATALOG_REVISION_KEY, + (entries.len() as u64).to_be_bytes().as_slice(), + )?; + meta.insert(CATALOG_CHECKPOINT_KEY, checkpoint.as_slice())?; + } + { + let mut catalog = write.open_table(CATALOG)?; + if !catalog.is_empty()? { + return Err(PersistentWalletError::NonemptyNewDatabase); + } + for entry in entries { + let locator_bytes = entry.locator.to_bytes(); + let nonce = &locator_bytes[2..2 + CATALOG_NONCE_BYTES]; + let value = encode_catalog_value(entry.revision, entry.locator); + if catalog.insert(nonce, value.as_slice())?.is_some() { + return Err(PersistentWalletError::DuplicateCatalogNonce); + } + } + } + write.commit()?; + Ok(()) +} + +fn load_envelope( + database: &Database, + expected_identity: ProviderIdentity, +) -> Result { + let read = database.begin_read()?; + let meta = read.open_table(META)?; + if meta.len()? != META_ENTRY_COUNT { + return Err(PersistentWalletError::CorruptMetadata); + } + let schema = read_metadata_u32(&meta, SCHEMA_VERSION_KEY)?; + if schema != SCHEMA_VERSION { + return Err(PersistentWalletError::UnsupportedSchemaVersion(schema)); + } + let identity = read_metadata_array::<96>(&meta, PROVIDER_IDENTITY_KEY)?; + if identity != identity_bytes(expected_identity) { + return Err(PersistentWalletError::IdentityMismatch); + } + let bytes = read_metadata_vec(&meta, ENCRYPTED_KEYSTORE_KEY)?; + let envelope = EncryptedKeystore::from_bytes(bytes)?; + let expected_digest = read_metadata_array::<32>(&meta, KEYSTORE_DIGEST_KEY)?; + if !bool::from(expected_digest.ct_eq(&keystore_digest(&envelope))) { + return Err(PersistentWalletError::WalletBindingMismatch); + } + Ok(envelope) +} + +fn validate_database( + database: &Database, + wallet: &RfqWallet, + identity: ProviderIdentity, + envelope: &EncryptedKeystore, +) -> Result<(), PersistentWalletError> { + let state = read_and_validate_state(database, wallet, identity)?; + if state.envelope != *envelope { + return Err(PersistentWalletError::WalletBindingMismatch); + } + Ok(()) +} + +fn read_and_validate_state( + database: &Database, + wallet: &RfqWallet, + expected_identity: ProviderIdentity, +) -> Result { + let read = database.begin_read()?; + let meta = read.open_table(META)?; + if meta.len()? != META_ENTRY_COUNT { + return Err(PersistentWalletError::CorruptMetadata); + } + let schema = read_metadata_u32(&meta, SCHEMA_VERSION_KEY)?; + if schema != SCHEMA_VERSION { + return Err(PersistentWalletError::UnsupportedSchemaVersion(schema)); + } + if read_metadata_array::<96>(&meta, PROVIDER_IDENTITY_KEY)? != identity_bytes(expected_identity) + { + return Err(PersistentWalletError::IdentityMismatch); + } + if read_metadata_array::<16>(&meta, WALLET_ID_KEY)? != wallet.wallet_id() { + return Err(PersistentWalletError::WalletBindingMismatch); + } + let envelope = + EncryptedKeystore::from_bytes(read_metadata_vec(&meta, ENCRYPTED_KEYSTORE_KEY)?)?; + if !bool::from( + read_metadata_array::<32>(&meta, KEYSTORE_DIGEST_KEY)?.ct_eq(&keystore_digest(&envelope)), + ) { + return Err(PersistentWalletError::WalletBindingMismatch); + } + let revision = read_metadata_u64(&meta, CATALOG_REVISION_KEY)?; + if revision > MAX_WALLET_CATALOG_ENTRIES { + return Err(PersistentWalletError::CatalogFull); + } + let checkpoint = read_metadata_array::(&meta, CATALOG_CHECKPOINT_KEY)?; + drop(meta); + + let catalog = read.open_table(CATALOG)?; + if catalog.len()? != revision { + return Err(PersistentWalletError::CatalogRevisionMismatch); + } + let mut entries = Vec::with_capacity( + usize::try_from(revision).map_err(|_| PersistentWalletError::CatalogFull)?, + ); + let mut revisions = BTreeSet::new(); + for row in catalog.iter()? { + let (key, value) = row?; + let entry = decode_catalog_entry(key.value(), value.value())?; + wallet.validate_locator(entry.locator)?; + if wallet.locator_nonce(entry.locator)?.as_slice() != key.value() { + return Err(PersistentWalletError::CatalogNonceMismatch); + } + if !revisions.insert(entry.revision) { + return Err(PersistentWalletError::DuplicateCatalogRevision); + } + entries.push(entry); + } + entries.sort_by_key(|entry| entry.revision); + validate_contiguous_entries(&entries, revision)?; + let actual_checkpoint = recompute_catalog_checkpoint(wallet, &envelope, &entries)?; + if !bool::from(actual_checkpoint.ct_eq(&checkpoint)) { + return Err(PersistentWalletError::CatalogCheckpointMismatch); + } + Ok(StoredState { + envelope, + revision, + checkpoint, + entries, + }) +} + +fn validate_contiguous_entries( + entries: &[CatalogEntry], + revision: u64, +) -> Result<(), PersistentWalletError> { + if entries.len() as u64 != revision { + return Err(PersistentWalletError::CatalogRevisionMismatch); + } + for (index, entry) in entries.iter().enumerate() { + if entry.revision != index as u64 + 1 { + return Err(PersistentWalletError::CatalogRevisionMismatch); + } + } + Ok(()) +} + +fn catalog_root_checkpoint( + wallet: &RfqWallet, + envelope: &EncryptedKeystore, +) -> Result<[u8; CHECKPOINT_BYTES], PersistentWalletError> { + let mut payload = Vec::with_capacity(CATALOG_ROOT_DOMAIN.len() + 4 + 96 + 16 + 32); + payload.extend_from_slice(CATALOG_ROOT_DOMAIN); + payload.extend_from_slice(&CATALOG_CHECKPOINT_VERSION.to_be_bytes()); + payload.extend_from_slice(&identity_bytes(wallet.identity())); + payload.extend_from_slice(&wallet.wallet_id()); + payload.extend_from_slice(&keystore_digest(envelope)); + wallet + .backup_authentication_tag(&payload) + .map_err(PersistentWalletError::from) +} + +fn catalog_entry_checkpoint( + wallet: &RfqWallet, + previous: [u8; CHECKPOINT_BYTES], + revision: u64, + locator: WalletKeyLocator, +) -> Result<[u8; CHECKPOINT_BYTES], PersistentWalletError> { + let mut payload = Vec::with_capacity(CATALOG_ENTRY_DOMAIN.len() + 32 + 8 + 32); + payload.extend_from_slice(CATALOG_ENTRY_DOMAIN); + payload.extend_from_slice(&previous); + payload.extend_from_slice(&revision.to_be_bytes()); + payload.extend_from_slice(&locator.to_bytes()); + wallet + .backup_authentication_tag(&payload) + .map_err(PersistentWalletError::from) +} + +fn recompute_catalog_checkpoint( + wallet: &RfqWallet, + envelope: &EncryptedKeystore, + entries: &[CatalogEntry], +) -> Result<[u8; CHECKPOINT_BYTES], PersistentWalletError> { + let mut checkpoint = catalog_root_checkpoint(wallet, envelope)?; + for entry in entries { + checkpoint = catalog_entry_checkpoint(wallet, checkpoint, entry.revision, entry.locator)?; + } + Ok(checkpoint) +} + +fn encode_catalog_value(revision: u64, locator: WalletKeyLocator) -> [u8; CATALOG_VALUE_BYTES] { + let mut value = [0_u8; CATALOG_VALUE_BYTES]; + value[..8].copy_from_slice(&revision.to_be_bytes()); + value[8..].copy_from_slice(&locator.to_bytes()); + value +} + +fn decode_catalog_entry(key: &[u8], value: &[u8]) -> Result { + if key.len() != CATALOG_NONCE_BYTES || value.len() != CATALOG_VALUE_BYTES { + return Err(PersistentWalletError::CorruptCatalogEntry); + } + let locator_bytes: [u8; 32] = value[8..] + .try_into() + .map_err(|_| PersistentWalletError::CorruptCatalogEntry)?; + Ok(CatalogEntry { + revision: read_u64(value, 0), + locator: WalletKeyLocator::new(locator_bytes) + .map_err(|_| PersistentWalletError::CorruptCatalogEntry)?, + }) +} + +fn encode_backup( + identity: ProviderIdentity, + wallet_id: [u8; 16], + state: &StoredState, +) -> Result { + let count = + u32::try_from(state.entries.len()).map_err(|_| PersistentWalletError::BackupTooLarge)?; + let keystore_len = u32::try_from(state.envelope.as_bytes().len()) + .map_err(|_| PersistentWalletError::BackupTooLarge)?; + let total_len = BACKUP_HEADER_BYTES + .checked_add(state.envelope.as_bytes().len()) + .and_then(|value| value.checked_add(state.entries.len().checked_mul(32)?)) + .and_then(|value| value.checked_add(BACKUP_TRAILER_BYTES)) + .ok_or(PersistentWalletError::BackupTooLarge)?; + let total_len_u32 = + u32::try_from(total_len).map_err(|_| PersistentWalletError::BackupTooLarge)?; + let mut bytes = Vec::with_capacity(total_len); + bytes.extend_from_slice(BACKUP_MAGIC); + bytes.extend_from_slice(&BACKUP_VERSION.to_be_bytes()); + bytes.extend_from_slice(&BACKUP_FLAGS.to_be_bytes()); + bytes.extend_from_slice(&total_len_u32.to_be_bytes()); + bytes.extend_from_slice(&identity_bytes(identity)); + bytes.extend_from_slice(&wallet_id); + bytes.extend_from_slice(&state.revision.to_be_bytes()); + bytes.extend_from_slice(&keystore_len.to_be_bytes()); + bytes.extend_from_slice(&count.to_be_bytes()); + bytes.extend_from_slice(state.envelope.as_bytes()); + for entry in &state.entries { + bytes.extend_from_slice(&entry.locator.to_bytes()); + } + bytes.extend_from_slice(&state.checkpoint); + debug_assert_eq!(bytes.len(), total_len); + WalletBackup::from_bytes(bytes) +} + +struct ParsedBackup<'a> { + identity: [u8; 96], + wallet_id: [u8; 16], + revision: u64, + keystore: &'a [u8], + locators: &'a [u8], + checkpoint: [u8; CHECKPOINT_BYTES], +} + +fn parse_backup(bytes: &[u8]) -> Result, PersistentWalletError> { + if bytes.len() < BACKUP_HEADER_BYTES + BACKUP_TRAILER_BYTES || &bytes[..8] != BACKUP_MAGIC { + return Err(PersistentWalletError::InvalidBackup); + } + let version = read_u16(bytes, 8); + if version != BACKUP_VERSION { + return Err(PersistentWalletError::UnsupportedBackupVersion(version)); + } + if read_u16(bytes, 10) != BACKUP_FLAGS { + return Err(PersistentWalletError::UnsupportedBackupFlags); + } + let declared_len = read_u32(bytes, 12) as usize; + if declared_len != bytes.len() { + return Err(PersistentWalletError::InvalidBackup); + } + let identity = bytes[16..112] + .try_into() + .map_err(|_| PersistentWalletError::InvalidBackup)?; + let wallet_id = bytes[112..128] + .try_into() + .map_err(|_| PersistentWalletError::InvalidBackup)?; + if wallet_id == [0; 16] { + return Err(PersistentWalletError::InvalidBackup); + } + let revision = read_u64(bytes, 128); + let keystore_len = read_u32(bytes, 136) as usize; + let locator_count = read_u32(bytes, 140) as u64; + if revision != locator_count + || revision > MAX_WALLET_CATALOG_ENTRIES + || keystore_len == 0 + || keystore_len > MAX_BACKUP_KEYSTORE_BYTES + { + return Err(PersistentWalletError::InvalidBackup); + } + let locator_bytes = usize::try_from(locator_count) + .ok() + .and_then(|count| count.checked_mul(32)) + .ok_or(PersistentWalletError::BackupTooLarge)?; + let expected_len = BACKUP_HEADER_BYTES + .checked_add(keystore_len) + .and_then(|value| value.checked_add(locator_bytes)) + .and_then(|value| value.checked_add(BACKUP_TRAILER_BYTES)) + .ok_or(PersistentWalletError::BackupTooLarge)?; + if expected_len != bytes.len() { + return Err(PersistentWalletError::InvalidBackup); + } + let keystore_end = BACKUP_HEADER_BYTES + keystore_len; + let locators_end = keystore_end + locator_bytes; + let keystore = &bytes[BACKUP_HEADER_BYTES..keystore_end]; + EncryptedKeystore::from_bytes(keystore.to_vec())?; + let checkpoint = bytes[locators_end..] + .try_into() + .map_err(|_| PersistentWalletError::InvalidBackup)?; + Ok(ParsedBackup { + identity, + wallet_id, + revision, + keystore, + locators: &bytes[keystore_end..locators_end], + checkpoint, + }) +} + +fn validate_backup_catalog( + wallet: &RfqWallet, + envelope: &EncryptedKeystore, + backup: &ParsedBackup<'_>, +) -> Result, PersistentWalletError> { + let mut entries = Vec::with_capacity( + usize::try_from(backup.revision).map_err(|_| PersistentWalletError::BackupTooLarge)?, + ); + let mut nonces = BTreeSet::new(); + for (index, bytes) in backup.locators.chunks_exact(32).enumerate() { + let locator = WalletKeyLocator::new( + bytes + .try_into() + .map_err(|_| PersistentWalletError::InvalidBackup)?, + ) + .map_err(|_| PersistentWalletError::InvalidBackup)?; + wallet.validate_locator(locator)?; + if !nonces.insert(wallet.locator_nonce(locator)?) { + return Err(PersistentWalletError::DuplicateCatalogNonce); + } + entries.push(CatalogEntry { + revision: index as u64 + 1, + locator, + }); + } + let actual = recompute_catalog_checkpoint(wallet, envelope, &entries)?; + if !bool::from(actual.ct_eq(&backup.checkpoint)) { + return Err(PersistentWalletError::CatalogCheckpointMismatch); + } + Ok(entries) +} + +fn keystore_digest(envelope: &EncryptedKeystore) -> [u8; 32] { + Sha256::digest(envelope.as_bytes()).into() +} + +fn identity_bytes(identity: ProviderIdentity) -> [u8; 96] { + use elements::hashes::Hash as _; + + let mut bytes = [0_u8; 96]; + bytes[..32].copy_from_slice(&identity.provider().to_bytes()); + bytes[32..64].copy_from_slice(&identity.genesis_hash().to_byte_array()); + bytes[64..].copy_from_slice(&identity.policy_asset().into_inner().to_byte_array()); + bytes +} + +fn read_metadata_vec( + table: &impl ReadableTable<&'static str, &'static [u8]>, + key: &'static str, +) -> Result, PersistentWalletError> { + table + .get(key)? + .map(|value| value.value().to_vec()) + .ok_or(PersistentWalletError::MissingMetadata(key)) +} + +fn read_metadata_array( + table: &impl ReadableTable<&'static str, &'static [u8]>, + key: &'static str, +) -> Result<[u8; N], PersistentWalletError> { + let bytes = table + .get(key)? + .ok_or(PersistentWalletError::MissingMetadata(key))?; + bytes + .value() + .try_into() + .map_err(|_| PersistentWalletError::CorruptMetadata) +} + +fn read_metadata_u32( + table: &impl ReadableTable<&'static str, &'static [u8]>, + key: &'static str, +) -> Result { + read_metadata_array::<4>(table, key).map(u32::from_be_bytes) +} + +fn read_metadata_u64( + table: &impl ReadableTable<&'static str, &'static [u8]>, + key: &'static str, +) -> Result { + read_metadata_array::<8>(table, key).map(u64::from_be_bytes) +} + +fn read_u16(bytes: &[u8], offset: usize) -> u16 { + u16::from_be_bytes( + bytes[offset..offset + 2] + .try_into() + .expect("validated fixed backup offset"), + ) +} + +fn read_u32(bytes: &[u8], offset: usize) -> u32 { + u32::from_be_bytes( + bytes[offset..offset + 4] + .try_into() + .expect("validated fixed backup offset"), + ) +} + +fn read_u64(bytes: &[u8], offset: usize) -> u64 { + u64::from_be_bytes( + bytes[offset..offset + 8] + .try_into() + .expect("validated fixed backup offset"), + ) +} + +fn create_staging_database( + target: &Path, +) -> Result<(Database, TempPath, StagingFileIdentity), PersistentWalletError> { + ensure_target_absent(target)?; + validate_parent_directory(target)?; + let parent = target + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let temporary = tempfile::Builder::new() + .prefix(".deadcat-rfq-wallet-") + .tempfile_in(parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + temporary + .as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + } + validate_open_file(temporary.as_file())?; + let identity = StagingFileIdentity::from_file(temporary.as_file())?; + let (file, path) = temporary.into_parts(); + let database = database_from_file(file)?; + Ok((database, path, identity)) +} + +fn publish_staging_database( + database: Database, + staging: TempPath, + staging_identity: StagingFileIdentity, + target: &Path, +) -> Result { + #[cfg(test)] + mutation_failpoints::hit(mutation_failpoints::PUBLISH_BEFORE_LINK)?; + staging + .persist_noclobber(target) + .map_err(|error| PersistentWalletError::Io(error.error))?; + let confirmation = || -> Result<(), PersistentWalletError> { + staging_identity.verify_target(target)?; + #[cfg(test)] + mutation_failpoints::hit(mutation_failpoints::PUBLISH_AFTER_LINK)?; + sync_parent_directory(target) + }; + confirmation().map_err(PersistentWalletError::published_but_unconfirmed)?; + Ok(database) +} + +fn ensure_target_absent(path: &Path) -> Result<(), PersistentWalletError> { + match fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + Ok(_) => Err(PersistentWalletError::TargetAlreadyExists), + } +} + +fn parent_directory(path: &Path) -> &Path { + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) +} + +fn validate_parent_directory(path: &Path) -> Result<(), PersistentWalletError> { + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + let parent = parent_directory(path); + let metadata = fs::symlink_metadata(parent)?; + if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() { + return Err(PersistentWalletError::UnsupportedParentDirectory); + } + let expected_owner = rustix::process::geteuid().as_raw(); + if metadata.uid() != expected_owner { + return Err(PersistentWalletError::ParentOwnerMismatch { + expected: expected_owner, + actual: metadata.uid(), + }); + } + let mode = metadata.permissions().mode() & 0o777; + if mode & 0o022 != 0 { + return Err(PersistentWalletError::InsecureParentPermissions(mode)); + } + Ok(()) + } + #[cfg(not(unix))] + { + let _ = path; + Err(PersistentWalletError::UnsupportedPlatform) + } +} + +#[derive(Clone, Copy)] +struct StagingFileIdentity { + #[cfg(unix)] + device: u64, + #[cfg(unix)] + inode: u64, +} + +impl StagingFileIdentity { + fn from_file(file: &File) -> Result { + Self::from_metadata(&file.metadata()?) + } + + fn from_metadata(metadata: &fs::Metadata) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + + if !metadata.file_type().is_file() { + return Err(PersistentWalletError::UnsupportedFileType); + } + Ok(Self { + device: metadata.dev(), + inode: metadata.ino(), + }) + } + #[cfg(not(unix))] + { + let _ = metadata; + Err(PersistentWalletError::UnsupportedPlatform) + } + } + + fn verify_target(self, target: &Path) -> Result<(), PersistentWalletError> { + let metadata = fs::symlink_metadata(target)?; + if metadata.file_type().is_symlink() || !metadata.file_type().is_file() { + return Err(PersistentWalletError::PublishedFileMismatch); + } + let actual = Self::from_metadata(&metadata)?; + if actual.matches(self) { + Ok(()) + } else { + Err(PersistentWalletError::PublishedFileMismatch) + } + } + + fn verify_file(self, file: &File) -> Result<(), PersistentWalletError> { + let actual = Self::from_file(file)?; + if actual.matches(self) { + Ok(()) + } else { + Err(PersistentWalletError::PublishedFileMismatch) + } + } + + fn matches(self, other: Self) -> bool { + #[cfg(unix)] + { + self.device == other.device && self.inode == other.inode + } + #[cfg(not(unix))] + { + let _ = (self, other); + false + } + } +} + +fn open_database(path: &Path) -> Result { + let file = secure_open_existing(path)?; + if file.metadata()?.len() == 0 { + return Err(PersistentWalletError::EmptyDatabase); + } + database_from_file(file).map_err(Into::into) +} + +fn database_from_file(file: File) -> Result { + // redb deliberately continues when the backing filesystem reports that + // file locks are unsupported. Probe and release one lock first so that + // redb's immediately following acquisition is known to be supported. If + // another process wins the tiny unlocked interval, redb fails with + // DatabaseAlreadyOpen rather than admitting two writers. + match file.try_lock() { + Ok(()) => file.unlock()?, + Err(TryLockError::WouldBlock) => return Err(DatabaseError::DatabaseAlreadyOpen), + Err(TryLockError::Error(error)) => return Err(error.into()), + } + let mut builder = Database::builder(); + builder.set_cache_size(DATABASE_CACHE_BYTES); + builder.create_file(file) +} + +fn secure_open_existing(path: &Path) -> Result { + validate_parent_directory(path)?; + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.file_type().is_file() { + return Err(PersistentWalletError::UnsupportedFileType); + } + let expected_identity = StagingFileIdentity::from_metadata(&metadata)?; + let mut options = OpenOptions::new(); + options.read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + let file = options.open(path)?; + validate_open_file(&file)?; + expected_identity.verify_file(&file)?; + Ok(file) +} + +fn validate_open_file(file: &File) -> Result<(), PersistentWalletError> { + let metadata = file.metadata()?; + if !metadata.file_type().is_file() { + return Err(PersistentWalletError::UnsupportedFileType); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let expected_owner = rustix::process::geteuid().as_raw(); + if metadata.uid() != expected_owner { + return Err(PersistentWalletError::FileOwnerMismatch { + expected: expected_owner, + actual: metadata.uid(), + }); + } + let mode = metadata.permissions().mode() & 0o777; + if mode != 0o600 { + return Err(PersistentWalletError::InsecurePermissions(mode)); + } + } + Ok(()) +} + +fn sync_parent_directory(path: &Path) -> Result<(), PersistentWalletError> { + #[cfg(unix)] + { + let parent = path.parent().filter(|path| !path.as_os_str().is_empty()); + let directory = File::open(parent.unwrap_or_else(|| Path::new(".")))?; + directory.sync_all()?; + } + Ok(()) +} + +#[derive(Debug, Error)] +pub enum PersistentWalletError { + #[error("wallet path already exists, is missing, or could not be accessed: {0}")] + Io(#[from] std::io::Error), + #[error("wallet database error: {0}")] + Database(#[from] DatabaseError), + #[error("wallet transaction error: {0}")] + Transaction(#[from] TransactionError), + #[error("wallet table error: {0}")] + Table(#[from] TableError), + #[error("wallet storage error: {0}")] + Storage(#[from] StorageError), + #[error("wallet commit error: {0}")] + Commit(#[from] CommitError), + #[error("wallet durability configuration error: {0}")] + Durability(#[from] SetDurabilityError), + #[error(transparent)] + Keystore(#[from] KeystoreError), + #[error(transparent)] + Wallet(#[from] RfqWalletError), + #[error("wallet database is empty")] + EmptyDatabase, + #[error("wallet target already exists")] + TargetAlreadyExists, + #[error("wallet path is not a regular file")] + UnsupportedFileType, + #[error("wallet parent path is not a real directory")] + UnsupportedParentDirectory, + #[error("durable RFQ wallet storage is not supported on this platform")] + UnsupportedPlatform, + #[error("wallet file permissions are insecure: {0:#o}")] + InsecurePermissions(u32), + #[error("wallet file owner is {actual}, expected effective user {expected}")] + FileOwnerMismatch { expected: u32, actual: u32 }, + #[error("wallet parent-directory permissions are insecure: {0:#o}")] + InsecureParentPermissions(u32), + #[error("wallet parent directory owner is {actual}, expected effective user {expected}")] + ParentOwnerMismatch { expected: u32, actual: u32 }, + #[error("published wallet path does not name the database file that was created")] + PublishedFileMismatch, + #[error( + "wallet publication reached the target but final confirmation failed; inspect and reopen the existing target instead of deleting it: {source}" + )] + PublishedButUnconfirmed { + #[source] + source: Box, + }, + #[error("wallet database identity does not match the expected provider or chain")] + IdentityMismatch, + #[error("wallet database keystore, wallet id, or catalog binding does not match")] + WalletBindingMismatch, + #[error("wallet database metadata is corrupt")] + CorruptMetadata, + #[error("wallet database is missing metadata key {0}")] + MissingMetadata(&'static str), + #[error("wallet schema version {0} is unsupported")] + UnsupportedSchemaVersion(u32), + #[error("new wallet database unexpectedly contains state")] + NonemptyNewDatabase, + #[error("wallet catalog has reached its supported entry bound")] + CatalogFull, + #[error("wallet catalog revision overflowed")] + CatalogRevisionOverflow, + #[error("wallet catalog revision and entry count differ")] + CatalogRevisionMismatch, + #[error("wallet catalog contains a malformed entry")] + CorruptCatalogEntry, + #[error("wallet catalog contains a duplicate random nonce")] + DuplicateCatalogNonce, + #[error("wallet catalog contains duplicate issuance revisions")] + DuplicateCatalogRevision, + #[error("wallet catalog nonce does not match its authenticated locator")] + CatalogNonceMismatch, + #[error("wallet catalog authentication checkpoint does not match")] + CatalogCheckpointMismatch, + #[error("wallet destination entropy was exhausted by repeated catalog collisions")] + DestinationEntropyExhausted, + #[error("wallet operation lock is poisoned")] + OperationLockPoisoned, + #[error( + "wallet is poisoned after an ambiguous durable commit failure; reopen it before using wallet capabilities" + )] + Poisoned, + #[error("wallet backup is malformed")] + InvalidBackup, + #[error("wallet backup format version {0} is unsupported")] + UnsupportedBackupVersion(u16), + #[error("wallet backup flags are unsupported")] + UnsupportedBackupFlags, + #[error("wallet backup exceeds supported bounds")] + BackupTooLarge, + #[cfg(test)] + #[error("injected wallet mutation failure at {0}")] + InjectedMutationFailure(&'static str), +} + +impl PersistentWalletError { + fn published_but_unconfirmed(source: Self) -> Self { + Self::PublishedButUnconfirmed { + source: Box::new(source), + } + } +} + +#[cfg(test)] +mod mutation_failpoints { + use std::cell::RefCell; + + use super::PersistentWalletError; + + pub(super) const ISSUE_AFTER_DERIVATION: &str = "issue.after_derivation"; + pub(super) const ISSUE_AFTER_CATALOG_INSERT: &str = "issue.after_catalog_insert"; + pub(super) const ISSUE_AFTER_REVISION: &str = "issue.after_revision"; + pub(super) const ISSUE_BEFORE_COMMIT: &str = "issue.before_commit"; + pub(super) const ISSUE_COMMIT_AMBIGUOUS: &str = "issue.commit_ambiguous"; + pub(super) const ISSUE_AFTER_COMMIT: &str = "issue.after_commit"; + pub(super) const PUBLISH_BEFORE_LINK: &str = "publish.before_link"; + pub(super) const PUBLISH_AFTER_LINK: &str = "publish.after_link"; + + thread_local! { + static ACTIVE: RefCell> = const { RefCell::new(None) }; + } + + pub(super) struct Guard; + + pub(super) fn arm(name: &'static str) -> Guard { + ACTIVE.with(|active| { + assert!( + active.borrow().is_none(), + "a wallet failpoint is already armed" + ); + *active.borrow_mut() = Some(name); + }); + Guard + } + + pub(super) fn hit(name: &'static str) -> Result<(), PersistentWalletError> { + ACTIVE.with(|active| { + if active.borrow().as_ref() == Some(&name) { + *active.borrow_mut() = None; + return Err(PersistentWalletError::InjectedMutationFailure(name)); + } + Ok(()) + }) + } + + impl Drop for Guard { + fn drop(&mut self) { + ACTIVE.with(|active| *active.borrow_mut() = None); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/deadcat-rfq-wallet/src/persistent/tests.rs b/crates/deadcat-rfq-wallet/src/persistent/tests.rs new file mode 100644 index 0000000..27913fa --- /dev/null +++ b/crates/deadcat-rfq-wallet/src/persistent/tests.rs @@ -0,0 +1,1356 @@ +use std::collections::{BTreeSet, VecDeque}; +use std::fs; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Barrier}; +use std::thread; + +use deadcat_rfq_provider::{ + DestinationPurpose, DestinationSource as _, ProviderId, ProviderIdentity, +}; +use elements::hashes::Hash as _; +use elements::{AssetId, BlockHash}; +use rand::rngs::StdRng; +use rand::{CryptoRng, Error as RandError, RngCore, SeedableRng as _}; +use sha2::{Digest as _, Sha256}; +use tempfile::TempDir; + +use super::*; + +const PASSPHRASE: &[u8] = b"durable-wallet-test-only passphrase"; + +fn test_kdf() -> KdfParams { + // Keep the persistence matrix fast while still exercising the real + // Argon2id envelope on every create/open/restore boundary. + KdfParams::new(8 * 1_024, 1, 1).expect("test KDF") +} + +fn identity(marker: u8) -> ProviderIdentity { + ProviderIdentity::new( + ProviderId::new([marker; 32]), + BlockHash::from_byte_array([marker.wrapping_add(1); 32]), + AssetId::from_byte_array([marker.wrapping_add(2); 32]), + ) +} + +fn envelope(identity: ProviderIdentity) -> EncryptedKeystore { + EncryptedKeystore::generate_with_kdf(identity, PASSPHRASE, test_kdf()).expect("keystore") +} + +fn create_seeded( + path: &Path, + identity: ProviderIdentity, + seed: u64, +) -> PersistentRfqWallet { + PersistentRfqWallet::create_from_envelope( + path, + identity, + PASSPHRASE, + &envelope(identity), + StdRng::seed_from_u64(seed), + ) + .expect("create persistent wallet") +} + +fn open_seeded(path: &Path, identity: ProviderIdentity, seed: u64) -> PersistentRfqWallet { + PersistentRfqWallet::open_with_rng(path, identity, PASSPHRASE, StdRng::seed_from_u64(seed)) + .expect("open persistent wallet") +} + +fn issue_settlement( + wallet: &impl DestinationSource, + purpose: DestinationPurpose, +) -> ConfidentialDestination { + wallet + .fresh_confidential_destination(purpose) + .expect("issue settlement destination") +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[derive(Default)] +struct ScriptedRng { + bytes: VecDeque, +} + +impl ScriptedRng { + fn from_bytes(bytes: impl IntoIterator) -> Self { + Self { + bytes: bytes.into_iter().collect(), + } + } + + fn from_nonces(nonces: impl IntoIterator) -> Self { + Self::from_bytes(nonces.into_iter().flatten()) + } +} + +impl RngCore for ScriptedRng { + fn next_u32(&mut self) -> u32 { + let mut bytes = [0; 4]; + self.fill_bytes(&mut bytes); + u32::from_le_bytes(bytes) + } + + fn next_u64(&mut self) -> u64 { + let mut bytes = [0; 8]; + self.fill_bytes(&mut bytes); + u64::from_le_bytes(bytes) + } + + fn fill_bytes(&mut self, destination: &mut [u8]) { + for byte in destination { + *byte = self + .bytes + .pop_front() + .expect("scripted wallet RNG exhausted"); + } + } + + fn try_fill_bytes(&mut self, destination: &mut [u8]) -> Result<(), RandError> { + self.fill_bytes(destination); + Ok(()) + } +} + +impl CryptoRng for ScriptedRng {} + +#[test] +fn create_issue_every_purpose_and_reopen_exact_catalog() { + let directory = TempDir::new().expect("tempdir"); + let path = directory.path().join("wallet.redb"); + let identity = identity(1); + let wallet = create_seeded(&path, identity, 1); + + let inventory = wallet + .fresh_inventory_destination() + .expect("inventory destination"); + let receive = issue_settlement(&wallet, DestinationPurpose::SettlementReceive); + let change = issue_settlement(&wallet, DestinationPurpose::SettlementChange); + let expected_destinations = [inventory, receive, change]; + let expected_locators: Vec<_> = expected_destinations + .iter() + .map(ConfidentialDestination::wallet_locator) + .collect(); + let before = wallet.catalog_snapshot().expect("catalog snapshot"); + + assert_eq!(before.revision(), 3); + assert_eq!(before.locators(), expected_locators); + assert_eq!( + expected_locators + .iter() + .map(|locator| locator.to_bytes()[1]) + .collect::>(), + [0, 1, 2] + ); + drop(wallet); + + let reopened = open_seeded(&path, identity, 2); + assert_eq!(reopened.catalog_revision().expect("revision"), 3); + assert_eq!( + reopened.catalog_snapshot().expect("reopened snapshot"), + before + ); + for expected in expected_destinations { + assert_eq!( + reopened + .recover_confidential_destination(expected.wallet_locator()) + .expect("recover destination"), + expected + ); + } +} + +#[test] +fn staged_creation_publishes_only_a_complete_wallet() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(17); + let envelope = envelope(identity); + + let before_link = directory.path().join("before-link.redb"); + let guard = mutation_failpoints::arm(mutation_failpoints::PUBLISH_BEFORE_LINK); + assert!(matches!( + PersistentRfqWallet::create_from_envelope( + &before_link, + identity, + PASSPHRASE, + &envelope, + StdRng::seed_from_u64(32), + ), + Err(PersistentWalletError::InjectedMutationFailure(actual)) + if actual == mutation_failpoints::PUBLISH_BEFORE_LINK + )); + drop(guard); + assert!( + !before_link.exists(), + "the final path must remain absent until the complete staging database is linked" + ); + + let after_link = directory.path().join("after-link.redb"); + let guard = mutation_failpoints::arm(mutation_failpoints::PUBLISH_AFTER_LINK); + assert!(matches!( + PersistentRfqWallet::create_from_envelope( + &after_link, + identity, + PASSPHRASE, + &envelope, + StdRng::seed_from_u64(33), + ), + Err(PersistentWalletError::PublishedButUnconfirmed { source }) + if matches!( + source.as_ref(), + PersistentWalletError::InjectedMutationFailure(actual) + if *actual == mutation_failpoints::PUBLISH_AFTER_LINK + ) + )); + drop(guard); + let reopened = open_seeded(&after_link, identity, 34); + assert_eq!(reopened.catalog_revision().expect("complete wallet"), 0); +} + +#[test] +fn backup_and_catalog_checkpoint_have_a_pinned_compatibility_vector() { + let directory = TempDir::new().expect("tempdir"); + let path = directory.path().join("golden.redb"); + let identity = identity(0x21); + let mut envelope_rng = + ScriptedRng::from_bytes([0xa1; 16].into_iter().chain([0xb2; 24]).chain([0xc3; 16])); + let envelope = EncryptedKeystore::seal_entropy_with_rng( + identity, + PASSPHRASE, + test_kdf(), + &[0x55; 32], + &mut envelope_rng, + ) + .expect("fixed envelope"); + let wallet = PersistentRfqWallet::create_from_envelope( + &path, + identity, + PASSPHRASE, + &envelope, + ScriptedRng::from_nonces([[0x11; CATALOG_NONCE_BYTES], [0x22; CATALOG_NONCE_BYTES]]), + ) + .expect("fixed wallet"); + wallet + .fresh_inventory_destination() + .expect("first fixed destination"); + issue_settlement(&wallet, DestinationPurpose::SettlementReceive); + let snapshot = wallet.catalog_snapshot().expect("fixed snapshot"); + let backup = wallet.export_backup().expect("fixed backup"); + + assert_eq!( + hex(&snapshot.checkpoint()), + "7441a183727a17e7e382e104b45973083b9482acc8118615cd23d7b34ec34c68" + ); + assert_eq!(backup.as_bytes().len(), 494); + assert_eq!( + hex(&Sha256::digest(backup.as_bytes())), + "6c5c38e61a18aa462eefc378aff0e98d027ea76f7764abcf21fe947e6aa25e56" + ); +} + +#[test] +fn missing_open_and_create_existing_are_non_mutating() { + let directory = TempDir::new().expect("tempdir"); + let missing = directory.path().join("missing.redb"); + let identity = identity(2); + + assert!(matches!( + PersistentRfqWallet::open(&missing, identity, PASSPHRASE), + Err(PersistentWalletError::Io(error)) + if error.kind() == std::io::ErrorKind::NotFound + )); + assert!(!missing.exists(), "open must not initialize a missing path"); + + let path = directory.path().join("wallet.redb"); + let wallet = create_seeded(&path, identity, 3); + wallet + .fresh_inventory_destination() + .expect("inventory destination"); + let expected = wallet.catalog_snapshot().expect("snapshot"); + drop(wallet); + let bytes_before = fs::read(&path).expect("database bytes"); + + assert!(matches!( + PersistentRfqWallet::create_from_envelope( + &path, + identity, + PASSPHRASE, + &envelope(identity), + StdRng::seed_from_u64(4), + ), + Err(PersistentWalletError::TargetAlreadyExists) + )); + assert_eq!(fs::read(&path).expect("unchanged database"), bytes_before); + assert_eq!( + open_seeded(&path, identity, 5) + .catalog_snapshot() + .expect("unchanged snapshot"), + expected + ); + + let empty = directory.path().join("already-exists"); + fs::write(&empty, []).expect("empty sentinel"); + let sentinel_before = fs::read(&empty).expect("sentinel"); + assert!(matches!( + PersistentRfqWallet::create_from_envelope( + &empty, + identity, + PASSPHRASE, + &envelope(identity), + StdRng::seed_from_u64(6), + ), + Err(PersistentWalletError::TargetAlreadyExists) + )); + assert_eq!( + fs::read(empty).expect("unchanged sentinel"), + sentinel_before + ); +} + +#[test] +fn wrong_identity_and_passphrase_leave_the_wallet_unchanged() { + let directory = TempDir::new().expect("tempdir"); + let path = directory.path().join("wallet.redb"); + let identity = identity(3); + let wallet = create_seeded(&path, identity, 7); + wallet + .fresh_inventory_destination() + .expect("inventory destination"); + let expected = wallet.catalog_snapshot().expect("snapshot"); + drop(wallet); + + assert!(matches!( + PersistentRfqWallet::open_with_rng( + &path, + identity, + b"wrong passphrase", + StdRng::seed_from_u64(8), + ), + Err(PersistentWalletError::Keystore( + KeystoreError::DecryptionFailed + )) + )); + assert!(matches!( + PersistentRfqWallet::open_with_rng( + &path, + self::identity(4), + PASSPHRASE, + StdRng::seed_from_u64(9), + ), + Err(PersistentWalletError::IdentityMismatch) + )); + assert_eq!( + open_seeded(&path, identity, 10) + .catalog_snapshot() + .expect("snapshot after rejected opens"), + expected + ); +} + +#[test] +fn provider_genesis_and_policy_identity_mismatches_each_fail_non_mutating() { + let directory = TempDir::new().expect("tempdir"); + let path = directory.path().join("wallet.redb"); + let identity = identity(24); + let wallet = create_seeded(&path, identity, 56); + wallet + .fresh_inventory_destination() + .expect("inventory destination"); + let expected = wallet.catalog_snapshot().expect("snapshot"); + let backup = wallet.export_backup().expect("backup"); + drop(wallet); + + let mismatches = [ + ProviderIdentity::new( + ProviderId::new([0xf1; 32]), + identity.genesis_hash(), + identity.policy_asset(), + ), + ProviderIdentity::new( + identity.provider(), + BlockHash::from_byte_array([0xf2; 32]), + identity.policy_asset(), + ), + ProviderIdentity::new( + identity.provider(), + identity.genesis_hash(), + AssetId::from_byte_array([0xf3; 32]), + ), + ]; + for (index, mismatch) in mismatches.into_iter().enumerate() { + assert!(matches!( + PersistentRfqWallet::open_with_rng( + &path, + mismatch, + PASSPHRASE, + StdRng::seed_from_u64(57 + index as u64), + ), + Err(PersistentWalletError::IdentityMismatch) + )); + + let restore_path = directory.path().join(format!("mismatch-{index}.redb")); + assert!(matches!( + PersistentRfqWallet::restore_with_rng( + &restore_path, + mismatch, + PASSPHRASE, + &backup, + StdRng::seed_from_u64(60 + index as u64), + ), + Err(PersistentWalletError::IdentityMismatch) + )); + assert!(!restore_path.exists()); + } + assert_eq!( + open_seeded(&path, identity, 63) + .catalog_snapshot() + .expect("snapshot after rejected identities"), + expected + ); +} + +#[test] +fn wallet_database_is_exclusively_opened() { + let directory = TempDir::new().expect("tempdir"); + let path = directory.path().join("wallet.redb"); + let identity = identity(5); + let wallet = create_seeded(&path, identity, 11); + + assert!(matches!( + PersistentRfqWallet::open_with_rng(&path, identity, PASSPHRASE, StdRng::seed_from_u64(12),), + Err(PersistentWalletError::Database( + DatabaseError::DatabaseAlreadyOpen + )) + )); + drop(wallet); + open_seeded(&path, identity, 13); +} + +#[test] +fn simultaneous_creation_has_one_no_clobber_winner() { + let directory = TempDir::new().expect("tempdir"); + let path = directory.path().join("wallet.redb"); + let identity = identity(25); + let envelope = Arc::new(envelope(identity)); + let start = Arc::new(Barrier::new(3)); + let mut handles = Vec::new(); + for seed in [64, 65] { + let path = path.clone(); + let envelope = Arc::clone(&envelope); + let start = Arc::clone(&start); + handles.push(thread::spawn(move || { + start.wait(); + PersistentRfqWallet::create_from_envelope( + &path, + identity, + PASSPHRASE, + &envelope, + StdRng::seed_from_u64(seed), + ) + })); + } + start.wait(); + + let mut winner = None; + let mut conflicts = 0; + for result in handles + .into_iter() + .map(|handle| handle.join().expect("creation thread")) + { + match result { + Ok(wallet) => { + assert!(winner.replace(wallet).is_none(), "two creates succeeded"); + } + Err(PersistentWalletError::TargetAlreadyExists) => conflicts += 1, + Err(PersistentWalletError::Io(error)) + if error.kind() == std::io::ErrorKind::AlreadyExists => + { + // Both creators may pass the initial absence check; the + // atomic no-clobber publication is then the deciding boundary. + conflicts += 1; + } + Err(error) => panic!("unexpected creation race error: {error}"), + } + } + assert!(winner.is_some()); + assert_eq!(conflicts, 1); + drop(winner); + + let reopened = open_seeded(&path, identity, 66); + assert_eq!(reopened.catalog_revision().expect("winner revision"), 0); + let entries: Vec<_> = fs::read_dir(directory.path()) + .expect("wallet directory") + .map(|entry| entry.expect("directory entry").file_name()) + .collect(); + assert_eq!(entries, [path.file_name().expect("wallet filename")]); +} + +#[cfg(unix)] +#[test] +fn newly_created_wallet_has_mode_0600_and_insecure_reopen_fails_closed() { + use std::os::unix::fs::PermissionsExt as _; + use std::os::unix::fs::symlink; + + let directory = TempDir::new().expect("tempdir"); + let path = directory.path().join("wallet.redb"); + let identity = identity(6); + drop(create_seeded(&path, identity, 14)); + + assert_eq!( + fs::metadata(&path).expect("metadata").permissions().mode() & 0o777, + 0o600 + ); + let link = directory.path().join("wallet-link.redb"); + symlink(&path, &link).expect("symlink"); + assert!(matches!( + PersistentRfqWallet::open_with_rng(&link, identity, PASSPHRASE, StdRng::seed_from_u64(15),), + Err(PersistentWalletError::UnsupportedFileType) + )); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).expect("widen permissions"); + assert!(matches!( + PersistentRfqWallet::open_with_rng(&path, identity, PASSPHRASE, StdRng::seed_from_u64(16),), + Err(PersistentWalletError::InsecurePermissions(0o640)) + )); +} + +#[cfg(unix)] +#[test] +fn wallet_requires_an_owner_controlled_real_parent_directory() { + use std::os::unix::fs::PermissionsExt as _; + use std::os::unix::fs::symlink; + + let directory = TempDir::new().expect("tempdir"); + let identity = identity(22); + + let insecure_parent = directory.path().join("insecure-parent"); + fs::create_dir(&insecure_parent).expect("create insecure parent"); + fs::set_permissions(&insecure_parent, fs::Permissions::from_mode(0o770)) + .expect("widen parent permissions"); + let insecure_target = insecure_parent.join("wallet.redb"); + assert!(matches!( + PersistentRfqWallet::create_from_envelope( + &insecure_target, + identity, + PASSPHRASE, + &envelope(identity), + StdRng::seed_from_u64(52), + ), + Err(PersistentWalletError::InsecureParentPermissions(0o770)) + )); + assert!(!insecure_target.exists()); + + let changed_parent = directory.path().join("changed-parent"); + fs::create_dir(&changed_parent).expect("create initially secure parent"); + let changed_target = changed_parent.join("wallet.redb"); + drop(create_seeded(&changed_target, identity, 53)); + fs::set_permissions(&changed_parent, fs::Permissions::from_mode(0o772)) + .expect("make existing wallet parent insecure"); + assert!(matches!( + PersistentRfqWallet::open_with_rng( + &changed_target, + identity, + PASSPHRASE, + StdRng::seed_from_u64(54), + ), + Err(PersistentWalletError::InsecureParentPermissions(0o772)) + )); + fs::set_permissions(&changed_parent, fs::Permissions::from_mode(0o700)) + .expect("restore parent permissions for cleanup"); + + let real_parent = directory.path().join("real-parent"); + fs::create_dir(&real_parent).expect("create real parent"); + let linked_parent = directory.path().join("linked-parent"); + symlink(&real_parent, &linked_parent).expect("link parent"); + let linked_target = linked_parent.join("wallet.redb"); + assert!(matches!( + PersistentRfqWallet::create_from_envelope( + &linked_target, + identity, + PASSPHRASE, + &envelope(identity), + StdRng::seed_from_u64(55), + ), + Err(PersistentWalletError::UnsupportedParentDirectory) + )); + assert!(!real_parent.join("wallet.redb").exists()); +} + +#[test] +fn cross_purpose_nonce_collision_after_reopen_is_burned_and_retried() { + let directory = TempDir::new().expect("tempdir"); + let path = directory.path().join("wallet.redb"); + let identity = identity(7); + let first_nonce = [0x31; CATALOG_NONCE_BYTES]; + let second_nonce = [0x42; CATALOG_NONCE_BYTES]; + let envelope = envelope(identity); + let wallet = PersistentRfqWallet::create_from_envelope( + &path, + identity, + PASSPHRASE, + &envelope, + ScriptedRng::from_nonces([first_nonce]), + ) + .expect("create wallet"); + let inventory = wallet + .fresh_inventory_destination() + .expect("inventory destination"); + assert_eq!( + &inventory.wallet_locator().to_bytes()[2..18], + first_nonce.as_slice() + ); + drop(wallet); + + let reopened = PersistentRfqWallet::open_with_rng( + &path, + identity, + PASSPHRASE, + ScriptedRng::from_nonces([first_nonce, second_nonce]), + ) + .expect("reopen wallet"); + let receive = issue_settlement(&reopened, DestinationPurpose::SettlementReceive); + assert_eq!( + &receive.wallet_locator().to_bytes()[2..18], + second_nonce.as_slice() + ); + let snapshot = reopened.catalog_snapshot().expect("snapshot"); + assert_eq!(snapshot.revision(), 2); + assert_eq!( + snapshot.locators(), + [inventory.wallet_locator(), receive.wallet_locator()] + ); +} + +#[test] +fn every_precommit_issuance_failpoint_rolls_back_exactly() { + let directory = TempDir::new().expect("tempdir"); + let path = directory.path().join("wallet.redb"); + let identity = identity(8); + let wallet = create_seeded(&path, identity, 16); + let initial = wallet.catalog_snapshot().expect("initial snapshot"); + + for name in [ + mutation_failpoints::ISSUE_AFTER_DERIVATION, + mutation_failpoints::ISSUE_AFTER_CATALOG_INSERT, + mutation_failpoints::ISSUE_AFTER_REVISION, + mutation_failpoints::ISSUE_BEFORE_COMMIT, + ] { + let guard = mutation_failpoints::arm(name); + assert!(matches!( + wallet.fresh_inventory_destination(), + Err(PersistentWalletError::InjectedMutationFailure(actual)) if actual == name + )); + drop(guard); + assert_eq!( + wallet.catalog_snapshot().expect("rolled-back snapshot"), + initial, + "failpoint {name} leaked a catalog mutation" + ); + } + + let issued = wallet + .fresh_inventory_destination() + .expect("issue after failures"); + let committed = wallet.catalog_snapshot().expect("committed snapshot"); + assert_eq!(committed.revision(), 1); + assert_eq!(committed.locators(), [issued.wallet_locator()]); + drop(wallet); + assert_eq!( + open_seeded(&path, identity, 17) + .catalog_snapshot() + .expect("reopened snapshot"), + committed + ); +} + +#[test] +fn corrupt_metadata_and_catalog_checkpoint_fail_closed_on_reopen() { + let directory = TempDir::new().expect("tempdir"); + let identity = identity(20); + + let checkpoint_path = directory.path().join("checkpoint.redb"); + let wallet = create_seeded(&checkpoint_path, identity, 40); + wallet + .fresh_inventory_destination() + .expect("inventory destination"); + drop(wallet); + let database = Database::open(&checkpoint_path).expect("raw test database"); + let mut write = database.begin_write().expect("write"); + write + .set_durability(Durability::Immediate) + .expect("durability"); + { + let mut meta = write.open_table(META).expect("meta"); + meta.insert(CATALOG_CHECKPOINT_KEY, [0_u8; 32].as_slice()) + .expect("corrupt checkpoint"); + } + write.commit().expect("commit corruption"); + drop(database); + assert!(matches!( + PersistentRfqWallet::open_with_rng( + &checkpoint_path, + identity, + PASSPHRASE, + StdRng::seed_from_u64(41), + ), + Err(PersistentWalletError::CatalogCheckpointMismatch) + )); + + let missing_path = directory.path().join("missing-meta.redb"); + drop(create_seeded(&missing_path, identity, 42)); + let database = Database::open(&missing_path).expect("raw test database"); + let mut write = database.begin_write().expect("write"); + write + .set_durability(Durability::Immediate) + .expect("durability"); + { + let mut meta = write.open_table(META).expect("meta"); + meta.remove(CATALOG_REVISION_KEY).expect("remove revision"); + } + write.commit().expect("commit corruption"); + drop(database); + assert!(matches!( + PersistentRfqWallet::open_with_rng( + &missing_path, + identity, + PASSPHRASE, + StdRng::seed_from_u64(43), + ), + Err(PersistentWalletError::CorruptMetadata) + )); +} + +#[test] +fn after_commit_failure_burns_a_discoverable_destination() { + let directory = TempDir::new().expect("tempdir"); + let path = directory.path().join("wallet.redb"); + let identity = identity(9); + let wallet = create_seeded(&path, identity, 18); + + let guard = mutation_failpoints::arm(mutation_failpoints::ISSUE_AFTER_COMMIT); + assert!(matches!( + wallet.fresh_inventory_destination(), + Err(PersistentWalletError::InjectedMutationFailure(actual)) + if actual == mutation_failpoints::ISSUE_AFTER_COMMIT + )); + drop(guard); + let burned = wallet.catalog_snapshot().expect("post-commit snapshot"); + assert_eq!(burned.revision(), 1); + assert_eq!(burned.locators().len(), 1); + wallet + .recover_confidential_destination(burned.locators()[0]) + .expect("burned locator remains recoverable"); + + let returned = wallet + .fresh_inventory_destination() + .expect("next destination"); + assert_ne!(returned.wallet_locator(), burned.locators()[0]); + assert_eq!(wallet.catalog_revision().expect("revision"), 2); +} + +#[test] +fn ambiguous_commit_poison_requires_reopen_before_any_capability() { + let directory = TempDir::new().expect("tempdir"); + let path = directory.path().join("wallet.redb"); + let identity = identity(23); + let wallet = create_seeded(&path, identity, 54); + + let guard = mutation_failpoints::arm(mutation_failpoints::ISSUE_COMMIT_AMBIGUOUS); + assert!(matches!( + wallet.fresh_inventory_destination(), + Err(PersistentWalletError::InjectedMutationFailure(actual)) + if actual == mutation_failpoints::ISSUE_COMMIT_AMBIGUOUS + )); + drop(guard); + assert!(matches!( + wallet.catalog_snapshot(), + Err(PersistentWalletError::Poisoned) + )); + assert!(matches!( + wallet.fresh_inventory_destination(), + Err(PersistentWalletError::Poisoned) + )); + drop(wallet); + + let reopened = open_seeded(&path, identity, 55); + let snapshot = reopened + .catalog_snapshot() + .expect("reopen resolves the durable outcome"); + assert_eq!(snapshot.revision(), 1); + assert_eq!(snapshot.locators().len(), 1); + reopened + .recover_confidential_destination(snapshot.locators()[0]) + .expect("the committed locator remains recoverable"); +} + +#[test] +fn concurrent_issuance_returns_only_unique_durable_destinations() { + const THREADS: usize = 12; + + let directory = TempDir::new().expect("tempdir"); + let path = directory.path().join("wallet.redb"); + let identity = identity(10); + let wallet = Arc::new(create_seeded(&path, identity, 19)); + let barrier = Arc::new(Barrier::new(THREADS + 1)); + let mut handles = Vec::new(); + for index in 0..THREADS { + let wallet = Arc::clone(&wallet); + let barrier = Arc::clone(&barrier); + handles.push(thread::spawn(move || { + barrier.wait(); + match index % 3 { + 0 => wallet.fresh_inventory_destination(), + 1 => wallet.fresh_confidential_destination(DestinationPurpose::SettlementReceive), + _ => wallet.fresh_confidential_destination(DestinationPurpose::SettlementChange), + } + })); + } + barrier.wait(); + let destinations: Vec<_> = handles + .into_iter() + .map(|handle| handle.join().expect("issuance thread").expect("issuance")) + .collect(); + let returned: BTreeSet<_> = destinations + .iter() + .map(ConfidentialDestination::wallet_locator) + .collect(); + assert_eq!(returned.len(), THREADS); + + let snapshot = wallet.catalog_snapshot().expect("snapshot"); + assert_eq!(snapshot.revision(), THREADS as u64); + assert_eq!(snapshot.locators().len(), THREADS); + assert_eq!( + snapshot.locators().iter().copied().collect::>(), + returned + ); + let nonces: BTreeSet<_> = snapshot + .locators() + .iter() + .map(|locator| locator.to_bytes()[2..18].to_vec()) + .collect(); + assert_eq!(nonces.len(), THREADS); + drop(wallet); + assert_eq!( + open_seeded(&path, identity, 20) + .catalog_snapshot() + .expect("reopened snapshot"), + snapshot + ); +} + +#[test] +fn snapshots_racing_issuance_are_coherent_monotonic_prefixes() { + const ISSUANCES: usize = 12; + + let directory = TempDir::new().expect("tempdir"); + let path = directory.path().join("wallet.redb"); + let identity = identity(11); + let wallet = Arc::new(create_seeded(&path, identity, 21)); + let start = Arc::new(Barrier::new(3)); + let done = Arc::new(AtomicBool::new(false)); + + let issuer = { + let wallet = Arc::clone(&wallet); + let start = Arc::clone(&start); + let done = Arc::clone(&done); + thread::spawn(move || { + start.wait(); + for _ in 0..ISSUANCES { + wallet + .fresh_inventory_destination() + .expect("concurrent issuance"); + thread::yield_now(); + } + done.store(true, Ordering::Release); + }) + }; + let reader = { + let wallet = Arc::clone(&wallet); + let start = Arc::clone(&start); + let done = Arc::clone(&done); + thread::spawn(move || { + start.wait(); + let mut previous = Vec::new(); + loop { + let snapshot = wallet.catalog_snapshot().expect("concurrent snapshot"); + assert_eq!(snapshot.revision(), snapshot.locators().len() as u64); + assert!(snapshot.locators().starts_with(&previous)); + previous = snapshot.locators().to_vec(); + if done.load(Ordering::Acquire) { + let final_snapshot = wallet + .catalog_snapshot() + .expect("snapshot after issuer completion"); + assert!(final_snapshot.locators().starts_with(&previous)); + return final_snapshot.locators().to_vec(); + } + thread::yield_now(); + } + }) + }; + start.wait(); + issuer.join().expect("issuer thread"); + let observed = reader.join().expect("reader thread"); + let final_snapshot = wallet.catalog_snapshot().expect("final snapshot"); + assert_eq!(final_snapshot.revision(), ISSUANCES as u64); + assert_eq!(observed, final_snapshot.locators()); +} + +#[test] +fn logical_backup_restores_exactly_and_never_overwrites() { + let directory = TempDir::new().expect("tempdir"); + let source_path = directory.path().join("source.redb"); + let restored_path = directory.path().join("restored.redb"); + let identity = identity(12); + let source = create_seeded(&source_path, identity, 22); + let destinations = [ + source + .fresh_inventory_destination() + .expect("inventory destination"), + issue_settlement(&source, DestinationPurpose::SettlementReceive), + issue_settlement(&source, DestinationPurpose::SettlementChange), + ]; + let expected = source.catalog_snapshot().expect("source snapshot"); + let backup = source.export_backup().expect("backup"); + assert_eq!(backup.revision(), expected.revision()); + assert_eq!(backup.checkpoint(), expected.checkpoint()); + drop(source); + + let restored = PersistentRfqWallet::restore_with_rng( + &restored_path, + identity, + PASSPHRASE, + &backup, + StdRng::seed_from_u64(23), + ) + .expect("restore"); + assert_eq!( + restored.catalog_snapshot().expect("restored snapshot"), + expected + ); + for destination in destinations { + assert_eq!( + restored + .recover_confidential_destination(destination.wallet_locator()) + .expect("restored destination"), + destination + ); + } + + let existing = directory.path().join("existing"); + let sentinel = b"must not overwrite an existing recovery target"; + fs::write(&existing, sentinel).expect("write sentinel"); + assert!(matches!( + PersistentRfqWallet::restore_with_rng( + &existing, + identity, + PASSPHRASE, + &backup, + StdRng::seed_from_u64(24), + ), + Err(PersistentWalletError::TargetAlreadyExists) + )); + assert_eq!(fs::read(existing).expect("read sentinel"), sentinel); +} + +#[test] +fn repeated_export_and_restore_preserve_the_exact_encrypted_envelope() { + let directory = TempDir::new().expect("tempdir"); + let source_path = directory.path().join("source.redb"); + let restored_path = directory.path().join("restored.redb"); + let identity = identity(26); + let envelope = envelope(identity); + let source = PersistentRfqWallet::create_from_envelope( + &source_path, + identity, + PASSPHRASE, + &envelope, + StdRng::seed_from_u64(67), + ) + .expect("source wallet"); + source + .fresh_inventory_destination() + .expect("inventory destination"); + issue_settlement(&source, DestinationPurpose::SettlementChange); + + assert_eq!( + load_envelope(&source.database, identity).expect("stored source envelope"), + envelope + ); + let first = source.export_backup().expect("first export"); + let second = source.export_backup().expect("repeat export"); + assert_eq!(first, second); + assert_eq!( + parse_backup(first.as_bytes()) + .expect("parse source backup") + .keystore, + envelope.as_bytes() + ); + drop(source); + + let restored = PersistentRfqWallet::restore_with_rng( + &restored_path, + identity, + PASSPHRASE, + &first, + StdRng::seed_from_u64(68), + ) + .expect("restore"); + assert_eq!( + load_envelope(&restored.database, identity).expect("stored restored envelope"), + envelope + ); + assert_eq!(restored.export_backup().expect("restored export"), first); +} + +#[test] +fn backup_tampering_and_wrong_credentials_leave_no_restore_target() { + let directory = TempDir::new().expect("tempdir"); + let source_path = directory.path().join("source.redb"); + let identity = identity(13); + let source = create_seeded(&source_path, identity, 25); + source + .fresh_inventory_destination() + .expect("inventory destination"); + let backup = source.export_backup().expect("backup"); + + let wrong_passphrase_path = directory.path().join("wrong-passphrase.redb"); + assert!(matches!( + PersistentRfqWallet::restore_with_rng( + &wrong_passphrase_path, + identity, + b"wrong passphrase", + &backup, + StdRng::seed_from_u64(26), + ), + Err(PersistentWalletError::Keystore( + KeystoreError::DecryptionFailed + )) + )); + assert!(!wrong_passphrase_path.exists()); + + let wrong_identity_path = directory.path().join("wrong-identity.redb"); + assert!(matches!( + PersistentRfqWallet::restore_with_rng( + &wrong_identity_path, + self::identity(14), + PASSPHRASE, + &backup, + StdRng::seed_from_u64(27), + ), + Err(PersistentWalletError::IdentityMismatch) + )); + assert!(!wrong_identity_path.exists()); + + let mut tampered = backup.as_bytes().to_vec(); + let last = tampered.len() - 1; + tampered[last] ^= 1; + let tampered = WalletBackup::from_bytes(tampered).expect("structurally valid backup"); + let tampered_path = directory.path().join("tampered.redb"); + assert!(matches!( + PersistentRfqWallet::restore_with_rng( + &tampered_path, + identity, + PASSPHRASE, + &tampered, + StdRng::seed_from_u64(28), + ), + Err(PersistentWalletError::CatalogCheckpointMismatch) + )); + assert!(!tampered_path.exists()); +} + +#[test] +fn backup_parser_rejects_noncanonical_and_hostile_framing() { + let directory = TempDir::new().expect("tempdir"); + let source_path = directory.path().join("source.redb"); + let identity = identity(18); + let source = create_seeded(&source_path, identity, 35); + source + .fresh_inventory_destination() + .expect("inventory destination"); + let backup = source.export_backup().expect("backup"); + + let mut cases = Vec::new(); + let mut bad_magic = backup.as_bytes().to_vec(); + bad_magic[0] ^= 1; + cases.push(bad_magic); + let mut zero_wallet_id = backup.as_bytes().to_vec(); + zero_wallet_id[112..128].fill(0); + cases.push(zero_wallet_id); + let mut revision_count_mismatch = backup.as_bytes().to_vec(); + revision_count_mismatch[128..136].copy_from_slice(&2_u64.to_be_bytes()); + cases.push(revision_count_mismatch); + let mut zero_keystore_length = backup.as_bytes().to_vec(); + zero_keystore_length[136..140].fill(0); + cases.push(zero_keystore_length); + let mut oversized_keystore = backup.as_bytes().to_vec(); + oversized_keystore[136..140] + .copy_from_slice(&((MAX_BACKUP_KEYSTORE_BYTES + 1) as u32).to_be_bytes()); + cases.push(oversized_keystore); + cases.push(backup.as_bytes()[..backup.as_bytes().len() - 1].to_vec()); + let mut trailing = backup.as_bytes().to_vec(); + trailing.push(0); + cases.push(trailing); + let mut version = backup.as_bytes().to_vec(); + version[8..10].copy_from_slice(&2_u16.to_be_bytes()); + cases.push(version); + let mut flags = backup.as_bytes().to_vec(); + flags[10..12].copy_from_slice(&1_u16.to_be_bytes()); + cases.push(flags); + let mut declared_length = backup.as_bytes().to_vec(); + declared_length[12..16].copy_from_slice(&u32::MAX.to_be_bytes()); + cases.push(declared_length); + let mut hostile_count = backup.as_bytes().to_vec(); + let too_many = MAX_WALLET_CATALOG_ENTRIES + 1; + hostile_count[128..136].copy_from_slice(&too_many.to_be_bytes()); + hostile_count[140..144].copy_from_slice(&(too_many as u32).to_be_bytes()); + cases.push(hostile_count); + + for bytes in cases { + assert!( + WalletBackup::from_bytes(bytes).is_err(), + "noncanonical backup framing was accepted" + ); + } +} + +#[test] +fn multi_entry_backup_rejects_delete_reorder_duplicate_and_cross_wallet_substitution() { + let directory = TempDir::new().expect("tempdir"); + let source_path = directory.path().join("source.redb"); + let identity = identity(27); + let source = create_seeded(&source_path, identity, 69); + source + .fresh_inventory_destination() + .expect("inventory destination"); + issue_settlement(&source, DestinationPurpose::SettlementReceive); + issue_settlement(&source, DestinationPurpose::SettlementChange); + let backup = source.export_backup().expect("three-entry backup"); + let parsed = parse_backup(backup.as_bytes()).expect("parse backup"); + let locators_start = BACKUP_HEADER_BYTES + parsed.keystore.len(); + + let mut deleted = backup.as_bytes().to_vec(); + deleted.drain(locators_start + 32..locators_start + 64); + let deleted_len = u32::try_from(deleted.len()).expect("backup length"); + deleted[12..16].copy_from_slice(&deleted_len.to_be_bytes()); + deleted[128..136].copy_from_slice(&2_u64.to_be_bytes()); + deleted[140..144].copy_from_slice(&2_u32.to_be_bytes()); + let deleted = WalletBackup::from_bytes(deleted).expect("structurally valid deletion"); + assert!(matches!( + PersistentRfqWallet::restore_with_rng( + &directory.path().join("deleted.redb"), + identity, + PASSPHRASE, + &deleted, + StdRng::seed_from_u64(70), + ), + Err(PersistentWalletError::CatalogCheckpointMismatch) + )); + + let mut reordered = backup.as_bytes().to_vec(); + let first = reordered[locators_start..locators_start + 32].to_vec(); + let second = reordered[locators_start + 32..locators_start + 64].to_vec(); + reordered[locators_start..locators_start + 32].copy_from_slice(&second); + reordered[locators_start + 32..locators_start + 64].copy_from_slice(&first); + let reordered = WalletBackup::from_bytes(reordered).expect("structurally valid reordering"); + assert!(matches!( + PersistentRfqWallet::restore_with_rng( + &directory.path().join("reordered.redb"), + identity, + PASSPHRASE, + &reordered, + StdRng::seed_from_u64(71), + ), + Err(PersistentWalletError::CatalogCheckpointMismatch) + )); + + let mut duplicate = backup.as_bytes().to_vec(); + let first = duplicate[locators_start..locators_start + 32].to_vec(); + duplicate[locators_start + 32..locators_start + 64].copy_from_slice(&first); + let duplicate = WalletBackup::from_bytes(duplicate).expect("structurally valid duplicate"); + assert!(matches!( + PersistentRfqWallet::restore_with_rng( + &directory.path().join("duplicate.redb"), + identity, + PASSPHRASE, + &duplicate, + StdRng::seed_from_u64(72), + ), + Err(PersistentWalletError::DuplicateCatalogNonce) + )); + + let foreign_path = directory.path().join("foreign.redb"); + let foreign = create_seeded(&foreign_path, identity, 73); + foreign + .fresh_inventory_destination() + .expect("foreign destination"); + let foreign_backup = foreign.export_backup().expect("foreign backup"); + let foreign_parsed = parse_backup(foreign_backup.as_bytes()).expect("parse foreign backup"); + let foreign_locator = &foreign_parsed.locators[..32]; + let mut substituted = backup.as_bytes().to_vec(); + substituted[locators_start + 32..locators_start + 64].copy_from_slice(foreign_locator); + let substituted = + WalletBackup::from_bytes(substituted).expect("structurally valid substitution"); + assert!(matches!( + PersistentRfqWallet::restore_with_rng( + &directory.path().join("cross-wallet.redb"), + identity, + PASSPHRASE, + &substituted, + StdRng::seed_from_u64(74), + ), + Err(PersistentWalletError::Wallet( + RfqWalletError::LocatorAuthenticationFailed + )) + )); +} + +#[test] +fn authenticated_backup_regions_cannot_be_substituted() { + let directory = TempDir::new().expect("tempdir"); + let source_path = directory.path().join("source.redb"); + let identity = identity(19); + let source = create_seeded(&source_path, identity, 36); + source + .fresh_inventory_destination() + .expect("inventory destination"); + let backup = source.export_backup().expect("backup"); + + let mut wallet_id = backup.as_bytes().to_vec(); + wallet_id[112] ^= 1; + let wallet_id = WalletBackup::from_bytes(wallet_id).expect("structurally valid"); + assert!(matches!( + PersistentRfqWallet::restore_with_rng( + &directory.path().join("wallet-id.redb"), + identity, + PASSPHRASE, + &wallet_id, + StdRng::seed_from_u64(37), + ), + Err(PersistentWalletError::WalletBindingMismatch) + )); + + let parsed = parse_backup(backup.as_bytes()).expect("parse source backup"); + let keystore_end = BACKUP_HEADER_BYTES + parsed.keystore.len(); + let mut ciphertext = backup.as_bytes().to_vec(); + ciphertext[keystore_end - 1] ^= 1; + let ciphertext = WalletBackup::from_bytes(ciphertext).expect("structurally valid"); + assert!(matches!( + PersistentRfqWallet::restore_with_rng( + &directory.path().join("ciphertext.redb"), + identity, + PASSPHRASE, + &ciphertext, + StdRng::seed_from_u64(38), + ), + Err(PersistentWalletError::Keystore( + KeystoreError::DecryptionFailed + )) + )); + + let mut locator = backup.as_bytes().to_vec(); + locator[keystore_end + 2] ^= 1; + let locator = WalletBackup::from_bytes(locator).expect("structurally valid"); + assert!(matches!( + PersistentRfqWallet::restore_with_rng( + &directory.path().join("locator.redb"), + identity, + PASSPHRASE, + &locator, + StdRng::seed_from_u64(39), + ), + Err(PersistentWalletError::Wallet( + RfqWalletError::LocatorAuthenticationFailed + )) + )); +} + +#[test] +fn stale_backup_restores_only_its_cataloged_prefix() { + let directory = TempDir::new().expect("tempdir"); + let source_path = directory.path().join("source.redb"); + let restored_path = directory.path().join("restored.redb"); + let identity = identity(15); + let source = create_seeded(&source_path, identity, 29); + let before_backup = source + .fresh_inventory_destination() + .expect("pre-backup destination"); + let backup = source.export_backup().expect("stale backup"); + let after_backup = issue_settlement(&source, DestinationPurpose::SettlementReceive); + assert_eq!(source.catalog_revision().expect("source revision"), 2); + drop(source); + + let restored = PersistentRfqWallet::restore_with_rng( + &restored_path, + identity, + PASSPHRASE, + &backup, + StdRng::seed_from_u64(30), + ) + .expect("restore stale backup"); + let snapshot = restored.catalog_snapshot().expect("restored snapshot"); + assert_eq!(snapshot.revision(), 1); + assert_eq!(snapshot.locators(), [before_backup.wallet_locator()]); + assert!(!snapshot.locators().contains(&after_backup.wallet_locator())); + + // The shared seed can still authenticate a later locator if some external + // record supplies it, but a chain scanner driven by this stale catalog has + // no way to discover that destination. + assert_eq!( + restored + .recover_confidential_destination(after_backup.wallet_locator()) + .expect("externally supplied post-backup locator"), + after_backup + ); + let fresh_after_restore = issue_settlement(&restored, DestinationPurpose::SettlementReceive); + assert_ne!( + fresh_after_restore.wallet_locator(), + after_backup.wallet_locator(), + "fresh post-restore RNG state must not replay the omitted locator" + ); + let after_fresh_issue = restored.catalog_snapshot().expect("updated stale catalog"); + assert_eq!(after_fresh_issue.revision(), 2); + assert_eq!( + after_fresh_issue.locators(), + [ + before_backup.wallet_locator(), + fresh_after_restore.wallet_locator() + ] + ); + assert!( + !after_fresh_issue + .locators() + .contains(&after_backup.wallet_locator()) + ); +} + +#[test] +fn durable_types_redact_wallet_secrets_and_catalog_contents() { + let directory = TempDir::new().expect("tempdir"); + let path = directory.path().join("wallet.redb"); + let identity = identity(16); + let wallet = create_seeded(&path, identity, 31); + let destination = wallet + .fresh_inventory_destination() + .expect("inventory destination"); + let snapshot = wallet.catalog_snapshot().expect("snapshot"); + let backup = wallet.export_backup().expect("backup"); + + let debug = format!("{wallet:?}\n{snapshot:?}\n{backup:?}"); + for secret in [ + String::from_utf8(PASSPHRASE.to_vec()).expect("UTF-8 passphrase"), + hex(&destination.wallet_locator().to_bytes()), + hex(&snapshot.checkpoint()), + hex(&wallet.wallet.wallet_id()), + ] { + assert!( + !debug.contains(&secret), + "debug output disclosed sentinel {secret}" + ); + } + assert!(debug.contains("[unlocked and redacted]")); + assert!(debug.contains("[1 opaque entries]")); + assert!(debug.contains("[encrypted keystore; opaque catalog; authentication deferred]")); +} diff --git a/crates/deadcat-rfq-wallet/src/wallet.rs b/crates/deadcat-rfq-wallet/src/wallet.rs index 74d0e27..ad9d0e9 100644 --- a/crates/deadcat-rfq-wallet/src/wallet.rs +++ b/crates/deadcat-rfq-wallet/src/wallet.rs @@ -3,9 +3,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::Mutex; use deadcat_rfq_provider::{ - ConfidentialDestination, DestinationPurpose, DestinationSource, ProviderIdentity, - ProviderInputSignature, ProviderOutputRecovery, ProviderSigner, SigningJob, SigningResponse, - WalletBoundaryError, WalletKeyLocator, + ConfidentialDestination, DestinationPurpose, ProviderIdentity, ProviderInputSignature, + ProviderOutputRecovery, ProviderSigner, SigningJob, SigningResponse, WalletBoundaryError, + WalletKeyLocator, WalletOwnedOutput, }; use elements::bitcoin::NetworkKind; use elements::bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv}; @@ -44,12 +44,12 @@ const RANDOM_PATH_COMPONENTS: usize = 5; const LOCATOR_AUTH_DOMAIN: &[u8] = b"deadcat/rfq/wallet/locator-auth/v1"; const LOCATOR_TAG_DOMAIN: &[u8] = b"deadcat/rfq/wallet/locator-tag/v1"; const LOCATOR_PATH_DOMAIN: &[u8] = b"deadcat/rfq/wallet/locator-path/v1"; +const BACKUP_AUTH_DOMAIN: &[u8] = b"deadcat/rfq/wallet/backup-auth/v1"; const SLIP21_DOMAIN: &[u8] = b"Symmetric key seed"; const SLIP77_LABEL: &[u8] = b"SLIP-0077"; struct IssuanceState { rng: R, - issued_nonces: BTreeSet<[u8; LOCATOR_NONCE_BYTES]>, } /// Unlocked, purpose-built provider hot wallet. @@ -76,7 +76,7 @@ impl RfqWallet { } impl RfqWallet { - fn with_rng(unlocked: UnlockedSeed, rng: R) -> Result { + pub(crate) fn with_rng(unlocked: UnlockedSeed, rng: R) -> Result { let (seed, identity, wallet_id) = unlocked.into_parts(); let master_blinding_key = derive_slip77_master(&seed[..])?; let locator_auth_key = derive_locator_auth_key(&seed, identity, wallet_id)?; @@ -86,10 +86,7 @@ impl RfqWallet { seed, master_blinding_key, locator_auth_key, - issuance: Mutex::new(IssuanceState { - rng, - issued_nonces: BTreeSet::new(), - }), + issuance: Mutex::new(IssuanceState { rng }), }) } @@ -98,16 +95,26 @@ impl RfqWallet { self.identity } - /// Issue a confidential destination for operator-provided initial or - /// replenishment liquidity. - /// - /// This is deliberately distinct from settlement receive and change so a - /// durable locator preserves the key's operational role without treating - /// an operator deposit as a quote settlement. - pub fn fresh_inventory_destination(&self) -> Result { + pub(crate) const fn wallet_id(&self) -> [u8; 16] { + self.wallet_id + } + + pub(crate) fn candidate_inventory_destination( + &self, + ) -> Result { self.issue_destination(KeyPurpose::InventoryDeposit) } + pub(crate) fn candidate_settlement_destination( + &self, + purpose: DestinationPurpose, + ) -> Result { + self.issue_destination(match purpose { + DestinationPurpose::SettlementReceive => KeyPurpose::SettlementReceive, + DestinationPurpose::SettlementChange => KeyPurpose::SettlementChange, + }) + } + /// Reconstruct the public spend script and blinding public key for an /// already-issued authenticated locator. /// @@ -122,6 +129,64 @@ impl RfqWallet { self.destination_for_locator(locator) } + /// Authenticate and recover one complete confidential wallet output while + /// keeping its blinding factors inside the provider capability boundary. + /// + /// A concrete chain scanner supplies the creating outpoint and full + /// consensus output, including its rangeproof and surjection proof. The + /// returned value retains the opening only in the provider crate's + /// redacted in-memory representation used for collaborative blinding. + pub fn recover_owned_output( + &self, + locator: WalletKeyLocator, + outpoint: OutPoint, + txout: TxOut, + ) -> Result { + let decoded = self.decode_locator(locator)?; + let mut keypair = self.derive_spend_keypair(decoded)?; + let (internal_key, _) = keypair.0.x_only_public_key(); + let expected_script = Script::new_v1_p2tr(&Secp256k1::new(), internal_key, None); + if txout.script_pubkey != expected_script + || !txout.asset.is_confidential() + || !txout.value.is_confidential() + || !txout.nonce.is_confidential() + { + return Err(RfqWalletError::OutputScriptOrConfidentialityMismatch); + } + let mut blinding_secret = self.slip77_blinding_secret(&expected_script)?; + let opening = txout + .unblind(&Secp256k1::new(), blinding_secret.0) + .map_err(|_| RfqWalletError::OutputUnblindFailed)?; + blinding_secret.0.non_secure_erase(); + keypair.0.non_secure_erase(); + WalletOwnedOutput::new(outpoint, txout, opening, internal_key, locator) + .map_err(RfqWalletError::from) + } + + pub(crate) fn validate_locator(&self, locator: WalletKeyLocator) -> Result<(), RfqWalletError> { + self.decode_locator(locator).map(|_| ()) + } + + pub(crate) fn locator_nonce( + &self, + locator: WalletKeyLocator, + ) -> Result<[u8; LOCATOR_NONCE_BYTES], RfqWalletError> { + self.decode_locator(locator).map(|decoded| decoded.nonce) + } + + pub(crate) fn backup_authentication_tag( + &self, + payload: &[u8], + ) -> Result<[u8; 32], RfqWalletError> { + let mut mac = HmacSha256::new_from_slice(self.locator_auth_key.as_ref()) + .map_err(|_| RfqWalletError::KeyDerivationFailed)?; + mac.update(BACKUP_AUTH_DOMAIN); + mac.update(&identity_bytes(self.identity)); + mac.update(&self.wallet_id); + mac.update(payload); + Ok(mac.finalize().into_bytes().into()) + } + fn issue_destination( &self, purpose: KeyPurpose, @@ -133,7 +198,7 @@ impl RfqWallet { for _ in 0..MAX_DESTINATION_ATTEMPTS { let mut nonce = [0_u8; LOCATOR_NONCE_BYTES]; issuance.rng.fill_bytes(&mut nonce); - if nonce == [0; LOCATOR_NONCE_BYTES] || !issuance.issued_nonces.insert(nonce) { + if nonce == [0; LOCATOR_NONCE_BYTES] { continue; } let locator = self.encode_locator(purpose, nonce)?; @@ -338,20 +403,6 @@ impl fmt::Debug for RfqWallet { } } -impl DestinationSource for RfqWallet { - type Error = RfqWalletError; - - fn fresh_confidential_destination( - &self, - purpose: DestinationPurpose, - ) -> Result { - self.issue_destination(match purpose { - DestinationPurpose::SettlementReceive => KeyPurpose::SettlementReceive, - DestinationPurpose::SettlementChange => KeyPurpose::SettlementChange, - }) - } -} - impl ProviderOutputRecovery for RfqWallet { type Error = RfqWalletError; @@ -618,9 +669,7 @@ mod tests { use core::str::FromStr as _; use std::fmt::Write as _; - use deadcat_rfq_provider::{ - DestinationSource as _, ProviderId, ProviderIdentity, ProviderOutputRecovery as _, - }; + use deadcat_rfq_provider::{ProviderId, ProviderIdentity, ProviderOutputRecovery as _}; use elements::confidential::{Asset, AssetBlindingFactor, Nonce, Value, ValueBlindingFactor}; use elements::hashes::Hash as _; use elements::hashes::hex::FromHex as _; @@ -741,13 +790,13 @@ mod tests { fn destinations_are_tree_less_p2tr_and_counter_rollback_independent() { let (wallet, restored) = wallets(); let receive = wallet - .fresh_confidential_destination(DestinationPurpose::SettlementReceive) + .candidate_settlement_destination(DestinationPurpose::SettlementReceive) .expect("receive"); let change = wallet - .fresh_confidential_destination(DestinationPurpose::SettlementChange) + .candidate_settlement_destination(DestinationPurpose::SettlementChange) .expect("change"); let after_restore = restored - .fresh_confidential_destination(DestinationPurpose::SettlementReceive) + .candidate_settlement_destination(DestinationPurpose::SettlementReceive) .expect("restored receive"); let recovered_after_restore = restored .recover_confidential_destination(receive.wallet_locator()) @@ -780,7 +829,7 @@ mod tests { fn locator_tampering_and_cross_wallet_use_fail_authentication() { let (wallet, _) = wallets(); let destination = wallet - .fresh_confidential_destination(DestinationPurpose::SettlementReceive) + .candidate_settlement_destination(DestinationPurpose::SettlementReceive) .expect("destination"); let mut bytes = destination.wallet_locator().to_bytes(); bytes[7] ^= 1; @@ -808,7 +857,7 @@ mod tests { fn output_recovery_requires_exact_locator_key_asset_and_amount() { let (wallet, _) = wallets(); let destination = wallet - .fresh_inventory_destination() + .candidate_inventory_destination() .expect("inventory destination"); let asset = AssetId::from_byte_array([77; 32]); let amount = 42_000; @@ -867,7 +916,7 @@ mod tests { fn signer_uses_elements_taptweak_and_explicit_sighash_all() { let (wallet, _) = wallets(); let destination = wallet - .fresh_inventory_destination() + .candidate_inventory_destination() .expect("inventory destination"); let outpoint = OutPoint::new(Txid::from_byte_array([90; 32]), 0); let prevout = TxOut { @@ -930,10 +979,10 @@ mod tests { fn signer_rejects_wrong_internal_key_non_all_and_noncanonical_payload() { let (wallet, _) = wallets(); let destination = wallet - .fresh_confidential_destination(DestinationPurpose::SettlementReceive) + .candidate_settlement_destination(DestinationPurpose::SettlementReceive) .expect("destination"); let other = wallet - .fresh_confidential_destination(DestinationPurpose::SettlementReceive) + .candidate_settlement_destination(DestinationPurpose::SettlementReceive) .expect("other"); let outpoint = OutPoint::new(Txid::from_byte_array([91; 32]), 1); let mut input = PsetInput::from_prevout(outpoint); diff --git a/docs/adr/0007-rfq-provider-state-machine.md b/docs/adr/0007-rfq-provider-state-machine.md index b9b7515..b7d6841 100644 --- a/docs/adr/0007-rfq-provider-state-machine.md +++ b/docs/adr/0007-rfq-provider-state-machine.md @@ -303,8 +303,10 @@ chain/mempool freshness are explicit backend obligations; the types cannot prove them. The provider crate deliberately supplies no concrete wallet backend. ADR 0008's adjacent `deadcat-rfq-wallet` crate implements destination, output-recovery, and durable-job signing capabilities using an encrypted, -in-memory-unlocked provider seed. Filesystem/passphrase operations, -authoritative inventory scanning, and runtime integration remain separate work. +in-memory-unlocked provider seed plus an identity-bound persistent locator +catalog and wallet-only logical snapshot. Protected passphrase delivery, +authoritative inventory scanning, coordinated provider-state recovery, and +runtime integration remain separate work. The settlement layer also implements the provider's non-last collaborative blinding stage. It binds the complete unblinded PSET to the exact live reserved @@ -342,10 +344,10 @@ nonempty witness would not be participant authorization. The remaining provider milestones are: -1. add the authoritative Elements-backed inventory/chain adapter, filesystem - durability, passphrase and backup/recovery operations, and RFQ-daemon - integration for the implemented custom wallet, blinding, validator, and - signer capabilities; +1. add the authoritative Elements-backed inventory/chain adapter, protected + passphrase and unattended-unlock operations, coordinated provider-state + recovery, and RFQ-daemon integration for the implemented persistent custom + wallet, blinding, validator, and signer capabilities; 2. define a dedicated authenticated RFQ protocol, signed quote envelope, identity, and ALPN; 3. persist relay and chain-reconciliation observations without ever reopening diff --git a/docs/adr/0008-rfq-service-owned-wallet.md b/docs/adr/0008-rfq-service-owned-wallet.md index 1fa6d2d..94f93aa 100644 --- a/docs/adr/0008-rfq-service-owned-wallet.md +++ b/docs/adr/0008-rfq-service-owned-wallet.md @@ -73,6 +73,50 @@ caused solely by restoring a stale "next-address" counter. It does not by itself provide a complete backup or chain-discovery system; the root alone is not a complete backup of the random wallet identity or issued-locator catalog. +The production destination capability is therefore implemented only by an +identity-bound persistent wrapper. It commits the nonce, complete locator, and +monotonic issuance revision to an append-only redb catalog with immediate +durability before returning the destination. A wallet-derived checkpoint chain +authenticates the exact encrypted envelope and ordered catalog. Revisioned +snapshots let a later chain scanner detect destination issuance that raced its +external chain observation. The unlocked cryptographic core can derive a +candidate internally, but it does not implement the provider's destination +source and cannot bypass this persist-before-return boundary. + +New and restored wallet files are built and validated in a restrictive +same-directory staging file, then published without replacing an existing +target while the same redb handle remains open. Publication verifies that the +final path names the exact staged inode. The current durable implementation is +Unix-only: wallet files are mode `0600`, and their immediate parent must be a +real directory owned by the effective user with no group or world write bits. +The configured path's full hierarchy must be trusted against rename or +replacement; retaining validated directory handles is deferred runtime +hardening, so an otherwise-secure immediate parent beneath an untrusted +non-sticky ancestor is unsupported. +Opening symlinks, special or empty files, insecure files or parents, wrong +provider or chain identity, swapped keystore state, malformed locators, or a +broken revision/checkpoint chain fails closed. Deployments must use a local +filesystem on which redb's exclusive file lock is supported; unsupported or +network filesystems are outside this boundary. + +No-clobber publication is a one-way boundary. If publication reaches the final +path but the subsequent inode check, parent-directory sync, or validation +fails, the API reports `PublishedButUnconfirmed`. Operators must inspect and +reopen an existing target; they must never blindly delete or recreate it, +because it may contain the complete generated wallet. + +The wallet can export a bounded, versioned logical snapshot containing the +exact encrypted envelope and complete catalog at one revision. The snapshot's +checkpoint authenticates edits, deletion, insertion, and reordering after +unlock, and restore preserves the original wallet identity and derivation. +This is a wallet-only artifact, not complete RFQ-service recovery. An authentic +older snapshot is indistinguishable from the latest one without an externally +retained checkpoint and cannot discover random locators issued later. It also +contains no reservations, committed signing jobs, signed artifacts, relay +state, or chain observations. A restored wallet must not quote or sign until +the provider database and authoritative chain view have been reconciled, and +the source wallet and its restored clone must never run concurrently. + ### Narrow capabilities The wallet exposes only the provider capabilities needed by the state machine: @@ -114,32 +158,37 @@ This blinding step remains pre-commit and does not cross ADR 0007's point of no return. Only later final-PSET validation and durable commitment can authorize signing. -## Initial implementation boundary +## Implemented boundary -The first implementation slice is intentionally a cryptographic and -transport-free capability layer. It adds the encrypted keystore, destination -derivation, output recovery, durable-job signer, and provider-side non-last -blinding coordinator with focused adversarial tests. +The implementation remains transport-free. It now includes the encrypted +keystore, destination derivation, identity-bound durable locator catalog, +staged no-clobber file publication, logical wallet-only export and restore, +output recovery, durable-job signer, and provider-side non-last blinding +coordinator with focused adversarial tests. It does **not** yet provide: -- atomic filesystem replacement, permissions, directory synchronization, - passphrase delivery, unattended unlock, memory locking, or process-dump - policy; +- protected passphrase delivery, unattended unlock, memory locking, swap or + process-dump policy; - an authoritative chain scanner or concrete `InventorySource`; - RFQ-daemon startup/configuration wiring, bounded and rate-limited remote destination issuance, or a live wallet-backed regtest flow; -- a complete backup catalog, stale-backup discovery and recovery workflow, or - key rotation; +- continuous/off-host backup transport, external backup-freshness checkpoints, + coordinated wallet/provider-state recovery, or key rotation; - the authenticated remote RFQ protocol, signed network quote, pricing source, relay and outspend reconciliation; or - an HSM or external-signer backend. +The current persistent store also does not implement Windows ACLs or a durable +Windows publication primitive; constructing or opening it on non-Unix systems +fails explicitly rather than silently weakening these guarantees. + Those are launch requirements or later hardening work, not properties implied -by the existence of the wallet library. In particular, a serializable encrypted -envelope is not yet production backup tooling, and a self-authenticating locator -does not discover an output whose script is absent from every restored catalog -and chain scan. +by the existence of the wallet library. In particular, a valid wallet-only +snapshot is not evidence that it is the newest snapshot or that the matching +provider state was restored, and a self-authenticating locator does not discover +an output whose script is absent from every restored catalog and external +record. ## Consequences @@ -153,4 +202,5 @@ and chain scan. - The capability boundary remains suitable for a later out-of-process signer or HSM without changing ADR 0007's reservation and commit-before-sign semantics. - The service must not be described as production-ready until the deferred - durability, scanning, runtime, recovery, and live acceptance work is complete. + passphrase, scanning, runtime, coordinated recovery, and live acceptance work + is complete. diff --git a/docs/liquidity-roadmap.md b/docs/liquidity-roadmap.md index 4ebd68f..aee4713 100644 --- a/docs/liquidity-roadmap.md +++ b/docs/liquidity-roadmap.md @@ -600,10 +600,12 @@ interface proposed here: - [ADR 0008](adr/0008-rfq-service-owned-wallet.md) selects the first adjacent provider-wallet implementation: a versioned encrypted, in-memory-unlocked service seed; domain-separated BIP32 spend and SLIP-77 blinding derivation; - self-authenticating high-entropy recovery locators; tree-less P2TR with the - Elements tap tweak; confidential-output recovery; and exact durable-job - `SIGHASH_ALL` signing. It exposes no arbitrary sign/send API, and it does not - run in `deadcat-node`. + self-authenticating high-entropy recovery locators; an identity-bound durable + catalog with persist-before-return issuance, revision checkpoints, and + wallet-only logical snapshots on a lock-supporting local Unix filesystem; + tree-less P2TR with the Elements tap tweak; confidential-output recovery; and + exact durable-job `SIGHASH_ALL` signing. It exposes no arbitrary sign/send + API, and it does not run in `deadcat-node`. - The provider settlement layer now performs the non-last collaborative blinding stage against the exact live reserved contribution. It rejects output aliasing and unrelated PSET mutation, consumes provider input openings @@ -661,9 +663,10 @@ unchanged, rechecks proofs and fee facts, and stores one canonical signed PSET before returning or replaying it. The API remains provisional until the custom wallet is connected to an authoritative Elements-backed inventory/chain scanner and daemon runtime and authenticated signed remote RFQ evidence -exercises the complete flow end to end. Filesystem/passphrase operations, -stale-backup recovery tooling, live wallet-backed regtest coverage, and HSM -support also remain outside the current slice. +exercises the complete flow end to end. Protected passphrase delivery, +external backup-freshness and coordinated provider-state recovery, live +wallet-backed regtest coverage, and HSM support also remain outside the current +slice. The initial profile verifies every non-provider input as a finalized tree-less P2TR key-path `SIGHASH_ALL` spend. Simplicity covenant inputs and more than one interactive RFQ signer remain later router/venue-verification work; they are