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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,38 @@ 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 (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,
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.

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)

```swift
Expand Down
6 changes: 6 additions & 0 deletions src/implementation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ impl EntropySource for RandEntropySource {
pub struct VssClient {
inner: Arc<ExternalVssClient<CustomRetryPolicy>>,
pub(crate) conditional_inner: Arc<ExternalVssClient<crate::conditional::SingleAttempt>>,
pub(crate) receipt_reader: Arc<crate::receipt::ReceiptReader>,
pub(crate) store_id: String,
pub(crate) storable_builder: Arc<StorableBuilder<RandEntropySource>>,
pub(crate) app_data_encryption_key: [u8; 32],
Expand Down Expand Up @@ -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));
Expand All @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod errors;
mod ffi_tests;
mod implementation;
mod ldk_client;
mod receipt;
mod tests;
mod types;

Expand Down Expand Up @@ -310,6 +311,34 @@ 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.
///
/// 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,
) -> Result<Option<VssReadReceipt>, 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.
Expand Down
235 changes: 235 additions & 0 deletions src/receipt.rs
Original file line number Diff line number Diff line change
@@ -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<dyn VssHeaderProvider>,
}

impl ReceiptReader {
pub(crate) fn new(
base_url: &str,
headers: Arc<dyn VssHeaderProvider>,
) -> Result<Self, VssError> {
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<Option<vss_client_ng::types::KeyValue>, 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<Option<vss_client_ng::types::KeyValue>, 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<Option<VssReadReceipt>, 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<Vec<u8>, 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<Option<vss_client_ng::types::KeyValue>, 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<bool, VssError> {
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;
Loading
Loading