sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 3d45ad617d6c0f90e7417b64b4518d65c81b69f7
parent 0ff9e2825de711d0e662fc3b3b5eda5d3cb6a38a
Author: triesap <tyson@radroots.org>
Date:   Wed, 15 Jul 2026 15:52:46 +0000

runtime: harden private store and signer boundary

Diffstat:
MCargo.lock | 2++
MCargo.toml | 2++
Mcrates/sdk/Cargo.toml | 6++++++
Mcrates/sdk/examples/runtime_local.rs | 5+++--
Mcrates/sdk/examples/sdk_v1_local_enqueue_and_mock_sync.rs | 5+++--
Mcrates/sdk/src/lib.rs | 12++++++------
Mcrates/sdk/src/private_store.rs | 570++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/sdk/src/runtime.rs | 2+-
Mcrates/sdk/src/signer_provider.rs | 19+++++++++++++------
Mcrates/sdk/tests/orders_runtime.rs | 7++++++-
Mcrates/sdk/tests/runtime_foundation.rs | 6+++---
Mcrates/sdk/tests/source_boundary.rs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/trade_product_publish_runtime.rs | 7+++++--
Mcrates/sdk/tests/unit/farms_runtime_tests.rs | 17++++++-----------
Mcrates/sdk/tests/unit/listings_runtime_tests.rs | 11++++-------
Mcrates/sdk/tests/unit/private_store_tests.rs | 165++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcrates/sdk/tests/unit/runtime_tests.rs | 6+++---
Mcrates/sdk/tests/unit/signer_provider_tests.rs | 14+++++++++++---
Mcrates/sdk/tests/unit/workflow_runtime_tests.rs | 5++---
19 files changed, 714 insertions(+), 241 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2106,11 +2106,13 @@ dependencies = [ "radroots_nostr_connect", "radroots_nostr_signer", "radroots_outbox", + "radroots_protected_store", "radroots_replica_schema", "radroots_replica_store", "radroots_replica_sync", "radroots_runtime_contract_v1", "radroots_runtime_paths", + "radroots_secret_vault", "radroots_sql_core", "radroots_trade", "radroots_transport", diff --git a/Cargo.toml b/Cargo.toml @@ -44,7 +44,9 @@ radroots_nostr_connect = { path = "../lib/crates/nostr_connect", version = "0.1. radroots_nostr_signer = { path = "../lib/crates/nostr_signer", version = "0.1.0-alpha.2", default-features = false } radroots_outbox = { path = "../lib/crates/outbox", version = "0.1.0-alpha.2", default-features = false } radroots_protocol_contract_v1 = { path = "../lib/crates/protocol_contract_v1", version = "0.1.0-alpha.2", default-features = false } +radroots_protected_store = { path = "../lib/crates/protected_store", version = "0.1.0-alpha.2", default-features = false } radroots_runtime_contract_v1 = { path = "crates/runtime_contract_v1", version = "0.1.0", default-features = false } +radroots_secret_vault = { path = "../lib/crates/secret_vault", version = "0.1.0-alpha.2", default-features = false } radroots_transport = { path = "../lib/crates/transport", version = "0.1.0-alpha.2", default-features = false } radroots_transport_publish_protocol = { path = "../lib/crates/transport_publish_protocol", version = "0.1.0-alpha.2", default-features = false } radroots_transport_nostr = { path = "../lib/crates/transport_nostr", version = "0.1.0-alpha.2", default-features = false } diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -81,10 +81,12 @@ runtime = [ "dep:radroots_event_store", "dep:radroots_geocoder", "dep:radroots_outbox", + "dep:radroots_protected_store", "dep:radroots_transport", "dep:radroots_transport_nostr", "dep:radroots_transport_reticulum", "dep:radroots_runtime_paths", + "dep:radroots_secret_vault", "dep:sha2", "dep:sqlx", "dep:uuid", @@ -93,6 +95,8 @@ runtime = [ "radroots_event_store/runtime-tokio", "radroots_outbox/sqlite", "radroots_outbox/runtime-tokio", + "radroots_protected_store/std", + "radroots_secret_vault/std", "radroots_transport/serde", "radroots_transport_nostr/std", "radroots_transport_nostr/storage", @@ -136,11 +140,13 @@ radroots_event = { workspace = true, default-features = false } radroots_event_codec = { workspace = true, default-features = false } radroots_geocoder = { workspace = true, optional = true } radroots_outbox = { workspace = true, optional = true, default-features = false } +radroots_protected_store = { workspace = true, optional = true, default-features = false } radroots_transport = { workspace = true, optional = true, default-features = false } radroots_transport_publish_protocol = { workspace = true, optional = true, default-features = false } radroots_transport_nostr = { workspace = true, optional = true, default-features = false } radroots_transport_reticulum = { workspace = true, optional = true, default-features = false } radroots_runtime_paths = { workspace = true, optional = true, default-features = false } +radroots_secret_vault = { workspace = true, optional = true, default-features = false } radroots_runtime_contract_v1 = { workspace = true, default-features = false } radroots_trade = { workspace = true, default-features = false } radroots_identity = { workspace = true, optional = true, default-features = false } diff --git a/crates/sdk/examples/runtime_local.rs b/crates/sdk/examples/runtime_local.rs @@ -1,4 +1,4 @@ -use radroots_authority::RadrootsActorContext; +use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner}; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, @@ -20,7 +20,8 @@ const RELAY: &str = "wss://relay.example.com"; async fn main() -> Result<(), Box<dyn std::error::Error>> { let keys = RadrootsNostrKeys::generate(); let seller = keys.public_key().to_hex(); - let signer = RadrootsSdkLocalKeySigner::new(keys)?; + let signer = + RadrootsSdkLocalKeySigner::from_event_signer(RadrootsLocalEventSigner::new(keys)?)?; let sdk = RadrootsClient::builder() .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) .signer_provider(RadrootsSdkSignerProvider::LocalKey(signer)) diff --git a/crates/sdk/examples/sdk_v1_local_enqueue_and_mock_sync.rs b/crates/sdk/examples/sdk_v1_local_enqueue_and_mock_sync.rs @@ -1,4 +1,4 @@ -use radroots_authority::RadrootsActorContext; +use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner}; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, @@ -21,7 +21,8 @@ const LOCAL_RELAY: &str = "ws://localhost:7777"; async fn main() -> Result<(), Box<dyn std::error::Error>> { let keys = RadrootsNostrKeys::generate(); let seller = keys.public_key().to_hex(); - let signer = RadrootsSdkLocalKeySigner::new(keys)?; + let signer = + RadrootsSdkLocalKeySigner::from_event_signer(RadrootsLocalEventSigner::new(keys)?)?; let sdk = RadrootsClient::builder() .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) .signer_provider(RadrootsSdkSignerProvider::LocalKey(signer)) diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -200,12 +200,12 @@ pub use crate::runtime::{ pub use crate::signer_provider::{ RADROOTS_SDK_MYC_NIP46_DEFAULT_REQUEST_TIMEOUT_MS, RADROOTS_SDK_MYC_NIP46_PRODUCT_SIGN_EVENT_KINDS, RadrootsSdkLocalKeySigner, - RadrootsSdkMycNip46RequestPolicy, RadrootsSdkMycNip46Signer, RadrootsSdkNip46Transport, - RadrootsSdkNip46TransportFuture, RadrootsSdkSignReceipt, RadrootsSdkSignRequest, - RadrootsSdkSignerCapability, RadrootsSdkSignerMode, RadrootsSdkSignerProgress, - RadrootsSdkSignerProgressSink, RadrootsSdkSignerProvider, RadrootsSdkSignerState, - RadrootsSdkSignerStatus, radroots_sdk_myc_nip46_product_permission_strings, - radroots_sdk_myc_nip46_product_permissions, + RadrootsSdkLocalSignerCapability, RadrootsSdkMycNip46RequestPolicy, RadrootsSdkMycNip46Signer, + RadrootsSdkNip46Transport, RadrootsSdkNip46TransportFuture, RadrootsSdkSignReceipt, + RadrootsSdkSignRequest, RadrootsSdkSignerCapability, RadrootsSdkSignerMode, + RadrootsSdkSignerProgress, RadrootsSdkSignerProgressSink, RadrootsSdkSignerProvider, + RadrootsSdkSignerState, RadrootsSdkSignerStatus, + radroots_sdk_myc_nip46_product_permission_strings, radroots_sdk_myc_nip46_product_permissions, }; #[cfg(feature = "runtime")] pub use crate::sync_runtime::{ diff --git a/crates/sdk/src/private_store.rs b/crates/sdk/src/private_store.rs @@ -1,40 +1,153 @@ #![cfg(feature = "runtime")] use crate::RadrootsSdkError; -use radroots_event::ids::RadrootsAddressableCoordinate; +use radroots_event::ids::{RadrootsAddressableCoordinate, RadrootsAddressableCoordinateParts}; +use radroots_event::kinds::KIND_FARM; +use radroots_protected_store::{RadrootsProtectedFileKeySource, RadrootsProtectedStoreEnvelope}; +use radroots_secret_vault::{RadrootsSecretKeyWrapping, RadrootsSecretVaultAccessError}; +use serde::{Deserialize, Serialize}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::{Row, SqlitePool}; use std::path::Path; use std::str::FromStr; +use std::sync::Arc; -pub(crate) const SDK_PRIVATE_STORE_SCHEMA_VERSION: i64 = 2; +pub(crate) const SDK_PRIVATE_STORE_SCHEMA_VERSION: i64 = 1; + +const PRIVATE_STORE_KEY_VERSION: i64 = 1; +const PRIVATE_STORE_FILE_CREDENTIAL_BACKEND: &str = "protected_file_wrapped_v1"; +const PRIVATE_STORE_MEMORY_CREDENTIAL_BACKEND: &str = "memory_test_wrapped_v1"; const PRIVATE_STORE_MIGRATION_UP: &str = r#" -CREATE TABLE IF NOT EXISTS sdk_private_farm_location ( - farm_addr TEXT PRIMARY KEY NOT NULL, - farm_pubkey TEXT NOT NULL, +CREATE TABLE IF NOT EXISTS private_metadata ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + schema_version INTEGER NOT NULL CHECK(schema_version = 1), + profile_id BLOB NOT NULL CHECK(length(profile_id) = 16), + runtime_contract_hash BLOB NOT NULL CHECK(length(runtime_contract_hash) = 32), + key_version INTEGER NOT NULL CHECK(key_version > 0), + sqlite_source_id TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS wrapped_profile_key ( + key_version INTEGER PRIMARY KEY CHECK(key_version > 0), + credential_backend TEXT NOT NULL, + wrapped_key BLOB NOT NULL, + nonce BLOB NOT NULL CHECK(length(nonce) = 24), + created_at_ms INTEGER NOT NULL, + retired_at_ms INTEGER +) STRICT; + +CREATE TABLE IF NOT EXISTS wrapped_signing_secret ( + account_id BLOB PRIMARY KEY CHECK(length(account_id) = 16), + public_key BLOB NOT NULL UNIQUE CHECK(length(public_key) = 32), + key_version INTEGER NOT NULL REFERENCES wrapped_profile_key(key_version), + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL CHECK(length(nonce) = 24), + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS private_farm_location ( + farm_kind INTEGER NOT NULL CHECK(farm_kind = 30340), + owner_pubkey BLOB NOT NULL CHECK(length(owner_pubkey) = 32), farm_d_tag TEXT NOT NULL, - label TEXT, - latitude REAL NOT NULL CHECK(latitude >= -90.0 AND latitude <= 90.0), - longitude REAL NOT NULL CHECK(longitude >= -180.0 AND longitude <= 180.0), - locality_primary TEXT NOT NULL, - locality_city TEXT, - locality_region TEXT, - locality_country TEXT, - geohash5 TEXT NOT NULL CHECK(length(geohash5) = 5), - geonames_feature_id INTEGER, - geonames_country_id TEXT, + key_version INTEGER NOT NULL REFERENCES wrapped_profile_key(key_version), + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL CHECK(length(nonce) = 24), + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + PRIMARY KEY(farm_kind, owner_pubkey, farm_d_tag) +) STRICT, WITHOUT ROWID; + +CREATE TABLE IF NOT EXISTS trade_private_thread ( + private_thread_id BLOB PRIMARY KEY CHECK(length(private_thread_id) = 32), + order_id BLOB NOT NULL CHECK(length(order_id) = 16), + root_request_event_id BLOB NOT NULL CHECK(length(root_request_event_id) = 32), + counterparty_pubkey BLOB NOT NULL CHECK(length(counterparty_pubkey) = 32), + key_version INTEGER NOT NULL REFERENCES wrapped_profile_key(key_version), + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL CHECK(length(nonce) = 24), + expires_at_ms INTEGER, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + UNIQUE(order_id, root_request_event_id, counterparty_pubkey) +) STRICT; + +CREATE INDEX IF NOT EXISTS trade_private_expiry_idx + ON trade_private_thread(expires_at_ms, private_thread_id) + WHERE expires_at_ms IS NOT NULL; + +CREATE TABLE IF NOT EXISTS buyer_contact_private ( + contact_id BLOB PRIMARY KEY CHECK(length(contact_id) = 16), + order_id BLOB NOT NULL CHECK(length(order_id) = 16), + root_request_event_id BLOB NOT NULL CHECK(length(root_request_event_id) = 32), + key_version INTEGER NOT NULL REFERENCES wrapped_profile_key(key_version), + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL CHECK(length(nonce) = 24), + expires_at_ms INTEGER, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + UNIQUE(order_id, root_request_event_id) +) STRICT; + +CREATE INDEX IF NOT EXISTS buyer_contact_expiry_idx + ON buyer_contact_private(expires_at_ms, contact_id) + WHERE expires_at_ms IS NOT NULL; + +CREATE TABLE IF NOT EXISTS cursor_hmac_key ( + key_id BLOB PRIMARY KEY CHECK(length(key_id) = 16), + key_version INTEGER NOT NULL REFERENCES wrapped_profile_key(key_version), + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL CHECK(length(nonce) = 24), + created_at_ms INTEGER NOT NULL, + retired_at_ms INTEGER +) STRICT; + +CREATE TABLE IF NOT EXISTS nip46_session_private ( + session_id BLOB PRIMARY KEY CHECK(length(session_id) = 16), + user_pubkey BLOB NOT NULL CHECK(length(user_pubkey) = 32), + remote_signer_pubkey BLOB NOT NULL CHECK(length(remote_signer_pubkey) = 32), + client_pubkey BLOB NOT NULL CHECK(length(client_pubkey) = 32), + key_version INTEGER NOT NULL REFERENCES wrapped_profile_key(key_version), + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL CHECK(length(nonce) = 24), + expires_at_ms INTEGER NOT NULL, + status TEXT NOT NULL CHECK(status IN ('active','expired','revoked')), + created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL -); -CREATE INDEX IF NOT EXISTS sdk_private_farm_location_pubkey - ON sdk_private_farm_location (farm_pubkey, farm_d_tag); +) STRICT; + +CREATE TABLE IF NOT EXISTS key_rotation_progress ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + from_key_version INTEGER NOT NULL, + to_key_version INTEGER NOT NULL, + table_name TEXT NOT NULL, + last_primary_key BLOB, + state TEXT NOT NULL CHECK(state IN ('running','verifying','complete','failed')), + started_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + error_code TEXT +) STRICT; "#; #[derive(Clone)] pub(crate) struct SdkPrivateStore { pool: SqlitePool, + key_source: SdkPrivateStoreKeySource, + credential_backend: &'static str, +} + +#[derive(Clone)] +enum SdkPrivateStoreKeySource { + Memory(Arc<SdkPrivateStoreMemoryKeySource>), + File(RadrootsProtectedFileKeySource), } +#[derive(Default)] +struct SdkPrivateStoreMemoryKeySource; + #[derive(Clone, Debug, PartialEq)] pub(crate) struct SdkPrivateFarmLocationRecord { pub farm_addr: RadrootsAddressableCoordinate, @@ -58,6 +171,21 @@ pub(crate) struct SdkPrivateStoreStatusSummary { pub farm_private_locations: i64, } +#[derive(Debug, Serialize, Deserialize)] +struct SdkPrivateFarmLocationPayload { + label: Option<String>, + latitude: f64, + longitude: f64, + locality_primary: String, + locality_city: Option<String>, + locality_region: Option<String>, + locality_country: Option<String>, + geohash5: String, + geonames_feature_id: Option<i64>, + geonames_country_id: Option<String>, + updated_at_ms: i64, +} + impl SdkPrivateStore { pub async fn open_memory() -> Result<Self, RadrootsSdkError> { let options = @@ -67,12 +195,18 @@ impl SdkPrivateStore { .connect_with(options) .await .map_err(private_store_error)?; - configure_connection(&pool, false).await?; - apply_up(&pool).await?; - Ok(Self { pool }) + let store = Self { + pool, + key_source: SdkPrivateStoreKeySource::Memory(Arc::default()), + credential_backend: PRIVATE_STORE_MEMORY_CREDENTIAL_BACKEND, + }; + store.configure_connection(false).await?; + store.apply_up().await?; + Ok(store) } pub async fn open_file(path: impl AsRef<Path>) -> Result<Self, RadrootsSdkError> { + let path = path.as_ref(); let options = SqliteConnectOptions::new() .filename(path) .create_if_missing(true); @@ -81,9 +215,17 @@ impl SdkPrivateStore { .connect_with(options) .await .map_err(private_store_error)?; - configure_connection(&pool, true).await?; - apply_up(&pool).await?; - Ok(Self { pool }) + let store = Self { + pool, + key_source: SdkPrivateStoreKeySource::File( + RadrootsProtectedFileKeySource::from_sidecar_suffix(path, ".vault.key"), + ), + credential_backend: PRIVATE_STORE_FILE_CREDENTIAL_BACKEND, + }; + store.configure_connection(true).await?; + store.reject_pre_v1_private_store().await?; + store.apply_up().await?; + Ok(store) } pub fn pool(&self) -> &SqlitePool { @@ -106,7 +248,7 @@ impl SdkPrivateStore { Ok(SdkPrivateStoreStatusSummary { farm_private_locations: query_i64( &self.pool, - "SELECT COUNT(*) FROM sdk_private_farm_location", + "SELECT COUNT(*) FROM private_farm_location", ) .await?, }) @@ -117,53 +259,37 @@ impl SdkPrivateStore { record: &SdkPrivateFarmLocationRecord, ) -> Result<(), RadrootsSdkError> { validate_location_record(record)?; + let parts = farm_location_parts(&record.farm_addr)?; + let owner_pubkey = public_key_bytes(parts.pubkey.as_str())?; + let envelope = self.seal_farm_location(record)?; + let nonce = envelope.header.nonce.to_vec(); + let ciphertext = envelope.encode_json().map_err(private_store_error)?; sqlx::query( r#" - INSERT INTO sdk_private_farm_location ( - farm_addr, - farm_pubkey, + INSERT INTO private_farm_location ( + farm_kind, + owner_pubkey, farm_d_tag, - label, - latitude, - longitude, - locality_primary, - locality_city, - locality_region, - locality_country, - geohash5, - geonames_feature_id, - geonames_country_id, + key_version, + ciphertext, + nonce, + created_at_ms, updated_at_ms - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) - ON CONFLICT(farm_addr) DO UPDATE SET - farm_pubkey = excluded.farm_pubkey, - farm_d_tag = excluded.farm_d_tag, - label = excluded.label, - latitude = excluded.latitude, - longitude = excluded.longitude, - locality_primary = excluded.locality_primary, - locality_city = excluded.locality_city, - locality_region = excluded.locality_region, - locality_country = excluded.locality_country, - geohash5 = excluded.geohash5, - geonames_feature_id = excluded.geonames_feature_id, - geonames_country_id = excluded.geonames_country_id, + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(farm_kind, owner_pubkey, farm_d_tag) DO UPDATE SET + key_version = excluded.key_version, + ciphertext = excluded.ciphertext, + nonce = excluded.nonce, updated_at_ms = excluded.updated_at_ms "#, ) - .bind(record.farm_addr.as_str()) - .bind(record.farm_pubkey.as_str()) - .bind(record.farm_d_tag.as_str()) - .bind(record.label.as_deref()) - .bind(record.latitude) - .bind(record.longitude) - .bind(record.locality_primary.as_str()) - .bind(record.locality_city.as_deref()) - .bind(record.locality_region.as_deref()) - .bind(record.locality_country.as_deref()) - .bind(record.geohash5.as_str()) - .bind(record.geonames_feature_id) - .bind(record.geonames_country_id.as_deref()) + .bind(i64::from(KIND_FARM)) + .bind(owner_pubkey) + .bind(parts.d_tag.as_str()) + .bind(PRIVATE_STORE_KEY_VERSION) + .bind(ciphertext) + .bind(nonce) + .bind(record.updated_at_ms) .bind(record.updated_at_ms) .execute(&self.pool) .await @@ -175,32 +301,22 @@ impl SdkPrivateStore { &self, farm_addr: &RadrootsAddressableCoordinate, ) -> Result<Option<SdkPrivateFarmLocationRecord>, RadrootsSdkError> { + let parts = farm_location_parts(farm_addr)?; + let owner_pubkey = public_key_bytes(parts.pubkey.as_str())?; let row = sqlx::query( r#" - SELECT - farm_addr, - farm_pubkey, - farm_d_tag, - label, - latitude, - longitude, - locality_primary, - locality_city, - locality_region, - locality_country, - geohash5, - geonames_feature_id, - geonames_country_id, - updated_at_ms - FROM sdk_private_farm_location - WHERE farm_addr = ?1 + SELECT ciphertext, nonce + FROM private_farm_location + WHERE farm_kind = ?1 AND owner_pubkey = ?2 AND farm_d_tag = ?3 "#, ) - .bind(farm_addr.as_str()) + .bind(i64::from(KIND_FARM)) + .bind(owner_pubkey) + .bind(parts.d_tag.as_str()) .fetch_optional(&self.pool) .await .map_err(private_store_error)?; - row.map(|row| private_farm_location_from_row(farm_addr.clone(), row)) + row.map(|row| self.private_farm_location_from_row(farm_addr.clone(), parts, row)) .transpose() } @@ -208,47 +324,223 @@ impl SdkPrivateStore { &self, farm_addr: &RadrootsAddressableCoordinate, ) -> Result<bool, RadrootsSdkError> { + let parts = farm_location_parts(farm_addr)?; + let owner_pubkey = public_key_bytes(parts.pubkey.as_str())?; sqlx::query( r#" - DELETE FROM sdk_private_farm_location - WHERE farm_addr = ?1 + DELETE FROM private_farm_location + WHERE farm_kind = ?1 AND owner_pubkey = ?2 AND farm_d_tag = ?3 "#, ) - .bind(farm_addr.as_str()) + .bind(i64::from(KIND_FARM)) + .bind(owner_pubkey) + .bind(parts.d_tag.as_str()) .execute(&self.pool) .await .map(|receipt| receipt.rows_affected() > 0) .map_err(private_store_error) } -} -async fn configure_connection( - pool: &SqlitePool, - file_backed: bool, -) -> Result<(), RadrootsSdkError> { - sqlx::query("PRAGMA foreign_keys = ON") - .execute(pool) + async fn configure_connection(&self, file_backed: bool) -> Result<(), RadrootsSdkError> { + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&self.pool) + .await + .map_err(private_store_error)?; + sqlx::query("PRAGMA busy_timeout = 5000") + .execute(&self.pool) + .await + .map_err(private_store_error)?; + sqlx::query("PRAGMA trusted_schema = OFF") + .execute(&self.pool) + .await + .map_err(private_store_error)?; + sqlx::query("PRAGMA temp_store = MEMORY") + .execute(&self.pool) + .await + .map_err(private_store_error)?; + sqlx::query("PRAGMA secure_delete = FAST") + .execute(&self.pool) + .await + .map_err(private_store_error)?; + if file_backed { + sqlx::query("PRAGMA journal_mode = WAL") + .execute(&self.pool) + .await + .map_err(private_store_error)?; + } + Ok(()) + } + + async fn apply_up(&self) -> Result<(), RadrootsSdkError> { + sqlx::raw_sql(PRIVATE_STORE_MIGRATION_UP) + .execute(&self.pool) + .await + .map_err(private_store_error)?; + sqlx::query( + r#" + INSERT OR IGNORE INTO private_metadata ( + singleton, + schema_version, + profile_id, + runtime_contract_hash, + key_version, + sqlite_source_id, + created_at_ms, + updated_at_ms + ) VALUES ( + 1, + 1, + randomblob(16), + zeroblob(32), + 1, + sqlite_source_id(), + CAST(strftime('%s','now') AS INTEGER) * 1000, + CAST(strftime('%s','now') AS INTEGER) * 1000 + ) + "#, + ) + .execute(&self.pool) .await .map_err(private_store_error)?; - sqlx::query("PRAGMA busy_timeout = 5000") - .execute(pool) + sqlx::query( + r#" + INSERT OR IGNORE INTO wrapped_profile_key ( + key_version, + credential_backend, + wrapped_key, + nonce, + created_at_ms, + retired_at_ms + ) VALUES ( + 1, + ?1, + zeroblob(1), + randomblob(24), + CAST(strftime('%s','now') AS INTEGER) * 1000, + NULL + ) + "#, + ) + .bind(self.credential_backend) + .execute(&self.pool) .await .map_err(private_store_error)?; - if file_backed { - sqlx::query("PRAGMA journal_mode = WAL") - .execute(pool) + sqlx::query("PRAGMA user_version = 1") + .execute(&self.pool) .await + .map(|_| ()) + .map_err(private_store_error) + } + + async fn reject_pre_v1_private_store(&self) -> Result<(), RadrootsSdkError> { + let exists = query_i64( + &self.pool, + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sdk_private_farm_location'", + ) + .await?; + if exists != 0 { + return Err(RadrootsSdkError::UnsupportedProfileSchema { + path: Path::new("private.sqlite").to_path_buf(), + message: "pre-V1 SDK private store is unsupported; use explicit quarantine reset" + .to_owned(), + }); + } + Ok(()) + } + + fn seal_farm_location( + &self, + record: &SdkPrivateFarmLocationRecord, + ) -> Result<RadrootsProtectedStoreEnvelope, RadrootsSdkError> { + let payload = SdkPrivateFarmLocationPayload { + label: record.label.clone(), + latitude: record.latitude, + longitude: record.longitude, + locality_primary: record.locality_primary.clone(), + locality_city: record.locality_city.clone(), + locality_region: record.locality_region.clone(), + locality_country: record.locality_country.clone(), + geohash5: record.geohash5.clone(), + geonames_feature_id: record.geonames_feature_id, + geonames_country_id: record.geonames_country_id.clone(), + updated_at_ms: record.updated_at_ms, + }; + let plaintext = serde_json::to_vec(&payload).map_err(private_store_error)?; + RadrootsProtectedStoreEnvelope::seal_with_wrapped_key( + &self.key_source, + farm_location_key_slot(record.farm_addr.as_str()).as_str(), + plaintext.as_slice(), + ) + .map_err(private_store_error) + } + + fn private_farm_location_from_row( + &self, + farm_addr: RadrootsAddressableCoordinate, + parts: RadrootsAddressableCoordinateParts, + row: sqlx::sqlite::SqliteRow, + ) -> Result<SdkPrivateFarmLocationRecord, RadrootsSdkError> { + let ciphertext: Vec<u8> = row.try_get("ciphertext").map_err(private_store_error)?; + let nonce: Vec<u8> = row.try_get("nonce").map_err(private_store_error)?; + let envelope = RadrootsProtectedStoreEnvelope::decode_json(ciphertext.as_slice()) + .map_err(private_store_error)?; + if envelope.header.nonce.as_slice() != nonce.as_slice() { + return Err(RadrootsSdkError::PrivateStore { + message: "private farm location envelope nonce does not match row nonce".to_owned(), + }); + } + let plaintext = envelope + .open_with_wrapped_key(&self.key_source) .map_err(private_store_error)?; + let payload: SdkPrivateFarmLocationPayload = + serde_json::from_slice(plaintext.as_slice()).map_err(private_store_error)?; + Ok(SdkPrivateFarmLocationRecord { + farm_addr, + farm_pubkey: parts.pubkey.as_str().to_owned(), + farm_d_tag: parts.d_tag.as_str().to_owned(), + label: payload.label, + latitude: payload.latitude, + longitude: payload.longitude, + locality_primary: payload.locality_primary, + locality_city: payload.locality_city, + locality_region: payload.locality_region, + locality_country: payload.locality_country, + geohash5: payload.geohash5, + geonames_feature_id: payload.geonames_feature_id, + geonames_country_id: payload.geonames_country_id, + updated_at_ms: payload.updated_at_ms, + }) } - Ok(()) } -async fn apply_up(pool: &SqlitePool) -> Result<(), RadrootsSdkError> { - sqlx::raw_sql(PRIVATE_STORE_MIGRATION_UP) - .execute(pool) - .await - .map(|_| ()) - .map_err(private_store_error) +impl RadrootsSecretKeyWrapping for SdkPrivateStoreKeySource { + type Error = RadrootsSecretVaultAccessError; + + fn wrap_data_key(&self, key_slot: &str, plaintext_key: &[u8]) -> Result<Vec<u8>, Self::Error> { + match self { + Self::Memory(source) => source.wrap_data_key(key_slot, plaintext_key), + Self::File(source) => source.wrap_data_key(key_slot, plaintext_key), + } + } + + fn unwrap_data_key(&self, key_slot: &str, wrapped_key: &[u8]) -> Result<Vec<u8>, Self::Error> { + match self { + Self::Memory(source) => source.unwrap_data_key(key_slot, wrapped_key), + Self::File(source) => source.unwrap_data_key(key_slot, wrapped_key), + } + } +} + +impl RadrootsSecretKeyWrapping for SdkPrivateStoreMemoryKeySource { + type Error = RadrootsSecretVaultAccessError; + + fn wrap_data_key(&self, _key_slot: &str, plaintext_key: &[u8]) -> Result<Vec<u8>, Self::Error> { + Ok(plaintext_key.to_vec()) + } + + fn unwrap_data_key(&self, _key_slot: &str, wrapped_key: &[u8]) -> Result<Vec<u8>, Self::Error> { + Ok(wrapped_key.to_vec()) + } } async fn query_i64(pool: &SqlitePool, sql: &'static str) -> Result<i64, RadrootsSdkError> { @@ -267,38 +559,35 @@ async fn query_string(pool: &SqlitePool, sql: &'static str) -> Result<String, Ra row.try_get(0).map_err(private_store_error) } -fn private_farm_location_from_row( - farm_addr: RadrootsAddressableCoordinate, - row: sqlx::sqlite::SqliteRow, -) -> Result<SdkPrivateFarmLocationRecord, RadrootsSdkError> { - Ok(SdkPrivateFarmLocationRecord { - farm_addr, - farm_pubkey: row.try_get("farm_pubkey").map_err(private_store_error)?, - farm_d_tag: row.try_get("farm_d_tag").map_err(private_store_error)?, - label: row.try_get("label").map_err(private_store_error)?, - latitude: row.try_get("latitude").map_err(private_store_error)?, - longitude: row.try_get("longitude").map_err(private_store_error)?, - locality_primary: row - .try_get("locality_primary") - .map_err(private_store_error)?, - locality_city: row.try_get("locality_city").map_err(private_store_error)?, - locality_region: row - .try_get("locality_region") - .map_err(private_store_error)?, - locality_country: row - .try_get("locality_country") - .map_err(private_store_error)?, - geohash5: row.try_get("geohash5").map_err(private_store_error)?, - geonames_feature_id: row - .try_get("geonames_feature_id") - .map_err(private_store_error)?, - geonames_country_id: row - .try_get("geonames_country_id") - .map_err(private_store_error)?, - updated_at_ms: row.try_get("updated_at_ms").map_err(private_store_error)?, +fn farm_location_parts( + farm_addr: &RadrootsAddressableCoordinate, +) -> Result<RadrootsAddressableCoordinateParts, RadrootsSdkError> { + let parts = RadrootsAddressableCoordinateParts::parse(farm_addr.as_str()).map_err(|error| { + RadrootsSdkError::InvalidRequest { + message: format!("farm address is invalid: {error}"), + } + })?; + if parts.kind != KIND_FARM { + return Err(RadrootsSdkError::InvalidRequest { + message: format!( + "private farm location address kind must be {KIND_FARM}, got {}", + parts.kind + ), + }); + } + Ok(parts) +} + +fn public_key_bytes(pubkey: &str) -> Result<Vec<u8>, RadrootsSdkError> { + hex::decode(pubkey).map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("public key is invalid hex: {error}"), }) } +fn farm_location_key_slot(farm_addr: &str) -> String { + format!("private_farm_location:{farm_addr}") +} + fn validate_location_record(record: &SdkPrivateFarmLocationRecord) -> Result<(), RadrootsSdkError> { if !record.latitude.is_finite() || !record.longitude.is_finite() @@ -331,6 +620,17 @@ fn validate_location_record(record: &SdkPrivateFarmLocationRecord) -> Result<(), message: "farm private location label must not be empty".to_owned(), }); } + let parts = farm_location_parts(&record.farm_addr)?; + if parts.pubkey.as_str() != record.farm_pubkey { + return Err(RadrootsSdkError::InvalidRequest { + message: "farm private location address pubkey does not match record pubkey".to_owned(), + }); + } + if parts.d_tag.as_str() != record.farm_d_tag { + return Err(RadrootsSdkError::InvalidRequest { + message: "farm private location address d tag does not match record d tag".to_owned(), + }); + } Ok(()) } diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -1200,7 +1200,7 @@ async fn private_store_status_summary_from_pool( Ok(crate::private_store::SdkPrivateStoreStatusSummary { farm_private_locations: sqlite_query_i64( pool, - "SELECT COUNT(*) FROM sdk_private_farm_location", + "SELECT COUNT(*) FROM private_farm_location", SqliteStoreRole::PrivateStore, ) .await?, diff --git a/crates/sdk/src/signer_provider.rs b/crates/sdk/src/signer_provider.rs @@ -1,7 +1,5 @@ use crate::RadrootsSdkError; use nostr::{JsonUtil, Kind, PublicKey, Tag, Tags, Timestamp, UnsignedEvent}; -#[cfg(feature = "local-signer")] -use radroots_authority::RadrootsLocalEventSigner; use radroots_authority::{ RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, authorize_actor_for_draft, authorize_signer_for_draft, sign_authorized_draft, validate_signed_event_matches_draft, @@ -26,6 +24,7 @@ use tokio::time::timeout; use uuid::Uuid; pub type RadrootsSdkNip46TransportFuture<'a, T> = RadrootsNostrConnectClientTransportFuture<'a, T>; +pub type RadrootsSdkLocalSignerCapability = dyn RadrootsEventSigner + Send + Sync; pub const RADROOTS_SDK_MYC_NIP46_PRODUCT_SIGN_EVENT_KINDS: [u32; 5] = [ KIND_FARM, @@ -212,17 +211,25 @@ impl RadrootsSdkSignerProvider { #[cfg(feature = "local-signer")] #[derive(Clone)] pub struct RadrootsSdkLocalKeySigner { - signer: Arc<RadrootsLocalEventSigner>, + signer: Arc<RadrootsSdkLocalSignerCapability>, signer_pubkey: String, } #[cfg(feature = "local-signer")] impl RadrootsSdkLocalKeySigner { - pub fn new(keys: RadrootsNostrKeys) -> Result<Self, RadrootsSdkError> { - let signer = RadrootsLocalEventSigner::new(keys)?; + pub fn from_event_signer<S>(signer: S) -> Result<Self, RadrootsSdkError> + where + S: RadrootsEventSigner + Send + Sync + 'static, + { + Self::from_shared_event_signer(Arc::new(signer)) + } + + pub fn from_shared_event_signer( + signer: Arc<RadrootsSdkLocalSignerCapability>, + ) -> Result<Self, RadrootsSdkError> { let signer_pubkey = signer.pubkey().as_str().to_owned(); Ok(Self { - signer: Arc::new(signer), + signer, signer_pubkey, }) } diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs @@ -12,6 +12,8 @@ use futures::future::BoxFuture; use nostr::JsonUtil; #[cfg(feature = "signer-adapters")] use radroots_authority::RadrootsActorContext; +#[cfg(all(feature = "signer-adapters", feature = "local-signer"))] +use radroots_authority::RadrootsLocalEventSigner; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, }; @@ -468,7 +470,10 @@ async fn directory_sdk_with_signer_and_relays( .directory_storage(storage_root) .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::new(signer_keys).expect("local signer"), + RadrootsSdkLocalKeySigner::from_event_signer( + RadrootsLocalEventSigner::new(signer_keys).expect("local event signer"), + ) + .expect("local signer"), )); if !relays.is_empty() { builder = builder.transport_profile(TransportProfile::nostr( diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs @@ -230,7 +230,7 @@ async fn sdk_memory_storage_status_and_integrity_report_canonical_stores() { assert_eq!(status.paths, None); assert_eq!(status.event_store.store.schema_version, 1); assert_eq!(status.outbox.store.schema_version, 1); - assert_eq!(status.private_store.store.schema_version, 2); + assert_eq!(status.private_store.store.schema_version, 1); assert!(status.event_store.store.foreign_keys_enabled); assert!(status.outbox.store.foreign_keys_enabled); assert!(status.private_store.store.foreign_keys_enabled); @@ -740,7 +740,7 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { checkpoint_complete: true, }; let private_store = SdkSqliteStoreStatus { - schema_version: 2, + schema_version: 1, ..store.clone() }; assert_eq!( @@ -829,7 +829,7 @@ fn storage_backup_and_integrity_contract_dtos_serialize() { }, "private_store": { "store": { - "schema_version": 2, + "schema_version": 1, "journal_mode": "wal", "foreign_keys_enabled": true, "busy_timeout_ms": 5000, diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -1268,6 +1268,100 @@ fn retired_dvm_runtime_public_exports_are_absent() { } #[test] +fn local_sdk_signer_uses_event_signer_capability_boundary() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let signer_source = read_source(manifest_dir.join("src/signer_provider.rs").as_path()); + let lib_source = read_source(manifest_dir.join("src/lib.rs").as_path()); + + for required in [ + "pub type RadrootsSdkLocalSignerCapability", + "dyn RadrootsEventSigner + Send + Sync", + "Arc<RadrootsSdkLocalSignerCapability>", + "pub fn from_event_signer", + "pub fn from_shared_event_signer", + ] { + assert!( + signer_source.contains(required), + "signer provider must expose the protected local signer capability boundary `{required}`" + ); + } + + assert!( + lib_source.contains("RadrootsSdkLocalSignerCapability"), + "SDK root exports the local signer capability type for downstream protected signer wiring" + ); + for forbidden in [ + "RadrootsSdkLocalKeySigner::new", + "pub fn new(keys", + "RadrootsLocalEventSigner", + ] { + assert!( + !signer_source.contains(forbidden), + "signer provider must not expose or couple to retired raw local signer constructor `{forbidden}`" + ); + } +} + +#[test] +fn private_store_schema_is_v1_encrypted_private_authority_only() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let source = read_source(manifest_dir.join("src/private_store.rs").as_path()); + + for required in [ + "private_metadata", + "wrapped_profile_key", + "wrapped_signing_secret", + "private_farm_location", + "trade_private_thread", + "buyer_contact_private", + "cursor_hmac_key", + "nip46_session_private", + "key_rotation_progress", + "RadrootsProtectedStoreEnvelope", + "RadrootsProtectedFileKeySource", + "credential_backend", + ] { + assert!( + source.contains(required), + "private store source must contain V1 encrypted private authority contract `{required}`" + ); + } + + assert!( + !source.contains("CREATE TABLE IF NOT EXISTS sdk_private_farm_location"), + "retired plaintext SDK private farm-location table must not be created" + ); + assert!( + source.matches("sdk_private_farm_location").count() == 1 + && source.contains("reject_pre_v1_private_store"), + "the retired private table name may appear only in the fail-closed pre-V1 rejection check" + ); + let migration_start = source + .find("const PRIVATE_STORE_MIGRATION_UP") + .expect("private store migration must exist"); + let migration_end = source[migration_start..] + .find("\"#;") + .map(|offset| migration_start + offset) + .expect("private store migration must close"); + let migration = &source[migration_start..migration_end]; + for forbidden_plaintext_column in [ + "label TEXT", + "latitude REAL", + "longitude REAL", + "locality_city TEXT", + "locality_region TEXT", + "locality_country TEXT", + "geohash5 TEXT", + "geonames_id", + ] { + assert!( + !migration.contains(forbidden_plaintext_column), + "private farm-location migration must not expose plaintext column `{forbidden_plaintext_column}`" + ); + } +} + +#[test] fn identity_public_surface_is_an_explicit_feature_module() { let lib_source = read_source( Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/sdk/tests/trade_product_publish_runtime.rs b/crates/sdk/tests/trade_product_publish_runtime.rs @@ -5,7 +5,7 @@ feature = "radrootsd-execution" ))] -use radroots_authority::RadrootsActorContext; +use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner}; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, }; @@ -240,7 +240,10 @@ async fn trade_product_propose_enqueue_and_publish_uses_ack_policy() { .directory_storage(tempdir.path().join("sdk")) .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::new(signer_keys).expect("local signer"), + RadrootsSdkLocalKeySigner::from_event_signer( + RadrootsLocalEventSigner::new(signer_keys).expect("local event signer"), + ) + .expect("local signer"), )) .radrootsd_execution_profile(RadrootsdExecutionProfile::new(endpoint)) .build() diff --git a/crates/sdk/tests/unit/farms_runtime_tests.rs b/crates/sdk/tests/unit/farms_runtime_tests.rs @@ -1,6 +1,5 @@ use super::*; use crate::{RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider}; -use radroots_nostr::prelude::RadrootsNostrKeys; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use crate::fixture_signer::FixtureSigner; @@ -595,18 +594,16 @@ async fn farm_client_enqueue_methods_cover_source_attached_workflow_paths() { #[tokio::test] async fn farm_configured_local_signer_enqueues_publish_without_explicit_signer() { - let keys = RadrootsNostrKeys::generate(); - let farmer = keys.public_key().to_hex(); let sdk = crate::RadrootsClient::builder() .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::new(keys).expect("signer"), + RadrootsSdkLocalKeySigner::from_event_signer(FixtureSigner::new(FARMER)) + .expect("signer"), )) .build() .await .expect("sdk"); - let actor = - RadrootsActorContext::test(farmer.as_str(), [RadrootsActorRole::Farmer]).expect("actor"); + let actor = RadrootsActorContext::test(FARMER, [RadrootsActorRole::Farmer]).expect("actor"); let receipt = sdk .farms() @@ -629,18 +626,16 @@ async fn farm_configured_local_signer_enqueues_publish_without_explicit_signer() #[tokio::test] async fn farm_configured_enqueue_reports_prepare_and_signer_errors() { - let keys = RadrootsNostrKeys::generate(); - let farmer = keys.public_key().to_hex(); let configured_sdk = crate::RadrootsClient::builder() .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::new(keys).expect("signer"), + RadrootsSdkLocalKeySigner::from_event_signer(FixtureSigner::new(FARMER)) + .expect("signer"), )) .build() .await .expect("configured sdk"); - let actor = - RadrootsActorContext::test(farmer.as_str(), [RadrootsActorRole::Farmer]).expect("actor"); + let actor = RadrootsActorContext::test(FARMER, [RadrootsActorRole::Farmer]).expect("actor"); assert!(matches!( configured_sdk diff --git a/crates/sdk/tests/unit/listings_runtime_tests.rs b/crates/sdk/tests/unit/listings_runtime_tests.rs @@ -11,7 +11,6 @@ use radroots_event::{ listing::{RadrootsListingBin, RadrootsListingProduct}, resource_area::RadrootsResourceAreaRef, }; -use radroots_nostr::prelude::RadrootsNostrKeys; use crate::fixture_signer::FixtureSigner; use crate::serializer_failure::assert_struct_serialize_error_paths; @@ -303,25 +302,23 @@ async fn listing_client_enqueue_methods_cover_source_attached_workflow_paths() { #[tokio::test] async fn listing_configured_local_signer_enqueues_publish_without_explicit_signer() { - let keys = RadrootsNostrKeys::generate(); - let seller = keys.public_key().to_hex(); let sdk = crate::RadrootsClient::builder() .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500)) .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::new(keys).expect("signer"), + RadrootsSdkLocalKeySigner::from_event_signer(FixtureSigner::new(SELLER)) + .expect("signer"), )) .build() .await .expect("sdk"); - let actor = - RadrootsActorContext::test(seller.as_str(), [RadrootsActorRole::Seller]).expect("actor"); + let actor = RadrootsActorContext::test(SELLER, [RadrootsActorRole::Seller]).expect("actor"); let receipt = sdk .listings() .enqueue_publish( ListingEnqueuePublishRequest::new( actor, - listing_for_seller(seller.as_str(), LISTING_C_D_TAG, "Configured Greens"), + listing_for_seller(SELLER, LISTING_C_D_TAG, "Configured Greens"), TargetPolicy::try_nostr_relays([RELAY_A], NostrRelayUrlPolicy::Public) .expect("transport targets"), ) diff --git a/crates/sdk/tests/unit/private_store_tests.rs b/crates/sdk/tests/unit/private_store_tests.rs @@ -1,11 +1,8 @@ use super::*; use radroots_event::ids::RadrootsAddressableCoordinate; +use sqlx::Row; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; -fn farm_addr() -> RadrootsAddressableCoordinate { - farm_addr_for("AAAAAAAAAAAAAAAAAAAAAA") -} - fn farm_addr_for(d_tag: &str) -> RadrootsAddressableCoordinate { RadrootsAddressableCoordinate::parse(format!( "{}:{}:{}", @@ -16,11 +13,8 @@ fn farm_addr_for(d_tag: &str) -> RadrootsAddressableCoordinate { .expect("farm addr") } -fn private_store_error_message<T>(result: Result<T, RadrootsSdkError>) -> String { - match result.err().expect("private store error") { - RadrootsSdkError::PrivateStore { message } => message, - other => panic!("expected private store error, got {other:?}"), - } +fn farm_addr() -> RadrootsAddressableCoordinate { + farm_addr_for("AAAAAAAAAAAAAAAAAAAAAA") } fn private_location_record() -> SdkPrivateFarmLocationRecord { @@ -43,48 +37,6 @@ fn private_location_record() -> SdkPrivateFarmLocationRecord { } #[tokio::test] -async fn private_farm_location_row_decode_reports_each_missing_column() { - let store = SdkPrivateStore::open_memory().await.expect("private store"); - let columns = [ - ( - "farm_pubkey", - "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'", - ), - ("farm_d_tag", "'AAAAAAAAAAAAAAAAAAAAAA'"), - ("label", "'Main pickup point'"), - ("latitude", "12.26"), - ("longitude", "-34.51"), - ("locality_primary", "'Fixture Town'"), - ("locality_city", "'Fixture Town'"), - ("locality_region", "'Fixture Region'"), - ("locality_country", "'Fixture Country'"), - ("geohash5", "'e4pmw'"), - ("geonames_feature_id", "1"), - ("geonames_country_id", "'FX'"), - ("updated_at_ms", "1700000123000"), - ]; - - for missing in columns.map(|(name, _)| name) { - let select = columns - .iter() - .filter(|(name, _)| *name != missing) - .map(|(name, value)| format!("{value} AS {name}")) - .collect::<Vec<_>>() - .join(", "); - let sql = format!("SELECT {select}"); - let row = sqlx::query(sqlx::AssertSqlSafe(sql)) - .fetch_one(store.pool()) - .await - .expect("row"); - let message = private_store_error_message(private_farm_location_from_row(farm_addr(), row)); - assert!( - message.contains(missing), - "{message:?} should mention {missing:?}" - ); - } -} - -#[tokio::test] async fn private_store_file_open_rejects_directory_paths() { let tempdir = tempfile::tempdir().expect("tempdir"); assert!(matches!( @@ -128,6 +80,7 @@ async fn private_store_status_update_delete_and_pragmas_round_trip() { .upsert_farm_location(&record) .await .expect("insert private location"); + assert_private_farm_location_payload_is_encrypted(&store, &record).await; assert_eq!( store .status_summary() @@ -194,7 +147,7 @@ async fn private_store_status_update_delete_and_pragmas_round_trip() { } #[tokio::test] -async fn private_store_file_open_rejects_pre_label_schema_without_repair() { +async fn private_store_file_open_rejects_pre_v1_schema_without_repair() { let tempdir = tempfile::tempdir().expect("tempdir"); let path = tempdir.path().join("private.sqlite"); let options = SqliteConnectOptions::new() @@ -229,10 +182,110 @@ async fn private_store_file_open_rejects_pre_label_schema_without_repair() { .expect("old private store schema"); pool.close().await; - let store = SdkPrivateStore::open_file(&path).await.expect("open store"); - let record = private_location_record(); assert!(matches!( - store.upsert_farm_location(&record).await, - Err(RadrootsSdkError::PrivateStore { .. }) + SdkPrivateStore::open_file(&path).await, + Err(RadrootsSdkError::UnsupportedProfileSchema { .. }) )); } + +#[tokio::test] +async fn private_store_schema_uses_v1_private_authority_tables() { + let store = SdkPrivateStore::open_memory().await.expect("private store"); + let tables = sqlx::query( + r#" + SELECT name FROM sqlite_master + WHERE type = 'table' + ORDER BY name + "#, + ) + .fetch_all(store.pool()) + .await + .expect("tables") + .into_iter() + .map(|row| row.try_get::<String, _>("name").expect("name")) + .collect::<Vec<_>>(); + + for table in [ + "buyer_contact_private", + "cursor_hmac_key", + "key_rotation_progress", + "nip46_session_private", + "private_farm_location", + "private_metadata", + "trade_private_thread", + "wrapped_profile_key", + "wrapped_signing_secret", + ] { + assert!(tables.iter().any(|name| name == table), "missing {table}"); + } + assert!( + !tables + .iter() + .any(|name| name == "sdk_private_farm_location") + ); + + let columns = sqlx::query("PRAGMA table_info(private_farm_location)") + .fetch_all(store.pool()) + .await + .expect("columns") + .into_iter() + .map(|row| row.try_get::<String, _>("name").expect("column name")) + .collect::<Vec<_>>(); + for forbidden in [ + "label", + "latitude", + "longitude", + "locality_primary", + "locality_city", + "locality_region", + "locality_country", + "geohash5", + "geonames_feature_id", + "geonames_country_id", + ] { + assert!( + !columns.iter().any(|column| column == forbidden), + "private_farm_location must not retain plaintext column {forbidden}" + ); + } +} + +async fn assert_private_farm_location_payload_is_encrypted( + store: &SdkPrivateStore, + record: &SdkPrivateFarmLocationRecord, +) { + let row = sqlx::query( + r#" + SELECT ciphertext, nonce + FROM private_farm_location + WHERE farm_kind = 30340 + "#, + ) + .fetch_one(store.pool()) + .await + .expect("encrypted private location row"); + let ciphertext: Vec<u8> = row.try_get("ciphertext").expect("ciphertext"); + let nonce: Vec<u8> = row.try_get("nonce").expect("nonce"); + assert_eq!(nonce.len(), 24); + let rendered = String::from_utf8_lossy(ciphertext.as_slice()); + let forbidden_values = vec![ + record.label.clone().expect("label"), + record.latitude.to_string(), + record.longitude.to_string(), + record.locality_primary.clone(), + record.locality_city.clone().expect("city"), + record.locality_region.clone().expect("region"), + record.locality_country.clone().expect("country"), + record.geohash5.clone(), + record + .geonames_country_id + .clone() + .expect("geonames country"), + ]; + for forbidden in forbidden_values { + assert!( + !rendered.contains(forbidden.as_str()), + "encrypted private location payload leaked {forbidden}" + ); + } +} diff --git a/crates/sdk/tests/unit/runtime_tests.rs b/crates/sdk/tests/unit/runtime_tests.rs @@ -68,7 +68,7 @@ fn sqlite_status() -> SdkSqliteStoreStatus { fn private_sqlite_status() -> SdkSqliteStoreStatus { SdkSqliteStoreStatus { - schema_version: 2, + schema_version: 1, ..sqlite_status() } } @@ -276,7 +276,7 @@ async fn private_store_validates_location_rows_and_round_trips_valid_records() { Err(RadrootsSdkError::InvalidRequest { .. }) )); - sqlx::query("DROP TABLE sdk_private_farm_location") + sqlx::query("DROP TABLE private_farm_location") .execute(store.pool()) .await .expect("drop private location table"); @@ -779,7 +779,7 @@ async fn storage_status_integrity_and_backup_map_closed_pool_errors() { assert_outbox_error(outbox_status_summary(&outbox_summary_error._outbox, 1).await); let private_summary_error = RadrootsClient::builder().build().await.expect("sdk"); - sqlx::query("DROP TABLE sdk_private_farm_location") + sqlx::query("DROP TABLE private_farm_location") .execute(private_summary_error._private_store.pool()) .await .expect("drop private location"); diff --git a/crates/sdk/tests/unit/signer_provider_tests.rs b/crates/sdk/tests/unit/signer_provider_tests.rs @@ -1,6 +1,7 @@ use super::*; use nostr::nips::nip44::{self, Version}; use nostr::{EventBuilder, JsonUtil, Kind, Tag}; +use radroots_authority::RadrootsLocalEventSigner; use radroots_event::contract::RadrootsActorRole; use radroots_event::draft::RadrootsEventDraft; use radroots_event::kinds::{KIND_COOP, KIND_FARM}; @@ -33,6 +34,13 @@ fn user_keys() -> RadrootsNostrKeys { keys(USER_SECRET_KEY_HEX) } +fn local_sdk_signer(keys: RadrootsNostrKeys) -> RadrootsSdkLocalKeySigner { + RadrootsSdkLocalKeySigner::from_event_signer( + RadrootsLocalEventSigner::new(keys).expect("local event signer"), + ) + .expect("sdk local signer") +} + fn remote_keys() -> RadrootsNostrKeys { keys(REMOTE_SECRET_KEY_HEX) } @@ -230,7 +238,7 @@ impl RadrootsSdkNip46Transport for HangingNip46Transport { #[tokio::test] async fn local_key_provider_signs_authorized_frozen_draft() { - let signer = RadrootsSdkLocalKeySigner::new(user_keys()).expect("signer"); + let signer = local_sdk_signer(user_keys()); let provider = RadrootsSdkSignerProvider::LocalKey(signer.clone()); let draft = frozen_draft(); let actor = actor(); @@ -269,7 +277,7 @@ async fn local_key_provider_signs_authorized_frozen_draft() { #[tokio::test] async fn local_key_provider_returns_progress_sink_errors_without_transport_state() { - let signer = RadrootsSdkLocalKeySigner::new(user_keys()).expect("signer"); + let signer = local_sdk_signer(user_keys()); let draft = frozen_draft(); let actor = actor(); let wrong_actor = RadrootsActorContext::test("a".repeat(64), [RadrootsActorRole::Farmer]) @@ -1022,7 +1030,7 @@ async fn sdk_builder_installs_configured_signer_provider() { if mode == "configured" && reason.contains("no SDK signer provider") )); - let signer = RadrootsSdkLocalKeySigner::new(user_keys()).expect("signer"); + let signer = local_sdk_signer(user_keys()); let sdk = crate::RadrootsClient::builder() .signer_provider(RadrootsSdkSignerProvider::LocalKey(signer)) .build() diff --git a/crates/sdk/tests/unit/workflow_runtime_tests.rs b/crates/sdk/tests/unit/workflow_runtime_tests.rs @@ -481,15 +481,14 @@ async fn enqueue_signed_workflow_stores_signed_event_and_reports_idempotency_con #[cfg(feature = "signer-adapters")] #[tokio::test] async fn enqueue_configured_signed_workflow_uses_sdk_signer_provider() { - let secret_key = RadrootsNostrSecretKey::from_hex(FARMER_SECRET_KEY_HEX).expect("secret key"); - let keys = RadrootsNostrKeys::new(secret_key); let sdk = crate::RadrootsClient::builder() .transport_profile(nostr_profile("wss://relay.example.com")) .fixed_clock(crate::RadrootsSdkTimestamp::from_unix_seconds( 1_700_000_011, )) .signer_provider(RadrootsSdkSignerProvider::LocalKey( - RadrootsSdkLocalKeySigner::new(keys).expect("local signer"), + RadrootsSdkLocalKeySigner::from_event_signer(WorkflowSigner::new()) + .expect("local signer"), )) .build() .await