Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
156 changes: 156 additions & 0 deletions src/backup_session.rs
Original file line number Diff line number Diff line change
@@ -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<watch::Sender<bool>>,
target: BackupReadTarget,
receipt: Option<VssReadReceipt>,
}

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<watch::Sender<bool>>,
}

impl BackupReadSession {
pub async fn new(config: BackupReadConfig, seed: [u8; 64]) -> Result<Self, VssError> {
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<BackupReadObservation, BackupReadError> {
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<Option<&'a VssReadReceipt>, 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;
Loading
Loading