Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 5 additions & 3 deletions bindings/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ pip install .
```python
from vss_rust_client_ffi import *

# Initialize VSS client
vss_new_client(
# Initialize VSS client with LNURL-auth so backups are encrypted with a seed-derived key
vss_new_client_with_lnurl_auth(
Comment thread
ben-kaufman marked this conversation as resolved.
Outdated
"https://vss.example.com",
"my-store",
None
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
None,
"https://auth.example.com/lnurl",
)

# Store data
Expand Down
74 changes: 27 additions & 47 deletions src/ffi_tests.rs
Original file line number Diff line number Diff line change
@@ -1,41 +1,31 @@
#[cfg(test)]
mod ffi_tests {
use crate::*;

// Unit tests for the FFI interface
const MOCK_BASE_URL: &str = "https://vss.example.com";
const TEST_STORE_ID: &str = "test-store-ffi";

#[tokio::test]
async fn test_ffi_client_lifecycle() {
// Test that we can create and shutdown client without errors
let result = vss_new_client(
MOCK_BASE_URL.to_string(),
TEST_STORE_ID.to_string()
).await;

assert!(result.is_ok());

// Shutdown client
vss_shutdown_client();
let result = vss_new_client(MOCK_BASE_URL.to_string(), TEST_STORE_ID.to_string()).await;

assert!(matches!(result, Err(VssError::AuthError { .. })));
}

#[tokio::test]
async fn test_ffi_client() {
let result = vss_new_client(
MOCK_BASE_URL.to_string(),
TEST_STORE_ID.to_string()
).await;

assert!(result.is_ok());
vss_shutdown_client();
let result = vss_new_client(MOCK_BASE_URL.to_string(), TEST_STORE_ID.to_string()).await;

assert!(matches!(result, Err(VssError::AuthError { .. })));
}

#[tokio::test]
async fn test_ffi_error_no_client() {
// Don't initialize client, should get error
let result = vss_get("any-key".to_string()).await;

assert!(result.is_err());
match result {
Err(VssError::ConnectionError { error_details }) => {
Expand All @@ -44,31 +34,21 @@ mod ffi_tests {
_ => panic!("Expected ConnectionError for uninitialized client"),
}
}

#[tokio::test]
async fn test_ffi_client_reinitialize() {
// Test that we can create, shutdown, and recreate client
vss_new_client(
MOCK_BASE_URL.to_string(),
TEST_STORE_ID.to_string()
).await.expect("Failed to create first client");

vss_shutdown_client();

// Should be able to create again
let result = vss_new_client(
MOCK_BASE_URL.to_string(),
format!("{}-2", TEST_STORE_ID)
).await;

assert!(result.is_ok());
vss_shutdown_client();
let first = vss_new_client(MOCK_BASE_URL.to_string(), TEST_STORE_ID.to_string()).await;
assert!(matches!(first, Err(VssError::AuthError { .. })));

let second =
vss_new_client(MOCK_BASE_URL.to_string(), format!("{}-2", TEST_STORE_ID)).await;
assert!(matches!(second, Err(VssError::AuthError { .. })));
}

/*
// Integration tests for FFI functions would go here
// These require a live VSS server - see tests.rs for setup instructions

#[tokio::test]
#[ignore = "requires live VSS server"]
async fn integration_test_ffi_store_and_get() {
Expand All @@ -77,26 +57,26 @@ mod ffi_tests {
"your-store-id".to_string(),
None
).await.expect("Failed to create client");

let key = format!("ffi-test-{}", std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis());
let value = b"ffi-test-value".to_vec();

let stored = vss_store(key.clone(), value.clone()).await
.expect("Failed to store item");

assert_eq!(stored.key, key);
assert_eq!(stored.value, value);

let retrieved = vss_get(key).await
.expect("Failed to get item")
.expect("Item should exist");

assert_eq!(retrieved.value, value);

vss_shutdown_client();
}
*/
}
}
77 changes: 43 additions & 34 deletions src/implementation.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
use super::errors::VssError;
use super::types::*;
use bip39::Mnemonic;
use bitcoin::bip32::{ChildNumber, Xpriv};
use bitcoin::hashes::{sha256, Hash, HashEngine, Hmac, HmacEngine};
use bitcoin::secp256k1::Secp256k1;
use bitcoin::Network;
use prost::Message;
use rand::RngCore;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use vss_client_ng::client::VssClient as ExternalVssClient;
use vss_client_ng::error::VssError as ExternalVssError;
use vss_client_ng::headers::{FixedHeaders, LnurlAuthToJwtProvider, VssHeaderProvider};
use vss_client_ng::headers::{LnurlAuthToJwtProvider, VssHeaderProvider};
use vss_client_ng::types::{
DeleteObjectRequest, GetObjectRequest, KeyValue as ExternalKeyValue, ListKeyVersionsRequest,
PutObjectRequest, Storable,
Expand All @@ -21,8 +23,6 @@ use vss_client_ng::util::retry::{
MaxAttemptsRetryPolicy, MaxTotalDelayRetryPolicy, RetryPolicy,
};
use vss_client_ng::util::storable_builder::{EntropySource, StorableBuilder};
use bip39::Mnemonic;
use std::str::FromStr;

pub(crate) const VSS_HARDENED_CHILD_INDEX: u32 = 877;
pub(crate) const VSS_LNURL_AUTH_HARDENED_CHILD_INDEX: u32 = 138;
Expand Down Expand Up @@ -68,8 +68,12 @@ pub fn derive_vss_store_id(
.derive_priv(
&secp,
&[
ChildNumber::Hardened { index: VSS_HARDENED_CHILD_INDEX },
ChildNumber::Hardened { index: VSS_STORE_ID_HARDENED_CHILD_INDEX },
ChildNumber::Hardened {
index: VSS_HARDENED_CHILD_INDEX,
},
ChildNumber::Hardened {
index: VSS_STORE_ID_HARDENED_CHILD_INDEX,
},
],
)
.map_err(|e| VssError::ConnectionError {
Expand Down Expand Up @@ -114,18 +118,13 @@ pub struct VssClient {
}

impl VssClient {
/// Creates a new VSS client instance.
///
/// # Parameters
/// - `base_url`: The VSS server URL
/// - `store_id`: The storage namespace identifier
///
/// # Returns
/// A new VssClient instance or VssError on failure
/// Unauthenticated VSS is rejected. Use [`Self::new_with_lnurl_auth`] so backup
/// data is encrypted with a seed-derived key.
pub async fn new(base_url: String, store_id: String) -> Result<Self, VssError> {
let header_provider = Arc::new(FixedHeaders::new(HashMap::new()));

Self::new_with_header_provider(base_url, store_id, header_provider, None).await
let _ = (base_url, store_id);
Err(VssError::AuthError {
error_details: "Unauthenticated VSS is not supported. Use LNURL-auth so backup data is encrypted with a seed-derived key.".to_string(),
})
Comment thread
ben-kaufman marked this conversation as resolved.
}

/// Creates a new VSS client instance with LNURL-auth.
Expand Down Expand Up @@ -222,13 +221,12 @@ impl VssClient {

let storable_builder = Arc::new(StorableBuilder::new(RandEntropySource));

let (app_data_encryption_key, app_key_obfuscator) = if let Some(seed) = app_vss_seed {
let (dek, obfuscation_master_key) =
derive_data_encryption_and_obfuscation_keys(&seed);
(dek, Some(Arc::new(KeyObfuscator::new(obfuscation_master_key))))
} else {
([0u8; 32], None)
};
let app_vss_seed = app_vss_seed.ok_or_else(|| VssError::AuthError {
error_details: "VSS encryption requires a seed-derived key.".to_string(),
})?;
let (app_data_encryption_key, obfuscation_master_key) =
derive_data_encryption_and_obfuscation_keys(&app_vss_seed);
let app_key_obfuscator = Some(Arc::new(KeyObfuscator::new(obfuscation_master_key)));

Ok(VssClient {
inner: Arc::new(client),
Expand Down Expand Up @@ -288,10 +286,15 @@ impl VssClient {
pub async fn get(&self, key: String) -> Result<Option<VssItem>, VssError> {
let storage_key = self.build_key(&key);

if let Some((value, version)) =
self.try_get_raw(&storage_key, &self.app_data_encryption_key).await?
if let Some((value, version)) = self
.try_get_raw(&storage_key, &self.app_data_encryption_key)
.await?
{
return Ok(Some(VssItem { key, value, version }));
return Ok(Some(VssItem {
key,
value,
version,
}));
}

Ok(None)
Expand Down Expand Up @@ -324,7 +327,8 @@ impl VssClient {
/// Vector of KeyVersion structs (more efficient than list())
pub async fn list_keys(&self, prefix: Option<String>) -> Result<Vec<KeyVersion>, VssError> {
let storage_prefix = prefix.as_ref().map(|p| self.build_key(p));
self.list_key_versions(storage_prefix, &self.app_key_obfuscator).await
self.list_key_versions(storage_prefix, &self.app_key_obfuscator)
.await
}

/// Stores multiple key-value pairs in an atomic transaction.
Expand Down Expand Up @@ -408,10 +412,9 @@ impl VssClient {
storage_key: &str,
) -> Result<String, VssError> {
if let Some(ref obfuscator) = key_obfuscator {
Self::try_deobfuscate(obfuscator, storage_key)
.ok_or_else(|| VssError::ListError {
error_details: "Failed to deobfuscate key".to_string(),
})
Self::try_deobfuscate(obfuscator, storage_key).ok_or_else(|| VssError::ListError {
error_details: "Failed to deobfuscate key".to_string(),
})
} else {
Ok(storage_key.to_string())
}
Expand Down Expand Up @@ -536,7 +539,9 @@ impl VssClient {
}

/// Derives data encryption and obfuscation keys from VSS seed
pub(crate) fn derive_data_encryption_and_obfuscation_keys(vss_seed: &[u8; 32]) -> ([u8; 32], [u8; 32]) {
pub(crate) fn derive_data_encryption_and_obfuscation_keys(
vss_seed: &[u8; 32],
) -> ([u8; 32], [u8; 32]) {
let hkdf = |initial_key_material: &[u8], salt: &[u8]| -> [u8; 32] {
let mut engine = HmacEngine::<sha256::Hash>::new(salt);
engine.input(initial_key_material);
Expand All @@ -559,11 +564,15 @@ pub(crate) fn derive_data_encryption_and_obfuscation_keys(vss_seed: &[u8; 32]) -
/// Internal VssError with appropriate error details
pub(crate) fn convert_error(error: ExternalVssError, _operation: &str) -> VssError {
match error {
ExternalVssError::NoSuchKeyError(msg) => VssError::GetError { error_details: format!("Not found: {}", msg) },
ExternalVssError::NoSuchKeyError(msg) => VssError::GetError {
error_details: format!("Not found: {}", msg),
},
ExternalVssError::InternalServerError(msg) => VssError::NetworkError { error_details: msg },
ExternalVssError::InvalidRequestError(msg) => VssError::InvalidData { error_details: msg },
ExternalVssError::InternalError(msg) => VssError::UnknownError { error_details: msg },
ExternalVssError::ConflictError(msg) => VssError::StoreError { error_details: format!("Conflict: {}", msg) },
ExternalVssError::ConflictError(msg) => VssError::StoreError {
error_details: format!("Conflict: {}", msg),
},
ExternalVssError::AuthError(msg) => VssError::AuthError { error_details: msg },
}
}
Loading