From 32daa7c7ac06b681327fb367d147638746aa4f4b Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 3 Nov 2026 21:19:01 -0500 Subject: [PATCH 1/2] feat: add exact encrypted backup read receipts --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 29 +++ src/implementation.rs | 6 + src/lib.rs | 27 +++ src/receipt.rs | 235 +++++++++++++++++++++++ src/receipt/tests.rs | 423 ++++++++++++++++++++++++++++++++++++++++++ src/types.rs | 17 ++ 8 files changed, 739 insertions(+) create mode 100644 src/receipt.rs create mode 100644 src/receipt/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 40d7710..1e11555 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2015,6 +2015,7 @@ dependencies = [ name = "vss-rust-client-ffi" version = "0.5.26-rc1" dependencies = [ + "async-trait", "bip39", "bitcoin", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index 9b8043b..51e09e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ rand = "0.8.5" reqwest = { version = "0.12.25", default-features = false, features = ["rustls-tls"] } [dev-dependencies] +async-trait = "0.1.89" tokio = { version = "1.40.0", features = ["full"] } # Override to use vendored OpenSSL for Android builds diff --git a/README.md b/README.md index d97cf37..2805c82 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,35 @@ or exclude old clients. Migration policy, immutable component references, causal snapshot consistency and restore gates are application/server responsibilities. This API does not enable a new Bitkit backup format. +### Exact encrypted-object read observations (source API) + +`vss_get_with_receipt(key, max_response_bytes)` returns the decrypted app object, +logical and opaque storage keys, configured store ID, actual outer server version, +and a lowercase SHA-256 digest of the exact encrypted `KeyValue.value` bytes. The +hash is computed before protobuf decoding, including any unknown envelope fields. +Legacy unconditional envelopes with embedded version `-1` remain readable; other +embedded versions must match the positive outer version. Key derivation and +key-bound authentication are unchanged. + +The storage request is made once without redirects. Only an explicit typed HTTP +404 missing-key response returns `None`; an empty success, malformed response, +authentication failure, wrong key/version, or exceeded limit is an error. The +caller chooses a complete protobuf response limit between 1 byte and 64 MiB. The +ten-second deadline includes credential acquisition and response reading; the +byte limit applies to the storage response, not the existing credential provider's +separate requests. Authentic encrypted empty plaintext remains a valid object. + +This is a local observation, not a signed server receipt or proof of freshness, +principal identity, migration authority, or multi-object coherence. Keep the +original wallet and endpoint context, reject stale-owner completions, and never +log the receipt or its plaintext. Replacing or shutting down the global client +does not retarget or necessarily cancel an in-flight read. No backup format, +reader/writer fence, or app activation is enabled by this API. + +The checked-in Swift package continues to use its matching released wrapper and +binary. This new source API requires regenerated bindings and a future coordinated +SDK release before app adoption; it is not present in the existing release. + ### Swift (iOS) ```swift diff --git a/src/implementation.rs b/src/implementation.rs index 52f32f6..bc405f6 100644 --- a/src/implementation.rs +++ b/src/implementation.rs @@ -112,6 +112,7 @@ impl EntropySource for RandEntropySource { pub struct VssClient { inner: Arc>, pub(crate) conditional_inner: Arc>, + pub(crate) receipt_reader: Arc, pub(crate) store_id: String, pub(crate) storable_builder: Arc>, pub(crate) app_data_encryption_key: [u8; 32], @@ -220,6 +221,10 @@ impl VssClient { let conditional_inner = crate::conditional::client(&base_url, Arc::clone(&header_provider))?; + let receipt_reader = Arc::new(crate::receipt::ReceiptReader::new( + &base_url, + Arc::clone(&header_provider), + )?); let client = ExternalVssClient::new_with_headers(base_url, retry_policy, header_provider); let storable_builder = Arc::new(StorableBuilder::new(RandEntropySource)); @@ -234,6 +239,7 @@ impl VssClient { Ok(VssClient { inner: Arc::new(client), conditional_inner: Arc::new(conditional_inner), + receipt_reader, store_id, storable_builder, app_data_encryption_key, diff --git a/src/lib.rs b/src/lib.rs index ec25a2e..5ad6cde 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ mod errors; mod ffi_tests; mod implementation; mod ldk_client; +mod receipt; mod tests; mod types; @@ -310,6 +311,32 @@ pub async fn vss_put_conditional( }) } +/// Reads an app object and hashes the exact encrypted `KeyValue.value` bytes +/// before decoding, without changing encryption, keys, or server data. +/// +/// Only a typed HTTP 404 `NoSuchKeyException` returns `None`. Empty, malformed, +/// unauthenticated, oversized, or mismatched responses are errors. The byte +/// limit includes the complete protobuf response and must be 1..=64 MiB. +/// The storage request is attempted once, without redirects; credentials may +/// be obtained by the captured client's existing header provider. The ten-second +/// deadline includes credential acquisition and reading the bounded response. +/// +/// Captures one client before awaiting; replacing the singleton cannot retarget +/// this read. It does not prevent an old read completing after shutdown. The +/// caller must reject stale-owner completions. This observation neither proves +/// freshness/absence against a malicious server nor fences legacy readers or +/// writers, authorizes migration, or makes a multi-object snapshot coherent. +#[uniffi::export] +pub async fn vss_get_with_receipt( + key: String, + max_response_bytes: u32, +) -> Result, VssError> { + execute_async!(async move { + let client = try_get_client()?; + client.get_with_receipt(key, max_response_bytes).await + }) +} + /// Deletes a key-value pair from the VSS server. /// /// This function removes the specified key and its associated data from storage. diff --git a/src/receipt.rs b/src/receipt.rs new file mode 100644 index 0000000..1b6e12c --- /dev/null +++ b/src/receipt.rs @@ -0,0 +1,235 @@ +//! Strict, read-only snapshots of the encrypted objects stored by VSS. + +use crate::{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::{ + headers::{VssHeaderProvider, VssHeaderProviderError}, + types::{ErrorCode, ErrorResponse, GetObjectRequest, GetObjectResponse, Storable}, +}; + +const TIMEOUT: Duration = Duration::from_secs(10); +const MAX_RESPONSE_BYTES: u32 = 64 * 1024 * 1024; + +pub(crate) struct ReceiptReader { + http: reqwest::Client, + url: String, + headers: Arc, +} + +impl ReceiptReader { + pub(crate) fn new( + base_url: &str, + headers: Arc, + ) -> Result { + let http = reqwest::Client::builder() + .timeout(TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .build() + .map_err(|_| network_error())?; + Ok(Self { + http, + url: format!("{}/getObject", base_url.trim_end_matches('/')), + headers, + }) + } + + async fn read( + &self, + request: GetObjectRequest, + limit: u32, + ) -> Result, VssError> { + if limit == 0 || limit > MAX_RESPONSE_BYTES { + return Err(invalid( + "Response limit must be between 1 and 67108864 bytes", + )); + } + tokio::time::timeout(TIMEOUT, self.request(request, limit as usize)) + .await + .map_err(|_| network_error())? + } + + async fn request( + &self, + request: GetObjectRequest, + limit: usize, + ) -> Result, VssError> { + let body = request.encode_to_vec(); + let headers = self + .headers + .get_headers(&body) + .await + .map_err(credential_error)?; + let mut request = self + .http + .post(&self.url) + .header("Content-Type", "application/octet-stream"); + for (name, value) in headers { + request = request.header(name, value); + } + let response = request + .body(body) + .send() + .await + .map_err(|_| network_error())?; + let status = response.status(); + let bytes = bounded_body(response, limit).await?; + decode_response(status, &bytes) + } +} + +impl VssClient { + /// Reads one app object without following redirects or retrying the storage request. + /// See [`crate::vss_get_with_receipt`] for the receipt's limits. + pub async fn get_with_receipt( + &self, + key: String, + 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); + 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); + }; + if stored.key != storage_key || stored.version <= 0 { + return Err(invalid("The response key or server version is invalid")); + } + // Hash before decoding: protobuf re-encoding can discard unknown fields. + let envelope_sha256 = sha256::Hash::hash(&stored.value).to_string(); + let storable = Storable::decode(stored.value.as_slice()) + .map_err(|_| invalid("Cannot decode the stored envelope"))?; + if !storable + .encryption_metadata + .as_ref() + .is_some_and(|metadata| metadata.cipher_format == "ChaCha20Poly1305") + { + 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(), + ) + .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 { + key, + value, + store_id: self.store_id.clone(), + storage_key, + envelope_sha256, + version: stored.version, + })) + } +} + +async fn bounded_body(mut response: reqwest::Response, limit: usize) -> Result, VssError> { + if response + .content_length() + .is_some_and(|size| size > limit as u64) + { + return Err(invalid( + "The backup response exceeds the requested byte limit", + )); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| network_error())? { + if chunk.len() > limit - bytes.len() { + return Err(invalid( + "The backup response exceeds the requested byte limit", + )); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +fn decode_response( + status: reqwest::StatusCode, + bytes: &[u8], +) -> Result, VssError> { + if status == reqwest::StatusCode::NOT_FOUND && is_missing_key_response(bytes)? { + return Ok(None); + } + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(VssError::AuthError { + error_details: "Backup read was not authorized".into(), + }); + } + if status != reqwest::StatusCode::OK { + return Err(VssError::GetError { + error_details: "Backup read did not return an object".into(), + }); + } + GetObjectResponse::decode(bytes) + .map_err(|_| invalid("Invalid backup response"))? + .value + .map(Some) + .ok_or_else(|| invalid("A successful backup response omitted its object")) +} + +fn is_missing_key_response(mut bytes: &[u8]) -> Result { + let malformed = || invalid("Invalid missing-key response"); + // Validate the known string field too, but do not trust the enum's narrowing + // conversion from a wire u64 to i32 when deciding that backup data is absent. + ErrorResponse::decode(bytes).map_err(|_| malformed())?; + let mut code = None; + while !bytes.is_empty() { + let (tag, wire) = decode_key(&mut bytes).map_err(|_| malformed())?; + if tag == 1 { + if wire != WireType::Varint || code.is_some() { + return Err(malformed()); + } + code = Some(decode_varint(&mut bytes).map_err(|_| malformed())?); + } else { + skip_field(wire, tag, &mut bytes, DecodeContext::default()).map_err(|_| malformed())?; + } + } + Ok(code == Some(ErrorCode::NoSuchKeyException as u64)) +} + +fn invalid(details: &str) -> VssError { + VssError::InvalidData { + error_details: details.into(), + } +} + +fn network_error() -> VssError { + VssError::NetworkError { + error_details: "Backup receipt transport failed or timed out".into(), + } +} + +fn credential_error(error: VssHeaderProviderError) -> VssError { + match error { + VssHeaderProviderError::AuthorizationError { .. } => VssError::AuthError { + error_details: "Backup read credentials were not authorized".into(), + }, + VssHeaderProviderError::RequestError { .. } => network_error(), + _ => invalid("Cannot obtain valid backup read credentials"), + } +} + +#[cfg(test)] +mod tests; diff --git a/src/receipt/tests.rs b/src/receipt/tests.rs new file mode 100644 index 0000000..d097deb --- /dev/null +++ b/src/receipt/tests.rs @@ -0,0 +1,423 @@ +use super::*; +use std::{ + collections::HashMap, + sync::atomic::{AtomicUsize, Ordering}, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::{Mutex, Notify}, + task::JoinHandle, +}; +use vss_client_ng::{headers::FixedHeaders, types::KeyValue}; + +#[derive(Clone)] +struct Reply { + status: u16, + body: Vec, + chunked: bool, + hold: bool, +} + +struct Server { + url: String, + reply: Arc>, + requests: Arc>>, + count: Arc, + received: Arc, + release: Arc, + task: JoinHandle<()>, +} + +impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl Server { + async fn new() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let reply = Arc::new(Mutex::new(Reply { + status: 200, + body: vec![], + chunked: false, + hold: false, + })); + let requests = Arc::new(Mutex::new(Vec::new())); + let count = Arc::new(AtomicUsize::new(0)); + let received = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let (replies, captured, calls, signal, resume) = ( + reply.clone(), + requests.clone(), + count.clone(), + received.clone(), + release.clone(), + ); + let task = tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + let mut bytes = Vec::new(); + let header_end = loop { + let mut buf = [0; 4096]; + let n = stream.read(&mut buf).await.unwrap(); + assert_ne!(n, 0); + bytes.extend_from_slice(&buf[..n]); + if let Some(i) = bytes.windows(4).position(|w| w == b"\r\n\r\n") { + break i + 4; + } + }; + let headers = std::str::from_utf8(&bytes[..header_end]).unwrap(); + assert!(headers.starts_with("POST /getObject HTTP/1.1")); + let length: usize = headers + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().unwrap()) + }) + .unwrap(); + while bytes.len() < header_end + length { + let mut buf = [0; 4096]; + let n = stream.read(&mut buf).await.unwrap(); + assert_ne!(n, 0); + bytes.extend_from_slice(&buf[..n]); + } + captured + .lock() + .await + .push(GetObjectRequest::decode(&bytes[header_end..]).unwrap()); + calls.fetch_add(1, Ordering::SeqCst); + let reply = replies.lock().await.clone(); + signal.notify_one(); + let framing = if reply.chunked { + "Transfer-Encoding: chunked".into() + } else { + format!("Content-Length: {}", reply.body.len()) + }; + let header = format!("HTTP/1.1 {} Test\r\n{framing}\r\nLocation: /getObject\r\nConnection: close\r\n\r\n", reply.status); + let _ = stream.write_all(header.as_bytes()).await; + if reply.hold { + resume.notified().await; + } + let mut response = Vec::new(); + if reply.chunked { + for chunk in reply.body.chunks(13) { + response.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes()); + response.extend_from_slice(chunk); + response.extend_from_slice(b"\r\n"); + } + response.extend_from_slice(b"0\r\n\r\n"); + } else { + response.extend_from_slice(&reply.body); + } + // Oversized/cancelled reads may intentionally close before consuming the body. + let _ = stream.write_all(&response).await; + } + }); + Self { + url, + reply, + requests, + count, + received, + release, + task, + } + } + + async fn client(&self, seed: u8) -> VssClient { + VssClient::new_with_header_provider( + self.url.clone(), + "app-store".into(), + Arc::new(FixedHeaders::new(HashMap::new())), + Some([seed; 32]), + ) + .await + .unwrap() + } + + async fn object(&self, kv: KeyValue) { + self.reply.lock().await.body = GetObjectResponse { value: Some(kv) }.encode_to_vec(); + } +} + +fn encrypted(client: &VssClient, key: &str, embedded: i64, version: i64) -> KeyValue { + let key = client.build_key(key); + let value = client + .storable_builder + .build( + b"complete snapshot".to_vec(), + embedded, + &client.app_data_encryption_key, + key.as_bytes(), + ) + .encode_to_vec(); + KeyValue { + key, + value, + version, + } +} + +#[tokio::test] +async fn hashes_exact_envelope_including_unknown_protobuf_fields() { + let server = Server::new().await; + let client = server.client(42).await; + let mut kv = encrypted(&client, "WALLET", 2, 2); + let reencoded = kv.value.clone(); + kv.value.extend_from_slice(&[0xf8, 0x07, 0x2a]); + let expected = sha256::Hash::hash(&kv.value).to_string(); + server.object(kv.clone()).await; + let receipt = client + .get_with_receipt("WALLET".into(), 4096) + .await + .unwrap() + .unwrap(); + assert_eq!(receipt.envelope_sha256, expected); + assert_ne!( + receipt.envelope_sha256, + sha256::Hash::hash(&reencoded).to_string() + ); + assert_eq!(receipt.value, b"complete snapshot"); + assert_eq!(receipt.storage_key, kv.key); + assert_eq!(receipt.key, "WALLET"); + assert_eq!(receipt.store_id, "app-store"); + assert_eq!(receipt.version, 2); + let requests = server.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].key, receipt.storage_key); + assert_eq!(requests[0].store_id, receipt.store_id); +} + +#[tokio::test] +async fn accepts_legacy_unconditional_envelope_without_rewriting_it() { + let server = Server::new().await; + let client = server.client(42).await; + server.object(encrypted(&client, "WALLET", -1, 7)).await; + let receipt = client + .get_with_receipt("WALLET".into(), 4096) + .await + .unwrap() + .unwrap(); + assert_eq!(receipt.version, 7); + assert_eq!(receipt.value, b"complete snapshot"); +} + +#[tokio::test] +async fn only_explicit_missing_key_is_absence() { + let server = Server::new().await; + let client = server.client(42).await; + let missing = ErrorResponse { + error_code: ErrorCode::NoSuchKeyException as i32, + message: "missing".into(), + } + .encode_to_vec(); + for (status, body) in [ + (200, vec![]), + (404, b"not found".to_vec()), + (401, missing.clone()), + (500, missing.clone()), + (200, vec![0xff]), + (307, vec![]), + ] { + *server.reply.lock().await = Reply { + status, + body, + chunked: false, + hold: false, + }; + let before = server.count.load(Ordering::SeqCst); + assert!(client + .get_with_receipt("WALLET".into(), 4096) + .await + .is_err()); + assert_eq!(server.count.load(Ordering::SeqCst), before + 1); + } + *server.reply.lock().await = Reply { + status: 404, + body: missing, + chunked: false, + hold: false, + }; + assert!(client + .get_with_receipt("WALLET".into(), 4096) + .await + .unwrap() + .is_none()); +} + +#[tokio::test] +async fn rejects_wrong_key_owner_and_invalid_versions() { + let server = Server::new().await; + let client = server.client(42).await; + let foreign = server.client(43).await; + let mut wrong_owner = encrypted(&foreign, "WALLET", 1, 1); + wrong_owner.key = client.build_key("WALLET"); + let mut wrong_aad = encrypted(&client, "OTHER", 1, 1); + wrong_aad.key = client.build_key("WALLET"); + for kv in [ + wrong_owner, + wrong_aad, + encrypted(&client, "OTHER", 1, 1), + encrypted(&client, "WALLET", 1, 0), + encrypted(&client, "WALLET", 1, 2), + encrypted(&client, "WALLET", -2, 1), + ] { + server.object(kv).await; + assert!(client + .get_with_receipt("WALLET".into(), 4096) + .await + .is_err()); + } +} + +#[tokio::test] +async fn bounds_declared_and_chunked_responses_before_decoding() { + let server = Server::new().await; + let client = server.client(42).await; + server.object(encrypted(&client, "WALLET", 1, 1)).await; + for chunked in [false, true] { + server.reply.lock().await.chunked = chunked; + assert!(matches!( + client.get_with_receipt("WALLET".into(), 16).await, + Err(VssError::InvalidData { .. }) + )); + } + let before = server.count.load(Ordering::SeqCst); + for limit in [0, MAX_RESPONSE_BYTES + 1] { + assert!(client + .get_with_receipt("WALLET".into(), limit) + .await + .is_err()); + } + assert!(client.get_with_receipt(String::new(), 4096).await.is_err()); + assert_eq!(server.count.load(Ordering::SeqCst), before); +} + +#[tokio::test] +async fn replacing_global_client_does_not_retarget_inflight_receipt() { + let _guard = crate::FFI_TEST_LOCK.lock().await; + let server = Server::new().await; + let client = server.client(42).await; + server.object(encrypted(&client, "WALLET", 1, 1)).await; + server.reply.lock().await.hold = true; + *crate::get_vss_client().lock().unwrap() = Some(client); + let read = tokio::spawn(crate::vss_get_with_receipt("WALLET".into(), 4096)); + tokio::time::timeout(Duration::from_secs(3), server.received.notified()) + .await + .unwrap(); + let replacement = server.client(43).await; + *crate::get_vss_client().lock().unwrap() = Some(replacement); + server.release.notify_one(); + let receipt = read.await.unwrap().unwrap().unwrap(); + assert_eq!(receipt.value, b"complete snapshot"); + assert_eq!(server.count.load(Ordering::SeqCst), 1); + crate::vss_shutdown_client(); +} + +#[tokio::test] +async fn rejects_malformed_encryption_metadata_and_modified_ciphertext() { + let server = Server::new().await; + let client = server.client(42).await; + let original = encrypted(&client, "WALLET", 1, 1); + for variant in 0..5 { + let mut kv = original.clone(); + let mut envelope = Storable::decode(kv.value.as_slice()).unwrap(); + match variant { + 0 => envelope.encryption_metadata = None, + 1 => envelope.encryption_metadata.as_mut().unwrap().nonce.clear(), + 2 => envelope.encryption_metadata.as_mut().unwrap().tag.clear(), + 3 => envelope.encryption_metadata.as_mut().unwrap().cipher_format = "unknown".into(), + _ => envelope.data[0] ^= 1, + } + kv.value = envelope.encode_to_vec(); + server.object(kv).await; + assert!(client + .get_with_receipt("WALLET".into(), 4096) + .await + .is_err()); + } +} + +#[test] +fn credential_failures_retain_auth_network_distinction_without_details() { + let secret = "private provider diagnostic".to_owned(); + let auth = credential_error(VssHeaderProviderError::AuthorizationError { + error: secret.clone(), + }); + let network = credential_error(VssHeaderProviderError::RequestError { + error: secret.clone(), + }); + let malformed = credential_error(VssHeaderProviderError::InvalidData { + error: secret.clone(), + }); + assert!(matches!(auth, VssError::AuthError { .. })); + assert!(matches!(network, VssError::NetworkError { .. })); + assert!(matches!(malformed, VssError::InvalidData { .. })); + for error in [auth, network, malformed] { + assert!(!error.to_string().contains(&secret)); + } +} + +#[test] +fn malformed_error_codes_never_become_absence() { + let wrapped = [0x08, 0x84, 0x80, 0x80, 0x80, 0x10]; + assert_eq!( + ErrorResponse::decode(wrapped.as_slice()) + .unwrap() + .error_code, + ErrorCode::NoSuchKeyException as i32 + ); + for bytes in [ + vec![0x08, 0x84, 0x80, 0x80, 0x80, 0x10], // 2^32 + 4 narrows to 4 in the legacy enum. + vec![0x08, 4, 0x08, 1], + vec![0x08, 1, 0x08, 4], + vec![0x08, 4, 0x08, 4], + vec![0x0a, 1, 4], // Packed encoding is invalid for this singular enum. + vec![0x08, 4, 0x12, 1, 0xff], // Malformed UTF-8 message. + ] { + assert!(decode_response(reqwest::StatusCode::NOT_FOUND, &bytes).is_err()); + } +} + +struct PendingCredentials; + +#[async_trait::async_trait] +impl VssHeaderProvider for PendingCredentials { + async fn get_headers( + &self, + _: &[u8], + ) -> Result, VssHeaderProviderError> { + std::future::pending().await + } +} + +#[tokio::test] +async fn deadline_includes_credentials_and_held_storage_response() { + let server = Server::new().await; + let pending = VssClient::new_with_header_provider( + server.url.clone(), + "original".into(), + Arc::new(PendingCredentials), + Some([42; 32]), + ) + .await + .unwrap(); + let client = server.client(42).await; + server.object(encrypted(&client, "WALLET", 1, 1)).await; + server.reply.lock().await.hold = true; + let results = tokio::time::timeout(Duration::from_secs(12), async { + tokio::join!( + pending.get_with_receipt("WALLET".into(), 4096), + client.get_with_receipt("WALLET".into(), 4096) + ) + }) + .await + .expect("The combined credential/read deadline must bound both reads"); + assert!(matches!(results.0, Err(VssError::NetworkError { .. }))); + assert!(matches!(results.1, Err(VssError::NetworkError { .. }))); + assert_eq!(server.count.load(Ordering::SeqCst), 1); + server.release.notify_one(); +} diff --git a/src/types.rs b/src/types.rs index 4a7dd18..e8f6e4b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,5 +1,22 @@ use serde::{Deserialize, Serialize}; +/// A decrypted app 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 +/// before using the observation. Do not log this record or its plaintext value. +#[derive(Clone, uniffi::Record)] +pub struct VssReadReceipt { + pub key: String, + pub value: Vec, + pub store_id: String, + pub storage_key: String, + /// Lowercase, 64-character hexadecimal SHA-256 of exact stored envelope bytes. + pub envelope_sha256: String, + /// Actual outer server version, not the legacy envelope's embedded -1 sentinel. + pub version: i64, +} + #[derive(Debug, Clone, uniffi::Record, Serialize, Deserialize)] pub struct VssItem { pub key: String, From 03035b8865f6837d54e632af43c2cde6aedc5a38 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 3 Nov 2026 21:33:53 -0500 Subject: [PATCH 2/2] fix: defer receipt FFI until a coordinated release --- README.md | 11 +++++++---- src/lib.rs | 4 +++- src/types.rs | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2805c82..866aa94 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ or exclude old clients. Migration policy, immutable component references, causal snapshot consistency and restore gates are application/server responsibilities. This API does not enable a new Bitkit backup format. -### Exact encrypted-object read observations (source API) +### Exact encrypted-object read observations (Rust source API) `vss_get_with_receipt(key, max_response_bytes)` returns the decrypted app object, logical and opaque storage keys, configured store ID, actual outer server version, @@ -78,9 +78,12 @@ log the receipt or its plaintext. Replacing or shutting down the global client does not retarget or necessarily cancel an in-flight read. No backup format, reader/writer fence, or app activation is enabled by this API. -The checked-in Swift package continues to use its matching released wrapper and -binary. This new source API requires regenerated bindings and a future coordinated -SDK release before app adoption; it is not present in the existing release. +This operation and its record are not exported through UniFFI. The checked-in +Swift package and regenerated interfaces continue to match the released API. +Swift/Kotlin availability requires a coordinated SDK release that adds the exports, +regenerates the interfaces, and publishes their matching native binaries. The +crate currently produces static and dynamic libraries, not a Rust dependency +library; this is a Rust source API, not a new distributed SDK entry point. ### Swift (iOS) diff --git a/src/lib.rs b/src/lib.rs index 5ad6cde..58f6a84 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -326,7 +326,9 @@ pub async fn vss_put_conditional( /// caller must reject stale-owner completions. This observation neither proves /// freshness/absence against a malicious server nor fences legacy readers or /// writers, authorizes migration, or makes a multi-object snapshot coherent. -#[uniffi::export] +/// +/// Rust source API only. Exporting this through UniFFI requires a coordinated +/// release of the generated interfaces and their matching native binaries. pub async fn vss_get_with_receipt( key: String, max_response_bytes: u32, diff --git a/src/types.rs b/src/types.rs index e8f6e4b..cbf214a 100644 --- a/src/types.rs +++ b/src/types.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; /// authorization. Store IDs alone do not identify an authenticated principal. /// Callers must retain their original wallet/endpoint identity and revalidate it /// before using the observation. Do not log this record or its plaintext value. -#[derive(Clone, uniffi::Record)] +#[derive(Clone)] pub struct VssReadReceipt { pub key: String, pub value: Vec,