From 17c8373419b892ec0eda9070551c8f22f25b5e54 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 3 Nov 2026 23:22:19 -0500 Subject: [PATCH] feat: retain owner-bound backup read sessions --- Cargo.toml | 2 +- README.md | 31 +++ src/backup_session.rs | 156 ++++++++++++ src/backup_session/tests.rs | 485 ++++++++++++++++++++++++++++++++++++ src/ldk_client.rs | 58 ++--- src/lib.rs | 4 + src/receipt.rs | 75 +++++- src/types.rs | 2 +- 8 files changed, 770 insertions(+), 43 deletions(-) create mode 100644 src/backup_session.rs create mode 100644 src/backup_session/tests.rs diff --git a/Cargo.toml b/Cargo.toml index 51e09e4..d3eca59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ name = "vss_rust_client_ffi" [dependencies] uniffi = { version = "0.29.5", features = [ "cli" ] } serde = { version = "^1.0.209", features = ["derive"] } -tokio = "1.40.0" +tokio = { version = "1.40.0", features = ["macros", "sync"] } once_cell = "1.19.0" thiserror = "2.0.12" vss-client-ng = "0.4.0" diff --git a/README.md b/README.md index 866aa94..245e475 100644 --- a/README.md +++ b/README.md @@ -433,3 +433,34 @@ This project is licensed under the MIT License - see the LICENSE file for detail - [VSS Server](https://github.com/lightningdevkit/vss-server) - The VSS server implementation - [vss-client-ng](https://crates.io/crates/vss-client-ng) - The underlying Rust client library - [UniFFI](https://mozilla.github.io/uniffi-rs/) - The FFI binding generator + +### Retained backup read session (Rust source preparation) + +`BackupReadSession` constructs the app and dedicated LDK clients from one full +wallet seed and one endpoint/store/auth configuration. It preserves the truncated +app derivation and full-seed LDK derivation. It never combines separately replaced +global clients. `read(BackupReadTarget, byte_limit)` produces an opaque observation; +`receipt(&observation)` checks its original session and revocation state. Even a +second session with identical wallet arguments cannot adopt that observation. +Typed absence retains the original logical target and owner as well. + +`revoke()` permanently closes local read admission and wakes pending reads. A +cancelled/dropped read schedules no retry. Revocation cannot retract an already +admitted network request, previously borrowed data or copied plaintext. A receipt +check is valid only at that instant; it grants no later mutation authority. The +storage response is capped, while the inherited credential provider's separate +responses are not. The ten-second deadline covers both credential acquisition and +storage. No lock is held across a network await. + +LDK receipts use the dedicated client's existing V1 namespace/AAD and full-seed +encryption. A V1 missing-key result says nothing about older V0 objects. This client +does not control a separately running ldk-node. Equal store IDs do not establish +physical principal identity, and sequential reads do not prove a coherent archive. +App lifecycle drains, authoritative principal mapping, old-reader/writer exclusion +and conditional migration remain separate prerequisites. + +These types and methods have no UniFFI export. Like the earlier receipt primitive, +they are in-crate Rust source preparation: this package still emits only static +and dynamic libraries, not a downstream Cargo `rlib`. Released Swift/Kotlin +interfaces, binary tags and checksums are unchanged. No migration or writer is +activated by creating or reading a session. Do not log observations or plaintext. diff --git a/src/backup_session.rs b/src/backup_session.rs new file mode 100644 index 0000000..a2b1636 --- /dev/null +++ b/src/backup_session.rs @@ -0,0 +1,156 @@ +//! Read-only, in-memory ownership across app and LDK backup observations. + +use crate::{LdkNamespace, LdkVssClient, VssClient, VssError, VssReadReceipt}; +use std::sync::Arc; +use tokio::sync::watch; + +/// One immutable storage/auth configuration for both native derivation domains. +/// Equal store IDs do not establish equal physical server principals. +pub struct BackupReadConfig { + pub base_url: String, + pub store_id: String, + pub lnurl_auth_server_url: String, +} + +/// The original logical address, retained even for an explicit missing-key result. +pub enum BackupReadTarget { + App { + key: String, + }, + /// The existing ldk-node V1 namespace. Absence does not rule out older V0 storage. + Ldk { + key: String, + namespace: LdkNamespace, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum BackupReadError { + #[error("The backup read session was revoked")] + Revoked, + #[error("The observation belongs to a different backup read session")] + DifferentOwner, + #[error(transparent)] + Read(#[from] VssError), +} + +/// An observation bound to one native session. Deliberately not serializable or +/// Debug: it can contain a complete plaintext wallet and is not remote authority. +pub struct BackupReadObservation { + owner: Arc>, + target: BackupReadTarget, + receipt: Option, +} + +impl BackupReadObservation { + pub fn target(&self) -> &BackupReadTarget { + &self.target + } +} + +/// Captures both clients directly from one wallet seed, never from global slots. +/// Revocation is permanent. It rejects new reads and discards pending results, +/// but cannot preempt every already-admitted transport poll or revoke a previously +/// borrowed or copied plaintext value. +/// No method writes, authorizes migration, or proves multi-object coherence. +pub struct BackupReadSession { + app: VssClient, + ldk: LdkVssClient, + revoked: Arc>, +} + +impl BackupReadSession { + pub async fn new(config: BackupReadConfig, seed: [u8; 64]) -> Result { + let app = VssClient::new_with_lnurl_auth( + config.base_url.clone(), + config.store_id.clone(), + seed, + config.lnurl_auth_server_url.clone(), + ) + .await?; + let ldk = LdkVssClient::new_with_lnurl_auth( + config.base_url, + config.store_id, + seed, + config.lnurl_auth_server_url, + ) + .await?; + Ok(Self { + app, + ldk, + revoked: Arc::new(watch::channel(false).0), + }) + } + + pub fn revoke(&self) { + self.revoked.send_replace(true); + } + + /// Each operation has the receipt reader's ten-second deadline and storage byte cap. + /// The inherited credential provider's separate responses are not byte-capped. + /// Dropping this future abandons only this read; it never schedules a retry. + pub async fn read( + &self, + target: BackupReadTarget, + max_response_bytes: u32, + ) -> Result { + let mut cancellation = self.revoked.subscribe(); + self.ensure_active()?; + let read = async { + match &target { + BackupReadTarget::App { key } => { + self.app + .get_with_receipt(key.clone(), max_response_bytes) + .await + } + BackupReadTarget::Ldk { key, namespace } => { + self.ldk + .get_with_receipt(key.clone(), namespace, max_response_bytes) + .await + } + } + }; + let receipt = tokio::select! { + biased; + _ = cancellation.changed() => return Err(BackupReadError::Revoked), + result = read => result, + }; + self.ensure_active()?; + let receipt = receipt?; + Ok(BackupReadObservation { + owner: Arc::clone(&self.revoked), + target, + receipt, + }) + } + + /// Checks in-memory ownership and revocation at this instant. The returned + /// reference is an observation, not admission for a later write: callers must + /// separately enforce lifecycle drains, freshness and mutation preconditions. + pub fn receipt<'a>( + &self, + observation: &'a BackupReadObservation, + ) -> Result, BackupReadError> { + if !Arc::ptr_eq(&self.revoked, &observation.owner) { + return Err(BackupReadError::DifferentOwner); + } + self.ensure_active()?; + Ok(observation.receipt.as_ref()) + } + + fn ensure_active(&self) -> Result<(), BackupReadError> { + if *self.revoked.borrow() { + return Err(BackupReadError::Revoked); + } + Ok(()) + } +} + +impl Drop for BackupReadSession { + fn drop(&mut self) { + self.revoke(); + } +} + +#[cfg(test)] +mod tests; diff --git a/src/backup_session/tests.rs b/src/backup_session/tests.rs new file mode 100644 index 0000000..8cef39d --- /dev/null +++ b/src/backup_session/tests.rs @@ -0,0 +1,485 @@ +use super::*; +use bitcoin::{ + hashes::{sha256, Hash}, + hex::FromHex, + secp256k1::{ecdsa::Signature, Message, PublicKey, Secp256k1}, +}; +use prost::Message as ProstMessage; +use std::collections::HashMap; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::{Mutex, Notify}, + task::JoinHandle, +}; +use vss_client_ng::types::{ + ErrorCode, ErrorResponse, GetObjectRequest, GetObjectResponse, KeyValue, +}; + +struct Fixture { + url: String, + objects: Arc>>, + auth_keys: Arc>>, + requests: Arc>>, + hold: Arc>>, + received: Arc, + release: Arc, + task: JoinHandle<()>, +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl Fixture { + async fn new() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://localhost:{}", listener.local_addr().unwrap().port()); + let objects = Arc::new(Mutex::new(HashMap::::new())); + let auth_keys = Arc::new(Mutex::new(Vec::new())); + let requests = Arc::new(Mutex::new(Vec::new())); + let hold = Arc::new(Mutex::new(None::)); + let received = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let (base, rows, keys, reads, held, arrived, resume) = ( + url.clone(), + objects.clone(), + auth_keys.clone(), + requests.clone(), + hold.clone(), + received.clone(), + release.clone(), + ); + let task = tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + let mut data = Vec::new(); + let end = loop { + let mut chunk = [0; 4096]; + let n = stream.read(&mut chunk).await.unwrap(); + if n == 0 { + break None; + } + data.extend_from_slice(&chunk[..n]); + if let Some(i) = data.windows(4).position(|w| w == b"\r\n\r\n") { + break Some(i + 4); + } + }; + let Some(end) = end else { + continue; + }; + let headers = std::str::from_utf8(&data[..end]).unwrap(); + let path = headers + .lines() + .next() + .unwrap() + .split_whitespace() + .nth(1) + .unwrap() + .to_owned(); + let length: usize = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().unwrap()) + }) + .unwrap_or(0); + if path == "/getObject" { + assert!(headers.to_lowercase().contains("authorization: bearer ")); + } + while data.len() < end + length { + let mut chunk = [0; 4096]; + let n = stream.read(&mut chunk).await.unwrap(); + assert_ne!(n, 0); + data.extend_from_slice(&chunk[..n]); + } + let (status, body) = if path == "/auth" { + ( + 200, + format!("{base}/callback?k1={}", "01".repeat(32)).into_bytes(), + ) + } else if path.starts_with("/callback?") { + let query: HashMap<_, _> = reqwest::Url::parse(&format!("{base}{path}")) + .unwrap() + .query_pairs() + .into_owned() + .collect(); + let public = PublicKey::from_str(&query["key"]).unwrap(); + let signature = + Signature::from_der(&Vec::::from_hex(&query["sig"]).unwrap()).unwrap(); + Secp256k1::verification_only() + .verify_ecdsa(&Message::from_digest([1; 32]), &signature, &public) + .unwrap(); + keys.lock().await.push(query["key"].clone()); + ( + 200, + br#"{"token":"e30.eyJleHAiOjQxMDI0NDQ4MDB9.test"}"#.to_vec(), + ) + } else { + assert_eq!(path, "/getObject"); + let req = GetObjectRequest::decode(&data[end..]).unwrap(); + assert_eq!(req.store_id, "owned-store"); + reads.lock().await.push(req.clone()); + match rows.lock().await.get(&req.key).cloned() { + Some(value) => ( + 200, + GetObjectResponse { value: Some(value) }.encode_to_vec(), + ), + None => ( + 404, + ErrorResponse { + error_code: ErrorCode::NoSuchKeyException as i32, + message: "missing".into(), + } + .encode_to_vec(), + ), + } + }; + let should_hold = held.lock().await.as_deref() == Some(&path); + if should_hold && path != "/getObject" { + arrived.notify_one(); + resume.notified().await; + } + let response = format!("HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",body.len()); + let header_written = stream.write_all(response.as_bytes()).await; + let prefix = if should_hold && path == "/getObject" { + body.len() / 2 + } else { + 0 + }; + if prefix > 0 { + header_written.unwrap(); + stream.write_all(&body[..prefix]).await.unwrap(); + arrived.notify_one(); + resume.notified().await; + } + let _ = stream.write_all(&body[prefix..]).await; + } + }); + Self { + url, + objects, + auth_keys, + requests, + hold, + received, + release, + task, + } + } + + async fn session(&self, seed: [u8; 64]) -> BackupReadSession { + BackupReadSession::new( + BackupReadConfig { + base_url: self.url.clone(), + store_id: "owned-store".into(), + lnurl_auth_server_url: format!("{}/auth", self.url), + }, + seed, + ) + .await + .unwrap() + } + + async fn app_object(&self, session: &BackupReadSession) -> KeyValue { + let key = session.app.build_key("WALLET"); + let value = session + .app + .storable_builder + .build( + b"complete archive".to_vec(), + -1, + &session.app.app_data_encryption_key, + key.as_bytes(), + ) + .encode_to_vec(); + let row = KeyValue { + key, + value, + version: 2, + }; + self.objects + .lock() + .await + .insert(row.key.clone(), row.clone()); + row + } + + async fn ldk_object(&self, session: &BackupReadSession, namespace: &LdkNamespace) -> KeyValue { + let key = session.ldk.build_key("state", namespace); + let value = session + .ldk + .storable_builder + .build( + b"ldk state".to_vec(), + -1, + &session.ldk.data_encryption_key, + key.as_bytes(), + ) + .encode_to_vec(); + let row = KeyValue { + key, + value, + version: 3, + }; + self.objects + .lock() + .await + .insert(row.key.clone(), row.clone()); + row + } +} + +use std::str::FromStr; +fn app() -> BackupReadTarget { + BackupReadTarget::App { + key: "WALLET".into(), + } +} +fn ldk(namespace: LdkNamespace) -> BackupReadTarget { + BackupReadTarget::Ldk { + key: "state".into(), + namespace, + } +} + +#[tokio::test] +async fn both_domains_authenticate_and_preserve_legacy_derivations() { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + let app_row = fixture.app_object(&session).await; + let ldk_row = fixture.ldk_object(&session, &LdkNamespace::Monitors).await; + let app_read = session.read(app(), 4096).await.unwrap(); + let ldk_read = session + .read(ldk(LdkNamespace::Monitors), 4096) + .await + .unwrap(); + let a = session.receipt(&app_read).unwrap().unwrap(); + let l = session.receipt(&ldk_read).unwrap().unwrap(); + assert_eq!(a.value, b"complete archive"); + assert_eq!(l.value, b"ldk state"); + assert_eq!( + a.envelope_sha256, + sha256::Hash::hash(&app_row.value).to_string() + ); + assert_eq!( + l.envelope_sha256, + sha256::Hash::hash(&ldk_row.value).to_string() + ); + assert_eq!(a.storage_key, app_row.key); + assert_eq!(l.storage_key, ldk_row.key); + assert_eq!((a.version, l.version), (2, 3)); + let first_keys = fixture.auth_keys.lock().await.clone(); + assert_eq!(first_keys.len(), 2); + assert_ne!(first_keys[0], first_keys[1]); + let mut another_seed = [42; 64]; + another_seed[32..].fill(24); + let other = fixture.session(another_seed).await; + assert_eq!( + session.app.app_data_encryption_key, + other.app.app_data_encryption_key + ); + assert_ne!( + session.ldk.data_encryption_key, + other.ldk.data_encryption_key + ); + other.read(app(), 4096).await.unwrap(); + let missing = other.read(ldk(LdkNamespace::Monitors), 4096).await.unwrap(); + assert!(other.receipt(&missing).unwrap().is_none()); + let keys = fixture.auth_keys.lock().await; + assert_eq!(keys[0], keys[2]); + assert_ne!(keys[1], keys[3]); +} + +#[tokio::test] +async fn identical_wallet_arguments_do_not_adopt_another_session_observation() { + let fixture = Fixture::new().await; + let first = fixture.session([42; 64]).await; + let second = fixture.session([42; 64]).await; + fixture.app_object(&first).await; + let result = first.read(app(), 4096).await.unwrap(); + assert!(matches!( + second.receipt(&result), + Err(BackupReadError::DifferentOwner) + )); + assert!(first.receipt(&result).unwrap().is_some()); + first.revoke(); + assert!(matches!( + first.receipt(&result), + Err(BackupReadError::Revoked) + )); +} + +#[tokio::test] +async fn typed_absence_keeps_original_target_and_owner() { + let fixture = Fixture::new().await; + let first = fixture.session([42; 64]).await; + let missing = first + .read( + ldk(LdkNamespace::MonitorUpdates { + monitor_id: "original".into(), + }), + 4096, + ) + .await + .unwrap(); + assert!(first.receipt(&missing).unwrap().is_none()); + assert!( + matches!(missing.target(),BackupReadTarget::Ldk { key, namespace:LdkNamespace::MonitorUpdates { monitor_id } } if key=="state" && monitor_id=="original") + ); + let replacement = fixture.session([24; 64]).await; + assert!(matches!( + replacement.receipt(&missing), + Err(BackupReadError::DifferentOwner) + )); + first.revoke(); + assert!(matches!( + first.receipt(&missing), + Err(BackupReadError::Revoked) + )); +} + +#[tokio::test] +async fn revoked_session_never_starts_a_request() { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + session.revoke(); + session.revoke(); + assert!(matches!( + session.read(app(), 4096).await, + Err(BackupReadError::Revoked) + )); + assert!(fixture.auth_keys.lock().await.is_empty()); + assert!(fixture.requests.lock().await.is_empty()); +} + +#[tokio::test] +async fn revoke_wakes_pending_credentials_and_storage_without_replay() { + for held in ["/auth", "/getObject"] { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + fixture.app_object(&session).await; + *fixture.hold.lock().await = Some(held.into()); + let read = session.read(app(), 4096); + tokio::pin!(read); + tokio::select! { _=fixture.received.notified()=>{}, _=&mut read=>panic!("read completed before held boundary") } + session.revoke(); + assert!(matches!( + tokio::time::timeout(std::time::Duration::from_secs(1), read) + .await + .unwrap(), + Err(BackupReadError::Revoked) + )); + fixture.release.notify_one(); + assert!(matches!( + session.read(app(), 4096).await, + Err(BackupReadError::Revoked) + )); + assert_eq!( + fixture.requests.lock().await.len(), + usize::from(held == "/getObject") + ); + } +} + +#[tokio::test] +async fn dropping_one_pending_read_does_not_revoke_or_replay_the_session() { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + fixture.app_object(&session).await; + *fixture.hold.lock().await = Some("/getObject".into()); + { + let read = session.read(app(), 4096); + tokio::pin!(read); + tokio::select! { _=fixture.received.notified()=>{}, _=&mut read=>panic!("unexpected completion") } + } + *fixture.hold.lock().await = None; + fixture.release.notify_one(); + let observation = session.read(app(), 4096).await.unwrap(); + assert!(session.receipt(&observation).unwrap().is_some()); + assert_eq!(fixture.requests.lock().await.len(), 2); +} + +#[tokio::test] +async fn singleton_replacement_cannot_retarget_the_owned_pair() { + let _lock = crate::FFI_TEST_LOCK.lock().await; + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + fixture.app_object(&session).await; + fixture.ldk_object(&session, &LdkNamespace::Default).await; + let other_fixture = Fixture::new().await; + let other = other_fixture.session([24; 64]).await; + *crate::get_vss_client().lock().unwrap() = Some(session.app.clone()); + *crate::get_ldk_client().lock().unwrap() = Some(session.ldk.clone()); + *fixture.hold.lock().await = Some("/auth".into()); + let pending = session.read(app(), 4096); + tokio::pin!(pending); + tokio::select! { + _ = fixture.received.notified() => {}, + _ = &mut pending => panic!("original read completed before replacement"), + } + *crate::get_vss_client().lock().unwrap() = Some(other.app.clone()); + *crate::get_ldk_client().lock().unwrap() = Some(other.ldk.clone()); + *fixture.hold.lock().await = None; + fixture.release.notify_one(); + let original = pending.await.unwrap(); + assert_eq!( + session.receipt(&original).unwrap().unwrap().value, + b"complete archive" + ); + let original_ldk = session + .read(ldk(LdkNamespace::Default), 4096) + .await + .unwrap(); + assert_eq!( + session.receipt(&original_ldk).unwrap().unwrap().value, + b"ldk state" + ); + assert_eq!(fixture.requests.lock().await.len(), 2); + assert!(other_fixture.requests.lock().await.is_empty()); + assert!(other_fixture.auth_keys.lock().await.is_empty()); + assert_eq!(fixture.auth_keys.lock().await.len(), 2); + crate::vss_shutdown_client(); + crate::vss_shutdown_ldk_client(); +} + +#[tokio::test] +async fn ldk_receipt_refuses_cross_namespace_aad_wrong_key_and_response_limit() { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + let row = fixture.ldk_object(&session, &LdkNamespace::Monitors).await; + let wrong_key = session + .ldk + .build_key("state", &LdkNamespace::ArchivedMonitors); + fixture + .objects + .lock() + .await + .insert(wrong_key.clone(), row.clone()); + assert!(matches!( + session + .read(ldk(LdkNamespace::ArchivedMonitors), 4096) + .await, + Err(BackupReadError::Read(VssError::InvalidData { .. })) + )); + fixture.objects.lock().await.insert( + wrong_key.clone(), + KeyValue { + key: wrong_key, + ..row + }, + ); + assert!(matches!( + session + .read(ldk(LdkNamespace::ArchivedMonitors), 4096) + .await, + Err(BackupReadError::Read(VssError::InvalidData { .. })) + )); + assert!(matches!( + session.read(ldk(LdkNamespace::Monitors), 1).await, + Err(BackupReadError::Read(VssError::InvalidData { .. })) + )); + assert_eq!(fixture.requests.lock().await.len(), 3); +} diff --git a/src/ldk_client.rs b/src/ldk_client.rs index bb746d1..f6a8a1a 100644 --- a/src/ldk_client.rs +++ b/src/ldk_client.rs @@ -46,14 +46,15 @@ type LdkRetryPolicy = FilteredRetryPolicy< /// 4. HKDF(vss_seed) -> (data_encryption_key, obfuscation_master_key) /// 5. KeyObfuscator::new(obfuscation_master_key) /// -/// LNURL-auth: derived from truncated 32-byte seed path (m/877'/138') -/// to maintain the same server identity as the app client. +/// LNURL-auth uses the full 64-byte seed path (m/877'/138'), matching ldk-node. +/// The app client uses a different, truncated-seed identity. #[derive(Clone)] pub struct LdkVssClient { inner: Arc>, - store_id: String, - storable_builder: Arc>, - data_encryption_key: [u8; 32], + pub(crate) receipt_reader: Arc, + pub(crate) store_id: String, + pub(crate) storable_builder: Arc>, + pub(crate) data_encryption_key: [u8; 32], key_obfuscator: Arc, } @@ -69,10 +70,8 @@ impl LdkVssClient { // LNURL-auth: from full 64-byte seed (same server identity as ldk-node) let auth_master_xprv = - Xpriv::new_master(Network::Bitcoin, &seed).map_err(|e| { - VssError::ConnectionError { - error_details: format!("Failed to create auth master key: {}", e), - } + Xpriv::new_master(Network::Bitcoin, &seed).map_err(|e| VssError::ConnectionError { + error_details: format!("Failed to create auth master key: {}", e), })?; let auth_vss_xprv = auth_master_xprv .derive_priv( @@ -124,24 +123,28 @@ impl LdkVssClient { derive_data_encryption_and_obfuscation_keys(&vss_seed); let key_obfuscator = Arc::new(KeyObfuscator::new(obfuscation_master_key)); - let retry_policy = - ExponentialBackoffRetryPolicy::new(std::time::Duration::from_millis(10)) - .with_max_attempts(10) - .with_max_total_delay(std::time::Duration::from_secs(15)) - .with_max_jitter(std::time::Duration::from_millis(10)) - .skip_retry_on_error(Box::new(|e: &ExternalVssError| { - matches!( - e, - ExternalVssError::NoSuchKeyError(..) - | ExternalVssError::InvalidRequestError(..) - | ExternalVssError::ConflictError(..) - ) - }) as _); + let retry_policy = ExponentialBackoffRetryPolicy::new(std::time::Duration::from_millis(10)) + .with_max_attempts(10) + .with_max_total_delay(std::time::Duration::from_secs(15)) + .with_max_jitter(std::time::Duration::from_millis(10)) + .skip_retry_on_error(Box::new(|e: &ExternalVssError| { + matches!( + e, + ExternalVssError::NoSuchKeyError(..) + | ExternalVssError::InvalidRequestError(..) + | ExternalVssError::ConflictError(..) + ) + }) as _); + let receipt_reader = Arc::new(crate::receipt::ReceiptReader::new( + &base_url, + Arc::clone(&header_provider) as Arc, + )?); let client = ExternalVssClient::new_with_headers(base_url, retry_policy, header_provider); Ok(LdkVssClient { inner: Arc::new(client), + receipt_reader, store_id, storable_builder: Arc::new(StorableBuilder::new(RandEntropySource)), data_encryption_key, @@ -225,11 +228,7 @@ impl LdkVssClient { } /// Deletes a key-value pair using ldk-node's namespaced key format. - pub async fn delete( - &self, - key: String, - namespace: &LdkNamespace, - ) -> Result { + pub async fn delete(&self, key: String, namespace: &LdkNamespace) -> Result { let storage_key = self.build_key(&key, namespace); let request = DeleteObjectRequest { store_id: self.store_id.clone(), @@ -247,10 +246,7 @@ impl LdkVssClient { } /// Lists keys in a specific namespace with client-side deobfuscation. - pub async fn list_keys( - &self, - namespace: &LdkNamespace, - ) -> Result, VssError> { + pub async fn list_keys(&self, namespace: &LdkNamespace) -> Result, VssError> { let prefix = Some(self.build_prefix(namespace)); self.list_key_versions_deobfuscated(prefix).await } diff --git a/src/lib.rs b/src/lib.rs index 58f6a84..2dccd27 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +mod backup_session; mod conditional; mod errors; #[cfg(test)] @@ -8,6 +9,9 @@ mod receipt; mod tests; mod types; +pub use backup_session::{ + BackupReadConfig, BackupReadError, BackupReadObservation, BackupReadSession, BackupReadTarget, +}; pub use errors::*; pub use implementation::{derive_vss_store_id, VssClient}; pub use ldk_client::LdkVssClient; diff --git a/src/receipt.rs b/src/receipt.rs index 1b6e12c..98e8135 100644 --- a/src/receipt.rs +++ b/src/receipt.rs @@ -1,12 +1,14 @@ //! Strict, read-only snapshots of the encrypted objects stored by VSS. -use crate::{VssClient, VssError, VssReadReceipt}; +use crate::implementation::RandEntropySource; +use crate::{LdkNamespace, LdkVssClient, VssClient, VssError, VssReadReceipt}; use bitcoin::hashes::{sha256, Hash}; use prost::{ encoding::{decode_key, decode_varint, skip_field, DecodeContext, WireType}, Message, }; use std::{sync::Arc, time::Duration}; +use vss_client_ng::util::storable_builder::StorableBuilder; use vss_client_ng::{ headers::{VssHeaderProvider, VssHeaderProviderError}, types::{ErrorCode, ErrorResponse, GetObjectRequest, GetObjectResponse, Storable}, @@ -106,6 +108,63 @@ impl VssClient { else { return Ok(None); }; + ReceiptDecoder { + store_id: &self.store_id, + builder: &self.storable_builder, + encryption_key: &self.app_data_encryption_key, + } + .decode(stored, key, storage_key) + .map(Some) + } +} + +impl LdkVssClient { + /// Reads one V1-namespaced LDK object with the same strict limits as app receipts. + /// This Rust source API does not affect the running ldk-node or its admission. + pub async fn get_with_receipt( + &self, + key: String, + namespace: &LdkNamespace, + max_response_bytes: u32, + ) -> Result, VssError> { + if key.is_empty() { + return Err(invalid("The key must not be empty")); + } + let storage_key = self.build_key(&key, namespace); + let request = GetObjectRequest { + store_id: self.store_id.clone(), + key: storage_key.clone(), + }; + let Some(stored) = self + .receipt_reader + .read(request, max_response_bytes) + .await? + else { + return Ok(None); + }; + ReceiptDecoder { + store_id: &self.store_id, + builder: &self.storable_builder, + encryption_key: &self.data_encryption_key, + } + .decode(stored, key, storage_key) + .map(Some) + } +} + +struct ReceiptDecoder<'a> { + store_id: &'a str, + builder: &'a StorableBuilder, + encryption_key: &'a [u8; 32], +} + +impl ReceiptDecoder<'_> { + fn decode( + &self, + stored: vss_client_ng::types::KeyValue, + key: String, + storage_key: String, + ) -> Result { if stored.key != storage_key || stored.version <= 0 { return Err(invalid("The response key or server version is invalid")); } @@ -121,26 +180,22 @@ impl VssClient { return Err(invalid("Unsupported encrypted backup format")); } let (value, embedded_version) = self - .storable_builder - .deconstruct( - storable, - &self.app_data_encryption_key, - storage_key.as_bytes(), - ) + .builder + .deconstruct(storable, self.encryption_key, storage_key.as_bytes()) .map_err(|_| invalid("Cannot authenticate the stored envelope"))?; if embedded_version != -1 && embedded_version != stored.version { return Err(invalid( "The encrypted version does not match the server version", )); } - Ok(Some(VssReadReceipt { + Ok(VssReadReceipt { key, value, - store_id: self.store_id.clone(), + store_id: self.store_id.to_owned(), storage_key, envelope_sha256, version: stored.version, - })) + }) } } diff --git a/src/types.rs b/src/types.rs index cbf214a..b4a1bc2 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -/// A decrypted app object and the SHA-256 of its exact stored encrypted envelope. +/// A decrypted backup object and the SHA-256 of its exact stored encrypted envelope. /// This is a local read observation, not a signed server receipt or migration /// authorization. Store IDs alone do not identify an authenticated principal. /// Callers must retain their original wallet/endpoint identity and revalidate it