From ee6546aebda5f01097dbd760363184ce1f6d6007 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:28:52 -0400 Subject: [PATCH 01/30] feat(kotlin-sdk): wire-compatible encrypted txMetadata document create + decrypt-on-fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the wallet-contract encrypted-document surface the Android wallet needs to retire the legacy org.dashj.platform stack (closes #4086 create/update, closes The encryption ENVELOPE is byte-for-byte wire-compatible with the legacy BlockchainIdentity.publishTxMetaData / getTxMetaData so documents written by either stack decrypt with the other (migrated users keep their history): - AES-256 key = the raw 32-byte secp256k1 private scalar of a hardened HD child (mirrors KeyCrypterAESCBC.deriveKey(ECKey); no ECDH, no HKDF). - Path: identity-auth path of the identity's encryption key (its id = the document's keyIndex) extended by / 32769' / encryptionKeyIndex'. - Cipher: AES-256-CBC / PKCS7, random 16-byte IV. - encryptedMetadata blob = version(1) ‖ IV(16) ‖ AES-256-CBC(payload); the version byte is OUTSIDE the ciphertext (0 = CBOR, 1 = protobuf). The plaintext payload stays OPAQUE to the SDK — the app owns the protobuf TxMetadataBatch item schema and the batching policy, exactly as on the legacy stack. The SDK owns only the crypto envelope + the {keyIndex, encryptionKeyIndex, encryptedMetadata} document fields. Layers: - rs-platform-wallet: crypto/tx_metadata.rs (derive/seal/open + tests); network/encrypted_document.rs (create_encrypted_document_with_signer reusing the tested create path; fetch_encrypted_documents mirroring the contactInfo paginated decrypt loop). - rs-platform-wallet-ffi: platform_wallet_create_encrypted_document_with_signer, platform_wallet_fetch_encrypted_documents (JSON-out; payload as base64). - rs-unified-sdk-jni: documentCreateEncrypted / documentFetchEncrypted. - kotlin-sdk: DocumentTransactions.createEncryptedDocument / fetchEncryptedDocuments (additive; no existing signatures change). Tests: seal↔open round-trip, key-derivation determinism + index separation, wrong-key/ malformed-blob fail cleanly, and a NIST SP 800-38A CBC-AES256 cross-stack vector pinning the cipher core + blob framing. cc @quantumexplorer Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + .../dashsdk/documents/DocumentTransactions.kt | 90 +++++ .../dashsdk/ffi/TransactionsNative.kt | 49 +++ packages/rs-platform-wallet-ffi/Cargo.toml | 4 + .../rs-platform-wallet-ffi/src/document.rs | 173 +++++++++ packages/rs-platform-wallet/src/lib.rs | 3 +- .../src/wallet/identity/crypto/mod.rs | 5 + .../src/wallet/identity/crypto/tx_metadata.rs | 315 ++++++++++++++++ .../identity/network/encrypted_document.rs | 351 ++++++++++++++++++ .../src/wallet/identity/network/mod.rs | 2 + .../rs-unified-sdk-jni/src/transactions.rs | 163 ++++++++ 11 files changed, 1155 insertions(+), 1 deletion(-) create mode 100644 packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs create mode 100644 packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs diff --git a/Cargo.lock b/Cargo.lock index 15e0c8da3c..2b2194333b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5245,6 +5245,7 @@ version = "4.0.0" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "bincode", "bs58", "cbindgen 0.27.0", diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt index 518bff7827..51b01a928b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt @@ -251,4 +251,94 @@ class DocumentTransactions internal constructor( ) } } + + /** + * Create + broadcast an ENCRYPTED wallet-contract document (the wire- + * compatible `txMetadata` shape) on [contractId]'s [documentType], owned by + * [ownerId] — signed via [signerHandle]. Implements the create half of the + * legacy `BlockchainIdentity.publishTxMetaData` retirement + * (dashpay/platform#4086): the SDK derives the identity encryption key, + * seals [payload] into the legacy `version ‖ IV ‖ AES-256-CBC` blob, and + * writes `{keyIndex, encryptionKeyIndex, encryptedMetadata}`. + * + * Batching stays app-side: the caller serializes its items into [payload] + * (a protobuf `TxMetadataBatch`) and supplies its own per-document + * [encryptionKeyIndex] (dash-wallet's `1 + countAllRequests()` counter). + * The identity encryption key id (the `keyIndex` field) is chosen SDK-side + * to match the legacy stack, so the key never crosses the FFI boundary. + * + * @param encryptionKeyIndex per-document index; non-negative. + * @param version payload version byte (`1` = protobuf, as the wallet writes). + * @param payload already-serialized opaque plaintext; the SDK does not + * parse it. + * @return the confirmed document's canonical JSON (its 32-byte id is the + * base58 `$id` field). + */ + suspend fun createEncryptedDocument( + walletHandle: Long, + ownerId: ByteArray, + contractId: ByteArray, + documentType: String, + encryptionKeyIndex: Int, + version: Int, + payload: ByteArray, + signerHandle: Long, + ): String = withContext(Dispatchers.IO) { + require(ownerId.size == 32) { "ownerId must be 32 bytes" } + require(contractId.size == 32) { "contractId must be 32 bytes" } + require(encryptionKeyIndex >= 0) { + "encryptionKeyIndex must be non-negative, got $encryptionKeyIndex" + } + require(version in 0..255) { "version must be in 0..255, got $version" } + mapNativeErrors { + TransactionsNative.documentCreateEncrypted( + walletHandle, + ownerId, + contractId, + documentType, + encryptionKeyIndex, + version, + payload, + signerHandle, + ) + } + } + + /** + * Fetch + DECRYPT every encrypted wallet-contract document owned by + * [ownerId] on [contractId]'s [documentType] updated at or after [sinceMs] + * (epoch-millis). Implements the read half of the legacy + * `BlockchainIdentity.getTxMetaData(since, key)` retirement + * (dashpay/platform#4087): the SDK fetches the owner-scoped, since-timestamp + * documents and decrypts each with the identity's derived key. Documents + * that fail to decrypt are skipped Rust-side (a bad document never aborts + * the fetch). + * + * @return a JSON array; each element is `{ "id", "ownerId" (base58), + * "keyIndex", "encryptionKeyIndex", "version", "updatedAt" (number|null), + * "payload" (base64 of the decrypted opaque plaintext) }`. The caller + * parses each `payload` itself (a protobuf `TxMetadataBatch` for + * `version == 1`) and reconciles memo / taxCategory / exchangeRate / + * service / giftCard fields into its local store. + */ + suspend fun fetchEncryptedDocuments( + walletHandle: Long, + ownerId: ByteArray, + contractId: ByteArray, + documentType: String, + sinceMs: Long, + ): String = withContext(Dispatchers.IO) { + require(ownerId.size == 32) { "ownerId must be 32 bytes" } + require(contractId.size == 32) { "contractId must be 32 bytes" } + require(sinceMs >= 0) { "sinceMs must be non-negative, got $sinceMs" } + mapNativeErrors { + TransactionsNative.documentFetchEncrypted( + walletHandle, + ownerId, + contractId, + documentType, + sinceMs, + ) + } + } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index d41c25b750..4e30459dc6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -164,6 +164,55 @@ internal object TransactionsNative { signerHandle: Long, ): String + /** + * Create + broadcast an ENCRYPTED wallet-contract document (the wire- + * compatible `txMetadata` shape) on [contractId]'s [documentType], owned by + * [ownerId], signed via [signerHandle]. Bridges + * `platform_wallet_create_encrypted_document_with_signer`. + * + * The Rust side selects the identity's ENCRYPTION key id (the `keyIndex` + * field), derives the AES key from the wallet HD tree, and seals [payload] + * into the legacy `version ‖ IV ‖ AES-256-CBC` blob — decryptable by the + * legacy `org.dashj.platform` stack and vice versa. + * + * @param encryptionKeyIndex the app's per-document index (dash-wallet's + * monotonic `1 + countAllRequests()` counter); non-negative. + * @param version payload version byte (`1` = protobuf, as the wallet writes). + * @param payload the already-serialized opaque plaintext (a protobuf + * `TxMetadataBatch`); the SDK does not parse it. + * @return the confirmed document's canonical JSON (its 32-byte id is the + * base58 `$id` field). + */ + external fun documentCreateEncrypted( + walletHandle: Long, + ownerId: ByteArray, + contractId: ByteArray, + documentType: String, + encryptionKeyIndex: Int, + version: Int, + payload: ByteArray, + signerHandle: Long, + ): String + + /** + * Fetch + DECRYPT every encrypted wallet-contract document owned by + * [ownerId] on [contractId]'s [documentType] updated at or after [sinceMs] + * (epoch-millis). Bridges `platform_wallet_fetch_encrypted_documents` — the + * wire-compatible read counterpart of the legacy `getTxMetaData(since, key)`. + * + * @return a JSON array; each element is `{ "id", "ownerId" (base58), + * "keyIndex", "encryptionKeyIndex", "version", "updatedAt" (number|null), + * "payload" (base64 of the decrypted opaque plaintext) }`. Documents that + * fail to decrypt are skipped Rust-side. + */ + external fun documentFetchEncrypted( + walletHandle: Long, + ownerId: ByteArray, + contractId: ByteArray, + documentType: String, + sinceMs: Long, + ): String + /** * Cast a masternode contested-resource vote and wait for the response. * Bridges `dash_sdk_contested_resource_cast_vote` (Swift diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index 5d9d0aceff..93cfdcbcb4 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -56,6 +56,10 @@ anyhow = { version = "1.0.81" } # Swift decodes via Codable. See `tokens/group_queries.rs`. serde_json = "1.0" bs58 = "0.5" +# Base64-encode the decrypted (opaque) txMetadata payload in the fetch JSON, +# matching the codebase's binary-in-JSON convention. See `document.rs` +# `platform_wallet_fetch_encrypted_documents`. +base64 = "0.22.1" # Zeroize intermediate key material crossing the FFI boundary. zeroize = { version = "1", features = ["derive"] } diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index 32509bc335..ebb37a0d08 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -155,6 +155,179 @@ fn confirmed_document_to_json(document: &Document) -> Result PlatformWalletFFIResult { + check_ptr!(signer_handle); + check_ptr!(document_type_name); + check_ptr!(out_document_id); + check_ptr!(out_document_json); + + *out_document_json = ptr::null_mut(); + + let owner_id = unwrap_result_or_return!(read_identifier(owner_identity_id)); + let contract_id_value = unwrap_result_or_return!(read_identifier(contract_id)); + let document_type_str = + unwrap_result_or_return!(CStr::from_ptr(document_type_name).to_str()).to_string(); + + // Copy the payload into an owned Vec (it is moved into the async block; a + // borrow of `payload` can't outlive this call). Null is allowed only for a + // zero-length payload. + let payload_vec: Vec = if payload_len == 0 { + Vec::new() + } else { + check_ptr!(payload); + slice::from_raw_parts(payload, payload_len).to_vec() + }; + + let signer_addr = signer_handle as usize; + let owner_id_for_async = owner_id; + let contract_id_for_async = contract_id_value; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let result: Result<(Identifier, String), PlatformWalletError> = block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let confirmed: Document = identity_wallet + .create_encrypted_document_with_signer( + &owner_id_for_async, + &contract_id_for_async, + &document_type_str, + encryption_key_index, + version, + &payload_vec, + signer, + ) + .await?; + let json_string = confirmed_document_to_json(&confirmed)?; + Ok::<_, PlatformWalletError>((confirmed.id(), json_string)) + }); + result + }); + let result = unwrap_option_or_return!(option); + let (document_id, document_json) = unwrap_result_or_return!(result); + + let json_cstring = unwrap_result_or_return!(CString::new(document_json)); + + let bytes = document_id.to_buffer(); + let dst = slice::from_raw_parts_mut(out_document_id, 32); + dst.copy_from_slice(&bytes); + *out_document_json = json_cstring.into_raw(); + PlatformWalletFFIResult::ok() +} + +/// Fetch + DECRYPT every encrypted wallet-contract document owned by +/// `owner_identity_id` on `contract_id`'s `document_type_name` updated at or +/// after `since_ms` (epoch-millis). +/// +/// Goes through `IdentityWallet::fetch_encrypted_documents` — the wire- +/// compatible read counterpart of the legacy `getTxMetaData(since, key)`. Each +/// document's `encryptedMetadata` blob is decrypted with the identity's derived +/// key; documents that can't be derived/decrypted are skipped (never abort the +/// fetch). +/// +/// On success `*out_documents_json` receives an owned NUL-terminated JSON array +/// (release with `platform_wallet_string_free`; left null on any error). Each +/// element is +/// `{ "id": base58, "ownerId": base58, "keyIndex": u32, "encryptionKeyIndex": +/// u32, "version": u8, "updatedAt": u64|null, "payload": base64 }`, where +/// `payload` is the decrypted, opaque plaintext the caller parses (a protobuf +/// `TxMetadataBatch` for `version == 1`). +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_fetch_encrypted_documents( + wallet_handle: Handle, + owner_identity_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + since_ms: u64, + out_documents_json: *mut *mut c_char, +) -> PlatformWalletFFIResult { + use base64::Engine; + + check_ptr!(document_type_name); + check_ptr!(out_documents_json); + + *out_documents_json = ptr::null_mut(); + + let owner_id = unwrap_result_or_return!(read_identifier(owner_identity_id)); + let contract_id_value = unwrap_result_or_return!(read_identifier(contract_id)); + let document_type_str = + unwrap_result_or_return!(CStr::from_ptr(document_type_name).to_str()).to_string(); + + let owner_id_for_async = owner_id; + let contract_id_for_async = contract_id_value; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + let result: Result, PlatformWalletError> = + block_on_worker(async move { + identity_wallet + .fetch_encrypted_documents( + &owner_id_for_async, + &contract_id_for_async, + &document_type_str, + since_ms, + ) + .await + }); + result + }); + let result = unwrap_option_or_return!(option); + let docs = unwrap_result_or_return!(result); + + let json_array: Vec = docs + .iter() + .map(|d| { + serde_json::json!({ + "id": bs58::encode(d.document_id.to_buffer()).into_string(), + "ownerId": bs58::encode(d.owner_id.to_buffer()).into_string(), + "keyIndex": d.key_index, + "encryptionKeyIndex": d.encryption_key_index, + "version": d.version, + "updatedAt": d.updated_at_ms, + "payload": base64::engine::general_purpose::STANDARD.encode(&d.payload), + }) + }) + .collect(); + let json_string = + unwrap_result_or_return!(serde_json::to_string(&serde_json::Value::Array(json_array))); + let json_cstring = unwrap_result_or_return!(CString::new(json_string)); + *out_documents_json = json_cstring.into_raw(); + PlatformWalletFFIResult::ok() +} + /// Replace + broadcast `document_id`'s properties on `contract_id`'s /// `document_type_name`, owned by `owner_identity_id`, signed via the /// external `signer_handle` with key `signing_key_id`. diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index e91c5ccee0..a8d6f0149d 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -64,7 +64,8 @@ pub use wallet::core::{CoreWallet, SignedCoreTransaction}; pub use wallet::core_address_key::CoreAddressPrivateKey; pub use wallet::identity::network::{ derive_identity_auth_keypair, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, - ContactInfoPublishOutcome, ContactInfoSealed, SeedBindingVerification, IDENTITY_GAP_LIMIT, + ContactInfoPublishOutcome, ContactInfoSealed, DecryptedEncryptedDocument, + SeedBindingVerification, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; pub use wallet::identity::{ diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs index c0a0687b44..7d5e0123b7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs @@ -7,6 +7,7 @@ pub mod auto_accept; pub mod contact_info; pub mod dip14; pub mod invitation; +pub mod tx_metadata; pub mod validation; pub use auto_accept::derive_auto_accept_private_key; @@ -22,4 +23,8 @@ pub use invitation::{ encode_invitation_uri, parse_invitation_uri, voucher_output_index, wif_network_matches, InviterInfo, ParsedInvitation, }; +pub use tx_metadata::{ + derive_tx_metadata_key, open_tx_metadata, seal_tx_metadata, OpenedTxMetadata, + TX_METADATA_ENCRYPTION_CHILD, VERSION_CBOR, VERSION_PROTOBUF, +}; pub use validation::pubkey_binds_expected_key_data; diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs new file mode 100644 index 0000000000..d44f9ee873 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs @@ -0,0 +1,315 @@ +//! Wallet `txMetadata` document self-encryption. +//! +//! **WIRE-COMPATIBLE with the legacy `org.dashj.platform` stack** +//! (`BlockchainIdentity.publishTxMetaData` / `getTxMetaData`, dash-sdk-kotlin +//! 4.0.0-RC2) so documents written by either stack decrypt with the other — +//! migrated users must not lose their tx-metadata history (memos, tax +//! categories, exchange-rate records, gift cards). The scheme below was +//! recovered byte-for-byte from the legacy jars (`BlockchainIdentity`, +//! `TxMetadataDocument`) and `org.bitcoinj.crypto.KeyCrypterAESCBC` +//! (dashj-core 22.0.3). +//! +//! ## Scheme +//! +//! - **AES key**: the RAW 32-byte secp256k1 private scalar of a hardened HD +//! child — NOT ECDH and NOT HKDF. This mirrors +//! `KeyCrypterAESCBC.deriveKey(ECKey)`, which is literally +//! `new KeyParameter(ecKey.getPrivKeyBytes())`. (Contrast the DIP-15 +//! DashPay fields in [`super::contact_info`], which DO use ECDH — a +//! different scheme that must not be reused here.) +//! - **Derivation path**: the identity-auth path of the identity's encryption +//! key (its key id is the document's `keyIndex` field) extended by two +//! hardened children `/ 32769' / encryptionKeyIndex'`. In dashj terms: +//! ` / keyIndex' / 32769' / encryptionKeyIndex'`. +//! Rust's [`identity_auth_derivation_path_for_type`] reproduces the dashj +//! `blockchainIdentityECDSADerivationPath(keyIndex)` prefix for the primary +//! identity (identity_index 0), so appending the two children reconstructs +//! the exact legacy key. This is the SAME base-path machinery a registered +//! identity's keys use, and the SAME extend-by-two-hardened-children shape +//! as [`super::contact_info::derive_contact_info_keys`]. +//! - **Cipher**: AES-256-CBC / PKCS7, random 16-byte IV (BouncyCastle +//! `PaddedBufferedBlockCipher(CBCBlockCipher(AESEngine))` in the legacy stack). +//! - **Stored `encryptedMetadata` blob layout** (the authoritative +//! `createTxMetadata` / `decryptTxMetadata` framing — NOT the alternate, +//! unused `TxMetadataDocument.decrypt` helper): +//! +//! ```text +//! byte[0] = version (0 = CBOR, 1 = protobuf) -- NOT encrypted +//! byte[1..17) = IV (16 bytes) -- NOT encrypted +//! byte[17..) = AES-256-CBC(key, IV, plaintext) -- PKCS7 padded +//! ``` +//! +//! ## Payload boundary (SDK owns the envelope, app owns the item schema) +//! +//! The decrypted plaintext is a protobuf `TxMetadataBatch` (version 1) or a +//! CBOR list (version 0) of the wallet's `TxMetadataItem`s. That item schema +//! (memo / taxCategory / exchangeRate / service / giftCard …) is an +//! APP-level concern — the legacy stack kept it in `org.dashj.platform.wallet` +//! and the app batches items itself. This crate therefore treats the plaintext +//! payload as OPAQUE bytes: [`seal_tx_metadata`] takes already-serialized +//! payload bytes + the version byte, and [`open_tx_metadata`] returns the +//! decrypted payload bytes + version byte. The caller (dash-wallet) keeps +//! ownership of the protobuf (de)serialization and the batching policy, exactly +//! as it did on the legacy stack. + +use key_wallet::bip32::ChildNumber; +use key_wallet::bip32::KeyDerivationType; +use key_wallet::wallet::Wallet; +use key_wallet::Network; +use zeroize::Zeroizing; + +use crate::error::PlatformWalletError; +use crate::wallet::identity::network::identity_auth_derivation_path_for_type; + +/// The fixed hardened child index between `keyIndex` and `encryptionKeyIndex` +/// in the tx-metadata key path (`ChildNumber(32769, hardened)` in the legacy +/// `TxMetadataDocument` static init — `0x8001`). "To discount other potential +/// derivations of this key in other applications", as with DIP-15's `1 << 16`. +pub const TX_METADATA_ENCRYPTION_CHILD: u32 = 32769; + +/// `encryptedMetadata` version byte: the plaintext is a CBOR list of items. +pub const VERSION_CBOR: u8 = 0; + +/// `encryptedMetadata` version byte: the plaintext is a protobuf +/// `TxMetadataBatch`. This is what the wallet writes +/// (`TxMetadataDocument.VERSION_PROTOBUF`). +pub const VERSION_PROTOBUF: u8 = 1; + +/// Layout overhead of the stored blob: 1 version byte + 16 IV bytes. +const BLOB_HEADER_LEN: usize = 1 + 16; + +/// AES block size — the ciphertext must be a non-zero multiple of this. +const AES_BLOCK_LEN: usize = 16; + +/// Derive the AES-256 key for one `txMetadata` document from the wallet seed. +/// +/// `key_index` is the document's `keyIndex` field (the identity's registered +/// ENCRYPTION key id); `encryption_key_index` is the document's +/// `encryptionKeyIndex` field (the app's per-document index). The derived key +/// is the raw private scalar at +/// `identity_auth_path(identity_index, key_index) / 32769' / encryption_key_index'`. +/// +/// Requires a key-resident wallet (mnemonic/seed); a watch-only wallet has no +/// in-process HD slot and would need a host-side signing hook (out of scope for +/// the dash-wallet migration, which uses a resident mnemonic wallet). +pub fn derive_tx_metadata_key( + wallet: &Wallet, + network: Network, + identity_index: u32, + key_index: u32, + encryption_key_index: u32, +) -> Result, PlatformWalletError> { + let root_path = identity_auth_derivation_path_for_type( + network, + KeyDerivationType::ECDSA, + identity_index, + key_index, + )?; + + let path = root_path.extend([ + ChildNumber::from_hardened_idx(TX_METADATA_ENCRYPTION_CHILD).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Invalid txMetadata encryption child index: {e}" + )) + })?, + ChildNumber::from_hardened_idx(encryption_key_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Invalid txMetadata encryptionKeyIndex: {e}" + )) + })?, + ]); + + let ext = wallet.derive_extended_private_key(&path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Failed to derive txMetadata key: {e}")) + })?; + Ok(Zeroizing::new(ext.private_key.secret_bytes())) +} + +/// Seal an already-serialized `txMetadata` payload into the stored +/// `encryptedMetadata` blob: `version(1) ‖ IV(16) ‖ AES-256-CBC(payload)`. +/// +/// `payload` is the app's opaque plaintext (a protobuf `TxMetadataBatch` when +/// `version == VERSION_PROTOBUF`); this crate does not parse it. `iv` MUST be a +/// fresh random 16 bytes per document (the legacy stack draws it from +/// `SecureRandom`). +pub fn seal_tx_metadata(key: &[u8; 32], version: u8, iv: &[u8; 16], payload: &[u8]) -> Vec { + let ciphertext = platform_encryption::encrypt_aes_256_cbc(key, iv, payload); + let mut blob = Vec::with_capacity(BLOB_HEADER_LEN + ciphertext.len()); + blob.push(version); + blob.extend_from_slice(iv); + blob.extend_from_slice(&ciphertext); + blob +} + +/// The plaintext recovered from a stored `encryptedMetadata` blob. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenedTxMetadata { + /// The blob's leading version byte (0 = CBOR, 1 = protobuf). The app + /// dispatches its payload parse on this. + pub version: u8, + /// The decrypted, PKCS7-unpadded payload bytes — opaque to this crate. + pub payload: Vec, +} + +/// Open a stored `encryptedMetadata` blob: split off the version byte + IV and +/// AES-256-CBC-decrypt the remainder, returning the version + opaque payload. +/// +/// Errors (never panics) on a malformed blob — too short, a ciphertext length +/// that is not a positive multiple of the AES block size, or a decrypt/unpad +/// failure (e.g. the wrong key, which PKCS7 rejects). A malformed or +/// wrong-keyed document must be skipped by the caller, not abort a sync. +pub fn open_tx_metadata( + key: &[u8; 32], + blob: &[u8], +) -> Result { + if blob.len() < BLOB_HEADER_LEN + AES_BLOCK_LEN { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "txMetadata encryptedMetadata is {} bytes; below the {}-byte minimum \ + (version + IV + one AES block)", + blob.len(), + BLOB_HEADER_LEN + AES_BLOCK_LEN + ))); + } + let ciphertext = &blob[BLOB_HEADER_LEN..]; + if !ciphertext.len().is_multiple_of(AES_BLOCK_LEN) { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "txMetadata ciphertext length {} is not a multiple of the AES block size", + ciphertext.len() + ))); + } + + let version = blob[0]; + let iv: [u8; 16] = blob[1..BLOB_HEADER_LEN] + .try_into() + .expect("slice [1..17) is exactly 16 bytes"); + + let payload = platform_encryption::decrypt_aes_256_cbc(key, &iv, ciphertext).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("txMetadata decrypt failed: {e}")) + })?; + + Ok(OpenedTxMetadata { version, payload }) +} + +#[cfg(test)] +mod tests { + use super::*; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + + fn test_wallet() -> Wallet { + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::None) + .expect("test wallet") + } + + /// Key derivation is deterministic and every path component + /// (`key_index`, `encryption_key_index`) is load-bearing. + #[test] + fn key_derivation_is_deterministic_and_index_separated() { + let wallet = test_wallet(); + + let a = derive_tx_metadata_key(&wallet, Network::Testnet, 0, 3, 1).expect("derive"); + let a2 = derive_tx_metadata_key(&wallet, Network::Testnet, 0, 3, 1).expect("derive"); + assert_eq!(*a, *a2, "same inputs must yield the same key"); + + let diff_enc = derive_tx_metadata_key(&wallet, Network::Testnet, 0, 3, 2).expect("derive"); + assert_ne!( + *a, *diff_enc, + "encryptionKeyIndex must change the derived key" + ); + + let diff_key = derive_tx_metadata_key(&wallet, Network::Testnet, 0, 4, 1).expect("derive"); + assert_ne!(*a, *diff_key, "keyIndex must change the derived key"); + } + + /// Full seal → open round-trip across both version bytes. + #[test] + fn seal_open_round_trips() { + let key = [0x11u8; 32]; + let iv = [0x22u8; 16]; + for version in [VERSION_CBOR, VERSION_PROTOBUF] { + let payload = b"opaque protobuf TxMetadataBatch bytes".to_vec(); + let blob = seal_tx_metadata(&key, version, &iv, &payload); + // Framing: version at [0], IV at [1..17), ciphertext after. + assert_eq!(blob[0], version); + assert_eq!(&blob[1..17], &iv); + let opened = open_tx_metadata(&key, &blob).expect("open"); + assert_eq!(opened.version, version); + assert_eq!(opened.payload, payload); + } + } + + /// A wrong key can never recover the plaintext: PKCS7 rejects it (Err), or + /// on the rare valid-padding collision the payload differs — never the + /// original. Must not panic. + #[test] + fn wrong_key_open_fails_cleanly() { + let key = [0x33u8; 32]; + let wrong = [0x44u8; 32]; + let iv = [0x55u8; 16]; + let payload = b"secret memo".to_vec(); + let blob = seal_tx_metadata(&key, VERSION_PROTOBUF, &iv, &payload); + + match open_tx_metadata(&wrong, &blob) { + Err(_) => {} + Ok(opened) => assert_ne!( + opened.payload, payload, + "a wrong key must not recover the original plaintext" + ), + } + } + + /// Malformed blobs error rather than panic. + #[test] + fn open_rejects_malformed_blobs() { + let key = [0u8; 32]; + // Too short (only version + partial IV). + assert!(open_tx_metadata(&key, &[1u8; 10]).is_err()); + // Version + IV but ciphertext not block-aligned (17 + 5 bytes). + assert!(open_tx_metadata(&key, &[0u8; 22]).is_err()); + } + + /// Cross-stack anchor for the AES-256-CBC core + blob framing, pinned to a + /// PUBLISHED third-party vector (NIST SP 800-38A F.2.5, CBC-AES256.Encrypt). + /// Any conformant AES-256-CBC implementation — including the legacy stack's + /// BouncyCastle `KeyCrypterAESCBC` — produces this exact first ciphertext + /// block for this (key, IV, plaintext-block). PKCS7 appends a full padding + /// block for a 16-byte plaintext but does NOT alter the first block, so the + /// leading 16 ciphertext bytes match NIST byte-for-byte. This proves the + /// ENVELOPE (cipher + `version ‖ IV ‖ ciphertext` layout) is wire-correct. + /// + /// The one piece NOT pinned here is the mnemonic→key HD derivation-path + /// account prefix, which cannot be reconstructed from the legacy jars alone + /// (it lives in dashj wallet config) — see the PR body's honest gap note; + /// a single legacy-written sample document confirms it end-to-end. + #[test] + fn nist_cbc_aes256_cross_stack_vector() { + // NIST SP 800-38A F.2.5. + let key: [u8; 32] = + hex_lit("603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4"); + let iv: [u8; 16] = hex_lit("000102030405060708090a0b0c0d0e0f"); + let plaintext_block: [u8; 16] = hex_lit("6bc1bee22e409f96e93d7e117393172a"); + let expected_ct_block1: [u8; 16] = hex_lit("f58c4c04d6e5f1ba779eabfb5f7bfbd6"); + + let blob = seal_tx_metadata(&key, VERSION_PROTOBUF, &iv, &plaintext_block); + + // version ‖ IV ‖ ciphertext(2 blocks: data + PKCS7 pad). + assert_eq!(blob.len(), 1 + 16 + 32, "1 version + 16 IV + 2 AES blocks"); + assert_eq!(blob[0], VERSION_PROTOBUF, "version byte at offset 0"); + assert_eq!(&blob[1..17], &iv, "IV at offset 1..17"); + assert_eq!( + &blob[17..33], + &expected_ct_block1, + "first ciphertext block must match the NIST CBC-AES256 vector" + ); + + // And the framing round-trips back to the original block. + let opened = open_tx_metadata(&key, &blob).expect("open"); + assert_eq!(opened.version, VERSION_PROTOBUF); + assert_eq!(opened.payload, plaintext_block); + } + + /// Tiny fixed-size hex decoder for the test vectors (no extra dep). + fn hex_lit(s: &str) -> [u8; N] { + let bytes = hex::decode(s).expect("valid hex"); + bytes.try_into().expect("length matches") + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs new file mode 100644 index 0000000000..5f942f643f --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -0,0 +1,351 @@ +//! Encrypted `txMetadata` document create + decrypt-on-fetch on +//! `IdentityWallet`. +//! +//! Implements the wallet-contract encrypted-document surface the Android +//! wallet needs to retire the legacy `org.dashj.platform` stack +//! (dashpay/platform#4086 create, #4087 decrypt-on-fetch; +//! dashpay/dash-wallet#1507). The encryption ENVELOPE — key derivation, the +//! `version ‖ IV ‖ AES-256-CBC(payload)` blob, and the `keyIndex` / +//! `encryptionKeyIndex` / `encryptedMetadata` document fields — is +//! wire-compatible with the legacy `BlockchainIdentity.publishTxMetaData` / +//! `getTxMetaData` (see [`crate::wallet::identity::crypto::tx_metadata`] for the +//! byte-level scheme). The PAYLOAD inside the blob is opaque to the SDK: the +//! app owns the protobuf `TxMetadataBatch` item schema and the batching policy, +//! exactly as it did on the legacy stack. +//! +//! Lives on `IdentityWallet` (like `document.rs` / `contact_info.rs`) because +//! it spans an identity, needs the wallet's HD tree to derive the self- +//! encryption key, and broadcasts a document state transition through the +//! external signer. + +use std::sync::Arc; + +use dpp::document::{Document, DocumentV0Getters}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::signer::Signer; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::Value; +use dpp::prelude::{DataContract, Identifier}; + +use crate::error::PlatformWalletError; +use crate::wallet::identity::crypto::tx_metadata::{ + derive_tx_metadata_key, open_tx_metadata, seal_tx_metadata, +}; + +use super::*; + +/// Wallet-contract document field names (wire-compatible with the legacy +/// `TxMetadataDocument` schema — `wallet-utils-contract` `tx_metadata`). +const FIELD_KEY_INDEX: &str = "keyIndex"; +const FIELD_ENCRYPTION_KEY_INDEX: &str = "encryptionKeyIndex"; +const FIELD_ENCRYPTED_METADATA: &str = "encryptedMetadata"; + +/// One decrypted encrypted-document, returned to the caller (serialized to +/// JSON at the FFI boundary). The `payload` is the opaque, decrypted plaintext +/// the app parses itself (a protobuf `TxMetadataBatch` for `version == 1`). +#[derive(Debug, Clone)] +pub struct DecryptedEncryptedDocument { + /// Canonical 32-byte document id. + pub document_id: Identifier, + /// Document owner ($ownerId). + pub owner_id: Identifier, + /// The document's `keyIndex` field (the identity's ENCRYPTION key id used + /// to derive the decryption key). + pub key_index: u32, + /// The document's `encryptionKeyIndex` field (the app's per-document index). + pub encryption_key_index: u32, + /// The blob's leading version byte (0 = CBOR, 1 = protobuf). + pub version: u8, + /// $updatedAt in epoch-millis, if the document carries it. The app tracks + /// this as its since-timestamp high-water mark for the next fetch. + pub updated_at_ms: Option, + /// The decrypted, opaque payload bytes. + pub payload: Vec, +} + +impl IdentityWallet { + /// Select the identity's encryption key id (the document's `keyIndex` + /// field): an `ECDSA_SECP256K1` `Purpose::ENCRYPTION` / `MEDIUM` key, falling + /// back to an `AUTHENTICATION` / `HIGH` key — mirroring the legacy + /// `BlockchainIdentity.createTxMetadata` selection + /// (`getFirstPublicKey(ENCRYPTION, MEDIUM)` → `getHighAuthenticationKey`). + fn select_encryption_key_id( + identity: &dpp::identity::Identity, + ) -> Result { + identity + .get_first_public_key_matching( + Purpose::ENCRYPTION, + [SecurityLevel::MEDIUM].into(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) + .or_else(|| { + identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::HIGH].into(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) + }) + .map(|k| k.id()) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "Identity has no ECDSA_SECP256K1 ENCRYPTION (MEDIUM) or AUTHENTICATION \ + (HIGH) key to derive the txMetadata encryption key" + .to_string(), + ) + }) + } + + /// Resolve `(identity, identity_index, wallet)` for `owner_identity_id` + /// from the in-process wallet manager — the inputs the tx-metadata key + /// derivation needs. Errors for a watch-only / out-of-wallet identity (no + /// resident HD slot); the dash-wallet migration uses a resident mnemonic + /// wallet. + async fn resolve_encryption_context( + &self, + owner_identity_id: &Identifier, + ) -> Result<(dpp::identity::Identity, u32, key_wallet::wallet::Wallet), PlatformWalletError> { + let wm = self.wallet_manager.read().await; + let info = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let managed = info + .identity_manager + .managed_identity(owner_identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*owner_identity_id))?; + let identity_index = managed.identity_index.ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Identity {owner_identity_id} is watch-only (no resident HD slot); \ + cannot derive its txMetadata encryption key in-process" + )) + })?; + let identity = managed.identity.clone(); + let wallet = wm + .get_wallet(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))? + .clone(); + Ok((identity, identity_index, wallet)) + } + + /// Create + broadcast an ENCRYPTED `txMetadata`-style document on + /// `contract_id`'s `document_type_name`, owned by `owner_identity_id`. + /// + /// The SDK derives the identity encryption key, seals `payload` into the + /// wire-compatible `version ‖ IV ‖ AES-256-CBC` blob, and writes the + /// `{keyIndex, encryptionKeyIndex, encryptedMetadata}` document — the exact + /// shape the legacy `publishTxMetaData` wrote, so the legacy stack decrypts + /// it and vice versa. + /// + /// The caller supplies: + /// - `encryption_key_index`: the per-document index (dash-wallet's + /// monotonic `1 + countAllRequests()` counter). Batching stays app-side. + /// - `version`: the payload version byte (`1` = protobuf, as the wallet + /// writes). + /// - `payload`: the already-serialized opaque plaintext (a protobuf + /// `TxMetadataBatch`) — the SDK does not parse it. + /// + /// The `keyIndex` field (the identity encryption key id) is selected + /// SDK-side to match the legacy stack. Returns the confirmed `Document`. + #[allow(clippy::too_many_arguments)] + pub async fn create_encrypted_document_with_signer( + &self, + owner_identity_id: &Identifier, + contract_id: &Identifier, + document_type_name: &str, + encryption_key_index: u32, + version: u8, + payload: &[u8], + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + use dashcore::secp256k1::rand::{thread_rng, RngCore}; + + let (identity, identity_index, wallet) = + self.resolve_encryption_context(owner_identity_id).await?; + let key_index = Self::select_encryption_key_id(&identity)?; + + // Derive the AES key and seal the payload into the wire blob. + let aes_key = derive_tx_metadata_key( + &wallet, + self.sdk.network, + identity_index, + key_index, + encryption_key_index, + )?; + let mut iv = [0u8; 16]; + thread_rng().fill_bytes(&mut iv); + let blob = seal_tx_metadata(&aes_key, version, &iv, payload); + + // Reuse the generic create path: it fetches the contract, sanitizes the + // hex `encryptedMetadata` into `Bytes` against the schema, auto-selects + // the AUTHENTICATION signing key, and broadcasts on the 8 MB worker + // stack. Byte-array fields are accepted as hex strings there. + let properties_json = serde_json::json!({ + FIELD_KEY_INDEX: key_index, + FIELD_ENCRYPTION_KEY_INDEX: encryption_key_index, + FIELD_ENCRYPTED_METADATA: hex::encode(&blob), + }) + .to_string(); + + self.create_document_with_signer( + owner_identity_id, + contract_id, + document_type_name, + &properties_json, + signer, + ) + .await + } + + /// Fetch every encrypted `txMetadata`-style document owned by + /// `owner_identity_id` on `contract_id`'s `document_type_name` updated at or + /// after `since_ms`, and DECRYPT each with the identity's derived key. + /// + /// Mirrors the legacy `getTxMetaData(sinceTime, key)`: the query is + /// `$ownerId == owner AND $updatedAt >= since_ms` ordered by `$updatedAt` + /// ascending, paginated so a wallet with many documents isn't truncated. A + /// document whose key can't be derived or whose blob doesn't decrypt is + /// SKIPPED with a warning (a malformed document must not abort the sync), + /// matching the resident `contactInfo` sweep. + pub async fn fetch_encrypted_documents( + &self, + owner_identity_id: &Identifier, + contract_id: &Identifier, + document_type_name: &str, + since_ms: u64, + ) -> Result, PlatformWalletError> { + use dash_sdk::dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; + use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; + use dash_sdk::platform::{ContextProvider, Fetch, FetchMany}; + use dpp::platform_value::platform_value; + + // Fetch the contract and register it so `fetch_many`'s proof + // verification can resolve it through the context provider (the mobile + // provider never fetches contracts itself). + let contract = DataContract::fetch(&self.sdk, *contract_id) + .await + .map_err(PlatformWalletError::Sdk)? + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Data contract {contract_id} not found on Platform; cannot fetch documents" + )) + })?; + if let Some(provider) = self.sdk.context_provider() { + provider.register_data_contract(Arc::new(contract.clone())); + } + let contract = Arc::new(contract); + + let (_identity, identity_index, wallet) = + self.resolve_encryption_context(owner_identity_id).await?; + + // Paginated owner-scoped, since-timestamp scan. The `$updatedAt >=` + // where-clause + `$updatedAt asc` order-by bind to the contract's + // `($ownerId, $updatedAt)` index (the same query shape the legacy + // `TxMetadata.get(userId, since)` builder used). + const PAGE: u32 = 100; + let mut raw_docs: Vec<(Identifier, Option)> = Vec::new(); + let mut start: Option = None; + loop { + let query = dash_sdk::platform::DocumentQuery { + select: dash_sdk::drive::query::SelectProjection::documents(), + data_contract: Arc::clone(&contract), + document_type_name: document_type_name.to_string(), + where_clauses: vec![ + WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: platform_value!(owner_identity_id), + }, + WhereClause { + field: "$updatedAt".to_string(), + operator: WhereOperator::GreaterThanOrEquals, + value: platform_value!(since_ms), + }, + ], + group_by: vec![], + having: vec![], + order_by_clauses: vec![OrderClause { + field: "$updatedAt".to_string(), + ascending: true, + }], + limit: PAGE, + start: start.clone(), + }; + + let page = Document::fetch_many(&self.sdk, query) + .await + .map_err(PlatformWalletError::Sdk)?; + let page_len = page.len(); + let last_id = page.keys().last().copied(); + raw_docs.extend(page); + + if page_len < PAGE as usize { + break; + } + match last_id { + Some(id) => start = Some(Start::StartAfter(id.to_buffer().to_vec())), + None => break, + } + } + + let mut out = Vec::new(); + for (doc_id, maybe_doc) in raw_docs.iter() { + let Some(doc) = maybe_doc else { continue }; + let props = doc.properties(); + let (Some(key_index), Some(encryption_key_index)) = ( + props + .get(FIELD_KEY_INDEX) + .and_then(|v: &Value| v.to_integer::().ok()), + props + .get(FIELD_ENCRYPTION_KEY_INDEX) + .and_then(|v: &Value| v.to_integer::().ok()), + ) else { + tracing::warn!(owner = %owner_identity_id, doc = %doc_id, "encrypted document missing key indices; skipping"); + continue; + }; + let Some(blob) = props + .get(FIELD_ENCRYPTED_METADATA) + .and_then(|v: &Value| v.to_binary_bytes().ok()) + else { + tracing::warn!(owner = %owner_identity_id, doc = %doc_id, "encrypted document missing encryptedMetadata; skipping"); + continue; + }; + + let aes_key = match derive_tx_metadata_key( + &wallet, + self.sdk.network, + identity_index, + key_index, + encryption_key_index, + ) { + Ok(k) => k, + Err(e) => { + tracing::warn!(owner = %owner_identity_id, doc = %doc_id, error = %e, "txMetadata key derivation failed; skipping"); + continue; + } + }; + let opened = match open_tx_metadata(&aes_key, &blob) { + Ok(o) => o, + Err(e) => { + tracing::warn!(owner = %owner_identity_id, doc = %doc_id, error = %e, "txMetadata decrypt failed; skipping"); + continue; + } + }; + + out.push(DecryptedEncryptedDocument { + document_id: *doc_id, + owner_id: doc.owner_id(), + key_index, + encryption_key_index, + version: opened.version, + updated_at_ms: doc.updated_at(), + payload: opened.payload, + }); + } + Ok(out) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index bbcc27c09e..6cf227bd53 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -23,6 +23,7 @@ mod contract; mod discovery; mod document; +mod encrypted_document; mod dpns; mod identity_handle; mod loading; @@ -64,6 +65,7 @@ pub use seed_binding::SeedBindingVerification; mod tokens; pub use contact_info::ContactInfoPublishOutcome; +pub use encrypted_document::DecryptedEncryptedDocument; pub use contact_requests::{ AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, ContactInfoSealed, }; diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 04552c1e9e..cdc2a77da9 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -750,6 +750,169 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do }) } +// ── Encrypted document create / fetch (wallet txMetadata contract) ───── + +/// Create + broadcast an ENCRYPTED wallet-contract document (the wire- +/// compatible `txMetadata` shape) — the JNI bridge over +/// `platform_wallet_create_encrypted_document_with_signer`. +/// +/// The SDK derives the identity encryption key, seals `payload` into the +/// legacy `version ‖ IV ‖ AES-256-CBC` blob, and writes +/// `{keyIndex, encryptionKeyIndex, encryptedMetadata}`. `encryptionKeyIndex` is +/// the app's per-document index; `version` is the payload version byte +/// (`1` = protobuf); `payload` is the already-serialized opaque plaintext (a +/// protobuf `TxMetadataBatch`) — the SDK does not parse it. Returns the +/// confirmed document's canonical JSON (its 32-byte id is the base58 `$id` +/// field); null after throwing on error. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_documentCreateEncrypted( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + owner_id: JByteArray, + contract_id: JByteArray, + document_type: JString, + encryption_key_index: jint, + version: jint, + payload: JByteArray, + signer_handle: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(owner) = read_id32(env, &owner_id, "ownerId") else { + return ptr::null_mut(); + }; + let Some(contract) = read_id32(env, &contract_id, "contractId") else { + return ptr::null_mut(); + }; + let Some(doc_type) = read_cstring(env, &document_type, "documentType") else { + return ptr::null_mut(); + }; + if encryption_key_index < 0 { + throw_sdk_exception(env, 1, "encryptionKeyIndex must be non-negative"); + return ptr::null_mut(); + } + if !(0..=255).contains(&version) { + throw_sdk_exception(env, 1, "version must be in 0..=255"); + return ptr::null_mut(); + } + let payload_bytes = match env.convert_byte_array(&payload) { + Ok(b) => b, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "payload byte[] was null/invalid"); + return ptr::null_mut(); + } + }; + + let mut out_id = [0u8; 32]; + let mut out_json: *mut c_char = ptr::null_mut(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_create_encrypted_document_with_signer( + wallet_handle as Handle, + owner.as_ptr(), + contract.as_ptr(), + doc_type.as_ptr(), + encryption_key_index as u32, + version as u8, + payload_bytes.as_ptr(), + payload_bytes.len(), + signer_handle as *mut SignerHandle, + out_id.as_mut_ptr(), + &mut out_json as *mut *mut c_char, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + if out_json.is_null() { + throw_sdk_exception( + env, + 99, + "encrypted document create returned success but no canonical JSON", + ); + return ptr::null_mut(); + } + let json = unsafe { CStr::from_ptr(out_json) } + .to_string_lossy() + .into_owned(); + unsafe { platform_wallet_ffi::platform_wallet_string_free(out_json) }; + + env.new_string(json) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// Fetch + DECRYPT every encrypted wallet-contract document owned by `ownerId` +/// on `contractId`'s `documentType` updated at or after `sinceMs` — the JNI +/// bridge over `platform_wallet_fetch_encrypted_documents` (the wire-compatible +/// read counterpart of the legacy `getTxMetaData(since, key)`). +/// +/// Returns a JSON array; each element is +/// `{ "id", "ownerId" (base58), "keyIndex", "encryptionKeyIndex", "version", +/// "updatedAt" (u64|null), "payload" (base64 of the decrypted opaque plaintext)}`. +/// The caller parses each `payload` itself (a protobuf `TxMetadataBatch` for +/// `version == 1`). Documents that can't be decrypted are skipped Rust-side. +/// Null after throwing on error. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_documentFetchEncrypted( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + owner_id: JByteArray, + contract_id: JByteArray, + document_type: JString, + since_ms: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(owner) = read_id32(env, &owner_id, "ownerId") else { + return ptr::null_mut(); + }; + let Some(contract) = read_id32(env, &contract_id, "contractId") else { + return ptr::null_mut(); + }; + let Some(doc_type) = read_cstring(env, &document_type, "documentType") else { + return ptr::null_mut(); + }; + if since_ms < 0 { + throw_sdk_exception(env, 1, "sinceMs must be non-negative"); + return ptr::null_mut(); + } + + let mut out_json: *mut c_char = ptr::null_mut(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_fetch_encrypted_documents( + wallet_handle as Handle, + owner.as_ptr(), + contract.as_ptr(), + doc_type.as_ptr(), + since_ms as u64, + &mut out_json as *mut *mut c_char, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + if out_json.is_null() { + throw_sdk_exception( + env, + 99, + "encrypted document fetch returned success but no JSON", + ); + return ptr::null_mut(); + } + let json = unsafe { CStr::from_ptr(out_json) } + .to_string_lossy() + .into_owned(); + unsafe { platform_wallet_ffi::platform_wallet_string_free(out_json) }; + + env.new_string(json) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + // ── Contested-resource vote ─────────────────────────────────────────── /// Cast a masternode contested-resource vote and wait for the response — From 2571bbb4ce029b2c0053d3686774f84a9c6e5ec9 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:26:45 -0400 Subject: [PATCH 02/30] test(platform-wallet): pin txMetadata wire-compat to a dashj-generated vector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encrypted-txMetadata scheme claims byte-for-byte wire compatibility with the legacy org.dashj.platform stack, but the mnemonic->AES-key HD derivation prefix was previously only "sample-confirmed" (the module's NIST test pinned the AES-CBC envelope but explicitly could NOT pin the derivation-path account prefix). That gap is now closed by reconstructing the exact legacy recipe from the shipped jars and running it under a JVM. Recovered legacy derivation (the wire-compat reference this crate mirrors): AES-256-CBC key = raw private-key bytes of the ECKey at absolute HD path m / 9' / coinType' / 5' / 0' / 0' / 0' / keyId' / 32769' / encryptionKeyIndex' - account path 9'/coinType'/5'/0'/0'/0' is DerivationPathFactory.blockchainIdentityECDSADerivationPath() (dashj-core 22.0.3), the path the BLOCKCHAIN_IDENTITY AuthenticationKeyChain is built with (AuthenticationGroupExtension.getDefaultPath). - keyId' / 32769' / encryptionKeyIndex' are appended by BlockchainIdentity.privateKeyAtPath; keyId is the id of the identity's ENCRYPTION/MEDIUM ECDSA key (id 2 in createIdentityPublicKeys), 32769' is TxMetadataDocument.childNumber, encryptionKeyIndex is the app's per-document counter (dash-sdk-kotlin 4.0.0-RC2). - AES key bytes = KeyCrypterAESCBC.deriveKey(ecKey) = new KeyParameter( ecKey.getPrivKeyBytes()) (raw scalar, no ECDH / no KDF); framing is version(1) ‖ IV(16) ‖ AES-256-CBC/PKCS7(payload). This is an exact match to Rust's identity_auth_derivation_path_for_type(ECDSA, identity_index=0, key_index=keyId) extended by /32769'/encryptionKeyIndex': the three legacy zeros correspond to [subfeature-auth, keytype=ECDSA=0, identity_index=0]. No derivation-logic change is needed — verified by generating the key AND a full encryptedMetadata blob with the real dashj stack for the BIP-39 "abandon … about" mnemonic and asserting Rust reproduces the key and decrypts the blob to the original plaintext. - add legacy_dashj_wire_compat_vector: dashj-generated (key, blob, plaintext) vector with full provenance, so the derivation prefix + envelope are now CI-enforced rather than device-sample-confirmed. - retarget the NIST test's doc comment as the narrower cipher-conformance leg and drop the now-obsolete "cannot be reconstructed from the jars" caveat. Co-Authored-By: Claude Fable 5 --- .../src/wallet/identity/crypto/tx_metadata.rs | 110 ++++++++++++++++-- 1 file changed, 98 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs index d44f9ee873..95ebacda47 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs @@ -267,19 +267,19 @@ mod tests { assert!(open_tx_metadata(&key, &[0u8; 22]).is_err()); } - /// Cross-stack anchor for the AES-256-CBC core + blob framing, pinned to a - /// PUBLISHED third-party vector (NIST SP 800-38A F.2.5, CBC-AES256.Encrypt). - /// Any conformant AES-256-CBC implementation — including the legacy stack's - /// BouncyCastle `KeyCrypterAESCBC` — produces this exact first ciphertext - /// block for this (key, IV, plaintext-block). PKCS7 appends a full padding - /// block for a 16-byte plaintext but does NOT alter the first block, so the - /// leading 16 ciphertext bytes match NIST byte-for-byte. This proves the - /// ENVELOPE (cipher + `version ‖ IV ‖ ciphertext` layout) is wire-correct. + /// Secondary cross-stack check of the AES-256-CBC core + blob framing, + /// pinned to a PUBLISHED third-party vector (NIST SP 800-38A F.2.5, + /// CBC-AES256.Encrypt). Any conformant AES-256-CBC implementation — + /// including the legacy stack's BouncyCastle `KeyCrypterAESCBC` — produces + /// this exact first ciphertext block for this (key, IV, plaintext-block). + /// PKCS7 appends a full padding block for a 16-byte plaintext but does NOT + /// alter the first block, so the leading 16 ciphertext bytes match NIST + /// byte-for-byte. This isolates the ENVELOPE (cipher + `version ‖ IV ‖ + /// ciphertext` layout) against a standards body. /// - /// The one piece NOT pinned here is the mnemonic→key HD derivation-path - /// account prefix, which cannot be reconstructed from the legacy jars alone - /// (it lives in dashj wallet config) — see the PR body's honest gap note; - /// a single legacy-written sample document confirms it end-to-end. + /// The end-to-end HD-derivation + envelope wire-compat guarantee is pinned + /// by [`legacy_dashj_wire_compat_vector`], whose vector was generated by the + /// real dashj stack; this NIST test is the narrower cipher-conformance leg. #[test] fn nist_cbc_aes256_cross_stack_vector() { // NIST SP 800-38A F.2.5. @@ -312,4 +312,90 @@ mod tests { let bytes = hex::decode(s).expect("valid hex"); bytes.try_into().expect("length matches") } + + /// **The wire-compat anchor**: an end-to-end vector generated by the ACTUAL + /// legacy stack (dash-sdk-kotlin 4.0.0-RC2 + dashj-core 22.0.3, run under a + /// JVM), proving the mnemonic→AES-key HD derivation AND the full + /// `version ‖ IV ‖ AES-256-CBC(payload)` envelope match dashj byte-for-byte. + /// This pins the one piece static analysis of the jars alone could not (the + /// derivation-path account prefix): it is now reconstructed exactly and + /// checked in CI, so a future refactor that moves the path drifts loudly. + /// + /// ## How the vector was generated (reproducible) + /// + /// A JVM scratch program built the legacy key + blob for the BIP-39 test + /// mnemonic `abandon abandon … about` (empty passphrase), Testnet: + /// + /// 1. `seed = MnemonicCode.toSeed(words, "")`; + /// `root = HDKeyDerivation.createMasterPrivateKey(seed)`. + /// 2. `accountPath = DerivationPathFactory(TestNet3Params)` + /// `.blockchainIdentityECDSADerivationPath()` = `m/9'/1'/5'/0'/0'/0'` + /// (this is the account path the `BLOCKCHAIN_IDENTITY` + /// `AuthenticationKeyChain` is built with, via + /// `AuthenticationGroupExtension.getDefaultPath`). + /// 3. Reproducing `BlockchainIdentity.privateKeyAtPath(keyId, childNumber,` + /// `encryptionKeyIndex, ECDSA, …)`, the full path is + /// `accountPath / keyId' / 32769' / encryptionKeyIndex'` with + /// `keyId = 2` (the id of the identity's `ENCRYPTION`/`MEDIUM` public key + /// in `BlockchainIdentity.createIdentityPublicKeys`: keys are + /// id0=AUTH/MASTER, id1=AUTH/HIGH, **id2=ENCRYPTION/MEDIUM**, + /// id3=TRANSFER/CRITICAL), `32769'` = `TxMetadataDocument.childNumber`, + /// and `encryptionKeyIndex = 1` (dash-wallet's first + /// `1 + countAllRequests()`). The derived key is + /// `key = hierarchy.get(fullPath, false, true).getPrivKeyBytes()`. + /// 4. The blob was built exactly as `BlockchainIdentity.createTxMetadata` + /// does: `KeyCrypterAESCBC().deriveKey(ECKey.fromPrivate(key))` + /// (`= new KeyParameter(key)`), `KeyCrypterAESCBC.encrypt(payload, aes)`, + /// then framed `version(1) ‖ IV(16) ‖ encryptedBytes`. + /// + /// Legacy source of record (the wire-compat reference this crate mirrors): + /// `org.dashj.platform.dashpay.BlockchainIdentity.{createTxMetadata,` + /// `decryptTxMetadata,privateKeyAtPath}`, + /// `org.bitcoinj.wallet.DerivationPathFactory.blockchainIdentityECDSADerivationPath`, + /// `org.dashj.platform.contracts.wallet.TxMetadataDocument.childNumber`, + /// `org.bitcoinj.crypto.KeyCrypterAESCBC.{deriveKey,encrypt}`. + #[test] + fn legacy_dashj_wire_compat_vector() { + use key_wallet::mnemonic::{Language, Mnemonic}; + + // BIP-39 standard test mnemonic, empty passphrase, Testnet. + let mnemonic = Mnemonic::from_phrase( + "abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon about", + Language::English, + ) + .expect("valid test mnemonic"); + let wallet = Wallet::from_mnemonic(mnemonic, Network::Testnet, WalletAccountCreationOptions::None) + .expect("wallet from mnemonic"); + + // identity_index 0 (the wallet's single identity), key_index 2 (the + // ENCRYPTION/MEDIUM key id), encryptionKeyIndex 1 (first document). + let key = derive_tx_metadata_key(&wallet, Network::Testnet, 0, 2, 1).expect("derive"); + + // The AES key dashj derived at m/9'/1'/5'/0'/0'/0'/2'/32769'/1'. + let legacy_key: [u8; 32] = + hex_lit("4a2eaec1ad959105738996b49e0327f96a80b765249d2c9af8cf6aa689aa84d7"); + assert_eq!( + *key, legacy_key, + "tx-metadata HD key derivation must match the legacy dashj stack byte-for-byte" + ); + + // The full stored blob dashj produced (KeyCrypterAESCBC over the + // plaintext below, framed version ‖ IV ‖ ciphertext). Rust must open it + // and recover the exact plaintext — proving key + cipher + framing are + // all wire-compatible end to end. + let legacy_blob = hex::decode( + "01b79799f5f18c171741700d9906925eae84f1144e0e532e1981b99cf4fffb8ff\ + 13754d5a5408c24f1c51185fe53e3b8ae086aa57c30653c52907da21f18ec473c", + ) + .expect("valid hex"); + let expected_plaintext = b"legacy-txmetadata-wire-compat-vector".to_vec(); + + let opened = open_tx_metadata(&key, &legacy_blob).expect("open legacy blob"); + assert_eq!(opened.version, VERSION_PROTOBUF, "version byte"); + assert_eq!( + opened.payload, expected_plaintext, + "Rust must decrypt a dashj-produced txMetadata blob to the original plaintext" + ); + } } From dfc3c23e1e42370fc7ad03aeaf24a482196a2f2d Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:26:52 -0400 Subject: [PATCH 03/30] test(platform-wallet): pin encrypted-txMetadata FETCH to the real testnet docs + query diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-device decrypt-proof reported `sdkFetched=0` with ZERO decrypt-skip warnings, i.e. the fetch query returned nothing while the legacy stack sees 2 encrypted `txMetadata` documents for the same owner. This isolates the FETCH half of `IdentityWallet::fetch_encrypted_documents` and pins it against the real documents so a wire-query regression is caught in CI, and adds an on-device breadcrumb to localize any future empty result to the query vs the decrypt stage. What this proves (executable evidence): the exact production query — `$ownerId == owner AND $updatedAt >= since_ms`, ordered `$updatedAt asc`, paginated — returns BOTH real testnet documents (owner 532rVHxLD6Z3MNiu5LZyNqn55Ybz4bydZozXU4cqqp1L, wallet-utils contract 7CSFGeF4WNzgDmx94zwvHkYaG3Dx4XEe5LFsFgJswLbm, type `txMetadata`, since_ms 0) fully materialized, with `keyIndex=2`, `encryptionKeyIndex=1`, `encryptedMetadata` 3585 bytes. This holds for the exact production shape (`&Identifier` owner value, `U64` since bound), with and without the range clause, across pinned platform versions 1..=12 and the default, and including the production `register_data_contract` step. The query construction, value encoding, contract resolution (7CSFGeF4… is the built-in wallet-utils system contract), and JNI param marshalling (`read_id32`, `sinceMs` jlong→u64) are therefore all wire-correct; the on-device empty result is not reproducible from the query and points outside it (e.g. a stale native lib). Changes: - extract the paginated wire query into `query_owned_encrypted_documents` (takes the `Sdk` + fetched contract, no resident wallet/identity), re-exported so the new testnet integration test drives the SAME code the FFI path runs. Query logic unchanged. - add `tests/txmetadata_fetch.rs` (`#[ignore]`, testnet): asserts the query returns the 2 documents and that each decodes `keyIndex`/`encryptionKeyIndex` (u32) + `encryptedMetadata` (bytes) — i.e. the pipeline reaches decrypt for both, without needing the owner mnemonic. - log `raw_count`/`materialized` at INFO before the decrypt loop, so the next `adb logcat` run during the probe pins an empty result to the query (raw_count=0), a proof-materialization gap (raw_count>0, materialized=0), or the decrypt/JSON stage (materialized>0) — no guessing. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet/src/lib.rs | 2 +- .../identity/network/encrypted_document.rs | 154 ++++++++++++------ .../src/wallet/identity/network/mod.rs | 2 +- .../tests/txmetadata_fetch.rs | 112 +++++++++++++ 4 files changed, 215 insertions(+), 55 deletions(-) create mode 100644 packages/rs-platform-wallet/tests/txmetadata_fetch.rs diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index a8d6f0149d..1c27ae3aeb 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -65,7 +65,7 @@ pub use wallet::core_address_key::CoreAddressPrivateKey; pub use wallet::identity::network::{ derive_identity_auth_keypair, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, ContactInfoPublishOutcome, ContactInfoSealed, DecryptedEncryptedDocument, - SeedBindingVerification, IDENTITY_GAP_LIMIT, + query_owned_encrypted_documents, SeedBindingVerification, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; pub use wallet::identity::{ diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index 5f942f643f..a6e2170d6b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -218,10 +218,7 @@ impl IdentityWallet { document_type_name: &str, since_ms: u64, ) -> Result, PlatformWalletError> { - use dash_sdk::dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; - use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; - use dash_sdk::platform::{ContextProvider, Fetch, FetchMany}; - use dpp::platform_value::platform_value; + use dash_sdk::platform::{ContextProvider, Fetch}; // Fetch the contract and register it so `fetch_many`'s proof // verification can resolve it through the context provider (the mobile @@ -242,55 +239,17 @@ impl IdentityWallet { let (_identity, identity_index, wallet) = self.resolve_encryption_context(owner_identity_id).await?; - // Paginated owner-scoped, since-timestamp scan. The `$updatedAt >=` - // where-clause + `$updatedAt asc` order-by bind to the contract's - // `($ownerId, $updatedAt)` index (the same query shape the legacy - // `TxMetadata.get(userId, since)` builder used). - const PAGE: u32 = 100; - let mut raw_docs: Vec<(Identifier, Option)> = Vec::new(); - let mut start: Option = None; - loop { - let query = dash_sdk::platform::DocumentQuery { - select: dash_sdk::drive::query::SelectProjection::documents(), - data_contract: Arc::clone(&contract), - document_type_name: document_type_name.to_string(), - where_clauses: vec![ - WhereClause { - field: "$ownerId".to_string(), - operator: WhereOperator::Equal, - value: platform_value!(owner_identity_id), - }, - WhereClause { - field: "$updatedAt".to_string(), - operator: WhereOperator::GreaterThanOrEquals, - value: platform_value!(since_ms), - }, - ], - group_by: vec![], - having: vec![], - order_by_clauses: vec![OrderClause { - field: "$updatedAt".to_string(), - ascending: true, - }], - limit: PAGE, - start: start.clone(), - }; - - let page = Document::fetch_many(&self.sdk, query) - .await - .map_err(PlatformWalletError::Sdk)?; - let page_len = page.len(); - let last_id = page.keys().last().copied(); - raw_docs.extend(page); - - if page_len < PAGE as usize { - break; - } - match last_id { - Some(id) => start = Some(Start::StartAfter(id.to_buffer().to_vec())), - None => break, - } - } + // The wire query, split out so its exact shape is integration-testable + // against testnet without a resident wallet/identity (see + // `tests/txmetadata_fetch.rs`). + let raw_docs = query_owned_encrypted_documents( + &self.sdk, + Arc::clone(&contract), + owner_identity_id, + document_type_name, + since_ms, + ) + .await?; let mut out = Vec::new(); for (doc_id, maybe_doc) in raw_docs.iter() { @@ -349,3 +308,92 @@ impl IdentityWallet { Ok(out) } } + +/// Run the paginated owner-scoped, since-timestamp document scan that +/// [`IdentityWallet::fetch_encrypted_documents`] fetches from — split out +/// (taking only the `Sdk` + the already-fetched `contract`) so the exact wire +/// query is integration-testable against testnet without a resident +/// wallet/identity: the decrypt half needs the wallet mnemonic, this half does +/// not. Covered by `tests/txmetadata_fetch.rs`. +/// +/// Query shape (verified byte-for-byte against the legacy `TxMetadata.get` +/// builder and confirmed to return the real testnet documents): `$ownerId ==` +/// owner + `$updatedAt >= since_ms`, ordered `$updatedAt asc`. The order-by is +/// load-bearing, not cosmetic — drive answers a bare secondary-index equality +/// or an un-ordered range with a proof of ABSENCE (the same trap the +/// `contactInfo` sweep documents), and it also gives the deterministic order +/// pagination relies on. Returns the raw, still-encrypted documents; a +/// `None` entry is a proof of a document the SDK could not materialize and is +/// preserved so the caller's count/telemetry never silently under-reports. +pub async fn query_owned_encrypted_documents( + sdk: &dash_sdk::Sdk, + contract: Arc, + owner_identity_id: &Identifier, + document_type_name: &str, + since_ms: u64, +) -> Result)>, PlatformWalletError> { + use dash_sdk::dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; + use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; + use dash_sdk::platform::FetchMany; + use dpp::platform_value::platform_value; + + const PAGE: u32 = 100; + let mut raw_docs: Vec<(Identifier, Option)> = Vec::new(); + let mut start: Option = None; + loop { + let query = dash_sdk::platform::DocumentQuery { + select: dash_sdk::drive::query::SelectProjection::documents(), + data_contract: Arc::clone(&contract), + document_type_name: document_type_name.to_string(), + where_clauses: vec![ + WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: platform_value!(owner_identity_id), + }, + WhereClause { + field: "$updatedAt".to_string(), + operator: WhereOperator::GreaterThanOrEquals, + value: platform_value!(since_ms), + }, + ], + group_by: vec![], + having: vec![], + order_by_clauses: vec![OrderClause { + field: "$updatedAt".to_string(), + ascending: true, + }], + limit: PAGE, + start: start.clone(), + }; + + let page = Document::fetch_many(sdk, query) + .await + .map_err(PlatformWalletError::Sdk)?; + let page_len = page.len(); + let last_id = page.keys().last().copied(); + raw_docs.extend(page); + + if page_len < PAGE as usize { + break; + } + match last_id { + Some(id) => start = Some(Start::StartAfter(id.to_buffer().to_vec())), + None => break, + } + } + + // On-device diagnostic breadcrumb: the probe reported `sdkFetched=0` with + // ZERO decrypt-skip warnings, which can only mean the query itself returned + // nothing. Log the raw count (BEFORE decrypt) so an `adb logcat` run pins + // the empty result to the query vs the decrypt stage without guessing. + tracing::info!( + owner = %owner_identity_id, + document_type = document_type_name, + since_ms, + raw_count = raw_docs.len(), + materialized = raw_docs.iter().filter(|(_, d)| d.is_some()).count(), + "fetched raw encrypted documents" + ); + Ok(raw_docs) +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index 6cf227bd53..675ad98e53 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -65,7 +65,7 @@ pub use seed_binding::SeedBindingVerification; mod tokens; pub use contact_info::ContactInfoPublishOutcome; -pub use encrypted_document::DecryptedEncryptedDocument; +pub use encrypted_document::{query_owned_encrypted_documents, DecryptedEncryptedDocument}; pub use contact_requests::{ AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, ContactInfoSealed, }; diff --git a/packages/rs-platform-wallet/tests/txmetadata_fetch.rs b/packages/rs-platform-wallet/tests/txmetadata_fetch.rs new file mode 100644 index 0000000000..f1def575c1 --- /dev/null +++ b/packages/rs-platform-wallet/tests/txmetadata_fetch.rs @@ -0,0 +1,112 @@ +//! Testnet integration test for the encrypted `txMetadata` FETCH path +//! (dashpay/platform#4087). Runs the EXACT production query +//! ([`platform_wallet::query_owned_encrypted_documents`], the network half of +//! `IdentityWallet::fetch_encrypted_documents`) against a real testnet identity +//! that has two legacy-written encrypted `txMetadata` documents, and asserts the +//! query returns both with the expected `keyIndex` / `encryptionKeyIndex` / +//! `encryptedMetadata` fields. +//! +//! This pins the wire query so a regression in the where-clause / order-by / +//! encoding is caught in CI (against testnet) rather than only on-device. The +//! DECRYPT half is not exercised here — it needs the owner's mnemonic — but the +//! per-document field extraction that feeds decrypt IS asserted, proving the +//! pipeline reaches the decrypt step for both documents. +//! +//! # Running +//! ```bash +//! cargo test -p platform-wallet --test txmetadata_fetch -- --ignored --nocapture +//! ``` +//! Requires outbound HTTPS to testnet DAPI nodes + the testnet quorum service +//! (`https://quorums.testnet.networks.dash.org`). + +use std::num::NonZeroUsize; +use std::sync::Arc; + +use dash_sdk::platform::Fetch; +use dash_sdk::SdkBuilder; +use dpp::document::DocumentV0Getters; +use dpp::platform_value::string_encoding::Encoding; +use dpp::platform_value::Value; +use dpp::prelude::{DataContract, Identifier}; +use key_wallet::Network; +use platform_wallet::query_owned_encrypted_documents; +use rs_sdk_trusted_context_provider::TrustedHttpContextProvider; + +/// Testnet identity that owns the two legacy-written encrypted `txMetadata` +/// documents (base58). +const OWNER_B58: &str = "532rVHxLD6Z3MNiu5LZyNqn55Ybz4bydZozXU4cqqp1L"; +/// The wallet-utils system data contract (base58) — its `txMetadata` type. +const CONTRACT_B58: &str = "7CSFGeF4WNzgDmx94zwvHkYaG3Dx4XEe5LFsFgJswLbm"; +const DOC_TYPE: &str = "txMetadata"; + +async fn testnet_sdk() -> Arc { + let provider = + TrustedHttpContextProvider::new(Network::Testnet, None, NonZeroUsize::new(100).unwrap()) + .expect("trusted context provider"); + let sdk = SdkBuilder::new_testnet() + .with_context_provider(provider) + .build() + .expect("build testnet sdk"); + Arc::new(sdk) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "hits testnet"] +async fn fetch_returns_both_legacy_txmetadata_documents() { + let _ = tracing_subscriber::fmt().with_env_filter("info").try_init(); + + let sdk = testnet_sdk().await; + let owner = Identifier::from_string(OWNER_B58, Encoding::Base58).expect("owner id"); + let contract_id = Identifier::from_string(CONTRACT_B58, Encoding::Base58).expect("contract id"); + + let contract = DataContract::fetch(&sdk, contract_id) + .await + .expect("fetch contract") + .expect("wallet-utils contract present on testnet"); + let contract = Arc::new(contract); + + // The exact production query (since_ms = 0 => fetch everything, as the + // decrypt-proof probe does). + let docs = query_owned_encrypted_documents(&sdk, Arc::clone(&contract), &owner, DOC_TYPE, 0) + .await + .expect("query owned encrypted documents"); + + let materialized: Vec<_> = docs.iter().filter_map(|(_, d)| d.as_ref()).collect(); + assert_eq!( + materialized.len(), + 2, + "expected 2 legacy-written txMetadata documents for {OWNER_B58}, got {} (raw entries: {})", + materialized.len(), + docs.len() + ); + + // Every document must expose the fields the decrypt step consumes: + // integer keyIndex/encryptionKeyIndex and a byte-array encryptedMetadata. + for doc in materialized { + let key_index = doc + .properties() + .get("keyIndex") + .and_then(|v: &Value| v.to_integer::().ok()) + .expect("keyIndex is a u32"); + let encryption_key_index = doc + .properties() + .get("encryptionKeyIndex") + .and_then(|v: &Value| v.to_integer::().ok()) + .expect("encryptionKeyIndex is a u32"); + let encrypted_len = doc + .properties() + .get("encryptedMetadata") + .and_then(|v: &Value| v.to_binary_bytes().ok()) + .map(|b| b.len()) + .expect("encryptedMetadata is a byte array"); + + // These identities' documents were written by the Android wallet with + // the ENCRYPTION/MEDIUM key (id 2); the blob is version(1)+IV(16)+CBC. + assert_eq!(key_index, 2, "keyIndex should be the ENCRYPTION key id"); + assert!(encryption_key_index >= 1, "encryptionKeyIndex is 1-based"); + assert!( + encrypted_len > 1 + 16, + "encryptedMetadata must exceed the version+IV header ({encrypted_len} bytes)" + ); + } +} From edaa67f9bb912798317e88fbf56ae610902758c8 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:07:24 -0400 Subject: [PATCH 04/30] debug(platform-wallet): warn-level stage breadcrumbs on the encrypted-document fetch path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dash-wallet decrypt probe reported sdkFetched=0 on-device with NO Rust breadcrumb in logcat — either the JNI call never reached Rust or platform-wallet INFO tracing is filtered from logcat. Make every stage of the fetch path provably visible at WARN: - rs-platform-wallet fetch_encrypted_documents: entry log (owner/contract b58, type, since_ms), warn on every early-return (contract fetch failure, contract-not-found, encryption-context resolution failure, query failure) and a final raw/decrypted count; query_owned_encrypted_documents gets an entry log and a fetch_many error log, and the raw_count/materialized breadcrumb is raised from info! to warn!. - rs-unified-sdk-jni documentFetchEncrypted: entry log (wallet_handle nonzero?, since_ms), parsed-args log (owner/contract hex, doc type), per-early-return warns, and a success log with the returned JSON size. - take_pwffi_error / throw_sdk_exception warn-log every native->Kotlin error conversion (raw + offset code, full message), so a contained exception still leaves a logcat trail. Diagnostic only — no behavior change; cargo check -p rs-unified-sdk-jni and clippy on both touched crates are clean. Co-Authored-By: Claude Fable 5 --- .../identity/network/encrypted_document.rs | 76 +++++++++++++++++-- packages/rs-unified-sdk-jni/src/support.rs | 13 ++++ .../rs-unified-sdk-jni/src/transactions.rs | 33 ++++++++ 3 files changed, 114 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index a6e2170d6b..1d32c7fe86 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -220,13 +220,36 @@ impl IdentityWallet { ) -> Result, PlatformWalletError> { use dash_sdk::platform::{ContextProvider, Fetch}; + // On-device diagnostic breadcrumbs are warn!-level throughout this fn: + // INFO-level platform-wallet tracing is filtered from logcat on device, + // and this call sits under an active `sdkFetched=0` investigation — + // every stage must be provably visible in `adb logcat`. + tracing::warn!( + owner = %owner_identity_id, + contract = %contract_id, + document_type = document_type_name, + since_ms, + "fetch_encrypted_documents: entry" + ); + // Fetch the contract and register it so `fetch_many`'s proof // verification can resolve it through the context provider (the mobile // provider never fetches contracts itself). let contract = DataContract::fetch(&self.sdk, *contract_id) .await - .map_err(PlatformWalletError::Sdk)? + .map_err(|e| { + tracing::warn!( + contract = %contract_id, + error = %e, + "fetch_encrypted_documents: contract fetch failed" + ); + PlatformWalletError::Sdk(e) + })? .ok_or_else(|| { + tracing::warn!( + contract = %contract_id, + "fetch_encrypted_documents: contract not found on Platform" + ); PlatformWalletError::InvalidIdentityData(format!( "Data contract {contract_id} not found on Platform; cannot fetch documents" )) @@ -236,8 +259,16 @@ impl IdentityWallet { } let contract = Arc::new(contract); - let (_identity, identity_index, wallet) = - self.resolve_encryption_context(owner_identity_id).await?; + let (_identity, identity_index, wallet) = self + .resolve_encryption_context(owner_identity_id) + .await + .inspect_err(|e| { + tracing::warn!( + owner = %owner_identity_id, + error = %e, + "fetch_encrypted_documents: encryption-context resolution failed" + ); + })?; // The wire query, split out so its exact shape is integration-testable // against testnet without a resident wallet/identity (see @@ -249,7 +280,14 @@ impl IdentityWallet { document_type_name, since_ms, ) - .await?; + .await + .inspect_err(|e| { + tracing::warn!( + owner = %owner_identity_id, + error = %e, + "fetch_encrypted_documents: document query failed" + ); + })?; let mut out = Vec::new(); for (doc_id, maybe_doc) in raw_docs.iter() { @@ -305,6 +343,12 @@ impl IdentityWallet { payload: opened.payload, }); } + tracing::warn!( + owner = %owner_identity_id, + raw = raw_docs.len(), + decrypted = out.len(), + "fetch_encrypted_documents: returning decrypted documents" + ); Ok(out) } } @@ -335,9 +379,17 @@ pub async fn query_owned_encrypted_documents( use dash_sdk::dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; use dash_sdk::platform::FetchMany; + use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::platform_value::platform_value; const PAGE: u32 = 100; + tracing::warn!( + owner = %owner_identity_id, + contract = %contract.id(), + document_type = document_type_name, + since_ms, + "query_owned_encrypted_documents: entry" + ); let mut raw_docs: Vec<(Identifier, Option)> = Vec::new(); let mut start: Option = None; loop { @@ -367,9 +419,15 @@ pub async fn query_owned_encrypted_documents( start: start.clone(), }; - let page = Document::fetch_many(sdk, query) - .await - .map_err(PlatformWalletError::Sdk)?; + let page = Document::fetch_many(sdk, query).await.map_err(|e| { + tracing::warn!( + owner = %owner_identity_id, + document_type = document_type_name, + error = %e, + "query_owned_encrypted_documents: fetch_many failed" + ); + PlatformWalletError::Sdk(e) + })?; let page_len = page.len(); let last_id = page.keys().last().copied(); raw_docs.extend(page); @@ -387,7 +445,9 @@ pub async fn query_owned_encrypted_documents( // ZERO decrypt-skip warnings, which can only mean the query itself returned // nothing. Log the raw count (BEFORE decrypt) so an `adb logcat` run pins // the empty result to the query vs the decrypt stage without guessing. - tracing::info!( + // warn!-level, not info!: platform-wallet INFO tracing never showed up in + // logcat during the on-device run, so this breadcrumb must be at WARN. + tracing::warn!( owner = %owner_identity_id, document_type = document_type_name, since_ms, diff --git a/packages/rs-unified-sdk-jni/src/support.rs b/packages/rs-unified-sdk-jni/src/support.rs index 19a14d389c..ce07d0c7a4 100644 --- a/packages/rs-unified-sdk-jni/src/support.rs +++ b/packages/rs-unified-sdk-jni/src/support.rs @@ -75,6 +75,15 @@ pub fn take_pwffi_error(env: &mut JNIEnv, mut result: PlatformWalletFFIResult) - .to_string_lossy() .into_owned() }; + // Diagnostic breadcrumb (warn-level so it provably reaches logcat): the + // raw platform-wallet code, the offset code Kotlin will see, and the full + // message — visible even when the Kotlin caller contains the exception. + log::warn!( + "take_pwffi_error: platform-wallet code {} (thrown as DashSDKException code {}): {}", + result.code as i32, + result.code as i32 + PWFFI_CODE_OFFSET, + message + ); throw_sdk_exception(env, result.code as i32 + PWFFI_CODE_OFFSET, &message); // SAFETY: `result` is a fresh PlatformWalletFFIResult; free its message. unsafe { platform_wallet_ffi_result_free(&mut result) }; @@ -92,6 +101,10 @@ pub const SDK_EXCEPTION_CLASS: &str = "org/dashfoundation/dashsdk/ffi/DashSDKExc /// `RuntimeException` if the class or constructor lookup fails (e.g. the /// library is loaded outside the Kotlin SDK). pub fn throw_sdk_exception(env: &mut JNIEnv, code: i32, message: &str) { + // Diagnostic breadcrumb (warn-level so it provably reaches logcat): every + // native→Kotlin error conversion is visible even when the Kotlin caller + // contains the exception into a status line. + log::warn!("throw_sdk_exception: code={code} message={message}"); // If an exception is already pending we must not call further JNI // functions that would themselves throw. if env.exception_check().unwrap_or(false) { diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index cdc2a77da9..cbf4f70b84 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -866,19 +866,42 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do since_ms: jlong, ) -> jstring { guard(&mut env, ptr::null_mut(), |env| { + // Diagnostic breadcrumbs are warn-level: this entry point sits under an + // active on-device `sdkFetched=0` investigation and every stage — + // including "the JVM call reached native code at all" — must be + // provably visible in `adb logcat` (tag `DashSDK`). Error paths log via + // `take_pwffi_error` / `throw_sdk_exception` (both warn before + // throwing). + log::warn!( + "documentFetchEncrypted: entry wallet_handle={:#x} (nonzero={}) since_ms={}", + wallet_handle, + wallet_handle != 0, + since_ms + ); let Some(owner) = read_id32(env, &owner_id, "ownerId") else { + log::warn!("documentFetchEncrypted: ownerId byte[] invalid; throwing"); return ptr::null_mut(); }; let Some(contract) = read_id32(env, &contract_id, "contractId") else { + log::warn!("documentFetchEncrypted: contractId byte[] invalid; throwing"); return ptr::null_mut(); }; let Some(doc_type) = read_cstring(env, &document_type, "documentType") else { + log::warn!("documentFetchEncrypted: documentType string invalid; throwing"); return ptr::null_mut(); }; if since_ms < 0 { + log::warn!("documentFetchEncrypted: sinceMs {since_ms} negative; throwing"); throw_sdk_exception(env, 1, "sinceMs must be non-negative"); return ptr::null_mut(); } + log::warn!( + "documentFetchEncrypted: args owner={} contract={} document_type={:?} — \ + calling platform_wallet_fetch_encrypted_documents", + hex32(&owner), + hex32(&contract), + doc_type + ); let mut out_json: *mut c_char = ptr::null_mut(); let result = unsafe { @@ -895,6 +918,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do return ptr::null_mut(); } if out_json.is_null() { + log::warn!("documentFetchEncrypted: success code but null JSON; throwing"); throw_sdk_exception( env, 99, @@ -906,6 +930,10 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do .to_string_lossy() .into_owned(); unsafe { platform_wallet_ffi::platform_wallet_string_free(out_json) }; + log::warn!( + "documentFetchEncrypted: success, returning {} chars of JSON to Kotlin", + json.len() + ); env.new_string(json) .map(|s| s.into_raw()) @@ -913,6 +941,11 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do }) } +/// Lowercase-hex render of a 32-byte id for diagnostic log lines. +fn hex32(bytes: &[u8; 32]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + // ── Contested-resource vote ─────────────────────────────────────────── /// Cast a masternode contested-resource vote and wait for the response — From bcafc60efb4eababa6db511492fa49faad2327ca Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:33:59 -0400 Subject: [PATCH 05/30] fix(platform-wallet): dual-emit encrypted-document breadcrumbs through log AND tracing so they reach Android logcat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-07 on-device forensic tap proved the two logging facades diverge on Android: JNI_OnLoad installs android_logger as the global `log` logger (logcat tag DashSDK), so the JNI layer's log::warn! lines were visible — while the only tracing subscriber the Kotlin SDK installs (dash_sdk_enable_logging, a tracing_subscriber::fmt layer) writes to STDOUT, which Android discards. Every tracing::warn! breadcrumb in fetch_encrypted_documents therefore never reached logcat, even at WARN. Fix: a `breadcrumb()` helper in encrypted_document.rs emits each diagnostic line through BOTH facades — `tracing` for host tests / desktop, `log` for logcat — and every stage of the fetch path now uses it (entry, contract fetch/not-found, encryption-context resolution, query entry, fetch_many error, raw_count/materialized, per-document skips, final raw/decrypted counts). The previously SILENT skip of a raw-but-unmaterialized document (`let Some(doc) = maybe_doc else { continue }`) now leaves a trail too: under proofs that shape is exactly what turns "2 documents exist" into an empty result with no error. Root-cause status of the on-device sdkFetched=0: NOT locally reproducible. The device path was config-identical to the Mac repro (SdkBuilder:: new_testnet + TrustedHttpContextProvider::new(Testnet, None, 100), proofs on by default, platform version auto, since_ms=0, contract registered with the provider — the register step is now mirrored in tests/txmetadata_fetch.rs), and that repro still returns raw_count=2 materialized=2 from this Mac. A stale device lib is ruled out: the JNI warns visible in the tap were added in d29d523162, so the device ran current code. The next on-device tap will pin the failing stage: query-empty (raw_count=0) vs materialization drop (raw>0, NOT-materialized lines) vs decrypt skip (per-doc skip lines). cargo test -p platform-wallet --lib: 427 passed; testnet integration test txmetadata_fetch passes with the production-parity register step; clippy clean on platform-wallet + rs-unified-sdk-jni. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + packages/rs-platform-wallet/Cargo.toml | 8 +- .../identity/network/encrypted_document.rs | 155 ++++++++++-------- .../tests/txmetadata_fetch.rs | 12 ++ 4 files changed, 109 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2b2194333b..ebd61121e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5221,6 +5221,7 @@ dependencies = [ "image", "key-wallet", "key-wallet-manager", + "log", "platform-encryption", "rand 0.8.6", "rayon", diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 05d4f93308..91917f589b 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -32,8 +32,14 @@ bimap = "0.6" tokio = { version = "1", features = ["sync", "rt", "time", "macros"] } tokio-util = { version = "0.7.12" } -# Logging +# Logging. `log` sits alongside `tracing` for on-device (Android) +# diagnostics: the JNI layer installs `android_logger` as the global `log` +# logger (logcat tag `DashSDK`), while the only `tracing` subscriber the +# Kotlin SDK installs (`dash_sdk_enable_logging`) writes to stdout, which +# Android discards — so breadcrumbs that must be visible in logcat are +# emitted through BOTH facades. See `network/encrypted_document.rs`. tracing = "0.1" +log = "0.4" # Encoding hex = "0.4" diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index 1d32c7fe86..f4d7f6eb81 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -41,6 +41,22 @@ const FIELD_KEY_INDEX: &str = "keyIndex"; const FIELD_ENCRYPTION_KEY_INDEX: &str = "encryptionKeyIndex"; const FIELD_ENCRYPTED_METADATA: &str = "encryptedMetadata"; +/// Emit an on-device diagnostic breadcrumb through BOTH logging facades. +/// +/// On Android the two facades diverge: the JNI layer's `JNI_OnLoad` installs +/// `android_logger` as the global `log` logger (logcat tag `DashSDK`, Info+), +/// so `log::warn!` provably reaches logcat, while the only `tracing` +/// subscriber the Kotlin SDK installs (`dash_sdk_enable_logging`, a +/// `tracing_subscriber::fmt` layer) writes to STDOUT, which Android discards. +/// Proven live in the 2026-07 forensic tap: the JNI `log::warn!` lines +/// appeared under tag `DashSDK`; the `tracing::warn!` lines from this file +/// never did. Emitting through both keeps host tests / desktop file logging +/// on `tracing` while making the on-device trail visible in logcat. +fn breadcrumb(line: &str) { + tracing::warn!("{line}"); + log::warn!("{line}"); +} + /// One decrypted encrypted-document, returned to the caller (serialized to /// JSON at the FFI boundary). The `payload` is the opaque, decrypted plaintext /// the app parses itself (a protobuf `TxMetadataBatch` for `version == 1`). @@ -220,17 +236,13 @@ impl IdentityWallet { ) -> Result, PlatformWalletError> { use dash_sdk::platform::{ContextProvider, Fetch}; - // On-device diagnostic breadcrumbs are warn!-level throughout this fn: - // INFO-level platform-wallet tracing is filtered from logcat on device, - // and this call sits under an active `sdkFetched=0` investigation — - // every stage must be provably visible in `adb logcat`. - tracing::warn!( - owner = %owner_identity_id, - contract = %contract_id, - document_type = document_type_name, - since_ms, - "fetch_encrypted_documents: entry" - ); + // On-device diagnostic breadcrumbs, dual-emitted at warn level (see + // [`breadcrumb`]): this call sits under an active `sdkFetched=0` + // investigation — every stage must be provably visible in `adb logcat`. + breadcrumb(&format!( + "fetch_encrypted_documents: entry owner={owner_identity_id} \ + contract={contract_id} type={document_type_name} since_ms={since_ms}" + )); // Fetch the contract and register it so `fetch_many`'s proof // verification can resolve it through the context provider (the mobile @@ -238,18 +250,15 @@ impl IdentityWallet { let contract = DataContract::fetch(&self.sdk, *contract_id) .await .map_err(|e| { - tracing::warn!( - contract = %contract_id, - error = %e, - "fetch_encrypted_documents: contract fetch failed" - ); + breadcrumb(&format!( + "fetch_encrypted_documents: contract fetch failed contract={contract_id} error={e}" + )); PlatformWalletError::Sdk(e) })? .ok_or_else(|| { - tracing::warn!( - contract = %contract_id, - "fetch_encrypted_documents: contract not found on Platform" - ); + breadcrumb(&format!( + "fetch_encrypted_documents: contract not found on Platform contract={contract_id}" + )); PlatformWalletError::InvalidIdentityData(format!( "Data contract {contract_id} not found on Platform; cannot fetch documents" )) @@ -263,11 +272,10 @@ impl IdentityWallet { .resolve_encryption_context(owner_identity_id) .await .inspect_err(|e| { - tracing::warn!( - owner = %owner_identity_id, - error = %e, - "fetch_encrypted_documents: encryption-context resolution failed" - ); + breadcrumb(&format!( + "fetch_encrypted_documents: encryption-context resolution failed \ + owner={owner_identity_id} error={e}" + )); })?; // The wire query, split out so its exact shape is integration-testable @@ -282,16 +290,25 @@ impl IdentityWallet { ) .await .inspect_err(|e| { - tracing::warn!( - owner = %owner_identity_id, - error = %e, - "fetch_encrypted_documents: document query failed" - ); + breadcrumb(&format!( + "fetch_encrypted_documents: document query failed owner={owner_identity_id} error={e}" + )); })?; let mut out = Vec::new(); for (doc_id, maybe_doc) in raw_docs.iter() { - let Some(doc) = maybe_doc else { continue }; + let Some(doc) = maybe_doc else { + // A raw entry the SDK could not materialize (e.g. a proved + // fetch returning an id without a document). Previously a + // SILENT skip — under proofs this is exactly the shape that + // turns "2 documents exist" into an empty result with no + // error, so it must leave a trail. + breadcrumb(&format!( + "fetch_encrypted_documents: raw entry NOT materialized doc={doc_id} \ + owner={owner_identity_id}; skipping" + )); + continue; + }; let props = doc.properties(); let (Some(key_index), Some(encryption_key_index)) = ( props @@ -301,14 +318,20 @@ impl IdentityWallet { .get(FIELD_ENCRYPTION_KEY_INDEX) .and_then(|v: &Value| v.to_integer::().ok()), ) else { - tracing::warn!(owner = %owner_identity_id, doc = %doc_id, "encrypted document missing key indices; skipping"); + breadcrumb(&format!( + "fetch_encrypted_documents: document missing key indices doc={doc_id} \ + owner={owner_identity_id}; skipping" + )); continue; }; let Some(blob) = props .get(FIELD_ENCRYPTED_METADATA) .and_then(|v: &Value| v.to_binary_bytes().ok()) else { - tracing::warn!(owner = %owner_identity_id, doc = %doc_id, "encrypted document missing encryptedMetadata; skipping"); + breadcrumb(&format!( + "fetch_encrypted_documents: document missing encryptedMetadata doc={doc_id} \ + owner={owner_identity_id}; skipping" + )); continue; }; @@ -321,14 +344,20 @@ impl IdentityWallet { ) { Ok(k) => k, Err(e) => { - tracing::warn!(owner = %owner_identity_id, doc = %doc_id, error = %e, "txMetadata key derivation failed; skipping"); + breadcrumb(&format!( + "fetch_encrypted_documents: txMetadata key derivation failed doc={doc_id} \ + owner={owner_identity_id} error={e}; skipping" + )); continue; } }; let opened = match open_tx_metadata(&aes_key, &blob) { Ok(o) => o, Err(e) => { - tracing::warn!(owner = %owner_identity_id, doc = %doc_id, error = %e, "txMetadata decrypt failed; skipping"); + breadcrumb(&format!( + "fetch_encrypted_documents: txMetadata decrypt failed doc={doc_id} \ + owner={owner_identity_id} error={e}; skipping" + )); continue; } }; @@ -343,12 +372,12 @@ impl IdentityWallet { payload: opened.payload, }); } - tracing::warn!( - owner = %owner_identity_id, - raw = raw_docs.len(), - decrypted = out.len(), - "fetch_encrypted_documents: returning decrypted documents" - ); + breadcrumb(&format!( + "fetch_encrypted_documents: returning decrypted documents owner={owner_identity_id} \ + raw={} decrypted={}", + raw_docs.len(), + out.len() + )); Ok(out) } } @@ -383,13 +412,11 @@ pub async fn query_owned_encrypted_documents( use dpp::platform_value::platform_value; const PAGE: u32 = 100; - tracing::warn!( - owner = %owner_identity_id, - contract = %contract.id(), - document_type = document_type_name, - since_ms, - "query_owned_encrypted_documents: entry" - ); + breadcrumb(&format!( + "query_owned_encrypted_documents: entry owner={owner_identity_id} contract={} \ + type={document_type_name} since_ms={since_ms}", + contract.id() + )); let mut raw_docs: Vec<(Identifier, Option)> = Vec::new(); let mut start: Option = None; loop { @@ -420,12 +447,10 @@ pub async fn query_owned_encrypted_documents( }; let page = Document::fetch_many(sdk, query).await.map_err(|e| { - tracing::warn!( - owner = %owner_identity_id, - document_type = document_type_name, - error = %e, - "query_owned_encrypted_documents: fetch_many failed" - ); + breadcrumb(&format!( + "query_owned_encrypted_documents: fetch_many failed owner={owner_identity_id} \ + type={document_type_name} error={e}" + )); PlatformWalletError::Sdk(e) })?; let page_len = page.len(); @@ -443,17 +468,15 @@ pub async fn query_owned_encrypted_documents( // On-device diagnostic breadcrumb: the probe reported `sdkFetched=0` with // ZERO decrypt-skip warnings, which can only mean the query itself returned - // nothing. Log the raw count (BEFORE decrypt) so an `adb logcat` run pins - // the empty result to the query vs the decrypt stage without guessing. - // warn!-level, not info!: platform-wallet INFO tracing never showed up in - // logcat during the on-device run, so this breadcrumb must be at WARN. - tracing::warn!( - owner = %owner_identity_id, - document_type = document_type_name, - since_ms, - raw_count = raw_docs.len(), - materialized = raw_docs.iter().filter(|(_, d)| d.is_some()).count(), - "fetched raw encrypted documents" - ); + // nothing OR nothing materialized. Log the raw count (BEFORE decrypt) so an + // `adb logcat` run pins the empty result to the query vs the + // materialization vs the decrypt stage without guessing. + breadcrumb(&format!( + "query_owned_encrypted_documents: fetched raw encrypted documents \ + owner={owner_identity_id} type={document_type_name} since_ms={since_ms} \ + raw_count={} materialized={}", + raw_docs.len(), + raw_docs.iter().filter(|(_, d)| d.is_some()).count() + )); Ok(raw_docs) } diff --git a/packages/rs-platform-wallet/tests/txmetadata_fetch.rs b/packages/rs-platform-wallet/tests/txmetadata_fetch.rs index f1def575c1..1e825fa5b8 100644 --- a/packages/rs-platform-wallet/tests/txmetadata_fetch.rs +++ b/packages/rs-platform-wallet/tests/txmetadata_fetch.rs @@ -63,6 +63,18 @@ async fn fetch_returns_both_legacy_txmetadata_documents() { .await .expect("fetch contract") .expect("wallet-utils contract present on testnet"); + // Production parity (`IdentityWallet::fetch_encrypted_documents`): + // register the fetched contract with the trusted context provider before + // the query, exactly as the on-device path does. With this line the repro + // is config-identical to the device call: `SdkBuilder::new_testnet()` + + // `TrustedHttpContextProvider::new(Testnet, None, 100)`, proofs on + // (builder default), platform version auto (0), since_ms = 0. + { + use dash_sdk::platform::ContextProvider; + if let Some(provider) = sdk.context_provider() { + provider.register_data_contract(Arc::new(contract.clone())); + } + } let contract = Arc::new(contract); // The exact production query (since_ms = 0 => fetch everything, as the From 8b4290bebe3f079a1f052446a6c9393ff27c1a4d Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:32:23 -0400 Subject: [PATCH 06/30] fix(platform-wallet): derive txMetadata keys through the mnemonic resolver for external-signable wallets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The on-device decrypt-proof breadcrumbs delivered the verdict: the query was never broken (raw=3 materialized=3), but every document skipped with "txMetadata key derivation failed ... External signable wallet has no private key". The app's SDK wallet is EXTERNAL-SIGNABLE — no private keys in the Rust wallet; every key derives on demand through the registered mnemonic resolver (the app's security architecture) — while derive_tx_metadata_key derived from in-wallet private keys, which exist in test fixtures but never on-device. The CREATE path had the identical flaw. Fix, following the identity_key_preview / discovery capability convention: - rs-platform-wallet: new derive_tx_metadata_key_from_master (same path, caller-supplied master xprv; path construction shared via tx_metadata_derivation_path so the two sources can never drift) and a TxMetadataKeySource {ResidentWallet, Master} selector on both create_encrypted_document_with_signer and fetch_encrypted_documents; key-derivation breadcrumbs now name the active source. - rs-platform-wallet-ffi: both encrypted-document entry points take a nullable mnemonic_resolver_handle. Capability check under a short guard (never held across the host resolver callback), resolver consulted ONLY for external-signable / watch-only wallets (resident wallets keep the historical in-process derive and skip the Keystore read), master scalar wiped (non_secure_erase) before the result crosses back — atomic derive + use + zeroize. - rs-unified-sdk-jni + kotlin-sdk: documentCreateEncrypted / documentFetchEncrypted and DocumentTransactions.createEncryptedDocument / fetchEncryptedDocuments thread mnemonicResolverHandle through (the app passes PlatformWalletManager.mnemonicResolverHandle, as discoverIdentities already does). Regression tests (network-free): - master_derivation_matches_resident_wallet_derivation — both key sources agree at every probed (identity, key, encryptionKey) slot; - external_signable_wallet_derives_via_resolver_master — the device shape: in-wallet derive fails with the exact no-private-key error, the resolver-master path (stubbed with the test mnemonic) round-trips seal/open against a resident wallet in both directions; - legacy_dashj_wire_compat_vector now pins the resolver-master path to the dashj-generated vector too (both sources hit the legacy key byte-for-byte). cargo test -p platform-wallet --lib: 429 passed; -p platform-wallet-ffi --lib: 145 passed; clippy clean on all three crates; kotlin-sdk :sdk:compileDebugKotlin green. Co-Authored-By: Claude Fable 5 --- .../dashsdk/documents/DocumentTransactions.kt | 16 ++ .../dashsdk/ffi/TransactionsNative.kt | 12 + .../rs-platform-wallet-ffi/src/document.rs | 159 ++++++++++-- packages/rs-platform-wallet/src/lib.rs | 3 +- .../src/wallet/identity/crypto/mod.rs | 3 +- .../src/wallet/identity/crypto/tx_metadata.rs | 242 ++++++++++++++++-- .../identity/network/encrypted_document.rs | 110 +++++++- .../src/wallet/identity/network/mod.rs | 4 +- .../rs-unified-sdk-jni/src/transactions.rs | 9 +- 9 files changed, 503 insertions(+), 55 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt index 51b01a928b..699756db74 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt @@ -271,11 +271,18 @@ class DocumentTransactions internal constructor( * @param version payload version byte (`1` = protobuf, as the wallet writes). * @param payload already-serialized opaque plaintext; the SDK does not * parse it. + * [mnemonicResolverHandle] is the host mnemonic-resolver handle + * ([org.dashfoundation.dashsdk.wallet.PlatformWalletManager.mnemonicResolverHandle]): + * required for external-signable wallets (the app's shape — the AES key + * derives on demand through the resolver), ignored for wallets with + * resident private keys. + * * @return the confirmed document's canonical JSON (its 32-byte id is the * base58 `$id` field). */ suspend fun createEncryptedDocument( walletHandle: Long, + mnemonicResolverHandle: Long, ownerId: ByteArray, contractId: ByteArray, documentType: String, @@ -293,6 +300,7 @@ class DocumentTransactions internal constructor( mapNativeErrors { TransactionsNative.documentCreateEncrypted( walletHandle, + mnemonicResolverHandle, ownerId, contractId, documentType, @@ -320,9 +328,16 @@ class DocumentTransactions internal constructor( * parses each `payload` itself (a protobuf `TxMetadataBatch` for * `version == 1`) and reconciles memo / taxCategory / exchangeRate / * service / giftCard fields into its local store. + * + * [mnemonicResolverHandle] is the host mnemonic-resolver handle + * ([org.dashfoundation.dashsdk.wallet.PlatformWalletManager.mnemonicResolverHandle]): + * required for external-signable wallets (the app's shape — the AES key + * derives on demand through the resolver), ignored for wallets with + * resident private keys. */ suspend fun fetchEncryptedDocuments( walletHandle: Long, + mnemonicResolverHandle: Long, ownerId: ByteArray, contractId: ByteArray, documentType: String, @@ -334,6 +349,7 @@ class DocumentTransactions internal constructor( mapNativeErrors { TransactionsNative.documentFetchEncrypted( walletHandle, + mnemonicResolverHandle, ownerId, contractId, documentType, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index 4e30459dc6..838b630370 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -175,6 +175,11 @@ internal object TransactionsNative { * into the legacy `version ‖ IV ‖ AES-256-CBC` blob — decryptable by the * legacy `org.dashj.platform` stack and vice versa. * + * @param mnemonicResolverHandle the host mnemonic-resolver handle + * ([org.dashfoundation.dashsdk.wallet.PlatformWalletManager.mnemonicResolverHandle]); + * required (non-zero) for external-signable wallets — the app's shape — + * whose txMetadata AES key derives on demand through the resolver. + * Ignored for wallets with resident private keys. * @param encryptionKeyIndex the app's per-document index (dash-wallet's * monotonic `1 + countAllRequests()` counter); non-negative. * @param version payload version byte (`1` = protobuf, as the wallet writes). @@ -185,6 +190,7 @@ internal object TransactionsNative { */ external fun documentCreateEncrypted( walletHandle: Long, + mnemonicResolverHandle: Long, ownerId: ByteArray, contractId: ByteArray, documentType: String, @@ -200,6 +206,11 @@ internal object TransactionsNative { * (epoch-millis). Bridges `platform_wallet_fetch_encrypted_documents` — the * wire-compatible read counterpart of the legacy `getTxMetaData(since, key)`. * + * @param mnemonicResolverHandle the host mnemonic-resolver handle + * ([org.dashfoundation.dashsdk.wallet.PlatformWalletManager.mnemonicResolverHandle]); + * required (non-zero) for external-signable wallets — the app's shape — + * whose txMetadata AES key derives on demand through the resolver. + * Ignored for wallets with resident private keys. * @return a JSON array; each element is `{ "id", "ownerId" (base58), * "keyIndex", "encryptionKeyIndex", "version", "updatedAt" (number|null), * "payload" (base64 of the decrypted opaque plaintext) }`. Documents that @@ -207,6 +218,7 @@ internal object TransactionsNative { */ external fun documentFetchEncrypted( walletHandle: Long, + mnemonicResolverHandle: Long, ownerId: ByteArray, contractId: ByteArray, documentType: String, diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index ebb37a0d08..75a0b72bce 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -8,16 +8,78 @@ use std::slice; use dpp::document::{Document, DocumentV0Getters}; use dpp::prelude::Identifier; use dpp::serialization::ValueConvertible; -use platform_wallet::PlatformWalletError; -use rs_sdk_ffi::{SignerHandle, VTableSigner}; +use key_wallet::bip32::ExtendedPrivKey; +use platform_wallet::{PlatformWalletError, TxMetadataKeySource}; +use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle, VTableSigner}; use crate::check_ptr; use crate::error::*; use crate::handle::*; +use crate::identity_keys_from_mnemonic::resolve_master_from_resolver; use crate::runtime::block_on_worker; use crate::types::read_identifier; use crate::{unwrap_option_or_return, unwrap_result_or_return}; +/// Select the txMetadata key-derivation source for `wallet` by capability — +/// the same two-phase convention as `identity_key_preview` / +/// `identity_discovery`: +/// +/// - a wallet with resident private keys (mnemonic / seed / xprv) derives +/// in-process; the resolver is never touched (returns `Ok(None)`); +/// - an external-signable / watch-only wallet (the Android/iOS apps — no +/// in-process private keys, so the resident derive fails with `External +/// signable wallet has no private key`) requires the host mnemonic +/// resolver: the wallet's mnemonic is resolved on demand (keyed by the +/// wallet's own id) and returned as a master xprv (`Ok(Some(master))`). +/// The CALLER must wipe it (`master.private_key.non_secure_erase()`) once +/// the derive is done. When the resolver handle is null for this shape, +/// errors with a hint naming the requirement. +/// +/// The wallet-manager read guard is scoped to the capability check only and +/// is NEVER held across the host resolver callback (which synchronously +/// re-enters Kotlin/Swift and can stall on Keychain/Keystore access). +/// +/// # Safety +/// `mnemonic_resolver_handle`, when non-null, must come from +/// [`rs_sdk_ffi::dash_sdk_mnemonic_resolver_create`] and remain valid for the +/// duration of the call. +unsafe fn tx_metadata_key_master_for_wallet( + wallet: &platform_wallet::PlatformWallet, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, +) -> Result, PlatformWalletFFIResult> { + // Phase 1 — short capability-check guard, dropped before any resolver + // interaction. + let wallet_has_resident_keys = { + let wm = wallet.wallet_manager().blocking_read(); + match wm.get_wallet(&wallet.wallet_id()) { + Some(kw) => !kw.is_external_signable() && !kw.is_watch_only(), + None => { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Wallet not found in wallet manager", + )); + } + } + }; + if wallet_has_resident_keys { + return Ok(None); + } + if mnemonic_resolver_handle.is_null() { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "this wallet has no resident private keys (external-signable / watch-only); \ + a mnemonic resolver handle is required to derive its txMetadata encryption keys", + )); + } + let wallet_id = wallet.wallet_id(); + // SAFETY: handle is non-null (checked) and the caller's safety contract + // guarantees it came from `dash_sdk_mnemonic_resolver_create`. + let master = unsafe { + resolve_master_from_resolver(mnemonic_resolver_handle, &wallet_id, wallet.network())? + }; + Ok(Some(master)) +} + /// Create + broadcast a new document on `contract_id`'s /// `document_type_name`, owned by `owner_identity_id`, signed via the /// external `signer_handle`. @@ -166,6 +228,12 @@ fn confirmed_document_to_json(document: &Document) -> Result Result = block_on_worker(async move { - let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); - let confirmed: Document = identity_wallet - .create_encrypted_document_with_signer( - &owner_id_for_async, - &contract_id_for_async, - &document_type_str, - encryption_key_index, - version, - &payload_vec, - signer, - ) - .await?; - let json_string = confirmed_document_to_json(&confirmed)?; - Ok::<_, PlatformWalletError>((confirmed.id(), json_string)) - }); - result + + // Key-source selection by wallet capability (may synchronously call + // back into the host mnemonic resolver for external-signable + // wallets — see `tx_metadata_key_master_for_wallet`). + let master_opt = + unsafe { tx_metadata_key_master_for_wallet(wallet, mnemonic_resolver_handle) }?; + + let result: Result<(Identifier, String), PlatformWalletError> = + block_on_worker(async move { + let key_source = match master_opt.as_ref() { + Some(master) => TxMetadataKeySource::Master(master), + None => TxMetadataKeySource::ResidentWallet, + }; + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let created = identity_wallet + .create_encrypted_document_with_signer( + &owner_id_for_async, + &contract_id_for_async, + &document_type_str, + encryption_key_index, + version, + &payload_vec, + key_source, + signer, + ) + .await; + // Wipe the resolved master's scalar (external-signable path) + // before the result crosses back — `ExtendedPrivKey` has no + // `Drop`/`Zeroize` (same hygiene as `identity_key_preview`). + if let Some(mut master) = master_opt { + master.private_key.non_secure_erase(); + } + let confirmed: Document = created?; + let json_string = confirmed_document_to_json(&confirmed)?; + Ok::<_, PlatformWalletError>((confirmed.id(), json_string)) + }); + result.map_err(PlatformWalletFFIResult::from) }); let result = unwrap_option_or_return!(option); let (document_id, document_json) = unwrap_result_or_return!(result); @@ -258,6 +347,12 @@ pub unsafe extern "C" fn platform_wallet_create_encrypted_document_with_signer( /// key; documents that can't be derived/decrypted are skipped (never abort the /// fetch). /// +/// The AES key source is selected by the wallet's capability: a key-resident +/// wallet derives in-process; an external-signable / watch-only wallet (the +/// Android/iOS apps) derives through `mnemonic_resolver_handle` — required +/// non-null for that shape, ignored otherwise (see +/// `tx_metadata_key_master_for_wallet`). +/// /// On success `*out_documents_json` receives an owned NUL-terminated JSON array /// (release with `platform_wallet_string_free`; left null on any error). Each /// element is @@ -268,6 +363,7 @@ pub unsafe extern "C" fn platform_wallet_create_encrypted_document_with_signer( #[no_mangle] pub unsafe extern "C" fn platform_wallet_fetch_encrypted_documents( wallet_handle: Handle, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, owner_identity_id: *const u8, contract_id: *const u8, document_type_name: *const c_char, @@ -291,18 +387,37 @@ pub unsafe extern "C" fn platform_wallet_fetch_encrypted_documents( let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let identity_wallet = wallet.identity().clone(); + + // Key-source selection by wallet capability (may synchronously call + // back into the host mnemonic resolver for external-signable + // wallets — see `tx_metadata_key_master_for_wallet`). + let master_opt = + unsafe { tx_metadata_key_master_for_wallet(wallet, mnemonic_resolver_handle) }?; + let result: Result, PlatformWalletError> = block_on_worker(async move { - identity_wallet + let key_source = match master_opt.as_ref() { + Some(master) => TxMetadataKeySource::Master(master), + None => TxMetadataKeySource::ResidentWallet, + }; + let fetched = identity_wallet .fetch_encrypted_documents( &owner_id_for_async, &contract_id_for_async, &document_type_str, since_ms, + key_source, ) - .await + .await; + // Wipe the resolved master's scalar (external-signable path) + // before the result crosses back — `ExtendedPrivKey` has no + // `Drop`/`Zeroize` (same hygiene as `identity_key_preview`). + if let Some(mut master) = master_opt { + master.private_key.non_secure_erase(); + } + fetched }); - result + result.map_err(PlatformWalletFFIResult::from) }); let result = unwrap_option_or_return!(option); let docs = unwrap_result_or_return!(result); diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 1c27ae3aeb..94818af08c 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -65,7 +65,8 @@ pub use wallet::core_address_key::CoreAddressPrivateKey; pub use wallet::identity::network::{ derive_identity_auth_keypair, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, ContactInfoPublishOutcome, ContactInfoSealed, DecryptedEncryptedDocument, - query_owned_encrypted_documents, SeedBindingVerification, IDENTITY_GAP_LIMIT, + query_owned_encrypted_documents, TxMetadataKeySource, SeedBindingVerification, + IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; pub use wallet::identity::{ diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs index 7d5e0123b7..d299baf148 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs @@ -24,7 +24,8 @@ pub use invitation::{ InviterInfo, ParsedInvitation, }; pub use tx_metadata::{ - derive_tx_metadata_key, open_tx_metadata, seal_tx_metadata, OpenedTxMetadata, + derive_tx_metadata_key, derive_tx_metadata_key_from_master, open_tx_metadata, + seal_tx_metadata, tx_metadata_derivation_path, OpenedTxMetadata, TX_METADATA_ENCRYPTION_CHILD, VERSION_CBOR, VERSION_PROTOBUF, }; pub use validation::pubkey_binds_expected_key_data; diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs index 95ebacda47..135cacd419 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs @@ -53,7 +53,7 @@ //! as it did on the legacy stack. use key_wallet::bip32::ChildNumber; -use key_wallet::bip32::KeyDerivationType; +use key_wallet::bip32::{DerivationPath, ExtendedPrivKey, KeyDerivationType}; use key_wallet::wallet::Wallet; use key_wallet::Network; use zeroize::Zeroizing; @@ -81,24 +81,17 @@ const BLOB_HEADER_LEN: usize = 1 + 16; /// AES block size — the ciphertext must be a non-zero multiple of this. const AES_BLOCK_LEN: usize = 16; -/// Derive the AES-256 key for one `txMetadata` document from the wallet seed. -/// -/// `key_index` is the document's `keyIndex` field (the identity's registered -/// ENCRYPTION key id); `encryption_key_index` is the document's -/// `encryptionKeyIndex` field (the app's per-document index). The derived key -/// is the raw private scalar at -/// `identity_auth_path(identity_index, key_index) / 32769' / encryption_key_index'`. -/// -/// Requires a key-resident wallet (mnemonic/seed); a watch-only wallet has no -/// in-process HD slot and would need a host-side signing hook (out of scope for -/// the dash-wallet migration, which uses a resident mnemonic wallet). -pub fn derive_tx_metadata_key( - wallet: &Wallet, +/// Build the full tx-metadata key derivation path +/// `identity_auth_path(identity_index, key_index) / 32769' / encryption_key_index'` +/// — the single path both key sources ([`derive_tx_metadata_key`] and +/// [`derive_tx_metadata_key_from_master`]) derive at, so the resident-wallet +/// and resolver-master paths can never drift apart. +pub fn tx_metadata_derivation_path( network: Network, identity_index: u32, key_index: u32, encryption_key_index: u32, -) -> Result, PlatformWalletError> { +) -> Result { let root_path = identity_auth_derivation_path_for_type( network, KeyDerivationType::ECDSA, @@ -106,7 +99,7 @@ pub fn derive_tx_metadata_key( key_index, )?; - let path = root_path.extend([ + Ok(root_path.extend([ ChildNumber::from_hardened_idx(TX_METADATA_ENCRYPTION_CHILD).map_err(|e| { PlatformWalletError::InvalidIdentityData(format!( "Invalid txMetadata encryption child index: {e}" @@ -117,7 +110,33 @@ pub fn derive_tx_metadata_key( "Invalid txMetadata encryptionKeyIndex: {e}" )) })?, - ]); + ])) +} + +/// Derive the AES-256 key for one `txMetadata` document from the wallet seed. +/// +/// `key_index` is the document's `keyIndex` field (the identity's registered +/// ENCRYPTION key id); `encryption_key_index` is the document's +/// `encryptionKeyIndex` field (the app's per-document index). The derived key +/// is the raw private scalar at +/// `identity_auth_path(identity_index, key_index) / 32769' / encryption_key_index'`. +/// +/// Requires a key-resident wallet (mnemonic / seed / xprv). An +/// external-signable or watch-only wallet has no in-process private keys and +/// fails here with `External signable wallet has no private key` — the caller +/// must resolve the wallet's mnemonic host-side (the platform mnemonic +/// resolver) and use [`derive_tx_metadata_key_from_master`] instead. This is +/// exactly the shape the Android/iOS apps run: their SDK wallets are +/// external-signable and every key derives on demand through the resolver. +pub fn derive_tx_metadata_key( + wallet: &Wallet, + network: Network, + identity_index: u32, + key_index: u32, + encryption_key_index: u32, +) -> Result, PlatformWalletError> { + let path = + tx_metadata_derivation_path(network, identity_index, key_index, encryption_key_index)?; let ext = wallet.derive_extended_private_key(&path).map_err(|e| { PlatformWalletError::InvalidIdentityData(format!("Failed to derive txMetadata key: {e}")) @@ -125,6 +144,44 @@ pub fn derive_tx_metadata_key( Ok(Zeroizing::new(ext.private_key.secret_bytes())) } +/// Derive the AES-256 key for one `txMetadata` document from a caller-supplied +/// master extended private key — the external-signable-wallet counterpart of +/// [`derive_tx_metadata_key`], deriving the identical path from the identical +/// seed material (see the cross-path agreement test). +/// +/// This is the tx-metadata leg of the codebase's resolver convention (mirrors +/// `derive_ecdsa_identity_auth_keypair_from_master` and the discovery / +/// key-preview paths): when the in-process wallet is external-signable / +/// watch-only, the FFI layer resolves the wallet's mnemonic on demand via the +/// host `MnemonicResolverHandle`, builds the master xprv, calls this, and +/// wipes the master (`master.private_key.non_secure_erase()`) before +/// returning — atomic derive + use + zeroize. The returned scalar is +/// [`Zeroizing`], so the key itself is scrubbed on drop as well. +pub fn derive_tx_metadata_key_from_master( + master: &ExtendedPrivKey, + network: Network, + identity_index: u32, + key_index: u32, + encryption_key_index: u32, +) -> Result, PlatformWalletError> { + use dashcore::secp256k1::Secp256k1; + + let path = + tx_metadata_derivation_path(network, identity_index, key_index, encryption_key_index)?; + + let secp = Secp256k1::new(); + // `ExtendedPrivKey` has no `Drop`/`Zeroize`; its inner + // `secp256k1::SecretKey` memzeroes on drop, and the scalar copy we + // return is wrapped in `Zeroizing` (same hygiene note as + // `derive_ecdsa_identity_auth_keypair_from_master`). + let derived = master.derive_priv(&secp, &path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive txMetadata key from master: {e}" + )) + })?; + Ok(Zeroizing::new(derived.private_key.secret_bytes())) +} + /// Seal an already-serialized `txMetadata` payload into the stored /// `encryptedMetadata` blob: `version(1) ‖ IV(16) ‖ AES-256-CBC(payload)`. /// @@ -267,6 +324,135 @@ mod tests { assert!(open_tx_metadata(&key, &[0u8; 22]).is_err()); } + /// The two key sources — resident wallet vs a resolver-supplied master + /// xprv from the SAME mnemonic — must derive the IDENTICAL key at every + /// `(identity_index, key_index, encryption_key_index)` slot. This pins + /// the external-signable-wallet fix (the Android/iOS shape derives via + /// the mnemonic resolver → master; test fixtures derive in-wallet): + /// if the two paths ever drift, decrypt breaks silently on-device. + #[test] + fn master_derivation_matches_resident_wallet_derivation() { + use key_wallet::mnemonic::{Language, Mnemonic}; + + let mnemonic = Mnemonic::from_phrase( + "abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon about", + Language::English, + ) + .expect("valid test mnemonic"); + let seed = mnemonic.to_seed(""); + let wallet = Wallet::from_mnemonic( + mnemonic, + Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("wallet from mnemonic"); + // The exact master the FFI's `resolve_master_from_resolver` builds + // from the host-resolved mnemonic (`to_seed("") → new_master`). + let master = + ExtendedPrivKey::new_master(Network::Testnet, &seed).expect("master from seed"); + + for (identity_index, key_index, encryption_key_index) in + [(0, 2, 1), (0, 2, 7), (0, 3, 1), (1, 2, 1)] + { + let resident = derive_tx_metadata_key( + &wallet, + Network::Testnet, + identity_index, + key_index, + encryption_key_index, + ) + .expect("resident derive"); + let from_master = derive_tx_metadata_key_from_master( + &master, + Network::Testnet, + identity_index, + key_index, + encryption_key_index, + ) + .expect("master derive"); + assert_eq!( + *resident, *from_master, + "resident-wallet and resolver-master key derivations must agree at \ + ({identity_index},{key_index},{encryption_key_index})" + ); + } + } + + /// The external-signable wallet shape (the Android/iOS apps: NO resident + /// private keys — every key derives host-side through the mnemonic + /// resolver): the in-wallet derive must fail (this exact failure zeroed + /// the on-device decrypt-proof), and the resolver-master path — fed by a + /// stub "resolver" supplying the test mnemonic — must decrypt a blob the + /// resident stack sealed. Round-trips seal(resident) → open(master) and + /// seal(master) → open(resident), proving an external-signable device + /// wallet reads and writes documents interchangeably with a key-resident + /// wallet on the same mnemonic. + #[test] + fn external_signable_wallet_derives_via_resolver_master() { + use key_wallet::account::AccountCollection; + use key_wallet::mnemonic::{Language, Mnemonic}; + + let mnemonic = Mnemonic::from_phrase( + "abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon about", + Language::English, + ) + .expect("valid test mnemonic"); + let seed = mnemonic.to_seed(""); + + // The device shape: an external-signable wallet with no in-process + // private keys. + let external_wallet = Wallet::new_external_signable( + Network::Testnet, + [0x42u8; 32], + AccountCollection::new(), + ); + let err = derive_tx_metadata_key(&external_wallet, Network::Testnet, 0, 2, 1) + .expect_err("an external-signable wallet has no in-process key to derive from"); + assert!( + err.to_string().contains("no private key"), + "must fail with the no-private-key shape the device hit, got: {err}" + ); + + // The resolver stub: the host returns the wallet's mnemonic; the FFI + // builds the master exactly like this and derives from it. + let master = + ExtendedPrivKey::new_master(Network::Testnet, &seed).expect("master from seed"); + let master_key = derive_tx_metadata_key_from_master(&master, Network::Testnet, 0, 2, 1) + .expect("master derive"); + + // A resident wallet on the same mnemonic (the legacy stack / a test + // fixture) seals; the external-signable wallet (via the resolver + // master) opens — and vice versa. + let resident_wallet = Wallet::from_mnemonic( + Mnemonic::from_phrase( + "abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon about", + Language::English, + ) + .expect("valid test mnemonic"), + Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("wallet from mnemonic"); + let resident_key = derive_tx_metadata_key(&resident_wallet, Network::Testnet, 0, 2, 1) + .expect("resident derive"); + + let payload = b"external-signable round-trip".to_vec(); + let iv = [0x66u8; 16]; + + let sealed_by_resident = seal_tx_metadata(&resident_key, VERSION_PROTOBUF, &iv, &payload); + let opened_by_master = + open_tx_metadata(&master_key, &sealed_by_resident).expect("master key opens"); + assert_eq!(opened_by_master.payload, payload); + + let sealed_by_master = seal_tx_metadata(&master_key, VERSION_PROTOBUF, &iv, &payload); + let opened_by_resident = + open_tx_metadata(&resident_key, &sealed_by_master).expect("resident key opens"); + assert_eq!(opened_by_resident.payload, payload); + } + /// Secondary cross-stack check of the AES-256-CBC core + blob framing, /// pinned to a PUBLISHED third-party vector (NIST SP 800-38A F.2.5, /// CBC-AES256.Encrypt). Any conformant AES-256-CBC implementation — @@ -380,6 +566,28 @@ mod tests { "tx-metadata HD key derivation must match the legacy dashj stack byte-for-byte" ); + // The resolver-master path (the on-device external-signable shape) + // must hit the same dashj key — pins the fix's derivation to the + // legacy vector, not just to the resident path. + let master = ExtendedPrivKey::new_master( + Network::Testnet, + &key_wallet::mnemonic::Mnemonic::from_phrase( + "abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon about", + key_wallet::mnemonic::Language::English, + ) + .expect("valid test mnemonic") + .to_seed(""), + ) + .expect("master from seed"); + let key_via_master = + derive_tx_metadata_key_from_master(&master, Network::Testnet, 0, 2, 1) + .expect("master derive"); + assert_eq!( + *key_via_master, legacy_key, + "resolver-master tx-metadata derivation must match the legacy dashj stack too" + ); + // The full stored blob dashj produced (KeyCrypterAESCBC over the // plaintext below, framed version ‖ IV ‖ ciphertext). Rust must open it // and recover the exact plaintext — proving key + cipher + framing are diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index f4d7f6eb81..f9d0a7e147 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -30,11 +30,81 @@ use dpp::prelude::{DataContract, Identifier}; use crate::error::PlatformWalletError; use crate::wallet::identity::crypto::tx_metadata::{ - derive_tx_metadata_key, open_tx_metadata, seal_tx_metadata, + derive_tx_metadata_key, derive_tx_metadata_key_from_master, open_tx_metadata, + seal_tx_metadata, }; use super::*; +/// Where one encrypted-document call derives the per-document txMetadata AES +/// key from. Selected by the CALLER (the FFI layer) from the wallet's shape — +/// the same capability convention as the identity discovery / key-preview +/// paths (`identity_key_preview.rs`): +/// +/// - a wallet with resident private keys (mnemonic / seed / xprv — test +/// fixtures, desktop wallets) derives in-process +/// ([`TxMetadataKeySource::ResidentWallet`], the historical path); +/// - an external-signable / watch-only wallet (the Android/iOS apps: the seed +/// lives host-side, keys derive on demand through the registered mnemonic +/// resolver) holds NO in-process private keys — the in-wallet derive fails +/// with `External signable wallet has no private key` (the exact on-device +/// failure that zeroed the decrypt-proof). For that shape the FFI resolves +/// the wallet's mnemonic via the host `MnemonicResolverHandle`, builds the +/// master xprv, passes [`TxMetadataKeySource::Master`], and wipes the +/// master after the call — atomic derive + use + zeroize. +/// +/// Both sources derive the IDENTICAL path +/// ([`crate::wallet::identity::crypto::tx_metadata::tx_metadata_derivation_path`]), +/// pinned equal by unit test. +#[derive(Clone, Copy)] +pub enum TxMetadataKeySource<'a> { + /// Derive from the in-process resident wallet's private keys. + ResidentWallet, + /// Derive from this caller-resolved master extended private key + /// (external-signable / watch-only wallet). The caller owns the master's + /// lifecycle and MUST wipe it (`private_key.non_secure_erase()`) once the + /// call returns. + Master(&'a key_wallet::bip32::ExtendedPrivKey), +} + +impl TxMetadataKeySource<'_> { + /// Compact breadcrumb label. + fn label(&self) -> &'static str { + match self { + TxMetadataKeySource::ResidentWallet => "resident-wallet", + TxMetadataKeySource::Master(_) => "resolver-master", + } + } + + /// Derive the AES key for one document from this source. `wallet` is the + /// in-process wallet (only consulted by the resident variant). + fn derive( + &self, + wallet: &key_wallet::wallet::Wallet, + network: key_wallet::Network, + identity_index: u32, + key_index: u32, + encryption_key_index: u32, + ) -> Result, PlatformWalletError> { + match self { + TxMetadataKeySource::ResidentWallet => derive_tx_metadata_key( + wallet, + network, + identity_index, + key_index, + encryption_key_index, + ), + TxMetadataKeySource::Master(master) => derive_tx_metadata_key_from_master( + master, + network, + identity_index, + key_index, + encryption_key_index, + ), + } + } +} + /// Wallet-contract document field names (wire-compatible with the legacy /// `TxMetadataDocument` schema — `wallet-utils-contract` `tx_metadata`). const FIELD_KEY_INDEX: &str = "keyIndex"; @@ -163,7 +233,10 @@ impl IdentityWallet { /// `TxMetadataBatch`) — the SDK does not parse it. /// /// The `keyIndex` field (the identity encryption key id) is selected - /// SDK-side to match the legacy stack. Returns the confirmed `Document`. + /// SDK-side to match the legacy stack. `key_source` selects where the AES + /// key derives from (see [`TxMetadataKeySource`] — resident wallet vs the + /// resolver-supplied master for external-signable wallets). Returns the + /// confirmed `Document`. #[allow(clippy::too_many_arguments)] pub async fn create_encrypted_document_with_signer( &self, @@ -173,6 +246,7 @@ impl IdentityWallet { encryption_key_index: u32, version: u8, payload: &[u8], + key_source: TxMetadataKeySource<'_>, signer: &S, ) -> Result where @@ -185,13 +259,21 @@ impl IdentityWallet { let key_index = Self::select_encryption_key_id(&identity)?; // Derive the AES key and seal the payload into the wire blob. - let aes_key = derive_tx_metadata_key( - &wallet, - self.sdk.network, - identity_index, - key_index, - encryption_key_index, - )?; + let aes_key = key_source + .derive( + &wallet, + self.sdk.network, + identity_index, + key_index, + encryption_key_index, + ) + .inspect_err(|e| { + breadcrumb(&format!( + "create_encrypted_document: txMetadata key derivation failed \ + key_source={} owner={owner_identity_id} error={e}", + key_source.label() + )); + })?; let mut iv = [0u8; 16]; thread_rng().fill_bytes(&mut iv); let blob = seal_tx_metadata(&aes_key, version, &iv, payload); @@ -233,6 +315,7 @@ impl IdentityWallet { contract_id: &Identifier, document_type_name: &str, since_ms: u64, + key_source: TxMetadataKeySource<'_>, ) -> Result, PlatformWalletError> { use dash_sdk::platform::{ContextProvider, Fetch}; @@ -241,7 +324,9 @@ impl IdentityWallet { // investigation — every stage must be provably visible in `adb logcat`. breadcrumb(&format!( "fetch_encrypted_documents: entry owner={owner_identity_id} \ - contract={contract_id} type={document_type_name} since_ms={since_ms}" + contract={contract_id} type={document_type_name} since_ms={since_ms} \ + key_source={}", + key_source.label() )); // Fetch the contract and register it so `fetch_many`'s proof @@ -335,7 +420,7 @@ impl IdentityWallet { continue; }; - let aes_key = match derive_tx_metadata_key( + let aes_key = match key_source.derive( &wallet, self.sdk.network, identity_index, @@ -346,7 +431,8 @@ impl IdentityWallet { Err(e) => { breadcrumb(&format!( "fetch_encrypted_documents: txMetadata key derivation failed doc={doc_id} \ - owner={owner_identity_id} error={e}; skipping" + owner={owner_identity_id} key_source={} error={e}; skipping", + key_source.label() )); continue; } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index 675ad98e53..ee4326a95f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -65,7 +65,9 @@ pub use seed_binding::SeedBindingVerification; mod tokens; pub use contact_info::ContactInfoPublishOutcome; -pub use encrypted_document::{query_owned_encrypted_documents, DecryptedEncryptedDocument}; +pub use encrypted_document::{ + query_owned_encrypted_documents, DecryptedEncryptedDocument, TxMetadataKeySource, +}; pub use contact_requests::{ AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, ContactInfoSealed, }; diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index cbf4f70b84..600b175712 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -770,6 +770,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do mut env: JNIEnv, _class: JClass, wallet_handle: jlong, + mnemonic_resolver_handle: jlong, owner_id: JByteArray, contract_id: JByteArray, document_type: JString, @@ -810,6 +811,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do let result = unsafe { platform_wallet_ffi::platform_wallet_create_encrypted_document_with_signer( wallet_handle as Handle, + mnemonic_resolver_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, owner.as_ptr(), contract.as_ptr(), doc_type.as_ptr(), @@ -860,6 +862,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do mut env: JNIEnv, _class: JClass, wallet_handle: jlong, + mnemonic_resolver_handle: jlong, owner_id: JByteArray, contract_id: JByteArray, document_type: JString, @@ -873,9 +876,12 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do // `take_pwffi_error` / `throw_sdk_exception` (both warn before // throwing). log::warn!( - "documentFetchEncrypted: entry wallet_handle={:#x} (nonzero={}) since_ms={}", + "documentFetchEncrypted: entry wallet_handle={:#x} (nonzero={}) \ + mnemonic_resolver_handle={:#x} (nonzero={}) since_ms={}", wallet_handle, wallet_handle != 0, + mnemonic_resolver_handle, + mnemonic_resolver_handle != 0, since_ms ); let Some(owner) = read_id32(env, &owner_id, "ownerId") else { @@ -907,6 +913,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do let result = unsafe { platform_wallet_ffi::platform_wallet_fetch_encrypted_documents( wallet_handle as Handle, + mnemonic_resolver_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, owner.as_ptr(), contract.as_ptr(), doc_type.as_ptr(), From ebfc9c4f943b06014ed6bc97b9a9460c302a8162 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:26:07 -0400 Subject: [PATCH 07/30] fix(platform-wallet): nonzero-identity wire vector; keep master xprv off the await; redact plaintext Debug; quiet breadcrumbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the latest review on dashpay/platform#4091. Wire-compat vector (BLOCKING): the existing legacy_dashj_wire_compat_vector pinned identity_index=0, indistinguishable from KeyDerivationType::ECDSA=0 at the adjacent path slot, so it could not prove identity_index is wired correctly. Add legacy_dashj_wire_compat_vector_nonzero_identity_index, generated by the REAL legacy stack (dashj-core 22.0.3 + blockchainIdentityECDSADerivationPath / KeyCrypterAESCBC, run under a JVM) at identity_index=1 (m/9'/1'/5'/0'/0'/1'/2'/32769'/1'). Its key (8cda…5196) is provably distinct from the index-0 key (4a2e…84d7); both the resident-wallet and resolver-master derivations are asserted to hit it. The reproducible generator (LegacyKeyN.java) and a README are checked in under tests/legacy_wire_compat/ so the vector's provenance is independently verifiable. Master-key exposure across await (document.rs create+fetch): the resolved master xprv previously lived across the network broadcast/pagination awaits and was wiped only afterwards (skipped on panic/early-return). Create now derives the AES key + seals the wire blob SYNCHRONOUSLY (new IdentityWallet:: prepare_encrypted_txmetadata_properties) and wipes the master before the async broadcast, so no key material crosses the await. Fetch cannot pre-derive (per-doc keyIndex/encryptionKeyIndex are discovered during pagination), so the master is wrapped in a WipingMaster Drop guard that scrubs on every exit path, with the tradeoff documented. FFI decision tests (decide_key_source) cover null-handle, external-signable dispatch, and resolver-required. Hygiene: manual Debug impls redacting the decrypted payload on DecryptedEncryptedDocument and OpenedTxMetadata; transactions.rs no longer logs the raw mnemonic_resolver_handle pointer (nonzero=bool only); per-poll informational breadcrumbs downgraded warn!->debug! (genuine error/skip paths stay warn!), with the android_logger Info-level visibility implication noted so identity-correlated data stops reaching logcat on every successful fetch now that the sdkFetched=0 root cause is fixed. Co-Authored-By: Claude Fable 5 --- .../rs-platform-wallet-ffi/src/document.rs | 225 +++++++++++++----- .../src/wallet/identity/crypto/tx_metadata.rs | 117 ++++++++- .../identity/network/encrypted_document.rs | 181 +++++++++----- .../tests/legacy_wire_compat/LegacyKeyN.java | 66 +++++ .../tests/legacy_wire_compat/README.md | 51 ++++ .../rs-unified-sdk-jni/src/transactions.rs | 26 +- 6 files changed, 535 insertions(+), 131 deletions(-) create mode 100644 packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyKeyN.java create mode 100644 packages/rs-platform-wallet/tests/legacy_wire_compat/README.md diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index 75a0b72bce..73e0a70152 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -20,6 +20,20 @@ use crate::runtime::block_on_worker; use crate::types::read_identifier; use crate::{unwrap_option_or_return, unwrap_result_or_return}; +/// RAII guard scrubbing a resolved master xprv's secret scalar on drop. +/// `ExtendedPrivKey` has no `Drop`/`Zeroize` of its own, so a resolved master +/// would otherwise linger on the stack past its use — and a manual +/// `non_secure_erase()` placed after an `.await` is skipped on panic / early +/// return. Wrapping the master here scrubs it on EVERY exit path +/// (dashpay/platform#4091). Mirrors `WipingSecretKey` in `utils.rs`. +struct WipingMaster(ExtendedPrivKey); + +impl Drop for WipingMaster { + fn drop(&mut self) { + self.0.private_key.non_secure_erase(); + } +} + /// Select the txMetadata key-derivation source for `wallet` by capability — /// the same two-phase convention as `identity_key_preview` / /// `identity_discovery`: @@ -31,9 +45,11 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return}; /// signable wallet has no private key`) requires the host mnemonic /// resolver: the wallet's mnemonic is resolved on demand (keyed by the /// wallet's own id) and returned as a master xprv (`Ok(Some(master))`). -/// The CALLER must wipe it (`master.private_key.non_secure_erase()`) once -/// the derive is done. When the resolver handle is null for this shape, -/// errors with a hint naming the requirement. +/// The CALLER must wipe it once the derive is done — wrap it in +/// [`WipingMaster`] so its scalar is scrubbed on every exit path (normal, +/// early return, panic), not only after a manual `non_secure_erase()`. When +/// the resolver handle is null for this shape, errors with a hint naming the +/// requirement. /// /// The wallet-manager read guard is scoped to the capability check only and /// is NEVER held across the host resolver callback (which synchronously @@ -61,23 +77,55 @@ unsafe fn tx_metadata_key_master_for_wallet( } } }; - if wallet_has_resident_keys { - return Ok(None); - } - if mnemonic_resolver_handle.is_null() { - return Err(PlatformWalletFFIResult::err( + match decide_key_source(wallet_has_resident_keys, mnemonic_resolver_handle.is_null()) { + KeySourceDecision::ResidentWallet => Ok(None), + KeySourceDecision::ResolverRequired => Err(PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, "this wallet has no resident private keys (external-signable / watch-only); \ a mnemonic resolver handle is required to derive its txMetadata encryption keys", - )); + )), + KeySourceDecision::ResolveMaster => { + let wallet_id = wallet.wallet_id(); + // SAFETY: handle is non-null (the decision proves it) and the + // caller's safety contract guarantees it came from + // `dash_sdk_mnemonic_resolver_create`. + let master = unsafe { + resolve_master_from_resolver(mnemonic_resolver_handle, &wallet_id, wallet.network())? + }; + Ok(Some(master)) + } + } +} + +/// The key-source outcome of the capability + resolver-handle check, factored +/// out of [`tx_metadata_key_master_for_wallet`] as a pure decision so the +/// dispatch is unit-testable without a live `PlatformWallet` +/// (dashpay/platform#4091). +#[derive(Debug, PartialEq, Eq)] +enum KeySourceDecision { + /// Resident-key wallet — derive in-process; the resolver handle is ignored + /// (may be null). + ResidentWallet, + /// External-signable / watch-only wallet with a non-null resolver — resolve + /// the master xprv via the host mnemonic resolver. + ResolveMaster, + /// External-signable / watch-only wallet but the resolver handle is null — + /// the caller must surface the "resolver required" error. + ResolverRequired, +} + +/// Pure dispatch for [`tx_metadata_key_master_for_wallet`]: a resident-key +/// wallet always derives in-process (a null resolver handle is fine); an +/// external-signable / watch-only wallet needs the host resolver, so a null +/// handle for that shape is the "resolver required" error. +fn decide_key_source(wallet_has_resident_keys: bool, resolver_is_null: bool) -> KeySourceDecision { + if wallet_has_resident_keys { + KeySourceDecision::ResidentWallet + } else if resolver_is_null { + KeySourceDecision::ResolverRequired + } else { + KeySourceDecision::ResolveMaster } - let wallet_id = wallet.wallet_id(); - // SAFETY: handle is non-null (checked) and the caller's safety contract - // guarantees it came from `dash_sdk_mnemonic_resolver_create`. - let master = unsafe { - resolve_master_from_resolver(mnemonic_resolver_handle, &wallet_id, wallet.network())? - }; - Ok(Some(master)) } /// Create + broadcast a new document on `contract_id`'s @@ -221,12 +269,16 @@ fn confirmed_document_to_json(document: &Document) -> Result TxMetadataKeySource::Master(&master.0), + None => TxMetadataKeySource::ResidentWallet, + }; + let properties_json = identity_wallet + .prepare_encrypted_txmetadata_properties( + &owner_id_for_async, + encryption_key_index, + version, + &payload_vec, + key_source, + ) + .map_err(PlatformWalletFFIResult::from)?; + // Scrub the master now — it is not needed for the broadcast. + drop(master_opt); let result: Result<(Identifier, String), PlatformWalletError> = block_on_worker(async move { - let key_source = match master_opt.as_ref() { - Some(master) => TxMetadataKeySource::Master(master), - None => TxMetadataKeySource::ResidentWallet, - }; let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); - let created = identity_wallet - .create_encrypted_document_with_signer( + // Generic create path (no key material in scope): fetches the + // contract, sanitizes the hex `encryptedMetadata` into `Bytes`, + // auto-selects the AUTHENTICATION signing key, and broadcasts on + // the 8 MB worker stack. + let confirmed: Document = identity_wallet + .create_document_with_signer( &owner_id_for_async, &contract_id_for_async, &document_type_str, - encryption_key_index, - version, - &payload_vec, - key_source, + &properties_json, signer, ) - .await; - // Wipe the resolved master's scalar (external-signable path) - // before the result crosses back — `ExtendedPrivKey` has no - // `Drop`/`Zeroize` (same hygiene as `identity_key_preview`). - if let Some(mut master) = master_opt { - master.private_key.non_secure_erase(); - } - let confirmed: Document = created?; + .await?; let json_string = confirmed_document_to_json(&confirmed)?; Ok::<_, PlatformWalletError>((confirmed.id(), json_string)) }); @@ -390,14 +455,26 @@ pub unsafe extern "C" fn platform_wallet_fetch_encrypted_documents( // Key-source selection by wallet capability (may synchronously call // back into the host mnemonic resolver for external-signable - // wallets — see `tx_metadata_key_master_for_wallet`). - let master_opt = - unsafe { tx_metadata_key_master_for_wallet(wallet, mnemonic_resolver_handle) }?; + // wallets — see `tx_metadata_key_master_for_wallet`). The resolved + // master is wrapped in a Drop-wiping guard. + let master_opt = unsafe { + tx_metadata_key_master_for_wallet(wallet, mnemonic_resolver_handle) + }? + .map(WipingMaster); let result: Result, PlatformWalletError> = block_on_worker(async move { + // TRADEOFF (dashpay/platform#4091): unlike create, a document's + // (keyIndex, encryptionKeyIndex) are only known AFTER its page is + // fetched, so the master cannot be fully pre-derived before the + // network work. It therefore stays resident across the pagination + // awaits — but inside the `WipingMaster` Drop guard, so a panic or + // early return still scrubs its scalar (a manual post-await erase + // would be skipped on those paths). Per-document key derivation is + // itself synchronous, between page fetches (see + // `fetch_encrypted_documents`). let key_source = match master_opt.as_ref() { - Some(master) => TxMetadataKeySource::Master(master), + Some(master) => TxMetadataKeySource::Master(&master.0), None => TxMetadataKeySource::ResidentWallet, }; let fetched = identity_wallet @@ -409,12 +486,7 @@ pub unsafe extern "C" fn platform_wallet_fetch_encrypted_documents( key_source, ) .await; - // Wipe the resolved master's scalar (external-signable path) - // before the result crosses back — `ExtendedPrivKey` has no - // `Drop`/`Zeroize` (same hygiene as `identity_key_preview`). - if let Some(mut master) = master_opt { - master.private_key.non_secure_erase(); - } + drop(master_opt); // scrub as soon as the fetch completes fetched }); result.map_err(PlatformWalletFFIResult::from) @@ -858,4 +930,51 @@ mod tests { json.get("$createdAt") ); } + + // ── tx_metadata_key_master_for_wallet dispatch (dashpay/platform#4091) ── + // + // `tx_metadata_key_master_for_wallet` needs a live `PlatformWallet` (wallet + // manager + SDK), which a unit test can't cheaply build, so its load-bearing + // branch logic is factored into the pure `decide_key_source`. These pin the + // capability dispatch, the null-handle handling, and the resolver-required + // error path that the FFI create/fetch entry points rely on. + + /// A resident-key wallet derives in-process — the resolver handle is + /// irrelevant, so a NULL handle is fine (never the "resolver required" error). + #[test] + fn resident_wallet_ignores_resolver_handle_even_when_null() { + assert_eq!( + decide_key_source(true, true), + KeySourceDecision::ResidentWallet, + "resident wallet + null resolver must derive in-process, not error" + ); + assert_eq!( + decide_key_source(true, false), + KeySourceDecision::ResidentWallet, + "resident wallet + non-null resolver still derives in-process" + ); + } + + /// An external-signable / watch-only wallet dispatches to the resolver-master + /// path when a (non-null) resolver handle is supplied. + #[test] + fn external_signable_wallet_dispatches_to_resolver_master() { + assert_eq!( + decide_key_source(false, false), + KeySourceDecision::ResolveMaster, + "external-signable / watch-only wallet + resolver must resolve the master" + ); + } + + /// An external-signable / watch-only wallet with a NULL resolver handle is + /// the "resolver required" error path (the on-device shape that must not + /// silently derive the wrong key). + #[test] + fn external_signable_wallet_null_resolver_is_resolver_required() { + assert_eq!( + decide_key_source(false, true), + KeySourceDecision::ResolverRequired, + "external-signable / watch-only wallet + null resolver must error, not derive" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs index 135cacd419..3980b74abc 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs @@ -199,7 +199,12 @@ pub fn seal_tx_metadata(key: &[u8; 32], version: u8, iv: &[u8; 16], payload: &[u } /// The plaintext recovered from a stored `encryptedMetadata` blob. -#[derive(Debug, Clone, PartialEq, Eq)] +/// +/// `Debug` is hand-written (NOT derived) so a stray `{:?}` / `dbg!()` / tracing +/// statement can never leak the decrypted financial plaintext into a log — the +/// same redaction as [`super::super::network::encrypted_document::DecryptedEncryptedDocument`]. +/// The payload is redacted to its length. +#[derive(Clone, PartialEq, Eq)] pub struct OpenedTxMetadata { /// The blob's leading version byte (0 = CBOR, 1 = protobuf). The app /// dispatches its payload parse on this. @@ -208,6 +213,16 @@ pub struct OpenedTxMetadata { pub payload: Vec, } +impl std::fmt::Debug for OpenedTxMetadata { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OpenedTxMetadata") + .field("version", &self.version) + // Redacted: never render the decrypted plaintext. + .field("payload", &format_args!("<{} bytes redacted>", self.payload.len())) + .finish() + } +} + /// Open a stored `encryptedMetadata` blob: split off the version byte + IV and /// AES-256-CBC-decrypt the remainder, returning the version + opaque payload. /// @@ -606,4 +621,104 @@ mod tests { "Rust must decrypt a dashj-produced txMetadata blob to the original plaintext" ); } + + /// **The nonzero-`identity_index` wire-compat anchor** (dashpay/platform#4091, + /// blocking review finding). The primary [`legacy_dashj_wire_compat_vector`] + /// pins `identity_index = 0`, but `KeyDerivationType::ECDSA` is also `0` and + /// sits at the path position immediately before `identity_index` + /// (`base / key_type' / identity_index' / key_index' / …`, see + /// [`identity_auth_derivation_path_for_type`]), so at index 0 those two + /// adjacent `0'` components are indistinguishable — that vector would pass + /// even if `identity_index` were dropped, swapped, or misplaced. This vector + /// uses `identity_index = 1` so the derived path + /// `m/9'/1'/5'/0'/0'/1'/2'/32769'/1'` differs from the index-0 path in + /// exactly the `identity_index` component, and the resulting legacy key + /// (`8cda…5196`) is provably distinct from the index-0 key (`4a2e…84d7`) — + /// empirically proving the component is wired to the correct path slot. + /// + /// ## How the vector was generated (reproducible) + /// + /// Same real legacy stack (dash-sdk-kotlin 4.0.0-RC2 semantics + dashj-core + /// 22.0.3, run under a JVM) as [`legacy_dashj_wire_compat_vector`], via the + /// checked-in generator `LegacyKeyN.java` (see this crate's + /// `tests/legacy_wire_compat/README.md`): + /// + /// ```text + /// javac -cp LegacyKeyN.java + /// java -cp .: LegacyKeyN 1 2 1 + /// fullPath=m/9'/1'/5'/0'/0'/1'/2'/32769'/1' + /// AES_KEY=8cdadb6b8bcf8defd416f2f032255173df89478c971bb96ae9f3511aae355196 + /// BLOB=01496ce7…2cba627383 (random per run — the IV differs; key is fixed) + /// ``` + /// + /// `identity_index = 1` (the wallet's second Platform identity), `key_index` + /// (keyId) `2` (ENCRYPTION/MEDIUM), `encryptionKeyIndex` `1`. The BIP-39 test + /// mnemonic `abandon abandon … about`, empty passphrase, Testnet. The key is + /// deterministic; the blob's IV is fresh `SecureRandom` per generation, so + /// the exact blob bytes below are one captured run (any IV opens fine). + #[test] + fn legacy_dashj_wire_compat_vector_nonzero_identity_index() { + use key_wallet::mnemonic::{Language, Mnemonic}; + + const PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon about"; + + let wallet = Wallet::from_mnemonic( + Mnemonic::from_phrase(PHRASE, Language::English).expect("valid test mnemonic"), + Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("wallet from mnemonic"); + + // identity_index 1 (a NON-primary identity), key_index 2, encryptionKeyIndex 1. + let key = derive_tx_metadata_key(&wallet, Network::Testnet, 1, 2, 1).expect("derive"); + + // The AES key dashj derived at m/9'/1'/5'/0'/0'/1'/2'/32769'/1'. + let legacy_key: [u8; 32] = + hex_lit("8cdadb6b8bcf8defd416f2f032255173df89478c971bb96ae9f3511aae355196"); + // Distinct from the identity_index=0 key — the whole point of this vector. + let index0_key: [u8; 32] = + hex_lit("4a2eaec1ad959105738996b49e0327f96a80b765249d2c9af8cf6aa689aa84d7"); + assert_ne!( + legacy_key, index0_key, + "identity_index=1 must derive a different key than identity_index=0" + ); + assert_eq!( + *key, legacy_key, + "nonzero-identity_index tx-metadata HD key must match the legacy dashj stack \ + byte-for-byte (proves identity_index is wired to the correct path slot)" + ); + + // The resolver-master path (on-device external-signable shape) must hit + // the same dashj key at the nonzero identity_index too. + let master = ExtendedPrivKey::new_master( + Network::Testnet, + &Mnemonic::from_phrase(PHRASE, Language::English) + .expect("valid test mnemonic") + .to_seed(""), + ) + .expect("master from seed"); + let key_via_master = + derive_tx_metadata_key_from_master(&master, Network::Testnet, 1, 2, 1) + .expect("master derive"); + assert_eq!( + *key_via_master, legacy_key, + "resolver-master derivation must match the legacy dashj stack at identity_index=1" + ); + + // The full stored blob dashj produced at this slot — Rust must open it. + let legacy_blob = hex::decode( + "01496ce7b7aa8baa910eb278dc38aee86522e841414d7b273da86df2106b0548e\ + ee7b6957bb1789512cd00bf90663690cae4202bd1f9ae5f84859b8d2cba627383", + ) + .expect("valid hex"); + let expected_plaintext = b"legacy-txmetadata-wire-compat-vector".to_vec(); + + let opened = open_tx_metadata(&key, &legacy_blob).expect("open legacy blob"); + assert_eq!(opened.version, VERSION_PROTOBUF, "version byte"); + assert_eq!( + opened.payload, expected_plaintext, + "Rust must decrypt a dashj-produced nonzero-identity_index blob to the plaintext" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index f9d0a7e147..f6dc92d80f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -23,8 +23,7 @@ use std::sync::Arc; use dpp::document::{Document, DocumentV0Getters}; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dpp::identity::signer::Signer; -use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::identity::{KeyType, Purpose, SecurityLevel}; use dpp::platform_value::Value; use dpp::prelude::{DataContract, Identifier}; @@ -111,18 +110,33 @@ const FIELD_KEY_INDEX: &str = "keyIndex"; const FIELD_ENCRYPTION_KEY_INDEX: &str = "encryptionKeyIndex"; const FIELD_ENCRYPTED_METADATA: &str = "encryptedMetadata"; -/// Emit an on-device diagnostic breadcrumb through BOTH logging facades. +/// Emit an INFORMATIONAL stage breadcrumb through both logging facades at +/// **DEBUG** level. /// /// On Android the two facades diverge: the JNI layer's `JNI_OnLoad` installs -/// `android_logger` as the global `log` logger (logcat tag `DashSDK`, Info+), -/// so `log::warn!` provably reaches logcat, while the only `tracing` -/// subscriber the Kotlin SDK installs (`dash_sdk_enable_logging`, a -/// `tracing_subscriber::fmt` layer) writes to STDOUT, which Android discards. -/// Proven live in the 2026-07 forensic tap: the JNI `log::warn!` lines -/// appeared under tag `DashSDK`; the `tracing::warn!` lines from this file -/// never did. Emitting through both keeps host tests / desktop file logging -/// on `tracing` while making the on-device trail visible in logcat. +/// `android_logger` as the global `log` logger (logcat tag `DashSDK`) but at +/// `LevelFilter::Info`, while the only `tracing` subscriber the Kotlin SDK +/// installs (`dash_sdk_enable_logging`, a `tracing_subscriber::fmt` layer) +/// writes to STDOUT, which Android discards. Consequence: a DEBUG line reaches +/// NEITHER on-device sink, while host tests / desktop file logging still capture +/// it through `tracing`. +/// +/// These per-poll stage lines carry identity / contract / document ids, so now +/// that the `sdkFetched=0` root cause is fixed (external-signable txMetadata +/// derive, dashpay/platform#4091) they are deliberately DEBUG — they must NOT +/// persist identity-correlated data to logcat on every successful fetch. Genuine +/// failures use [`breadcrumb_error`] (WARN) so they stay visible on-device. fn breadcrumb(line: &str) { + tracing::debug!("{line}"); + log::debug!("{line}"); +} + +/// Emit a FAILURE breadcrumb through both logging facades at **WARN** level, so +/// a genuine error or skip stays visible in Android logcat (`android_logger` +/// Info+). Use ONLY for actual failure / skip paths — never per-poll +/// informational stages, which belong on [`breadcrumb`] (DEBUG) to keep +/// identity-correlated data out of the device log. +fn breadcrumb_error(line: &str) { tracing::warn!("{line}"); log::warn!("{line}"); } @@ -130,7 +144,13 @@ fn breadcrumb(line: &str) { /// One decrypted encrypted-document, returned to the caller (serialized to /// JSON at the FFI boundary). The `payload` is the opaque, decrypted plaintext /// the app parses itself (a protobuf `TxMetadataBatch` for `version == 1`). -#[derive(Debug, Clone)] +/// +/// `Debug` is hand-written (NOT derived) so a stray `{:?}` / `dbg!()` / tracing +/// statement can never leak the decrypted financial payload (memos, tax +/// categories, exchange-rate records, gift cards) into a log — mirroring the +/// deliberate omission of `Debug` on secret-bearing sibling types like +/// `DerivedIdentityAuthKey`. The plaintext is redacted to its length. +#[derive(Clone)] pub struct DecryptedEncryptedDocument { /// Canonical 32-byte document id. pub document_id: Identifier, @@ -150,6 +170,21 @@ pub struct DecryptedEncryptedDocument { pub payload: Vec, } +impl std::fmt::Debug for DecryptedEncryptedDocument { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DecryptedEncryptedDocument") + .field("document_id", &self.document_id) + .field("owner_id", &self.owner_id) + .field("key_index", &self.key_index) + .field("encryption_key_index", &self.encryption_key_index) + .field("version", &self.version) + .field("updated_at_ms", &self.updated_at_ms) + // Redacted: never render the decrypted financial plaintext. + .field("payload", &format_args!("<{} bytes redacted>", self.payload.len())) + .finish() + } +} + impl IdentityWallet { /// Select the identity's encryption key id (the document's `keyIndex` /// field): an `ECDSA_SECP256K1` `Purpose::ENCRYPTION` / `MEDIUM` key, falling @@ -215,50 +250,79 @@ impl IdentityWallet { Ok((identity, identity_index, wallet)) } - /// Create + broadcast an ENCRYPTED `txMetadata`-style document on - /// `contract_id`'s `document_type_name`, owned by `owner_identity_id`. + /// Synchronous (`blocking_read`) counterpart of + /// [`Self::resolve_encryption_context`], resolving + /// `(identity, identity_index, wallet)` without crossing an `.await`. MUST + /// be called from a sync context — never inside an async task (`blocking_read` + /// panics there). Used by [`Self::prepare_encrypted_txmetadata_properties`] + /// so the master xprv can be wiped BEFORE any network round-trip. + fn resolve_encryption_context_blocking( + &self, + owner_identity_id: &Identifier, + ) -> Result<(dpp::identity::Identity, u32, key_wallet::wallet::Wallet), PlatformWalletError> { + let wm = self.wallet_manager.blocking_read(); + let info = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let managed = info + .identity_manager + .managed_identity(owner_identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*owner_identity_id))?; + let identity_index = managed.identity_index.ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Identity {owner_identity_id} is watch-only (no resident HD slot); \ + cannot derive its txMetadata encryption key in-process" + )) + })?; + let identity = managed.identity.clone(); + let wallet = wm + .get_wallet(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))? + .clone(); + Ok((identity, identity_index, wallet)) + } + + /// Synchronously derive the identity encryption key and seal `payload` into + /// the wire-compatible `version ‖ IV ‖ AES-256-CBC` blob, returning the + /// `{keyIndex, encryptionKeyIndex, encryptedMetadata}` properties JSON ready + /// for [`Self::create_document_with_signer`] — the exact document shape the + /// legacy `publishTxMetaData` wrote, so the legacy stack decrypts it. /// - /// The SDK derives the identity encryption key, seals `payload` into the - /// wire-compatible `version ‖ IV ‖ AES-256-CBC` blob, and writes the - /// `{keyIndex, encryptionKeyIndex, encryptedMetadata}` document — the exact - /// shape the legacy `publishTxMetaData` wrote, so the legacy stack decrypts - /// it and vice versa. + /// **Crosses no `.await`** (resolves via `blocking_read`, derives, seals) so + /// the FFI caller can WIPE the resolved master xprv before the network + /// broadcast: the master never lives across an await + /// (dashpay/platform#4091). Call from a sync context only. The subsequent + /// generic [`Self::create_document_with_signer`] then broadcasts the returned + /// properties with no key material in scope. /// /// The caller supplies: - /// - `encryption_key_index`: the per-document index (dash-wallet's - /// monotonic `1 + countAllRequests()` counter). Batching stays app-side. + /// - `encryption_key_index`: the per-document index (dash-wallet's monotonic + /// `1 + countAllRequests()` counter). Batching stays app-side. /// - `version`: the payload version byte (`1` = protobuf, as the wallet /// writes). /// - `payload`: the already-serialized opaque plaintext (a protobuf /// `TxMetadataBatch`) — the SDK does not parse it. /// - /// The `keyIndex` field (the identity encryption key id) is selected - /// SDK-side to match the legacy stack. `key_source` selects where the AES - /// key derives from (see [`TxMetadataKeySource`] — resident wallet vs the - /// resolver-supplied master for external-signable wallets). Returns the - /// confirmed `Document`. - #[allow(clippy::too_many_arguments)] - pub async fn create_encrypted_document_with_signer( + /// The `keyIndex` field (the identity encryption key id) is selected SDK-side + /// to match the legacy stack; `key_source` selects where the AES key derives + /// from (see [`TxMetadataKeySource`]). + pub fn prepare_encrypted_txmetadata_properties( &self, owner_identity_id: &Identifier, - contract_id: &Identifier, - document_type_name: &str, encryption_key_index: u32, version: u8, payload: &[u8], key_source: TxMetadataKeySource<'_>, - signer: &S, - ) -> Result - where - S: Signer + Send + Sync, - { + ) -> Result { use dashcore::secp256k1::rand::{thread_rng, RngCore}; let (identity, identity_index, wallet) = - self.resolve_encryption_context(owner_identity_id).await?; + self.resolve_encryption_context_blocking(owner_identity_id)?; let key_index = Self::select_encryption_key_id(&identity)?; - // Derive the AES key and seal the payload into the wire blob. + // Derive the AES key and seal the payload into the wire blob — the only + // step that touches `key_source`'s master, done here synchronously so the + // caller can wipe it before broadcasting. let aes_key = key_source .derive( &wallet, @@ -268,8 +332,8 @@ impl IdentityWallet { encryption_key_index, ) .inspect_err(|e| { - breadcrumb(&format!( - "create_encrypted_document: txMetadata key derivation failed \ + breadcrumb_error(&format!( + "prepare_encrypted_txmetadata: key derivation failed \ key_source={} owner={owner_identity_id} error={e}", key_source.label() )); @@ -278,25 +342,14 @@ impl IdentityWallet { thread_rng().fill_bytes(&mut iv); let blob = seal_tx_metadata(&aes_key, version, &iv, payload); - // Reuse the generic create path: it fetches the contract, sanitizes the - // hex `encryptedMetadata` into `Bytes` against the schema, auto-selects - // the AUTHENTICATION signing key, and broadcasts on the 8 MB worker - // stack. Byte-array fields are accepted as hex strings there. - let properties_json = serde_json::json!({ + // Byte-array fields are accepted as hex strings by the generic create + // path, which sanitizes them into `Bytes` against the schema. + Ok(serde_json::json!({ FIELD_KEY_INDEX: key_index, FIELD_ENCRYPTION_KEY_INDEX: encryption_key_index, FIELD_ENCRYPTED_METADATA: hex::encode(&blob), }) - .to_string(); - - self.create_document_with_signer( - owner_identity_id, - contract_id, - document_type_name, - &properties_json, - signer, - ) - .await + .to_string()) } /// Fetch every encrypted `txMetadata`-style document owned by @@ -335,13 +388,13 @@ impl IdentityWallet { let contract = DataContract::fetch(&self.sdk, *contract_id) .await .map_err(|e| { - breadcrumb(&format!( + breadcrumb_error(&format!( "fetch_encrypted_documents: contract fetch failed contract={contract_id} error={e}" )); PlatformWalletError::Sdk(e) })? .ok_or_else(|| { - breadcrumb(&format!( + breadcrumb_error(&format!( "fetch_encrypted_documents: contract not found on Platform contract={contract_id}" )); PlatformWalletError::InvalidIdentityData(format!( @@ -357,7 +410,7 @@ impl IdentityWallet { .resolve_encryption_context(owner_identity_id) .await .inspect_err(|e| { - breadcrumb(&format!( + breadcrumb_error(&format!( "fetch_encrypted_documents: encryption-context resolution failed \ owner={owner_identity_id} error={e}" )); @@ -375,7 +428,7 @@ impl IdentityWallet { ) .await .inspect_err(|e| { - breadcrumb(&format!( + breadcrumb_error(&format!( "fetch_encrypted_documents: document query failed owner={owner_identity_id} error={e}" )); })?; @@ -388,7 +441,7 @@ impl IdentityWallet { // SILENT skip — under proofs this is exactly the shape that // turns "2 documents exist" into an empty result with no // error, so it must leave a trail. - breadcrumb(&format!( + breadcrumb_error(&format!( "fetch_encrypted_documents: raw entry NOT materialized doc={doc_id} \ owner={owner_identity_id}; skipping" )); @@ -403,7 +456,7 @@ impl IdentityWallet { .get(FIELD_ENCRYPTION_KEY_INDEX) .and_then(|v: &Value| v.to_integer::().ok()), ) else { - breadcrumb(&format!( + breadcrumb_error(&format!( "fetch_encrypted_documents: document missing key indices doc={doc_id} \ owner={owner_identity_id}; skipping" )); @@ -413,7 +466,7 @@ impl IdentityWallet { .get(FIELD_ENCRYPTED_METADATA) .and_then(|v: &Value| v.to_binary_bytes().ok()) else { - breadcrumb(&format!( + breadcrumb_error(&format!( "fetch_encrypted_documents: document missing encryptedMetadata doc={doc_id} \ owner={owner_identity_id}; skipping" )); @@ -429,7 +482,7 @@ impl IdentityWallet { ) { Ok(k) => k, Err(e) => { - breadcrumb(&format!( + breadcrumb_error(&format!( "fetch_encrypted_documents: txMetadata key derivation failed doc={doc_id} \ owner={owner_identity_id} key_source={} error={e}; skipping", key_source.label() @@ -440,7 +493,7 @@ impl IdentityWallet { let opened = match open_tx_metadata(&aes_key, &blob) { Ok(o) => o, Err(e) => { - breadcrumb(&format!( + breadcrumb_error(&format!( "fetch_encrypted_documents: txMetadata decrypt failed doc={doc_id} \ owner={owner_identity_id} error={e}; skipping" )); @@ -533,7 +586,7 @@ pub async fn query_owned_encrypted_documents( }; let page = Document::fetch_many(sdk, query).await.map_err(|e| { - breadcrumb(&format!( + breadcrumb_error(&format!( "query_owned_encrypted_documents: fetch_many failed owner={owner_identity_id} \ type={document_type_name} error={e}" )); diff --git a/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyKeyN.java b/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyKeyN.java new file mode 100644 index 0000000000..c8f57d8ad4 --- /dev/null +++ b/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyKeyN.java @@ -0,0 +1,66 @@ +import java.util.*; +import org.bitcoinj.crypto.*; + +/** + * Wire-compat vector generator for the Kotlin-SDK txMetadata migration. + * Reproduces the legacy org.dashj.platform / dashj-core createTxMetadata + * key derivation + AES-256-CBC envelope for a given identity slot. + * + * Args: + * (blockchainIdentityECDSADerivationPath = m/9'/1'/5'/0'//) + */ +public class LegacyKeyN { + static String hex(byte[] b){ StringBuilder s=new StringBuilder(); for(byte x:b) s.append(String.format("%02x",x)); return s.toString(); } + public static void main(String[] a) throws Exception { + int identityIndex = a.length > 0 ? Integer.parseInt(a[0]) : 0; + int keyId = a.length > 1 ? Integer.parseInt(a[1]) : 2; + int encryptionKeyIndex = a.length > 2 ? Integer.parseInt(a[2]) : 1; + + List words = Arrays.asList( + "abandon","abandon","abandon","abandon","abandon","abandon", + "abandon","abandon","abandon","abandon","abandon","about"); + byte[] seed = MnemonicCode.toSeed(words, ""); + + DeterministicKey root = HDKeyDerivation.createMasterPrivateKey(seed); + DeterministicHierarchy h = new DeterministicHierarchy(root); + + // blockchainIdentityECDSADerivationPath(testnet) for identity `identityIndex`: + // FEATURE_PURPOSE=9', coinType(testnet)=1', FEATURE_PURPOSE_IDENTITIES=5', + // 0' (subfeature), 0' (keyType=ECDSA), identityIndex' + List accountPath = new ArrayList<>(); + accountPath.add(new ChildNumber(9, true)); + accountPath.add(new ChildNumber(1, true)); + accountPath.add(new ChildNumber(5, true)); + accountPath.add(new ChildNumber(0, true)); + accountPath.add(new ChildNumber(0, true)); // keyType = ECDSA = 0 + accountPath.add(new ChildNumber(identityIndex, true)); // identity index + + int txMetaChild = 32769; // TxMetadataDocument.childNumber + + List full = new ArrayList<>(accountPath); + full.add(new ChildNumber(keyId, true)); + full.add(new ChildNumber(txMetaChild, true)); + full.add(new ChildNumber(encryptionKeyIndex, true)); + + System.out.print("fullPath=m"); + for (ChildNumber c : full) System.out.print("/" + c); + System.out.println(); + + DeterministicKey key = h.get(full, false, true); + byte[] aesKeyBytes = key.getPrivKeyBytes(); + System.out.println("AES_KEY=" + hex(aesKeyBytes)); + + org.bitcoinj.core.ECKey ecKey = org.bitcoinj.core.ECKey.fromPrivate(aesKeyBytes); + org.bitcoinj.crypto.KeyCrypterAESCBC kc = new org.bitcoinj.crypto.KeyCrypterAESCBC(); + org.bouncycastle.crypto.params.KeyParameter aesKp = kc.deriveKey(ecKey); + byte[] plaintext = "legacy-txmetadata-wire-compat-vector".getBytes("UTF-8"); + org.bitcoinj.crypto.EncryptedData ed = kc.encrypt(plaintext, aesKp); + int version = 1; // VERSION_PROTOBUF + byte[] blob = new byte[1 + ed.initialisationVector.length + ed.encryptedBytes.length]; + blob[0] = (byte) version; + System.arraycopy(ed.initialisationVector, 0, blob, 1, ed.initialisationVector.length); + System.arraycopy(ed.encryptedBytes, 0, blob, 1 + ed.initialisationVector.length, ed.encryptedBytes.length); + System.out.println("PLAINTEXT_hex=" + hex(plaintext)); + System.out.println("BLOB=" + hex(blob)); + } +} diff --git a/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md b/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md new file mode 100644 index 0000000000..41f90351ee --- /dev/null +++ b/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md @@ -0,0 +1,51 @@ +# Legacy txMetadata wire-compat vector generator + +`LegacyKeyN.java` is the reproducible JVM generator behind the hard-coded +cross-stack vectors in +`src/wallet/identity/crypto/tx_metadata.rs` +(`legacy_dashj_wire_compat_vector` and +`legacy_dashj_wire_compat_vector_nonzero_identity_index`). + +It runs the **actual legacy `org.dashj.platform` / dashj-core stack** — the same +`HDKeyDerivation`, `blockchainIdentityECDSADerivationPath` constants, +`KeyCrypterAESCBC.deriveKey/encrypt`, and `createTxMetadata` blob framing that +dash-sdk-kotlin 4.0.0-RC2 used — so the Rust `derive_tx_metadata_key` / +`seal_tx_metadata` implementations can be pinned against a value the Rust code +did not itself produce. This is what makes the wire-compat guarantee auditable +rather than self-referential (dashpay/platform#4091 review). + +## Why a nonzero `identity_index` vector exists + +The Rust path is `base / key_type' / identity_index' / key_index' / 32769' / +encryption_key_index'` and `KeyDerivationType::ECDSA == 0`. At +`identity_index = 0` the `key_type'` and `identity_index'` components are both +`0'` and adjacent, so an index-0-only vector cannot distinguish a correctly +placed `identity_index` from one that was dropped or swapped. The +`identity_index = 1` vector (`m/9'/1'/5'/0'/0'/1'/2'/32769'/1'`) derives a +provably different key (`8cda…5196` vs the index-0 `4a2e…84d7`), exercising that +component directly. + +## Reproduce + +Classpath jars come from the Gradle module cache +(`~/.gradle/caches/modules-2/files-2.1`): + +- `org.dashj/dashj-core/22.0.3/…/dashj-core-22.0.3.jar` +- `org.bouncycastle/bcprov-jdk18on/1.80/…/bcprov-jdk18on-1.80.jar` +- `com.google.guava/guava/30.0-jre/…/guava-30.0-jre.jar` +- `org.slf4j/slf4j-api/1.7.30/…/slf4j-api-1.7.30.jar` + +```sh +CP="dashj-core-22.0.3.jar:bcprov-jdk18on-1.80.jar:guava-30.0-jre.jar:slf4j-api-1.7.30.jar" +javac -cp "$CP" LegacyKeyN.java + +# args: +java -cp ".:$CP" LegacyKeyN 0 2 1 # -> AES_KEY=4a2e…84d7 (index-0 vector) +java -cp ".:$CP" LegacyKeyN 1 2 1 # -> AES_KEY=8cda…5196 (index-1 vector) +``` + +`AES_KEY` is deterministic for a given `(identityIndex, keyId, +encryptionKeyIndex)`; `BLOB` embeds a fresh `SecureRandom` IV per run, so its +bytes differ each invocation while any produced blob still opens under the key +(`open_tx_metadata` reads the IV from the blob). Mnemonic: the BIP-39 test +vector `abandon abandon … about`, empty passphrase, Testnet. diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 600b175712..f996a64d04 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -869,18 +869,18 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do since_ms: jlong, ) -> jstring { guard(&mut env, ptr::null_mut(), |env| { - // Diagnostic breadcrumbs are warn-level: this entry point sits under an - // active on-device `sdkFetched=0` investigation and every stage — - // including "the JVM call reached native code at all" — must be - // provably visible in `adb logcat` (tag `DashSDK`). Error paths log via - // `take_pwffi_error` / `throw_sdk_exception` (both warn before - // throwing). - log::warn!( - "documentFetchEncrypted: entry wallet_handle={:#x} (nonzero={}) \ - mnemonic_resolver_handle={:#x} (nonzero={}) since_ms={}", - wallet_handle, + // Informational stage breadcrumbs are DEBUG; only genuine failure paths + // are WARN. The `sdkFetched=0` root cause is fixed (external-signable + // txMetadata derive, dashpay/platform#4091), so these no longer need to + // be loud. Android visibility: `JNI_OnLoad` installs `android_logger` at + // `LevelFilter::Info`, so DEBUG lines stay OUT of on-device logcat while + // WARN error lines remain visible. NEVER log a raw handle value: only + // whether each handle is nonzero — `mnemonic_resolver_handle` is a live + // `*mut MnemonicResolverHandle`, so `{:#x}` would leak a heap pointer. + log::debug!( + "documentFetchEncrypted: entry wallet_handle_nonzero={} \ + mnemonic_resolver_handle_nonzero={} since_ms={}", wallet_handle != 0, - mnemonic_resolver_handle, mnemonic_resolver_handle != 0, since_ms ); @@ -901,7 +901,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do throw_sdk_exception(env, 1, "sinceMs must be non-negative"); return ptr::null_mut(); } - log::warn!( + log::debug!( "documentFetchEncrypted: args owner={} contract={} document_type={:?} — \ calling platform_wallet_fetch_encrypted_documents", hex32(&owner), @@ -937,7 +937,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do .to_string_lossy() .into_owned(); unsafe { platform_wallet_ffi::platform_wallet_string_free(out_json) }; - log::warn!( + log::debug!( "documentFetchEncrypted: success, returning {} chars of JSON to Kotlin", json.len() ); From 988368ae1b35e793a870af5237f1af68e24245cc Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:57:53 -0400 Subject: [PATCH 08/30] perf(platform-wallet): share the fetched DataContract Arc instead of deep-cloning it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review nitpick bbb24591b025 on dashpay/platform#4091. fetch_encrypted_documents wrapped the fetched DataContract in one Arc for the context provider (Arc::new(contract.clone())) and then moved the original into a SECOND Arc — a redundant deep clone of the whole contract (document-type/index metadata) on every fetch. Wrap once and hand the provider a cheap Arc::clone of the same handle. Verified: cargo test -p platform-wallet green (430 lib + 9 integration). Co-Authored-By: Claude Fable 5 --- .../src/wallet/identity/network/encrypted_document.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index f6dc92d80f..cf8ee1e1b3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -401,10 +401,13 @@ impl IdentityWallet { "Data contract {contract_id} not found on Platform; cannot fetch documents" )) })?; + // Wrap once and share the cheap `Arc` handle with the context provider + // rather than deep-cloning the whole `DataContract` (document-type/index + // metadata) a second time. + let contract = Arc::new(contract); if let Some(provider) = self.sdk.context_provider() { - provider.register_data_contract(Arc::new(contract.clone())); + provider.register_data_contract(Arc::clone(&contract)); } - let contract = Arc::new(contract); let (_identity, identity_index, wallet) = self .resolve_encryption_context(owner_identity_id) From 87b6f379122c763bdd825c1fb0643f5799c3f3ba Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:04:50 -0400 Subject: [PATCH 09/30] fix(kotlin-sdk): reject txMetadata version bytes the legacy stack can't decode Addresses review findings 0dd9fc55de07 / CodeRabbit a783199f on dashpay/platform#4091. createEncryptedDocument bounded `version` to 0..255, but only 0 (CBOR) and 1 (protobuf) are wire-meaningful: seal_tx_metadata writes the byte verbatim into the envelope and the legacy dashj decryptTxMetadata switches on exactly those two values. Accepting 2..255 would silently seal a document the legacy stack cannot decode, breaking the bidirectional wire-compat guarantee this PR exists to establish. Tighten to `require(version == 0 || version == 1)` with a message that names both wire versions. Adds DocumentTransactionsVersionValidationTest pinning the rejection of 2..255 and negative bytes (the `require` runs before the native call, so the rejection paths are exercised on the JVM). Full :sdk unit suite green (113). Co-Authored-By: Claude Fable 5 --- .../dashsdk/documents/DocumentTransactions.kt | 9 ++- ...cumentTransactionsVersionValidationTest.kt | 58 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt index 699756db74..7a96927fbb 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt @@ -296,7 +296,14 @@ class DocumentTransactions internal constructor( require(encryptionKeyIndex >= 0) { "encryptionKeyIndex must be non-negative, got $encryptionKeyIndex" } - require(version in 0..255) { "version must be in 0..255, got $version" } + // Only 0 (CBOR) and 1 (protobuf) are wire-meaningful: `seal_tx_metadata` + // writes this byte verbatim into the envelope and the legacy dashj stack + // (decryptTxMetadata) switches on exactly those two values. Accepting 2..255 + // would silently seal a document the legacy stack can't decode, breaking the + // bidirectional wire-compat guarantee (dashpay/platform#4091). + require(version == 0 || version == 1) { + "version must be 0 (CBOR) or 1 (protobuf), got $version" + } mapNativeErrors { TransactionsNative.documentCreateEncrypted( walletHandle, diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt new file mode 100644 index 0000000000..8291093935 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt @@ -0,0 +1,58 @@ +package org.dashfoundation.dashsdk.documents + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Version-byte validation for [DocumentTransactions.createEncryptedDocument] + * (dashpay/platform#4091). Only 0 (CBOR) and 1 (protobuf) are wire-meaningful — + * `seal_tx_metadata` writes the byte verbatim and the legacy dashj + * `decryptTxMetadata` switches on exactly those two values, so an out-of-range + * byte would silently seal a document the legacy stack can't decode. + * + * The `require` runs before any native call (`TransactionsNative`), so the + * REJECTION paths are exercised on the JVM without the JNI library loaded. The + * accepted values 0/1 would proceed into native and can't be unit-tested here. + */ +class DocumentTransactionsVersionValidationTest { + + private val id32 = ByteArray(32) + private val payload = ByteArray(4) { it.toByte() } + + private fun createWithVersion(version: Int) = runBlocking { + DocumentTransactions().createEncryptedDocument( + walletHandle = 0L, + mnemonicResolverHandle = 0L, + ownerId = id32, + contractId = id32, + documentType = "txMetadata", + encryptionKeyIndex = 0, + version = version, + payload = payload, + signerHandle = 0L, + ) + } + + /** Bytes 2..255 (previously accepted by the `0..255` range) are now rejected. */ + @Test + fun rejectsVersionBytesTheLegacyStackCannotDecode() { + for (version in intArrayOf(2, 3, 127, 255)) { + val e = assertThrows( + "version=$version must be rejected", + IllegalArgumentException::class.java, + ) { createWithVersion(version) } + assertTrue( + "message should name the wire-meaningful versions, got: ${e.message}", + e.message!!.contains("0 (CBOR) or 1 (protobuf)"), + ) + } + } + + /** A negative version byte is likewise rejected. */ + @Test + fun rejectsNegativeVersion() { + assertThrows(IllegalArgumentException::class.java) { createWithVersion(-1) } + } +} From 7f8ac4d22f984f135b96fc4acedaf1ac497076ec Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:43:50 -0400 Subject: [PATCH 10/30] fix(platform-wallet): enforce txMetadata wire version (0|1) in Rust core + JNI, not just Kotlin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version-byte wire-compat guard previously landed only as a Kotlin `require` (DocumentTransactions.kt); the JNI entry point still accepted the stale 0..=255 range and `seal_tx_metadata` wrote the byte verbatim, so a caller reaching the FFI/JNI directly could still seal a document with a version (2..=255) the legacy dashj `decryptTxMetadata` can't decode — silently breaking wire-compat (dashpay/platform#4091, findings 9c0ce58c3bb7 and 79595960d201). - seal_tx_metadata now returns Result and rejects any version != 0 (CBOR) / 1 (protobuf) at the one choke point every layer (JNI, FFI, resident wallet) funnels through; the FFI create path propagates the error. Added seal_rejects_non_wire_versions (asserts 0/1 seal, 2..=255 rejected). - The JNI create entry point replaces the `0..=255` check with `0..=1` and a message naming both wire versions, failing fast before the native call. Co-Authored-By: Claude Fable 5 --- .../src/wallet/identity/crypto/tx_metadata.rs | 62 ++++++++++++++++--- .../identity/network/encrypted_document.rs | 5 +- .../rs-unified-sdk-jni/src/transactions.rs | 14 ++++- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs index 3980b74abc..b3576b1ca8 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs @@ -189,13 +189,33 @@ pub fn derive_tx_metadata_key_from_master( /// `version == VERSION_PROTOBUF`); this crate does not parse it. `iv` MUST be a /// fresh random 16 bytes per document (the legacy stack draws it from /// `SecureRandom`). -pub fn seal_tx_metadata(key: &[u8; 32], version: u8, iv: &[u8; 16], payload: &[u8]) -> Vec { +/// +/// `version` MUST be [`VERSION_CBOR`] (0) or [`VERSION_PROTOBUF`] (1) — the only +/// two values the legacy dashj `decryptTxMetadata` switches on. Sealing any +/// other byte would produce a document that installs fine but the legacy stack +/// cannot decode, silently breaking the bidirectional wire-compat guarantee, so +/// it is rejected HERE, at the one choke point every layer (JNI, FFI, resident +/// wallet) funnels through — not only in the Kotlin `require` +/// (dashpay/platform#4091, findings 9c0ce58c3bb7 / 79595960d201). +pub fn seal_tx_metadata( + key: &[u8; 32], + version: u8, + iv: &[u8; 16], + payload: &[u8], +) -> Result, PlatformWalletError> { + if version != VERSION_CBOR && version != VERSION_PROTOBUF { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "txMetadata version byte {version} is not wire-decodable; only \ + {VERSION_CBOR} (CBOR) and {VERSION_PROTOBUF} (protobuf) are understood \ + by the legacy decryptTxMetadata" + ))); + } let ciphertext = platform_encryption::encrypt_aes_256_cbc(key, iv, payload); let mut blob = Vec::with_capacity(BLOB_HEADER_LEN + ciphertext.len()); blob.push(version); blob.extend_from_slice(iv); blob.extend_from_slice(&ciphertext); - blob + Ok(blob) } /// The plaintext recovered from a stored `encryptedMetadata` blob. @@ -299,7 +319,7 @@ mod tests { let iv = [0x22u8; 16]; for version in [VERSION_CBOR, VERSION_PROTOBUF] { let payload = b"opaque protobuf TxMetadataBatch bytes".to_vec(); - let blob = seal_tx_metadata(&key, version, &iv, &payload); + let blob = seal_tx_metadata(&key, version, &iv, &payload).expect("valid version"); // Framing: version at [0], IV at [1..17), ciphertext after. assert_eq!(blob[0], version); assert_eq!(&blob[1..17], &iv); @@ -309,6 +329,31 @@ mod tests { } } + /// Rust-side wire-version guard (dashpay/platform#4091, findings + /// 9c0ce58c3bb7 / 79595960d201): `seal_tx_metadata` accepts only the two + /// versions the legacy `decryptTxMetadata` understands (0 = CBOR, 1 = + /// protobuf) and rejects everything else, so the guard holds even when a + /// caller bypasses the Kotlin `require` (e.g. through the FFI/JNI directly). + #[test] + fn seal_rejects_non_wire_versions() { + let key = [0x11u8; 32]; + let iv = [0x22u8; 16]; + let payload = b"opaque".to_vec(); + + // The two legal versions seal successfully. + assert!(seal_tx_metadata(&key, VERSION_CBOR, &iv, &payload).is_ok()); + assert!(seal_tx_metadata(&key, VERSION_PROTOBUF, &iv, &payload).is_ok()); + + // Every other byte (2..=255) is rejected — none can be produced by + // sealing, so a non-decodable document can never reach the wire. + for version in 2u8..=255 { + assert!( + seal_tx_metadata(&key, version, &iv, &payload).is_err(), + "version {version} must be rejected as non-wire-decodable" + ); + } + } + /// A wrong key can never recover the plaintext: PKCS7 rejects it (Err), or /// on the rare valid-padding collision the payload differs — never the /// original. Must not panic. @@ -318,7 +363,7 @@ mod tests { let wrong = [0x44u8; 32]; let iv = [0x55u8; 16]; let payload = b"secret memo".to_vec(); - let blob = seal_tx_metadata(&key, VERSION_PROTOBUF, &iv, &payload); + let blob = seal_tx_metadata(&key, VERSION_PROTOBUF, &iv, &payload).expect("valid version"); match open_tx_metadata(&wrong, &blob) { Err(_) => {} @@ -457,12 +502,14 @@ mod tests { let payload = b"external-signable round-trip".to_vec(); let iv = [0x66u8; 16]; - let sealed_by_resident = seal_tx_metadata(&resident_key, VERSION_PROTOBUF, &iv, &payload); + let sealed_by_resident = + seal_tx_metadata(&resident_key, VERSION_PROTOBUF, &iv, &payload).expect("valid version"); let opened_by_master = open_tx_metadata(&master_key, &sealed_by_resident).expect("master key opens"); assert_eq!(opened_by_master.payload, payload); - let sealed_by_master = seal_tx_metadata(&master_key, VERSION_PROTOBUF, &iv, &payload); + let sealed_by_master = + seal_tx_metadata(&master_key, VERSION_PROTOBUF, &iv, &payload).expect("valid version"); let opened_by_resident = open_tx_metadata(&resident_key, &sealed_by_master).expect("resident key opens"); assert_eq!(opened_by_resident.payload, payload); @@ -490,7 +537,8 @@ mod tests { let plaintext_block: [u8; 16] = hex_lit("6bc1bee22e409f96e93d7e117393172a"); let expected_ct_block1: [u8; 16] = hex_lit("f58c4c04d6e5f1ba779eabfb5f7bfbd6"); - let blob = seal_tx_metadata(&key, VERSION_PROTOBUF, &iv, &plaintext_block); + let blob = + seal_tx_metadata(&key, VERSION_PROTOBUF, &iv, &plaintext_block).expect("valid version"); // version ‖ IV ‖ ciphertext(2 blocks: data + PKCS7 pad). assert_eq!(blob.len(), 1 + 16 + 32, "1 version + 16 IV + 2 AES blocks"); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index cf8ee1e1b3..3f581d9330 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -340,7 +340,10 @@ impl IdentityWallet { })?; let mut iv = [0u8; 16]; thread_rng().fill_bytes(&mut iv); - let blob = seal_tx_metadata(&aes_key, version, &iv, payload); + // Rejects a non-wire-decodable version byte (only 0/1) before it can be + // sealed into a document the legacy stack can't decode + // (dashpay/platform#4091, findings 9c0ce58c3bb7 / 79595960d201). + let blob = seal_tx_metadata(&aes_key, version, &iv, payload)?; // Byte-array fields are accepted as hex strings by the generic create // path, which sanitizes them into `Bytes` against the schema. diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index f996a64d04..3f38606a33 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -793,8 +793,18 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do throw_sdk_exception(env, 1, "encryptionKeyIndex must be non-negative"); return ptr::null_mut(); } - if !(0..=255).contains(&version) { - throw_sdk_exception(env, 1, "version must be in 0..=255"); + // Only 0 (CBOR) and 1 (protobuf) are wire-decodable by the legacy dashj + // decryptTxMetadata; anything else seals a document the legacy stack + // can't read. Fail fast here with the correct bound instead of the stale + // 0..=255 range (dashpay/platform#4091, finding 79595960d201). The Rust + // core `seal_tx_metadata` enforces the same invariant as the last line + // of defense. + if !(0..=1).contains(&version) { + throw_sdk_exception( + env, + 1, + "version must be 0 (CBOR) or 1 (protobuf) — the only wire-decodable txMetadata versions", + ); return ptr::null_mut(); } let payload_bytes = match env.convert_byte_array(&payload) { From 8a7491235e94582891857a8b3d66e5c5255f7c53 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:00:07 -0400 Subject: [PATCH 11/30] docs(txmetadata): scope legacy wire-compat to identity_index=0; relabel nonzero vector as internal slot check (#4091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewer was right on both threads. The legacy dashj createTxMetadata flow has NO identity-index component — it always derives against the primary identity via blockchainIdentityECDSADerivationPath() (index 0) — so legacy wire-compat is only defined at identity_index=0, and no legacy wallet ever wrote a document keyed at a nonzero index. Apply option (a) — vector VALUES unchanged, provenance corrected: 1. legacy_dashj_wire_compat_vector (identity_index=0, 4a2e…84d7): document that the account prefix was verified against the REAL dashj DerivationPathFactory.blockchainIdentityECDSADerivationPath() (path m/9'/1'/5'/0'/0'/0'/keyId'/32769'/encryptionKeyIndex'), not mirrored back from Rust's own tx_metadata_derivation_path (finding dd246b5e17d0). 2. Rename legacy_dashj_wire_compat_vector_nonzero_identity_index -> nonzero_identity_index_derivation_slot_is_internally_consistent and rewrite its docs/asserts: the 8cda…5196 value is SELF-REFERENTIAL (LegacyKeyN.java hand-builds the same path Rust constructs; it does not call the real DerivationPathFactory), so it pins internal slot placement + resident/master agreement only — explicitly NOT a legacy wire-compat claim (finding 4c0754158cc6). 3. Add a doc note on derive_tx_metadata_key and the module header stating wire-compat holds only at identity_index=0. Correct LegacyKeyN.java's header/inline comments and the README to state the generator hand-builds the account path and that the nonzero vector is an internal consistency cross-check, not a legacy sample. cargo test -p platform-wallet --lib: 431 passed / 0 failed. Co-Authored-By: Claude Fable 5 --- .../src/wallet/identity/crypto/tx_metadata.rs | 151 +++++++++++++----- .../tests/legacy_wire_compat/LegacyKeyN.java | 24 ++- .../tests/legacy_wire_compat/README.md | 51 +++--- 3 files changed, 159 insertions(+), 67 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs index b3576b1ca8..7e55b3368d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs @@ -22,11 +22,22 @@ //! hardened children `/ 32769' / encryptionKeyIndex'`. In dashj terms: //! ` / keyIndex' / 32769' / encryptionKeyIndex'`. //! Rust's [`identity_auth_derivation_path_for_type`] reproduces the dashj -//! `blockchainIdentityECDSADerivationPath(keyIndex)` prefix for the primary +//! `blockchainIdentityECDSADerivationPath()` prefix for the primary //! identity (identity_index 0), so appending the two children reconstructs //! the exact legacy key. This is the SAME base-path machinery a registered //! identity's keys use, and the SAME extend-by-two-hardened-children shape //! as [`super::contact_info::derive_contact_info_keys`]. +//! +//! **Wire-compat holds only at `identity_index == 0`.** The legacy +//! `createTxMetadata` flow always derives against the wallet's PRIMARY +//! blockchain identity (`AuthenticationGroupExtension.getDefaultPath` calls +//! `blockchainIdentityECDSADerivationPath()` with no argument = index 0), so +//! the legacy scheme has NO identity-index component. Rust exposes an +//! `identity_index` parameter for forward compatibility, but only the +//! `identity_index == 0` derivation corresponds to a key any legacy wallet +//! ever wrote. See [`derive_tx_metadata_key`] and the +//! `legacy_dashj_wire_compat_vector` test (verified byte-for-byte against the +//! real dashj `DerivationPathFactory`). //! - **Cipher**: AES-256-CBC / PKCS7, random 16-byte IV (BouncyCastle //! `PaddedBufferedBlockCipher(CBCBlockCipher(AESEngine))` in the legacy stack). //! - **Stored `encryptedMetadata` blob layout** (the authoritative @@ -121,6 +132,20 @@ pub fn tx_metadata_derivation_path( /// is the raw private scalar at /// `identity_auth_path(identity_index, key_index) / 32769' / encryption_key_index'`. /// +/// ## Legacy wire-compat is guaranteed ONLY at `identity_index == 0` +/// +/// The legacy dashj `createTxMetadata` flow has no identity-index parameter — +/// it always derives against the primary blockchain identity +/// (`blockchainIdentityECDSADerivationPath()`, index 0). Only +/// `derive_tx_metadata_key(_, _, 0, key_index, enc)` reproduces a key a legacy +/// wallet could have written; it matches the real dashj-derived key +/// byte-for-byte (see `legacy_dashj_wire_compat_vector`, whose value was +/// checked against the actual `DerivationPathFactory`). A nonzero +/// `identity_index` derives a valid, deterministic, distinct key for THIS +/// stack's own future use, but it corresponds to no legacy-written document — +/// there is no legacy path that reaches it. Do not treat a nonzero-index key as +/// a cross-stack compatibility guarantee. +/// /// Requires a key-resident wallet (mnemonic / seed / xprv). An /// external-signable or watch-only wallet has no in-process private keys and /// fails here with `External signable wallet has no private key` — the caller @@ -562,14 +587,35 @@ mod tests { bytes.try_into().expect("length matches") } - /// **The wire-compat anchor**: an end-to-end vector generated by the ACTUAL - /// legacy stack (dash-sdk-kotlin 4.0.0-RC2 + dashj-core 22.0.3, run under a - /// JVM), proving the mnemonic→AES-key HD derivation AND the full + /// **The wire-compat anchor** (identity_index 0 — the ONLY point at which + /// legacy wire-compat is defined; see [`derive_tx_metadata_key`] and the + /// module docs): an end-to-end vector generated by the ACTUAL legacy stack + /// (dash-sdk-kotlin 4.0.0-RC2 + dashj-core 22.0.3, run under a JVM), proving + /// the mnemonic→AES-key HD derivation AND the full /// `version ‖ IV ‖ AES-256-CBC(payload)` envelope match dashj byte-for-byte. /// This pins the one piece static analysis of the jars alone could not (the /// derivation-path account prefix): it is now reconstructed exactly and /// checked in CI, so a future refactor that moves the path drifts loudly. /// + /// ## Provenance verified against the REAL `DerivationPathFactory` + /// + /// The account prefix here is not hand-asserted: the `4a2eaec1…` key was + /// re-derived by driving the actual dashj + /// `org.bitcoinj.wallet.DerivationPathFactory(TestNet3Params)` + /// `.blockchainIdentityECDSADerivationPath()` — the same method + /// `AuthenticationGroupExtension.getDefaultPath` feeds the + /// `BLOCKCHAIN_IDENTITY` key chain — and reading the `32769'` child straight + /// off `org.dashj.platform.contracts.wallet.TxMetadataDocument`, then + /// deriving `key = hierarchy.get(path, …).getPrivKeyBytes()`. The factory + /// chose the full path `m/9'/1'/5'/0'/0'/0'/keyId'/32769'/encryptionKeyIndex'` + /// (`keyId = 2`, `encryptionKeyIndex = 1`) independently of anything this + /// crate constructs, and it produced exactly `4a2eaec1…`. So this vector's + /// path is proven by the legacy library, not merely mirrored back from + /// Rust's own `tx_metadata_derivation_path` (dashpay/platform#4091, finding + /// dd246b5e17d0). Note the factory has NO identity-index argument — the + /// legacy tx-metadata path is fixed at the primary identity, which is why + /// wire-compat is defined here and only here. + /// /// ## How the vector was generated (reproducible) /// /// A JVM scratch program built the legacy key + blob for the BIP-39 test @@ -670,42 +716,56 @@ mod tests { ); } - /// **The nonzero-`identity_index` wire-compat anchor** (dashpay/platform#4091, - /// blocking review finding). The primary [`legacy_dashj_wire_compat_vector`] - /// pins `identity_index = 0`, but `KeyDerivationType::ECDSA` is also `0` and - /// sits at the path position immediately before `identity_index` + /// **Internal derivation-slot consistency at a nonzero `identity_index` — + /// NOT a legacy wire-compat claim** (dashpay/platform#4091, finding + /// 4c0754158cc6). This exercises that the `identity_index` parameter lands in + /// the correct path slot and is deterministic across both key sources, so a + /// refactor that dropped, swapped, or misplaced it would fail loudly. It does + /// NOT assert cross-stack compatibility, because the legacy stack has no + /// identity-index component: `createTxMetadata` always derives against the + /// primary identity (`blockchainIdentityECDSADerivationPath()`, index 0), so + /// NO legacy wallet ever wrote a document keyed at `identity_index = 1`. + /// Legacy wire-compat is proven separately and exclusively by + /// [`legacy_dashj_wire_compat_vector`] at index 0. + /// + /// Why index 0 alone can't cover the slot: `KeyDerivationType::ECDSA` is also + /// `0` and sits immediately before `identity_index` /// (`base / key_type' / identity_index' / key_index' / …`, see /// [`identity_auth_derivation_path_for_type`]), so at index 0 those two - /// adjacent `0'` components are indistinguishable — that vector would pass - /// even if `identity_index` were dropped, swapped, or misplaced. This vector - /// uses `identity_index = 1` so the derived path - /// `m/9'/1'/5'/0'/0'/1'/2'/32769'/1'` differs from the index-0 path in - /// exactly the `identity_index` component, and the resulting legacy key - /// (`8cda…5196`) is provably distinct from the index-0 key (`4a2e…84d7`) — - /// empirically proving the component is wired to the correct path slot. + /// adjacent `0'` components are indistinguishable. Using `identity_index = 1` + /// makes the path `m/9'/1'/5'/0'/0'/1'/2'/32769'/1'` differ from the index-0 + /// path in exactly that component, and the resulting key (`8cda…5196`) is + /// provably distinct from the index-0 key (`4a2e…84d7`). /// - /// ## How the vector was generated (reproducible) + /// ## Provenance of the `8cda…5196` value: SELF-REFERENTIAL /// - /// Same real legacy stack (dash-sdk-kotlin 4.0.0-RC2 semantics + dashj-core - /// 22.0.3, run under a JVM) as [`legacy_dashj_wire_compat_vector`], via the - /// checked-in generator `LegacyKeyN.java` (see this crate's - /// `tests/legacy_wire_compat/README.md`): + /// This value is generated by `LegacyKeyN.java` (see + /// `tests/legacy_wire_compat/README.md`), which HAND-BUILDS the account path + /// `m/9'/1'/5'/0'/0'/identityIndex'` — it does NOT call the real dashj + /// `DerivationPathFactory` (contrast [`legacy_dashj_wire_compat_vector`], + /// whose index-0 path the factory itself chose). So for a nonzero index the + /// generator merely re-derives, under dashj-core's raw `HDKeyDerivation`, the + /// very path this crate's `tx_metadata_derivation_path` constructs. It + /// confirms Rust and dashj-core agree on the key for a given path — an + /// internal consistency check — but supplies no independent evidence that any + /// legacy platform code selects that path. Treat `8cda…5196` as a regression + /// pin on Rust's own slot placement, not a legacy sample. /// /// ```text /// javac -cp LegacyKeyN.java /// java -cp .: LegacyKeyN 1 2 1 - /// fullPath=m/9'/1'/5'/0'/0'/1'/2'/32769'/1' + /// fullPath=m/9'/1'/5'/0'/0'/1'/2'/32769'/1' (hand-built, not from the factory) /// AES_KEY=8cdadb6b8bcf8defd416f2f032255173df89478c971bb96ae9f3511aae355196 /// BLOB=01496ce7…2cba627383 (random per run — the IV differs; key is fixed) /// ``` /// - /// `identity_index = 1` (the wallet's second Platform identity), `key_index` - /// (keyId) `2` (ENCRYPTION/MEDIUM), `encryptionKeyIndex` `1`. The BIP-39 test - /// mnemonic `abandon abandon … about`, empty passphrase, Testnet. The key is - /// deterministic; the blob's IV is fresh `SecureRandom` per generation, so - /// the exact blob bytes below are one captured run (any IV opens fine). + /// `identity_index = 1`, `key_index` (keyId) `2` (ENCRYPTION/MEDIUM), + /// `encryptionKeyIndex` `1`. The BIP-39 test mnemonic `abandon abandon … + /// about`, empty passphrase, Testnet. The key is deterministic; the blob's IV + /// is fresh `SecureRandom` per generation, so the exact blob bytes below are + /// one captured run (any IV opens fine). #[test] - fn legacy_dashj_wire_compat_vector_nonzero_identity_index() { + fn nonzero_identity_index_derivation_slot_is_internally_consistent() { use key_wallet::mnemonic::{Language, Mnemonic}; const PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon \ @@ -718,27 +778,30 @@ mod tests { ) .expect("wallet from mnemonic"); - // identity_index 1 (a NON-primary identity), key_index 2, encryptionKeyIndex 1. + // identity_index 1 (a NON-primary slot), key_index 2, encryptionKeyIndex 1. let key = derive_tx_metadata_key(&wallet, Network::Testnet, 1, 2, 1).expect("derive"); - // The AES key dashj derived at m/9'/1'/5'/0'/0'/1'/2'/32769'/1'. - let legacy_key: [u8; 32] = + // The key at the hand-built path m/9'/1'/5'/0'/0'/1'/2'/32769'/1'. This + // is a self-referential cross-check (LegacyKeyN.java re-derives the same + // path Rust constructs), NOT a legacy-written sample — see the doc above. + let slot1_key: [u8; 32] = hex_lit("8cdadb6b8bcf8defd416f2f032255173df89478c971bb96ae9f3511aae355196"); - // Distinct from the identity_index=0 key — the whole point of this vector. + // Distinct from the identity_index=0 key — proves the slot is exercised. let index0_key: [u8; 32] = hex_lit("4a2eaec1ad959105738996b49e0327f96a80b765249d2c9af8cf6aa689aa84d7"); assert_ne!( - legacy_key, index0_key, - "identity_index=1 must derive a different key than identity_index=0" + slot1_key, index0_key, + "identity_index=1 must derive a different key than identity_index=0 \ + (the identity_index component must occupy its own path slot)" ); assert_eq!( - *key, legacy_key, - "nonzero-identity_index tx-metadata HD key must match the legacy dashj stack \ - byte-for-byte (proves identity_index is wired to the correct path slot)" + *key, slot1_key, + "derivation at identity_index=1 must be deterministic and match the \ + hand-built dashj-core path (internal slot-consistency pin, not legacy wire-compat)" ); // The resolver-master path (on-device external-signable shape) must hit - // the same dashj key at the nonzero identity_index too. + // the same key at this slot too — resident and master must never drift. let master = ExtendedPrivKey::new_master( Network::Testnet, &Mnemonic::from_phrase(PHRASE, Language::English) @@ -750,23 +813,25 @@ mod tests { derive_tx_metadata_key_from_master(&master, Network::Testnet, 1, 2, 1) .expect("master derive"); assert_eq!( - *key_via_master, legacy_key, - "resolver-master derivation must match the legacy dashj stack at identity_index=1" + *key_via_master, slot1_key, + "resolver-master derivation must match the resident derivation at identity_index=1" ); - // The full stored blob dashj produced at this slot — Rust must open it. - let legacy_blob = hex::decode( + // A blob sealed under this slot's key must round-trip through open — the + // cipher/framing works identically at any slot (blob captured from the + // same generator; any IV opens fine). + let slot1_blob = hex::decode( "01496ce7b7aa8baa910eb278dc38aee86522e841414d7b273da86df2106b0548e\ ee7b6957bb1789512cd00bf90663690cae4202bd1f9ae5f84859b8d2cba627383", ) .expect("valid hex"); let expected_plaintext = b"legacy-txmetadata-wire-compat-vector".to_vec(); - let opened = open_tx_metadata(&key, &legacy_blob).expect("open legacy blob"); + let opened = open_tx_metadata(&key, &slot1_blob).expect("open slot-1 blob"); assert_eq!(opened.version, VERSION_PROTOBUF, "version byte"); assert_eq!( opened.payload, expected_plaintext, - "Rust must decrypt a dashj-produced nonzero-identity_index blob to the plaintext" + "Rust must decrypt a blob sealed at identity_index=1 to the original plaintext" ); } } diff --git a/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyKeyN.java b/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyKeyN.java index c8f57d8ad4..b97bb75f41 100644 --- a/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyKeyN.java +++ b/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyKeyN.java @@ -2,12 +2,23 @@ import org.bitcoinj.crypto.*; /** - * Wire-compat vector generator for the Kotlin-SDK txMetadata migration. - * Reproduces the legacy org.dashj.platform / dashj-core createTxMetadata - * key derivation + AES-256-CBC envelope for a given identity slot. + * txMetadata key/blob generator for the Kotlin-SDK migration tests. + * + * IMPORTANT — provenance caveat: this generator HAND-BUILDS the account path + * m/9'/1'/5'/0'/0'/ below (see the explicit ChildNumber.add + * calls). It does NOT call the real dashj DerivationPathFactory + * .blockchainIdentityECDSADerivationPath(). At identityIndex 0 the hand-built + * path coincides with the factory's output (independently confirmed against the + * real factory — see the `legacy_dashj_wire_compat_vector` Rust test), so the + * index-0 key IS a genuine legacy wire-compat anchor. At a NONZERO identityIndex + * it merely re-derives, under dashj-core's raw HDKeyDerivation, the same path the + * Rust `tx_metadata_derivation_path` constructs — a SELF-REFERENTIAL internal + * consistency check, not proof that any legacy platform code selects that path. + * The legacy createTxMetadata flow has no identity-index component (it always + * uses the primary identity), so no legacy document is keyed at identityIndex>0. * * Args: - * (blockchainIdentityECDSADerivationPath = m/9'/1'/5'/0'//) + * (hand-built account path = m/9'/1'/5'/0'//) */ public class LegacyKeyN { static String hex(byte[] b){ StringBuilder s=new StringBuilder(); for(byte x:b) s.append(String.format("%02x",x)); return s.toString(); } @@ -24,9 +35,12 @@ public static void main(String[] a) throws Exception { DeterministicKey root = HDKeyDerivation.createMasterPrivateKey(seed); DeterministicHierarchy h = new DeterministicHierarchy(root); - // blockchainIdentityECDSADerivationPath(testnet) for identity `identityIndex`: + // Hand-built account path mirroring blockchainIdentityECDSADerivationPath's + // SHAPE (NOT a call to the real DerivationPathFactory — see class doc): // FEATURE_PURPOSE=9', coinType(testnet)=1', FEATURE_PURPOSE_IDENTITIES=5', // 0' (subfeature), 0' (keyType=ECDSA), identityIndex' + // At identityIndex=0 this equals the factory output; at >0 it is only a + // self-referential re-derivation of the Rust-constructed path. List accountPath = new ArrayList<>(); accountPath.add(new ChildNumber(9, true)); accountPath.add(new ChildNumber(1, true)); diff --git a/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md b/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md index 41f90351ee..86623f30f0 100644 --- a/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md +++ b/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md @@ -1,29 +1,42 @@ # Legacy txMetadata wire-compat vector generator `LegacyKeyN.java` is the reproducible JVM generator behind the hard-coded -cross-stack vectors in +vectors in `src/wallet/identity/crypto/tx_metadata.rs` (`legacy_dashj_wire_compat_vector` and -`legacy_dashj_wire_compat_vector_nonzero_identity_index`). +`nonzero_identity_index_derivation_slot_is_internally_consistent`). -It runs the **actual legacy `org.dashj.platform` / dashj-core stack** — the same -`HDKeyDerivation`, `blockchainIdentityECDSADerivationPath` constants, +It runs dashj-core's cryptographic primitives — the same `HDKeyDerivation`, `KeyCrypterAESCBC.deriveKey/encrypt`, and `createTxMetadata` blob framing that -dash-sdk-kotlin 4.0.0-RC2 used — so the Rust `derive_tx_metadata_key` / -`seal_tx_metadata` implementations can be pinned against a value the Rust code -did not itself produce. This is what makes the wire-compat guarantee auditable -rather than self-referential (dashpay/platform#4091 review). - -## Why a nonzero `identity_index` vector exists - -The Rust path is `base / key_type' / identity_index' / key_index' / 32769' / -encryption_key_index'` and `KeyDerivationType::ECDSA == 0`. At -`identity_index = 0` the `key_type'` and `identity_index'` components are both -`0'` and adjacent, so an index-0-only vector cannot distinguish a correctly -placed `identity_index` from one that was dropped or swapped. The -`identity_index = 1` vector (`m/9'/1'/5'/0'/0'/1'/2'/32769'/1'`) derives a -provably different key (`8cda…5196` vs the index-0 `4a2e…84d7`), exercising that -component directly. +dash-sdk-kotlin 4.0.0-RC2 used — but it **hand-builds the account path** rather +than calling the real `DerivationPathFactory.blockchainIdentityECDSADerivationPath()`. + +## What each vector proves (and what it does NOT) + +- **`legacy_dashj_wire_compat_vector` (identity_index 0) — a genuine legacy + wire-compat anchor.** The index-0 account path + `m/9'/1'/5'/0'/0'/0'/keyId'/32769'/encryptionKeyIndex'` was independently + confirmed to equal the output of the REAL dashj `DerivationPathFactory` + (driven directly, with `32769'` read straight off + `TxMetadataDocument`) — so the `4a2e…84d7` key is pinned against a path the + legacy library itself chose, not one this repo constructed. This is the sole + point at which legacy wire-compat is defined: the legacy `createTxMetadata` + flow has NO identity-index component (it always derives against the primary + identity), so identity_index 0 is the only slot a legacy wallet ever wrote. + +- **`nonzero_identity_index_derivation_slot_is_internally_consistent` + (identity_index 1) — a SELF-REFERENTIAL internal check, NOT a wire-compat + claim.** `KeyDerivationType::ECDSA == 0` sits immediately before + `identity_index'` in `base / key_type' / identity_index' / key_index' / + 32769' / encryption_key_index'`, so at index 0 the two adjacent `0'` + components are indistinguishable. The `identity_index = 1` vector + (`m/9'/1'/5'/0'/0'/1'/2'/32769'/1'`) derives a provably different key + (`8cda…5196` vs `4a2e…84d7`), exercising that the component occupies its own + slot. But because the generator hand-builds this path (the same one Rust's + `tx_metadata_derivation_path` constructs), the value is a cross-check of + Rust ⟷ dashj-core HD derivation for a path THIS repo picked — not evidence + that any legacy platform code selects it. No legacy document is keyed at + identity_index > 0. ## Reproduce From 5e5220d51e23a2659f87de07d1f87788779fb40b Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:33:04 -0400 Subject: [PATCH 12/30] test(txmetadata): check in a real-DerivationPathFactory provenance verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses blocker 989be307db0f on dashpay/platform#4091 (its still-open core ask: "check in the actual JVM repro script/tool for independent verification"). b7319b58d0 corrected the vectors' provenance in prose (scoped wire-compat to identity_index=0; relabeled the nonzero vector as an internal slot check), but the "confirmed against the REAL dashj DerivationPathFactory" claim was still only asserted — LegacyKeyN.java hand-builds its account path and never drives the factory, so a maintainer could not reproduce the equality from checked-in code. Adds LegacyDerivationPathCheck.java: it drives the real org.bitcoinj.wallet.DerivationPathFactory (dashj-core 22.0.3, Testnet) and asserts its primary-identity path — blockchainIdentityECDSADerivationPath() (no-arg) = m/9'/1'/5'/0'/0'/0' — equals LegacyKeyN's hand-built account path at identity_index 0, printing WIRE_COMPAT_ANCHOR_OK = true (verified: true). It also prints the factory's INDEXED overload m/9'/1'/5'/0'/0'/0'/i' beside the hand-built nonzero path m/9'/1'/5'/0'/0'/i', making the shape difference visible so the nonzero vector is self-evidently NOT a factory-produced legacy sample (cross-refs dd246b5e17d0 / 4c0754158cc6). README: document the verifier + run command, and add the missing de.sfuhrm/saphir-hash-core/3.0.10 jar (TestNet3Params.get() needs X11 genesis-block hashing — the factory path fails with NoClassDefFoundError without it; LegacyKeyN alone never touches network params so it was omitted before). Verified end to end: LegacyDerivationPathCheck 0 -> WIRE_COMPAT_ANCHOR_OK=true; LegacyKeyN 0 2 1 -> 4a2e…84d7 and LegacyKeyN 1 2 1 -> 8cda…5196, both matching the hard-coded Rust vectors exactly. Co-Authored-By: Claude Fable 5 --- .../LegacyDerivationPathCheck.java | 80 +++++++++++++++++++ .../tests/legacy_wire_compat/README.md | 56 +++++++++---- 2 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java diff --git a/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java b/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java new file mode 100644 index 0000000000..ea978ae252 --- /dev/null +++ b/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java @@ -0,0 +1,80 @@ +import java.util.*; +import org.bitcoinj.crypto.ChildNumber; +import org.bitcoinj.params.TestNet3Params; +import org.bitcoinj.wallet.DerivationPathFactory; + +/** + * Provenance verifier for the txMetadata wire-compat vectors. + * + * `LegacyKeyN.java` HAND-BUILDS its account path and only asserts, in prose, + * that at identityIndex 0 that path equals the real dashj factory's output. + * This tool makes that assertion INDEPENDENTLY REPRODUCIBLE: it drives the + * REAL `org.bitcoinj.wallet.DerivationPathFactory` (the same class the legacy + * dash-sdk-kotlin identity-key chain uses) and compares its output to the + * hand-built path, so a maintainer can confirm the wire-compat anchor without + * trusting either this repo's prose or an AI agent's word (dashpay/platform#4091, + * findings 989be307db0f / dd246b5e17d0 / 4c0754158cc6). + * + * Empirically (dashj-core 22.0.3, Testnet): + * noArg blockchainIdentityECDSADerivationPath() = m/9'/1'/5'/0'/0'/0' (6 components) + * int(i) blockchainIdentityECDSADerivationPath(i) = m/9'/1'/5'/0'/0'/0'/i' (7 components) + * + * The legacy `createTxMetadata` flow derives against the PRIMARY identity — the + * NO-ARG method — so the legacy tx-metadata key path is + * `noArg / keyId' / 32769' / encryptionKeyIndex'`, and identityIndex 0 is the + * only slot a legacy wallet ever wrote. At identityIndex 0 the hand-built path + * `m/9'/1'/5'/0'/0'/0'` equals `noArg` exactly (`WIRE_COMPAT_ANCHOR_OK=true` + * below) — that is what makes `legacy_dashj_wire_compat_vector` a genuine + * anchor. + * + * Note the factory's INDEXED overload `int(i)` is a DIFFERENT SHAPE from + * LegacyKeyN's hand-built nonzero path `m/9'/1'/5'/0'/0'/i'` (the factory keeps + * the primary-identity `0'` and appends `i'`; LegacyKeyN overwrites the last + * component). They are printed side by side so it is obvious the nonzero + * LegacyKeyN vector is NOT a factory-verified legacy sample — it is only the + * self-referential internal cross-check that + * `nonzero_identity_index_derivation_slot_is_internally_consistent` documents. + * + * Args: [identityIndex] (default 0) + */ +public class LegacyDerivationPathCheck { + static String p(List l) { + StringBuilder s = new StringBuilder("m"); + for (ChildNumber c : l) s.append("/").append(c); + return s.toString(); + } + + static List handBuilt(int identityIndex) { + // Byte-for-byte the account path LegacyKeyN.java constructs. + return new ArrayList<>(Arrays.asList( + new ChildNumber(9, true), + new ChildNumber(1, true), // coinType = Testnet + new ChildNumber(5, true), // FEATURE_PURPOSE_IDENTITIES + new ChildNumber(0, true), // subfeature + new ChildNumber(0, true), // keyType = ECDSA = 0 + new ChildNumber(identityIndex, true))); // identity index + } + + public static void main(String[] a) { + int identityIndex = a.length > 0 ? Integer.parseInt(a[0]) : 0; + DerivationPathFactory f = DerivationPathFactory.get(TestNet3Params.get()); + + List noArg = f.blockchainIdentityECDSADerivationPath(); + List indexed = f.blockchainIdentityECDSADerivationPath(identityIndex); + List hand = handBuilt(identityIndex); + + System.out.println("identityIndex = " + identityIndex); + System.out.println("factory noArg() = " + p(noArg)); + System.out.println("factory int(index) = " + p(indexed)); + System.out.println("LegacyKeyN hand-built = " + p(hand)); + // The load-bearing check: the wire-compat anchor is the PRIMARY-identity + // (no-arg) path, and LegacyKeyN reproduces it exactly at index 0. + boolean anchorOk = noArg.equals(handBuilt(0)); + System.out.println("WIRE_COMPAT_ANCHOR_OK = " + anchorOk + + " (noArg factory == LegacyKeyN hand-built at identityIndex 0)"); + if (!anchorOk) { + System.err.println("PROVENANCE MISMATCH: the wire-compat anchor no longer holds"); + System.exit(1); + } + } +} diff --git a/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md b/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md index 86623f30f0..ec22a2d6f1 100644 --- a/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md +++ b/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md @@ -1,15 +1,21 @@ # Legacy txMetadata wire-compat vector generator -`LegacyKeyN.java` is the reproducible JVM generator behind the hard-coded -vectors in +Two checked-in JVM tools back the hard-coded vectors in `src/wallet/identity/crypto/tx_metadata.rs` (`legacy_dashj_wire_compat_vector` and -`nonzero_identity_index_derivation_slot_is_internally_consistent`). +`nonzero_identity_index_derivation_slot_is_internally_consistent`): -It runs dashj-core's cryptographic primitives — the same `HDKeyDerivation`, -`KeyCrypterAESCBC.deriveKey/encrypt`, and `createTxMetadata` blob framing that -dash-sdk-kotlin 4.0.0-RC2 used — but it **hand-builds the account path** rather -than calling the real `DerivationPathFactory.blockchainIdentityECDSADerivationPath()`. +- **`LegacyKeyN.java`** — the reproducible key/blob *generator*. It runs + dashj-core's cryptographic primitives — the same `HDKeyDerivation`, + `KeyCrypterAESCBC.deriveKey/encrypt`, and `createTxMetadata` blob framing that + dash-sdk-kotlin 4.0.0-RC2 used — but it **hand-builds the account path** rather + than calling the real `DerivationPathFactory.blockchainIdentityECDSADerivationPath()`. +- **`LegacyDerivationPathCheck.java`** — the provenance *verifier*. It drives the + REAL `org.bitcoinj.wallet.DerivationPathFactory` and confirms that + `LegacyKeyN`'s hand-built account path equals the factory's output at + identityIndex 0, so the wire-compat anchor is independently reproducible from + checked-in code — not just asserted in prose (dashpay/platform#4091, findings + 989be307db0f / dd246b5e17d0 / 4c0754158cc6). ## What each vector proves (and what it does NOT) @@ -17,12 +23,17 @@ than calling the real `DerivationPathFactory.blockchainIdentityECDSADerivationPa wire-compat anchor.** The index-0 account path `m/9'/1'/5'/0'/0'/0'/keyId'/32769'/encryptionKeyIndex'` was independently confirmed to equal the output of the REAL dashj `DerivationPathFactory` - (driven directly, with `32769'` read straight off - `TxMetadataDocument`) — so the `4a2e…84d7` key is pinned against a path the - legacy library itself chose, not one this repo constructed. This is the sole - point at which legacy wire-compat is defined: the legacy `createTxMetadata` - flow has NO identity-index component (it always derives against the primary - identity), so identity_index 0 is the only slot a legacy wallet ever wrote. + (driven directly, with `32769'` read straight off `TxMetadataDocument`) — so + the `4a2e…84d7` key is pinned against a path the legacy library itself chose, + not one this repo constructed. **Run `LegacyDerivationPathCheck` (below) to + reproduce that equality yourself**: it prints + `WIRE_COMPAT_ANCHOR_OK = true` when the factory's primary-identity + (`blockchainIdentityECDSADerivationPath()`, no-arg = `m/9'/1'/5'/0'/0'/0'`) + path matches `LegacyKeyN`'s hand-built account path at identity_index 0. This + is the sole point at which legacy wire-compat is defined: the legacy + `createTxMetadata` flow has NO identity-index component (it always derives + against the primary identity via the no-arg method), so identity_index 0 is + the only slot a legacy wallet ever wrote. - **`nonzero_identity_index_derivation_slot_is_internally_consistent` (identity_index 1) — a SELF-REFERENTIAL internal check, NOT a wire-compat @@ -47,16 +58,31 @@ Classpath jars come from the Gradle module cache - `org.bouncycastle/bcprov-jdk18on/1.80/…/bcprov-jdk18on-1.80.jar` - `com.google.guava/guava/30.0-jre/…/guava-30.0-jre.jar` - `org.slf4j/slf4j-api/1.7.30/…/slf4j-api-1.7.30.jar` +- `de.sfuhrm/saphir-hash-core/3.0.10/…/saphir-hash-core-3.0.10.jar` + (X11 genesis-block hashing; needed by `LegacyDerivationPathCheck`'s + `TestNet3Params.get()`, not by `LegacyKeyN`) ```sh -CP="dashj-core-22.0.3.jar:bcprov-jdk18on-1.80.jar:guava-30.0-jre.jar:slf4j-api-1.7.30.jar" -javac -cp "$CP" LegacyKeyN.java +CP="dashj-core-22.0.3.jar:bcprov-jdk18on-1.80.jar:guava-30.0-jre.jar:slf4j-api-1.7.30.jar:saphir-hash-core-3.0.10.jar" + +# 1. Verify provenance: the hand-built path IS the real dashj factory path at +# identity_index 0 (prints WIRE_COMPAT_ANCHOR_OK = true). +javac -cp "$CP" LegacyDerivationPathCheck.java +java -cp ".:$CP" LegacyDerivationPathCheck 0 +# 2. Regenerate the key/blob vectors. +javac -cp "$CP" LegacyKeyN.java # args: java -cp ".:$CP" LegacyKeyN 0 2 1 # -> AES_KEY=4a2e…84d7 (index-0 vector) java -cp ".:$CP" LegacyKeyN 1 2 1 # -> AES_KEY=8cda…5196 (index-1 vector) ``` +`LegacyDerivationPathCheck` also prints the factory's INDEXED overload +`blockchainIdentityECDSADerivationPath(i)` = `m/9'/1'/5'/0'/0'/0'/i'` beside +`LegacyKeyN`'s hand-built nonzero path `m/9'/1'/5'/0'/0'/i'`, making the shape +difference visible: the nonzero `LegacyKeyN` vector is NOT a factory-produced +legacy sample, only the self-referential internal cross-check documented above. + `AES_KEY` is deterministic for a given `(identityIndex, keyId, encryptionKeyIndex)`; `BLOB` embeds a fresh `SecureRandom` IV per run, so its bytes differ each invocation while any produced blob still opens under the key From 194ec860b6ee80c6e1ac216fcd899ca80e3e551f Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:00:00 -0400 Subject: [PATCH 13/30] fix(kotlin-sdk): gate encrypted-document create/fetch through the TeardownGate createEncryptedDocument and fetchEncryptedDocuments borrow the wallet / mnemonic-resolver / signer handles but opened with plain withContext(Dispatchers.IO), bypassing the TeardownGate: a concurrent wallet shutdown could free the borrowed native handles mid-call (freed-handle UB) and the source-scanning GateCoverageLintTest.everyHandleBorrowingSuspendFunIsGated failed on both. Both now open with gate.op { } like their six sibling document methods (gate.op already runs the body on Dispatchers.IO, so the bodies are unchanged). Dropped the now-unused Dispatchers/withContext imports. GateCoverageLintTest green; full :sdk:testDebugUnitTest suite green (174 tests, 0 failures). Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/documents/DocumentTransactions.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt index 7a96927fbb..f763dd3dae 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt @@ -1,9 +1,6 @@ package org.dashfoundation.dashsdk.documents import org.dashfoundation.dashsdk.wallet.op - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.TransactionsNative @@ -290,7 +287,7 @@ class DocumentTransactions internal constructor( version: Int, payload: ByteArray, signerHandle: Long, - ): String = withContext(Dispatchers.IO) { + ): String = gate.op { require(ownerId.size == 32) { "ownerId must be 32 bytes" } require(contractId.size == 32) { "contractId must be 32 bytes" } require(encryptionKeyIndex >= 0) { @@ -349,7 +346,7 @@ class DocumentTransactions internal constructor( contractId: ByteArray, documentType: String, sinceMs: Long, - ): String = withContext(Dispatchers.IO) { + ): String = gate.op { require(ownerId.size == 32) { "ownerId must be 32 bytes" } require(contractId.size == 32) { "contractId must be 32 bytes" } require(sinceMs >= 0) { "sinceMs must be non-negative, got $sinceMs" } From d456822a2562d73609bd865d378aede362f99aaa Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:12:46 -0400 Subject: [PATCH 14/30] fix(platform-wallet): pre-check txMetadata payload size before derivation/network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seal_tx_metadata had no client-side size check: a payload too large for the encryptedMetadata field (maxItems 4096) derived the key and sealed only to be rejected at broadcast with an opaque DPP schema error. Add a typed PlatformWalletError::TxMetadataPayloadTooLarge { len, max } and a shared ensure_tx_metadata_payload_fits() precheck. It runs FIRST in prepare_encrypted_txmetadata_properties (before resolve/derive/broadcast) so an over-large batch fails fast with the length + accepted max, and again inside seal_tx_metadata as the choke-point last line of defense. The FFI maps the new variant to ErrorInvalidParameter (already mirrored in Swift/Kotlin — no new numeric code), so it surfaces sensibly across JNI/FFI with the typed Display. True envelope math, derived from the code (not hardcoded): the blob is version(1) + IV(16) + AES-256-CBC/PKCS7(plaintext). PKCS7 always adds a full block when the plaintext is block-aligned, so ciphertext = 16*(L/16 + 1) and blob = 17 + that. The largest ciphertext that fits 4096 is ((4096-17)/16)*16 = 254*16 = 4064, and since PKCS7 spends >=1 byte on padding the max plaintext is one less -> MAX_TX_METADATA_PLAINTEXT_LEN = 4063. That plaintext frames to a 4081-byte blob (NOT 4096 as the review's arithmetic stated); 4064 jumps to 4097 and is the first rejected length. The 4063/4064 boundary itself matches the reviewer; the "4063 -> 4096" envelope size does not (see the new boundary test, which pins the real 4081-byte blob). Boundary test seal_rejects_payload_above_size_limit: 4063 seals (blob == 4081, round-trips), 4064 rejected as TxMetadataPayloadTooLarge, and the standalone precheck agrees at the boundary. Also strips the co-located "finding " tracker tokens from the comments in these two files (rationale text kept); they move to the PR description. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/error.rs | 9 ++ packages/rs-platform-wallet/src/error.rs | 15 ++ .../src/wallet/identity/crypto/tx_metadata.rs | 130 +++++++++++++++++- .../identity/network/encrypted_document.rs | 16 ++- 4 files changed, 159 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 7d01177b5b..7f7b563eee 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -329,6 +329,15 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AssetLockFundingMismatch { .. } => { PlatformWalletFFIResultCode::ErrorAssetLockFundingMismatch } + // A txMetadata plaintext too large to seal into the encryptedMetadata + // field. Surfaced as a caller-input error (the payload parameter is + // out of range) rather than flattening to ErrorUnknown; the typed + // Display carries the supplied length and the accepted maximum. Maps + // to the already-mirrored ErrorInvalidParameter so no new numeric + // code churns the Swift/Kotlin mirror enums. + PlatformWalletError::TxMetadataPayloadTooLarge { .. } => { + PlatformWalletFFIResultCode::ErrorInvalidParameter + } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; PlatformWalletFFIResult::err(code, error.to_string()) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 6e11514bea..779439f6ed 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -32,6 +32,21 @@ pub enum PlatformWalletError { #[error("Invalid identity data: {0}")] InvalidIdentityData(String), + /// A `txMetadata` plaintext payload is too large to seal into a document + /// that fits the `encryptedMetadata` byteArray field (`maxItems` 4096). The + /// `version(1) ‖ IV(16) ‖ AES-256-CBC/PKCS7(plaintext)` envelope caps the + /// plaintext at [`crate::wallet::identity::crypto::tx_metadata::MAX_TX_METADATA_PLAINTEXT_LEN`] + /// bytes; anything larger would derive the key and seal only to be rejected + /// at broadcast with an opaque DPP schema error, so the caller is rejected + /// HERE — before any key derivation or network work. `max` is the largest + /// accepted plaintext length and `len` is what was supplied. + #[error( + "txMetadata payload is {len} bytes; the encryptedMetadata field caps the \ + plaintext at {max} bytes (version + IV + PKCS7 envelope must fit the \ + 4096-byte field). Reduce the batch and retry." + )] + TxMetadataPayloadTooLarge { len: usize, max: usize }, + #[error("Failed to persist state: {0}")] /// A persister `store(...)` round failed. Returned (not swallowed) by /// user-initiated writes whose loss leaves a silent, non-self-healing diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs index 7e55b3368d..a60e9f7346 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs @@ -92,6 +92,63 @@ const BLOB_HEADER_LEN: usize = 1 + 16; /// AES block size — the ciphertext must be a non-zero multiple of this. const AES_BLOCK_LEN: usize = 16; +/// `maxItems` of the wallet-utils contract's `encryptedMetadata` byteArray +/// field: the stored blob must not exceed this or the document is rejected by +/// DPP schema validation at broadcast. +const ENCRYPTED_METADATA_FIELD_MAX: usize = 4096; + +/// The largest plaintext payload [`seal_tx_metadata`] can accept while keeping +/// the stored blob within the [`ENCRYPTED_METADATA_FIELD_MAX`]-byte field limit. +/// +/// The blob is `version(1) ‖ IV(16) ‖ AES-256-CBC/PKCS7(plaintext)`. PKCS7 +/// **always** appends a full padding block when the plaintext is block-aligned, +/// so the ciphertext length for a plaintext of `L` bytes is +/// `16 * (L / 16 + 1)` — the next multiple of 16 strictly greater than `L`. +/// The blob length is therefore `17 + 16 * (L / 16 + 1)`. +/// +/// Derived (not hardcoded) from the field limit so the boundary stays correct +/// if the framing ever changes: the largest ciphertext that fits is +/// `((4096 - 17) / 16) * 16 = 254 * 16 = 4064` bytes, and because PKCS7 spends +/// at least one byte of the final block on padding, the largest plaintext is one +/// less — **4063**. That plaintext frames to `17 + 4064 = 4081` bytes, which +/// fits. `L = 4064` is block-aligned, so PKCS7 adds a whole 16-byte block → +/// ciphertext 4080 → blob `17 + 4080 = 4097`, which overflows. So 4063 is the +/// true maximum and 4064 the first rejected length. +/// +/// Note the envelope for the maximum plaintext is 4081 bytes, not 4096: the +/// gap 4082..=4096 is unreachable because the next plaintext byte (4064) forces +/// a fresh padding block that jumps straight to 4097. (The 4063/4064 boundary +/// itself matches the reviewer's figure; the "4063 → 4096" envelope size in the +/// review does not — see the module tests, which pin the real 4081-byte blob.) +pub const MAX_TX_METADATA_PLAINTEXT_LEN: usize = { + // Largest whole ciphertext (a multiple of the AES block) that still fits + // the field alongside the version+IV header. + let max_ciphertext = ((ENCRYPTED_METADATA_FIELD_MAX - BLOB_HEADER_LEN) / AES_BLOCK_LEN) + * AES_BLOCK_LEN; + // PKCS7 always consumes ≥ 1 byte of the final block for padding, so the + // plaintext is at most one byte short of that ciphertext length. + max_ciphertext - 1 +}; + +/// Reject a `txMetadata` plaintext that cannot fit the `encryptedMetadata` +/// field once sealed, BEFORE any key derivation or network work. +/// +/// Callers on the create path (`prepare_encrypted_txmetadata_properties`, and +/// the FFI/JNI entry points) run this first so an over-large batch fails with a +/// typed [`PlatformWalletError::TxMetadataPayloadTooLarge`] up front instead of +/// deriving the key, sealing, and dying at broadcast with an opaque DPP schema +/// error. [`seal_tx_metadata`] also enforces it as the choke-point last line of +/// defense. +pub fn ensure_tx_metadata_payload_fits(payload_len: usize) -> Result<(), PlatformWalletError> { + if payload_len > MAX_TX_METADATA_PLAINTEXT_LEN { + return Err(PlatformWalletError::TxMetadataPayloadTooLarge { + len: payload_len, + max: MAX_TX_METADATA_PLAINTEXT_LEN, + }); + } + Ok(()) +} + /// Build the full tx-metadata key derivation path /// `identity_auth_path(identity_index, key_index) / 32769' / encryption_key_index'` /// — the single path both key sources ([`derive_tx_metadata_key`] and @@ -221,7 +278,14 @@ pub fn derive_tx_metadata_key_from_master( /// cannot decode, silently breaking the bidirectional wire-compat guarantee, so /// it is rejected HERE, at the one choke point every layer (JNI, FFI, resident /// wallet) funnels through — not only in the Kotlin `require` -/// (dashpay/platform#4091, findings 9c0ce58c3bb7 / 79595960d201). +/// (dashpay/platform#4091). +/// +/// `payload` must be at most [`MAX_TX_METADATA_PLAINTEXT_LEN`] bytes: a larger +/// plaintext seals into a blob that overflows the `encryptedMetadata` field and +/// would be rejected at broadcast with an opaque DPP schema error. This is the +/// last-line-of-defense enforcement of the same limit the create path +/// pre-checks up front (see [`ensure_tx_metadata_payload_fits`]); over-large +/// payloads fail with a typed [`PlatformWalletError::TxMetadataPayloadTooLarge`]. pub fn seal_tx_metadata( key: &[u8; 32], version: u8, @@ -235,6 +299,10 @@ pub fn seal_tx_metadata( by the legacy decryptTxMetadata" ))); } + // Choke-point size guard: reject a plaintext that would overflow the + // encryptedMetadata field once framed (typed error, not an opaque DPP + // failure at broadcast). + ensure_tx_metadata_payload_fits(payload.len())?; let ciphertext = platform_encryption::encrypt_aes_256_cbc(key, iv, payload); let mut blob = Vec::with_capacity(BLOB_HEADER_LEN + ciphertext.len()); blob.push(version); @@ -354,8 +422,8 @@ mod tests { } } - /// Rust-side wire-version guard (dashpay/platform#4091, findings - /// 9c0ce58c3bb7 / 79595960d201): `seal_tx_metadata` accepts only the two + /// Rust-side wire-version guard (dashpay/platform#4091): + /// `seal_tx_metadata` accepts only the two /// versions the legacy `decryptTxMetadata` understands (0 = CBOR, 1 = /// protobuf) and rejects everything else, so the guard holds even when a /// caller bypasses the Kotlin `require` (e.g. through the FFI/JNI directly). @@ -379,6 +447,54 @@ mod tests { } } + /// Payload-size boundary (dashpay/platform#4091): the largest plaintext the + /// `encryptedMetadata` field (`maxItems` 4096) can hold once framed is + /// [`MAX_TX_METADATA_PLAINTEXT_LEN`] = 4063, and 4064 is the first rejected + /// length. Pins the REAL PKCS7 envelope math against the code, not the + /// reviewer's "4063 → 4096" arithmetic: because PKCS7 adds a whole padding + /// block when the plaintext is block-aligned, a 4063-byte plaintext frames to + /// a 4081-byte blob (1 version + 16 IV + 4064 ciphertext), and a 4064-byte + /// plaintext jumps to 4097 (4080 ciphertext) — overflowing the field. + #[test] + fn seal_rejects_payload_above_size_limit() { + let key = [0x11u8; 32]; + let iv = [0x22u8; 16]; + + assert_eq!(MAX_TX_METADATA_PLAINTEXT_LEN, 4063); + + // 4063 bytes: seals, and the blob is exactly 4081 bytes (≤ 4096) — the + // real envelope, NOT 4096. It also round-trips. + let max_payload = vec![0xabu8; MAX_TX_METADATA_PLAINTEXT_LEN]; + let blob = seal_tx_metadata(&key, VERSION_PROTOBUF, &iv, &max_payload) + .expect("the maximum-size payload must seal"); + assert_eq!( + blob.len(), + 4081, + "1 version + 16 IV + 4064 PKCS7 ciphertext = 4081 (fits the 4096 field)" + ); + assert!( + blob.len() <= ENCRYPTED_METADATA_FIELD_MAX, + "the max-payload blob must fit the encryptedMetadata field" + ); + let opened = open_tx_metadata(&key, &blob).expect("max-size blob round-trips"); + assert_eq!(opened.payload, max_payload); + + // 4064 bytes: rejected up front with the typed error, before any cipher + // work — it would frame to a 4097-byte blob and be refused at broadcast. + let over_payload = vec![0xabu8; MAX_TX_METADATA_PLAINTEXT_LEN + 1]; + match seal_tx_metadata(&key, VERSION_PROTOBUF, &iv, &over_payload) { + Err(PlatformWalletError::TxMetadataPayloadTooLarge { len, max }) => { + assert_eq!(len, 4064); + assert_eq!(max, 4063); + } + other => panic!("expected TxMetadataPayloadTooLarge, got {other:?}"), + } + + // The standalone precheck agrees on the boundary. + assert!(ensure_tx_metadata_payload_fits(MAX_TX_METADATA_PLAINTEXT_LEN).is_ok()); + assert!(ensure_tx_metadata_payload_fits(MAX_TX_METADATA_PLAINTEXT_LEN + 1).is_err()); + } + /// A wrong key can never recover the plaintext: PKCS7 rejects it (Err), or /// on the rare valid-padding collision the payload differs — never the /// original. Must not panic. @@ -611,8 +727,8 @@ mod tests { /// (`keyId = 2`, `encryptionKeyIndex = 1`) independently of anything this /// crate constructs, and it produced exactly `4a2eaec1…`. So this vector's /// path is proven by the legacy library, not merely mirrored back from - /// Rust's own `tx_metadata_derivation_path` (dashpay/platform#4091, finding - /// dd246b5e17d0). Note the factory has NO identity-index argument — the + /// Rust's own `tx_metadata_derivation_path` (dashpay/platform#4091). + /// Note the factory has NO identity-index argument — the /// legacy tx-metadata path is fixed at the primary identity, which is why /// wire-compat is defined here and only here. /// @@ -717,8 +833,8 @@ mod tests { } /// **Internal derivation-slot consistency at a nonzero `identity_index` — - /// NOT a legacy wire-compat claim** (dashpay/platform#4091, finding - /// 4c0754158cc6). This exercises that the `identity_index` parameter lands in + /// NOT a legacy wire-compat claim** (dashpay/platform#4091). This + /// exercises that the `identity_index` parameter lands in /// the correct path slot and is deterministic across both key sources, so a /// refactor that dropped, swapped, or misplaced it would fail loudly. It does /// NOT assert cross-stack compatibility, because the legacy stack has no diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index 3f581d9330..90a7dcaa82 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -29,8 +29,8 @@ use dpp::prelude::{DataContract, Identifier}; use crate::error::PlatformWalletError; use crate::wallet::identity::crypto::tx_metadata::{ - derive_tx_metadata_key, derive_tx_metadata_key_from_master, open_tx_metadata, - seal_tx_metadata, + derive_tx_metadata_key, derive_tx_metadata_key_from_master, ensure_tx_metadata_payload_fits, + open_tx_metadata, seal_tx_metadata, }; use super::*; @@ -316,6 +316,13 @@ impl IdentityWallet { ) -> Result { use dashcore::secp256k1::rand::{thread_rng, RngCore}; + // Reject an over-large payload BEFORE any key derivation or network + // work: a plaintext that cannot fit the encryptedMetadata field once + // sealed would otherwise derive the key, seal, and only then die at + // broadcast with an opaque DPP schema error. Fail fast with a typed + // error instead (dashpay/platform#4091). + ensure_tx_metadata_payload_fits(payload.len())?; + let (identity, identity_index, wallet) = self.resolve_encryption_context_blocking(owner_identity_id)?; let key_index = Self::select_encryption_key_id(&identity)?; @@ -341,8 +348,9 @@ impl IdentityWallet { let mut iv = [0u8; 16]; thread_rng().fill_bytes(&mut iv); // Rejects a non-wire-decodable version byte (only 0/1) before it can be - // sealed into a document the legacy stack can't decode - // (dashpay/platform#4091, findings 9c0ce58c3bb7 / 79595960d201). + // sealed into a document the legacy stack can't decode, and enforces the + // payload-size limit as the choke-point last line of defense + // (dashpay/platform#4091). let blob = seal_tx_metadata(&aes_key, version, &iv, payload)?; // Byte-array fields are accepted as hex strings by the generic create From 0c5527a56b8bb70ba75d01f26f19205a9e7cfc80 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:12:57 -0400 Subject: [PATCH 15/30] fix(platform-wallet-ffi): zeroize + drop txMetadata plaintext before broadcast await MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit platform_wallet_create_encrypted_document_with_signer copied the caller payload into a plain Vec that stayed live in the outer scope across the network broadcast .await — the native plaintext lingered in memory the whole time the document was being broadcast. Wrap the copy in Zeroizing> and make the with_item closure `move` so it OWNS the buffer, then drop(payload_vec) the instant the encrypted properties are prepared (right beside the existing master-key drop), before block_on_worker. The plaintext is now scrubbed and gone before any .await; only the sealed ciphertext properties cross into the async block. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet-ffi/src/document.rs | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index 73e0a70152..85442e4bbb 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -10,6 +10,7 @@ use dpp::prelude::Identifier; use dpp::serialization::ValueConvertible; use key_wallet::bip32::ExtendedPrivKey; use platform_wallet::{PlatformWalletError, TxMetadataKeySource}; +use zeroize::Zeroizing; use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle, VTableSigner}; use crate::check_ptr; @@ -323,21 +324,26 @@ pub unsafe extern "C" fn platform_wallet_create_encrypted_document_with_signer( let document_type_str = unwrap_result_or_return!(CStr::from_ptr(document_type_name).to_str()).to_string(); - // Copy the payload into an owned Vec (it is moved into the async block; a - // borrow of `payload` can't outlive this call). Null is allowed only for a - // zero-length payload. - let payload_vec: Vec = if payload_len == 0 { + // Copy the payload into an owned buffer. Null is allowed only for a + // zero-length payload. It is wrapped in `Zeroizing` so the native plaintext + // copy is scrubbed on drop, and it is dropped explicitly the instant the + // encrypted properties are prepared (below) — the plaintext must NOT linger + // in scope across the broadcast `.await` (dashpay/platform#4091). + let payload_vec: Zeroizing> = Zeroizing::new(if payload_len == 0 { Vec::new() } else { check_ptr!(payload); slice::from_raw_parts(payload, payload_len).to_vec() - }; + }); let signer_addr = signer_handle as usize; let owner_id_for_async = owner_id; let contract_id_for_async = contract_id_value; - let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + // `move` so the closure OWNS `payload_vec` and can drop it (scrubbing the + // plaintext) before the broadcast `.await`; the other captures are Copy or + // already moved into the nested `async move` block. + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, move |wallet| { let identity_wallet = wallet.identity().clone(); // Key-source selection by wallet capability (may synchronously call @@ -366,7 +372,11 @@ pub unsafe extern "C" fn platform_wallet_create_encrypted_document_with_signer( key_source, ) .map_err(PlatformWalletFFIResult::from)?; - // Scrub the master now — it is not needed for the broadcast. + // The plaintext is now sealed inside `properties_json` (ciphertext + // only). Scrub the native plaintext copy AND the master immediately — + // neither may cross the broadcast `.await` below. `payload_vec` is + // `Zeroizing`, so the drop also wipes its bytes (dashpay/platform#4091). + drop(payload_vec); drop(master_opt); let result: Result<(Identifier, String), PlatformWalletError> = From c8063e402f38e44d0bf03eaf171a31d354a97c76 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:13:04 -0400 Subject: [PATCH 16/30] docs(txmetadata): mark the fetch regression test as a manual/testnet-gated check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The txmetadata_fetch doc claimed the wire-query regression is "caught in CI (against testnet)", but the test is #[ignore = "hits testnet"] and nothing runs `--ignored`, so CI never executes it. Soften the wording: it is a MANUAL, testnet-gated check, run explicitly with `--ignored`, NOT part of the default `cargo test`/CI run and with no scheduled job running `--ignored` today — a local/pre-release regression gate. (Wiring a scheduled `--ignored` job is out of scope for this PR.) Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/tests/txmetadata_fetch.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/tests/txmetadata_fetch.rs b/packages/rs-platform-wallet/tests/txmetadata_fetch.rs index 1e825fa5b8..4e2c3202d6 100644 --- a/packages/rs-platform-wallet/tests/txmetadata_fetch.rs +++ b/packages/rs-platform-wallet/tests/txmetadata_fetch.rs @@ -7,7 +7,11 @@ //! `encryptedMetadata` fields. //! //! This pins the wire query so a regression in the where-clause / order-by / -//! encoding is caught in CI (against testnet) rather than only on-device. The +//! encoding is caught by this check rather than only on-device. NOTE: the test +//! is `#[ignore]`d because it hits live testnet, so it is a MANUAL, testnet- +//! gated check — run it explicitly with `--ignored` (see below). It is NOT part +//! of the default `cargo test` / CI run, and no scheduled job runs `--ignored` +//! today; treat it as a local / pre-release regression gate. The //! DECRYPT half is not exercised here — it needs the owner's mnemonic — but the //! per-document field extraction that feeds decrypt IS asserted, proving the //! pipeline reaches the decrypt step for both documents. From 9af3ece6f489af84e5f471d7b97b0597d8193d6b Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:13:13 -0400 Subject: [PATCH 17/30] chore(txmetadata): strip internal tracker tokens from comments Remove the "finding " / "blocker " internal tracking tokens from the remaining code comments and docs (the co-located tokens in tx_metadata.rs and encrypted_document.rs were stripped in the size-precheck commit). Rationale text and the dashpay/platform#4091 issue reference are kept; the tracker refs belong in the PR description, not the source. Co-Authored-By: Claude Opus 4.8 --- .../tests/legacy_wire_compat/LegacyDerivationPathCheck.java | 4 ++-- .../rs-platform-wallet/tests/legacy_wire_compat/README.md | 3 +-- packages/rs-unified-sdk-jni/src/transactions.rs | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java b/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java index ea978ae252..1eac7ecb8e 100644 --- a/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java +++ b/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java @@ -12,8 +12,8 @@ * REAL `org.bitcoinj.wallet.DerivationPathFactory` (the same class the legacy * dash-sdk-kotlin identity-key chain uses) and compares its output to the * hand-built path, so a maintainer can confirm the wire-compat anchor without - * trusting either this repo's prose or an AI agent's word (dashpay/platform#4091, - * findings 989be307db0f / dd246b5e17d0 / 4c0754158cc6). + * trusting either this repo's prose or an AI agent's word + * (dashpay/platform#4091). * * Empirically (dashj-core 22.0.3, Testnet): * noArg blockchainIdentityECDSADerivationPath() = m/9'/1'/5'/0'/0'/0' (6 components) diff --git a/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md b/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md index ec22a2d6f1..2d2d73d3e7 100644 --- a/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md +++ b/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md @@ -14,8 +14,7 @@ Two checked-in JVM tools back the hard-coded vectors in REAL `org.bitcoinj.wallet.DerivationPathFactory` and confirms that `LegacyKeyN`'s hand-built account path equals the factory's output at identityIndex 0, so the wire-compat anchor is independently reproducible from - checked-in code — not just asserted in prose (dashpay/platform#4091, findings - 989be307db0f / dd246b5e17d0 / 4c0754158cc6). + checked-in code — not just asserted in prose (dashpay/platform#4091). ## What each vector proves (and what it does NOT) diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 3f38606a33..26021a40b4 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -796,7 +796,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do // Only 0 (CBOR) and 1 (protobuf) are wire-decodable by the legacy dashj // decryptTxMetadata; anything else seals a document the legacy stack // can't read. Fail fast here with the correct bound instead of the stale - // 0..=255 range (dashpay/platform#4091, finding 79595960d201). The Rust + // 0..=255 range (dashpay/platform#4091). The Rust // core `seal_tx_metadata` enforces the same invariant as the last line // of defense. if !(0..=1).contains(&version) { From 5accc5db1f7d557f9e76d5d6adec44d91ab09a55 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:25:39 -0400 Subject: [PATCH 18/30] test(platform-wallet): anchor ENCRYPTED_METADATA_FIELD_MAX to the contract schema Review round 2: the 4096 field limit was a local const silently duplicating the wallet-utils contract's encryptedMetadata maxItems; pin them together so a contract-side limit change fails a test instead of drifting past the size precheck. Co-Authored-By: Claude Fable 5 --- .../src/wallet/identity/crypto/tx_metadata.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs index a60e9f7346..601176a5ca 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs @@ -495,6 +495,27 @@ mod tests { assert!(ensure_tx_metadata_payload_fits(MAX_TX_METADATA_PLAINTEXT_LEN + 1).is_err()); } + /// [`ENCRYPTED_METADATA_FIELD_MAX`] duplicates the `encryptedMetadata` + /// `maxItems` from the wallet-utils contract schema (the crate exports no + /// limit constant to anchor to), so pin it against the schema JSON itself: + /// if the contract ever changes the field limit, this fails instead of the + /// size precheck silently drifting. + #[test] + fn field_max_matches_wallet_utils_contract_schema() { + let schema: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../wallet-utils-contract/schema/v1/wallet-utils-contract-documents.json" + ))) + .expect("wallet-utils contract schema parses"); + let max_items = schema["txMetadata"]["properties"]["encryptedMetadata"]["maxItems"] + .as_u64() + .expect("encryptedMetadata.maxItems present in schema"); + assert_eq!( + ENCRYPTED_METADATA_FIELD_MAX as u64, max_items, + "ENCRYPTED_METADATA_FIELD_MAX must track the contract's encryptedMetadata maxItems" + ); + } + /// A wrong key can never recover the plaintext: PKCS7 rejects it (Err), or /// on the rare valid-padding collision the payload differs — never the /// original. Must not panic. From 64bec68dad1a1c0da9bf1fb5135a59dee9d7d250 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:06:26 -0400 Subject: [PATCH 19/30] test(txmetadata): pin an independent legacy-INSTALL wire-compat vector Adds the reviewer-requested independent check (dashpay/platform#4186): decrypt a txMetadata blob produced by a REAL legacy dash-wallet 11.9 install, not one this repo generated. A designated-throwaway testnet wallet (DPNS name `yabba2`, identity ESR1nfF3bj4TR2ZkLmDuSeu6r7VzpTurYi47BV6XwsoP) running stock dash-wallet 11.9 registered a username, did a send + receive, saved metadata, and published one encrypted `txMetadata` document to Platform. It was fetched back off testnet and decrypted with the NEW Rust crypto. - `legacy_install_yabba2_wire_compat_vector` (network-free fixture in tx_metadata.rs): hard-codes the recovery phrase, the real captured blob hex (version 1/protobuf, keyIndex 2, encryptionKeyIndex 1), and the expected protobuf `TxMetadataBatch` plaintext (two items, memos "username"/"faucet", USD exchange rates), and asserts the new `derive_tx_metadata_key` + `open_tx_metadata` path decrypts it byte-for-byte via both the resident and resolver-master key sources. Doc-commented as the independent legacy-install vector, distinct from the self-generated dashj-core scratch vectors. - `capture_legacy_yabba2_txmetadata_blobs` (testnet-gated helper in tests/txmetadata_fetch.rs): resolves the DPNS name, runs the exact production query, derives from the phrase, and prints the capture used to build the fixture. - README: documents the new real-install vector alongside the JVM-generated ones. Co-Authored-By: Claude Fable 5 --- .../src/wallet/identity/crypto/tx_metadata.rs | 126 +++++++++++++++++ .../tests/legacy_wire_compat/README.md | 26 +++- .../tests/txmetadata_fetch.rs | 132 ++++++++++++++++++ 3 files changed, 280 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs index 601176a5ca..f3f3f729eb 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs @@ -853,6 +853,132 @@ mod tests { ); } + /// **Independent legacy-INSTALL wire-compat vector (dashpay/platform#4186, + /// reviewer shumkov's "one independent check — decrypting a blob produced by + /// a real legacy dash-wallet install" ask).** + /// + /// Unlike [`legacy_dashj_wire_compat_vector`] and + /// [`nonzero_identity_index_derivation_slot_is_internally_consistent`] — + /// which this repo generated by driving dashj-core's crypto primitives from a + /// JVM scratch program (`tests/legacy_wire_compat/LegacyKeyN.java`) — this + /// vector was NOT produced by this repo at all. It is a blob a real + /// **dash-wallet 11.9 Android install** (the shipping dashj crypto path) + /// created on TESTNET, encrypted, and published to Dash Platform. It was then + /// fetched back off testnet and decrypted here with the NEW Rust crypto, + /// closing the loop the JVM-generated vectors cannot: those prove Rust ⟷ + /// dashj-core agree on primitives this repo invokes; THIS proves the new Rust + /// `open` path decrypts a document that a stock legacy app, running end to + /// end, actually wrote to the network. + /// + /// ## Provenance (how the blob was captured — reproducible) + /// + /// The wallet is a DESIGNATED THROWAWAY, testnet-only, provided by the owner + /// explicitly for this fixture; its recovery phrase is public by intent. On a + /// stock dash-wallet 11.9 testnet install it registered the DPNS username + /// `yabba2`, did a send + a receive, and saved transaction metadata; the app + /// encrypted that metadata and published one `txMetadata` document to + /// Platform. The manual, testnet-gated helper + /// `capture_legacy_yabba2_txmetadata_blobs` in `tests/txmetadata_fetch.rs` + /// resolves `yabba2` via DPNS to identity + /// `ESR1nfF3bj4TR2ZkLmDuSeu6r7VzpTurYi47BV6XwsoP`, runs the exact production + /// query ([`super::super::network::query_owned_encrypted_documents`]), and + /// prints the blob hex + `keyIndex`/`encryptionKeyIndex` + decrypted + /// plaintext hard-coded below. Captured document: `keyIndex = 2` + /// (the identity's registered ENCRYPTION/MEDIUM key), `encryptionKeyIndex = + /// 1`, `$updatedAt = 1784666696610`, blob version byte `1` (protobuf). + /// + /// ## What the decrypted plaintext is (real metadata, not a scratch string) + /// + /// The recovered plaintext is a genuine dash-wallet protobuf `TxMetadataBatch` + /// carrying two per-transaction items (the send + the receive), each with a + /// 32-byte transaction id, a millisecond timestamp, a memo string + /// (`"username"` and `"faucet"`), an exchange-rate double (USD-per-DASH), and + /// a `"USD"` currency code — the tax-category / memo / exchange-rate shape the + /// app persists. This test only asserts byte-for-byte decrypt equality; it + /// does not depend on the protobuf schema (the payload is opaque to this + /// crate), so it stays green regardless of future proto field changes. + /// + /// The key is derived from the throwaway recovery phrase with THIS branch's + /// own [`derive_tx_metadata_key`] at `identity_index = 0` (the only slot a + /// legacy `createTxMetadata` flow writes — see [`derive_tx_metadata_key`]), + /// using the document's own `keyIndex`/`encryptionKeyIndex`. This is entirely + /// network-free: the blob is the real captured bytes, and decryption + /// succeeding under PKCS7 is itself the proof the derivation matches the + /// legacy install byte-for-byte. + #[test] + fn legacy_install_yabba2_wire_compat_vector() { + use key_wallet::mnemonic::{Language, Mnemonic}; + + // The DESIGNATED THROWAWAY testnet wallet the legacy dash-wallet 11.9 + // install ran under (public by intent for this fixture). + const PHRASE: &str = + "across jungle only rocket promote mule behave siren crush pole awful deposit"; + + let wallet = Wallet::from_mnemonic( + Mnemonic::from_phrase(PHRASE, Language::English).expect("valid recovery phrase"), + Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("wallet from recovery phrase"); + + // The captured document's own indices: identity_index 0 (legacy always + // derives against the primary identity), keyIndex 2 (ENCRYPTION/MEDIUM), + // encryptionKeyIndex 1 (the document's `encryptionKeyIndex` field). + let key = derive_tx_metadata_key(&wallet, Network::Testnet, 0, 2, 1).expect("derive"); + + // The resolver-master path (the on-device external-signable shape) must + // derive the identical key — pins the fetched-blob decrypt to both key + // sources, not just the resident wallet. + let master = ExtendedPrivKey::new_master( + Network::Testnet, + &Mnemonic::from_phrase(PHRASE, Language::English) + .expect("valid recovery phrase") + .to_seed(""), + ) + .expect("master from seed"); + let key_via_master = + derive_tx_metadata_key_from_master(&master, Network::Testnet, 0, 2, 1) + .expect("master derive"); + assert_eq!( + *key, *key_via_master, + "resident and resolver-master derivation must agree for the legacy-install document" + ); + + // The REAL `encryptedMetadata` blob dash-wallet 11.9 published to testnet + // (version 1 ‖ IV(16) ‖ AES-256-CBC), fetched back off Platform. + let legacy_blob = hex::decode( + "0189b0af73dd2fdeee0141b225580d18dba09ca495c95ee14e5bc23d2683\ + 626c0e7522dc45ad1316900543ef9a63da3d3bb4893ac8df3e6a3ca94051\ + b2521e5a4bfd7db87d2f2352b64d8a216781386155b9e2d1cfccc194c98a\ + 51e436438b0eaea15fdded112a8c55d286818f82a7f2fce80c7688e8fbed\ + 4fab85e8f1da7ee0f2929066274add52f86082f37f52bbf3da21723b5b97\ + 46b3d9a42cc528f236ab39", + ) + .expect("valid hex"); + + // The exact protobuf `TxMetadataBatch` plaintext the legacy app encrypted + // (two items: memos "username"/"faucet", USD exchange-rate doubles). + let expected_plaintext = hex::decode( + "0a410a20ba248e210822fea2f26bc78368331dbcb45bfa08c7a4ef19e969\ + 8b06b568b93110b8d09fb3f8331a08757365726e616d6521f38e5374246f\ + 41402a035553440a3f0a2072615b227e464acd4b8fc6cd03f29a093e9e2e\ + e49e9e92e5e11eed04faf9b91d10d2d49cb3f8331a06666175636574212d\ + 211ff46c6e41402a03555344", + ) + .expect("valid hex"); + + let opened = open_tx_metadata(&key, &legacy_blob).expect("open legacy-install blob"); + assert_eq!( + opened.version, VERSION_PROTOBUF, + "the legacy install published a protobuf (version 1) txMetadata blob" + ); + assert_eq!( + opened.payload, expected_plaintext, + "the new Rust crypto must decrypt a real dash-wallet 11.9 install's testnet \ + txMetadata blob to its exact published plaintext, byte-for-byte" + ); + } + /// **Internal derivation-slot consistency at a nonzero `identity_index` — /// NOT a legacy wire-compat claim** (dashpay/platform#4091). This /// exercises that the `identity_index` parameter lands in diff --git a/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md b/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md index 2d2d73d3e7..1d23b21aa6 100644 --- a/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md +++ b/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md @@ -1,9 +1,27 @@ # Legacy txMetadata wire-compat vector generator -Two checked-in JVM tools back the hard-coded vectors in -`src/wallet/identity/crypto/tx_metadata.rs` -(`legacy_dashj_wire_compat_vector` and -`nonzero_identity_index_derivation_slot_is_internally_consistent`): +The hard-coded wire-compat vectors in +`src/wallet/identity/crypto/tx_metadata.rs` come from two independent sources: + +- **A real legacy dash-wallet INSTALL** (the strongest check — + dashpay/platform#4186, reviewer shumkov's "decrypt a blob produced by a real + legacy dash-wallet install" ask): `legacy_install_yabba2_wire_compat_vector`. + Its blob was NOT generated by this repo — a stock **dash-wallet 11.9** Android + install (shipping dashj crypto path) registered DPNS username `yabba2` on + testnet, did a send + receive, saved metadata, and published one encrypted + `txMetadata` document to Platform. The testnet-gated helper + `capture_legacy_yabba2_txmetadata_blobs` in `tests/txmetadata_fetch.rs` + fetched it back (identity `ESR1nfF3bj4TR2ZkLmDuSeu6r7VzpTurYi47BV6XwsoP`, + `keyIndex = 2`, `encryptionKeyIndex = 1`, version 1/protobuf) and the new Rust + crypto decrypted it to its real protobuf `TxMetadataBatch` plaintext (two + items, memos `"username"`/`"faucet"`, USD exchange rates). The wallet is a + designated throwaway; its recovery phrase is public by intent. This vector + needs no JVM tooling — it is checked in from the captured bytes. + +- **JVM-generated dashj-core vectors** — the two checked-in JVM tools below back + the other two hard-coded vectors + (`legacy_dashj_wire_compat_vector` and + `nonzero_identity_index_derivation_slot_is_internally_consistent`): - **`LegacyKeyN.java`** — the reproducible key/blob *generator*. It runs dashj-core's cryptographic primitives — the same `HDKeyDerivation`, diff --git a/packages/rs-platform-wallet/tests/txmetadata_fetch.rs b/packages/rs-platform-wallet/tests/txmetadata_fetch.rs index 4e2c3202d6..efea86e3db 100644 --- a/packages/rs-platform-wallet/tests/txmetadata_fetch.rs +++ b/packages/rs-platform-wallet/tests/txmetadata_fetch.rs @@ -126,3 +126,135 @@ async fn fetch_returns_both_legacy_txmetadata_documents() { ); } } + +/// Independent legacy-install capture SCAFFOLDING (dashpay/platform#4186, +/// reviewer shumkov's "decrypt a blob produced by a real legacy dash-wallet +/// install" ask). This is a MANUAL, testnet-gated helper — it resolves the +/// throwaway wallet's DPNS name `yabba2` to its identity id, fetches every +/// `txMetadata` document that identity owns, derives the tx-metadata key from +/// the wallet's recovery phrase with THIS branch's own derivation +/// (`derive_tx_metadata_key`, identity_index 0), opens each blob, and prints the +/// blob hex + key indices + decrypted plaintext so the captured values can be +/// hard-coded into the network-free fixture +/// `legacy_install_yabba2_wire_compat_vector` in +/// `src/wallet/identity/crypto/tx_metadata.rs`. +/// +/// The wallet is a DESIGNATED THROWAWAY provided for this fixture; its recovery +/// phrase is intended to become public in the repo. +/// +/// # Running +/// ```bash +/// cargo test -p platform-wallet --test txmetadata_fetch \ +/// capture_legacy_yabba2 -- --ignored --nocapture +/// ``` +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "hits testnet"] +async fn capture_legacy_yabba2_txmetadata_blobs() { + use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + use platform_wallet::wallet::identity::crypto::tx_metadata::{ + derive_tx_metadata_key, open_tx_metadata, + }; + + let _ = tracing_subscriber::fmt().with_env_filter("info").try_init(); + + // The DESIGNATED THROWAWAY testnet wallet the legacy dash-wallet 11.9 install + // ran under (public by design for this fixture). + const YABBA2_PHRASE: &str = + "across jungle only rocket promote mule behave siren crush pole awful deposit"; + const DPNS_NAME: &str = "yabba2"; + + let sdk = testnet_sdk().await; + + // 1. Resolve the DPNS name -> identity id (the SDK's own resolver). + let owner = sdk + .resolve_dpns_name(DPNS_NAME) + .await + .expect("resolve_dpns_name call") + .expect("DPNS name yabba2 resolves to an identity"); + println!( + "RESOLVED yabba2 -> identity {}", + owner.to_string(Encoding::Base58) + ); + + // 2. Fetch + register the wallet-utils contract (production parity). + let contract_id = Identifier::from_string(CONTRACT_B58, Encoding::Base58).expect("contract id"); + let contract = DataContract::fetch(&sdk, contract_id) + .await + .expect("fetch contract") + .expect("wallet-utils contract present on testnet"); + { + use dash_sdk::platform::ContextProvider; + if let Some(provider) = sdk.context_provider() { + provider.register_data_contract(Arc::new(contract.clone())); + } + } + let contract = Arc::new(contract); + + // 3. The exact production query (since_ms = 0 => fetch everything). + let docs = query_owned_encrypted_documents(&sdk, Arc::clone(&contract), &owner, DOC_TYPE, 0) + .await + .expect("query owned encrypted documents"); + let materialized: Vec<_> = docs.iter().filter_map(|(_, d)| d.as_ref()).collect(); + println!( + "FETCHED {} txMetadata document(s) (raw entries: {}) for identity {}", + materialized.len(), + docs.len(), + owner.to_string(Encoding::Base58) + ); + if materialized.is_empty() { + println!( + "ZERO documents. Queried contract={CONTRACT_B58} type={DOC_TYPE} owner={} since_ms=0", + owner.to_string(Encoding::Base58) + ); + return; + } + + // 4. Derive keys from the wallet's recovery phrase with the branch's own + // derivation (identity_index 0 — the only slot a legacy wallet writes). + let wallet = Wallet::from_mnemonic( + Mnemonic::from_phrase(YABBA2_PHRASE, Language::English).expect("valid recovery phrase"), + Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("wallet from recovery phrase"); + + // 5. Decrypt each document with the new Rust open path and print the capture. + for (i, doc) in materialized.iter().enumerate() { + let props = doc.properties(); + let key_index = props + .get("keyIndex") + .and_then(|v: &Value| v.to_integer::().ok()) + .expect("keyIndex is a u32"); + let encryption_key_index = props + .get("encryptionKeyIndex") + .and_then(|v: &Value| v.to_integer::().ok()) + .expect("encryptionKeyIndex is a u32"); + let blob = props + .get("encryptedMetadata") + .and_then(|v: &Value| v.to_binary_bytes().ok()) + .expect("encryptedMetadata is a byte array"); + let created_at = doc.created_at(); + let updated_at = doc.updated_at(); + + let aes_key = derive_tx_metadata_key(&wallet, Network::Testnet, 0, key_index, encryption_key_index) + .expect("derive txMetadata key at identity_index 0"); + let opened = open_tx_metadata(&aes_key, &blob).expect("open legacy blob"); + + println!("---- DOCUMENT {i} ----"); + println!("keyIndex = {key_index}"); + println!("encryptionKeyIndex = {encryption_key_index}"); + println!("createdAt = {created_at:?}"); + println!("updatedAt = {updated_at:?}"); + println!("blob_len = {}", blob.len()); + println!("BLOB_HEX = {}", hex::encode(&blob)); + println!("version = {}", opened.version); + println!("plaintext_len = {}", opened.payload.len()); + println!("PLAINTEXT_HEX = {}", hex::encode(&opened.payload)); + println!( + "PLAINTEXT_UTF8_LOSSY= {}", + String::from_utf8_lossy(&opened.payload) + ); + } +} From 983154de80bff2bb24a739623de87ef26955c20e Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:12:46 -0400 Subject: [PATCH 20/30] fix(kotlin-sdk): zeroize + early-drop the JNI txMetadata plaintext copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JNI-owned `payload_bytes` in `documentCreateEncrypted` was a plain `Vec` held across the entire synchronous FFI call — including the network broadcast that runs inside it — and freed unscrubbed at end of scope. The inner FFI copy (`payload_vec` in rs-platform-wallet-ffi's document.rs) already got `Zeroizing` + an explicit pre-broadcast drop; mirror that discipline for the JNI copy so the plaintext-lifetime guarantee holds end-to-end. - Wrap `payload_bytes` in `zeroize::Zeroizing` so it is scrubbed on drop. - Drop it explicitly the instant the FFI call returns (the earliest point reachable from JNI, since the broadcast completes inside that call), before result/JSON handling. It is the only plaintext copy in the fn. Also strip the two surviving `dashpay/platform#4091` tracker tokens from the version-guard and fetch-breadcrumb comments (rationale text kept). Co-Authored-By: Claude Opus 4.8 --- .../rs-unified-sdk-jni/src/transactions.rs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 26021a40b4..2bbad7b796 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -796,9 +796,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do // Only 0 (CBOR) and 1 (protobuf) are wire-decodable by the legacy dashj // decryptTxMetadata; anything else seals a document the legacy stack // can't read. Fail fast here with the correct bound instead of the stale - // 0..=255 range (dashpay/platform#4091). The Rust - // core `seal_tx_metadata` enforces the same invariant as the last line - // of defense. + // 0..=255 range. The Rust core `seal_tx_metadata` enforces the same + // invariant as the last line of defense. if !(0..=1).contains(&version) { throw_sdk_exception( env, @@ -807,8 +806,16 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do ); return ptr::null_mut(); } + // The JNI-owned plaintext copy. Wrapped in `Zeroizing` so it is scrubbed + // on drop, mirroring the inner FFI copy (`payload_vec` in + // `rs-platform-wallet-ffi/src/document.rs`). The inner copy is dropped + // before its broadcast `.await`; from here the whole broadcast happens + // synchronously *inside* the single FFI call below, so the earliest this + // buffer can be released is the instant that call returns — dropped + // explicitly there rather than left to linger (unscrubbed) to end of + // scope. This is the only plaintext copy in this function. let payload_bytes = match env.convert_byte_array(&payload) { - Ok(b) => b, + Ok(b) => zeroize::Zeroizing::new(b), Err(_) => { let _ = env.exception_clear(); throw_sdk_exception(env, 1, "payload byte[] was null/invalid"); @@ -834,6 +841,10 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do &mut out_json as *mut *mut c_char, ) }; + // The FFI call has returned: the plaintext has been sealed into + // ciphertext and the broadcast has already completed. Scrub this copy + // now (as soon as possible), before result/JSON handling. + drop(payload_bytes); if take_pwffi_error(env, result) { return ptr::null_mut(); } @@ -881,8 +892,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do guard(&mut env, ptr::null_mut(), |env| { // Informational stage breadcrumbs are DEBUG; only genuine failure paths // are WARN. The `sdkFetched=0` root cause is fixed (external-signable - // txMetadata derive, dashpay/platform#4091), so these no longer need to - // be loud. Android visibility: `JNI_OnLoad` installs `android_logger` at + // txMetadata derive), so these no longer need to be loud. Android visibility: `JNI_OnLoad` installs `android_logger` at // `LevelFilter::Info`, so DEBUG lines stay OUT of on-device logcat while // WARN error lines remain visible. NEVER log a raw handle value: only // whether each handle is nonzero — `mnemonic_resolver_handle` is a live From d0431bd6a4073d9c33f1b1605fa81ebea123f616 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:29:05 -0400 Subject: [PATCH 21/30] feat(platform-wallet): allocate txMetadata encryptionKeyIndex in Rust, not the host Follow-up to dashpay/platform#4186 (shumkov review on the encrypted-documents port): the encryptionKeyIndex allocation policy was left in the Kotlin host, which told callers to supply the legacy `1 + countAllRequests()` counter. Concurrent callers/devices could pick the same index, and a caller-side key-index policy loop violates the Kotlin SDK host-thin rule. Move the policy into Rust; hosts now provide only the opaque payload. Rust core (rs-platform-wallet): - IdentityWallet::allocate_encryption_key_index counts the identity's existing txMetadata documents on Platform and returns `1 + count`, matching dash-wallet's retired `1 + countAllRequests()` (SELECT COUNT(*) FROM transaction_metadata_platform) semantics EXACTLY (count+1, not max+1; empty state -> 1). - Allocation is serialized through a shared per-wallet allocator mutex (EncryptionKeyIndexAllocator on IdentityWallet, an Arc> shared across handle clones): two concurrent creates through the SAME process seed the in-process high-water once from Platform and then hand out monotonically increasing indices, so they can never pick the same index. - Cross-device uniqueness is best-effort only and is NOT data-loss: every document stores its own keyIndex/encryptionKeyIndex and the reader derives each document's key from its own stored indices, so two documents sharing an index each carry a fresh IV and both decrypt independently. - Unit tests: legacy 1+count math (incl. saturation), empty-state seed + increment, per-owner isolation, and a concurrent no-collision test. FFI (rs-platform-wallet-ffi): - Add ABI-additive sibling platform_wallet_create_encrypted_document_with_signer_auto_index (identical params minus encryption_key_index); the existing explicit-index export is unchanged and both share one impl taking Option. When None, the index is allocated from Platform state before any key material is resolved. JNI (rs-unified-sdk-jni): - documentCreateEncrypted treats encryptionKeyIndex == -1 as the "let Rust allocate" sentinel (routes to the auto-index export); a non-negative value routes to the explicit export; < -1 is rejected. Kotlin SDK: - DocumentTransactions.createEncryptedDocument takes encryptionKeyIndex: Int? = null (null -> allocate in Rust); removed the `1 + countAllRequests()` guidance and deprecated the caller-supplied counter in KDoc. Null maps to the -1 JNI sentinel. - Tests: explicit-negative rejection and no-index-path acceptance. Co-Authored-By: Claude Fable 5 --- .../dashsdk/documents/DocumentTransactions.kt | 36 ++- .../dashsdk/ffi/TransactionsNative.kt | 8 +- ...cumentTransactionsVersionValidationTest.kt | 87 +++++- .../rs-platform-wallet-ffi/src/document.rs | 141 +++++++++- .../identity/network/encrypted_document.rs | 263 ++++++++++++++++++ .../identity/network/identity_handle.rs | 11 + .../src/wallet/identity/network/payments.rs | 1 + .../src/wallet/platform_wallet.rs | 6 + .../rs-unified-sdk-jni/src/transactions.rs | 83 ++++-- 9 files changed, 586 insertions(+), 50 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt index f763dd3dae..5ff637c020 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt @@ -259,12 +259,29 @@ class DocumentTransactions internal constructor( * writes `{keyIndex, encryptionKeyIndex, encryptedMetadata}`. * * Batching stays app-side: the caller serializes its items into [payload] - * (a protobuf `TxMetadataBatch`) and supplies its own per-document - * [encryptionKeyIndex] (dash-wallet's `1 + countAllRequests()` counter). - * The identity encryption key id (the `keyIndex` field) is chosen SDK-side - * to match the legacy stack, so the key never crosses the FFI boundary. + * (a protobuf `TxMetadataBatch`). The identity encryption key id (the + * `keyIndex` field) is chosen SDK-side to match the legacy stack, so the key + * never crosses the FFI boundary. * - * @param encryptionKeyIndex per-document index; non-negative. + * ### `encryptionKeyIndex` allocation (dashpay/platform#4186 follow-up) + * Leave [encryptionKeyIndex] `null` (the default) to let the SDK allocate + * the per-document index in Rust from authoritative Platform state — the + * host-thin path. Rust counts the identity's existing txMetadata documents + * on Platform and uses `1 + count` (matching dash-wallet's retired + * `1 + countAllRequests()` semantics EXACTLY), serialized under the wallet's + * allocator mutex so concurrent creates through the same process never pick + * the same index. The index is best-effort unique PER DEVICE; a cross-device + * duplicate is not data-loss (each document stores its own index and the + * reader derives that document's key from it, so both decrypt independently). + * + * Passing an explicit non-negative [encryptionKeyIndex] is retained ONLY for + * migration / tests and is discouraged: the host must NOT reintroduce a + * caller-supplied `1 + countAllRequests()` counter (concurrent callers / + * devices could collide, and it violates the host-thin key-index rule). + * + * @param encryptionKeyIndex `null` to let the SDK allocate the index + * (preferred); or an explicit non-negative per-document index + * (migration / tests only). * @param version payload version byte (`1` = protobuf, as the wallet writes). * @param payload already-serialized opaque plaintext; the SDK does not * parse it. @@ -283,15 +300,15 @@ class DocumentTransactions internal constructor( ownerId: ByteArray, contractId: ByteArray, documentType: String, - encryptionKeyIndex: Int, version: Int, payload: ByteArray, signerHandle: Long, + encryptionKeyIndex: Int? = null, ): String = gate.op { require(ownerId.size == 32) { "ownerId must be 32 bytes" } require(contractId.size == 32) { "contractId must be 32 bytes" } - require(encryptionKeyIndex >= 0) { - "encryptionKeyIndex must be non-negative, got $encryptionKeyIndex" + require(encryptionKeyIndex == null || encryptionKeyIndex >= 0) { + "encryptionKeyIndex, when supplied, must be non-negative, got $encryptionKeyIndex" } // Only 0 (CBOR) and 1 (protobuf) are wire-meaningful: `seal_tx_metadata` // writes this byte verbatim into the envelope and the legacy dashj stack @@ -308,7 +325,8 @@ class DocumentTransactions internal constructor( ownerId, contractId, documentType, - encryptionKeyIndex, + // -1 is the JNI sentinel for "let Rust allocate the index". + encryptionKeyIndex ?: -1, version, payload, signerHandle, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index 838b630370..bfc00cbaff 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -180,8 +180,12 @@ internal object TransactionsNative { * required (non-zero) for external-signable wallets — the app's shape — * whose txMetadata AES key derives on demand through the resolver. * Ignored for wallets with resident private keys. - * @param encryptionKeyIndex the app's per-document index (dash-wallet's - * monotonic `1 + countAllRequests()` counter); non-negative. + * @param encryptionKeyIndex the per-document index, OR `-1` to let the SDK + * allocate it in Rust from authoritative Platform state + * (dashpay/platform#4186 follow-up). A non-negative value routes to the + * explicit-index FFI export (migration / tests); `-1` routes to + * `platform_wallet_create_encrypted_document_with_signer_auto_index`, which + * omits the index. Values `< -1` are rejected. * @param version payload version byte (`1` = protobuf, as the wallet writes). * @param payload the already-serialized opaque plaintext (a protobuf * `TxMetadataBatch`); the SDK does not parse it. diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt index 8291093935..cbc2e05073 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt @@ -1,20 +1,30 @@ package org.dashfoundation.dashsdk.documents import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test /** - * Version-byte validation for [DocumentTransactions.createEncryptedDocument] - * (dashpay/platform#4091). Only 0 (CBOR) and 1 (protobuf) are wire-meaningful — - * `seal_tx_metadata` writes the byte verbatim and the legacy dashj - * `decryptTxMetadata` switches on exactly those two values, so an out-of-range - * byte would silently seal a document the legacy stack can't decode. + * Input validation for [DocumentTransactions.createEncryptedDocument]. * - * The `require` runs before any native call (`TransactionsNative`), so the - * REJECTION paths are exercised on the JVM without the JNI library loaded. The - * accepted values 0/1 would proceed into native and can't be unit-tested here. + * Two independent guards are exercised here, both of which run BEFORE any + * native call (`TransactionsNative`), so the REJECTION paths are testable on the + * JVM without the JNI library loaded: + * + * 1. Version byte (dashpay/platform#4091): only 0 (CBOR) and 1 (protobuf) are + * wire-meaningful — `seal_tx_metadata` writes the byte verbatim and the + * legacy dashj `decryptTxMetadata` switches on exactly those two values, so + * an out-of-range byte would silently seal a document the legacy stack can't + * decode. + * 2. `encryptionKeyIndex` (dashpay/platform#4186 follow-up): `null` is the + * preferred path (Rust allocates the index from Platform state); an explicit + * value, when supplied, must be non-negative. + * + * Paths that PASS validation proceed into native and can't be fully unit-tested + * here (no JNI library); [noIndexPathPassesValidation] asserts only that the + * `null` index is accepted by the guard, not rejected as an argument error. */ class DocumentTransactionsVersionValidationTest { @@ -28,10 +38,10 @@ class DocumentTransactionsVersionValidationTest { ownerId = id32, contractId = id32, documentType = "txMetadata", - encryptionKeyIndex = 0, version = version, payload = payload, signerHandle = 0L, + encryptionKeyIndex = 0, ) } @@ -55,4 +65,63 @@ class DocumentTransactionsVersionValidationTest { fun rejectsNegativeVersion() { assertThrows(IllegalArgumentException::class.java) { createWithVersion(-1) } } + + /** + * An explicit NEGATIVE index (the migration/test-only path) is rejected by + * the `require`. `null` (the allocate-in-Rust path) is the only way to omit + * an index; a negative explicit value is a caller error. + */ + @Test + fun rejectsExplicitNegativeIndex() { + val e = assertThrows(IllegalArgumentException::class.java) { + runBlocking { + DocumentTransactions().createEncryptedDocument( + walletHandle = 0L, + mnemonicResolverHandle = 0L, + ownerId = id32, + contractId = id32, + documentType = "txMetadata", + version = 1, + payload = payload, + signerHandle = 0L, + encryptionKeyIndex = -5, + ) + } + } + assertTrue( + "message should name encryptionKeyIndex, got: ${e.message}", + e.message!!.contains("encryptionKeyIndex"), + ) + } + + /** + * The no-index path (`encryptionKeyIndex` omitted → `null`, the default and + * preferred allocate-in-Rust route) must PASS the argument guards. With all + * other inputs valid, the only failure that can surface is the native call + * itself (no JNI library in a JVM unit test), NOT an + * [IllegalArgumentException] from our `require`s — proving `null` is a valid + * argument rather than a rejected one. + */ + @Test + fun noIndexPathPassesValidation() { + val t = runCatching { + runBlocking { + DocumentTransactions().createEncryptedDocument( + walletHandle = 0L, + mnemonicResolverHandle = 0L, + ownerId = id32, + contractId = id32, + documentType = "txMetadata", + version = 1, + payload = payload, + signerHandle = 0L, + // encryptionKeyIndex omitted → null → allocate in Rust. + ) + } + }.exceptionOrNull() + assertFalse( + "the null-index path must not be rejected as an argument error, got: $t", + t is IllegalArgumentException, + ) + } } diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index 85442e4bbb..0af29535a7 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -287,11 +287,17 @@ fn confirmed_document_to_json(document: &Document) -> Result PlatformWalletFFIResult { + // ABI-stable explicit-index entry point: the host supplies the per-document + // encryptionKeyIndex (migration / tests). Delegates to the shared impl with + // `Some(index)`. + create_encrypted_document_impl( + wallet_handle, + mnemonic_resolver_handle, + owner_identity_id, + contract_id, + document_type_name, + Some(encryption_key_index), + version, + payload, + payload_len, + signer_handle, + out_document_id, + out_document_json, + ) +} + +/// Create + broadcast an encrypted `txMetadata` document, letting RUST allocate +/// the per-document `encryptionKeyIndex` from authoritative Platform state +/// (dashpay/platform#4186 follow-up). ABI-additive sibling of +/// [`platform_wallet_create_encrypted_document_with_signer`] — IDENTICAL +/// parameters minus `encryption_key_index`. +/// +/// The host omits the index; the SDK counts the identity's existing txMetadata +/// documents on Platform and uses `1 + count` (dash-wallet's retired +/// `1 + countAllRequests()` semantics), serialized under the wallet's allocator +/// mutex so concurrent creates through the same process never collide. +/// Best-effort unique per device; a cross-device duplicate index is NOT +/// data-loss (see `IdentityWallet::allocate_encryption_key_index`). Every other +/// behavior (identity-key selection, AES derivation, sealing, master wiping, +/// broadcast) matches the explicit-index export. +/// +/// # Safety +/// Same contract as [`platform_wallet_create_encrypted_document_with_signer`]. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_create_encrypted_document_with_signer_auto_index( + wallet_handle: Handle, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + owner_identity_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + version: u8, + payload: *const u8, + payload_len: usize, + signer_handle: *mut SignerHandle, + out_document_id: *mut u8, + out_document_json: *mut *mut c_char, +) -> PlatformWalletFFIResult { + // Rust-allocated-index entry point: the host omits encryptionKeyIndex, so + // the shared impl allocates it from Platform state (`None`). + create_encrypted_document_impl( + wallet_handle, + mnemonic_resolver_handle, + owner_identity_id, + contract_id, + document_type_name, + None, + version, + payload, + payload_len, + signer_handle, + out_document_id, + out_document_json, + ) +} + +/// Shared implementation behind the explicit-index +/// ([`platform_wallet_create_encrypted_document_with_signer`], `Some`) and +/// Rust-allocated +/// ([`platform_wallet_create_encrypted_document_with_signer_auto_index`], +/// `None`) encrypted-document create exports. +/// +/// When `index` is `None` the per-document `encryptionKeyIndex` is allocated +/// from Platform state via `IdentityWallet::allocate_encryption_key_index` +/// (serialized under the wallet's allocator mutex) BEFORE any key material is +/// resolved — the allocation touches no secrets and never crosses the broadcast +/// await with the master in scope. +/// +/// # Safety +/// All pointers must be valid for the duration of the call; `payload` may be +/// null only when `payload_len == 0`. +#[allow(clippy::too_many_arguments)] +unsafe fn create_encrypted_document_impl( + wallet_handle: Handle, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + owner_identity_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + index: Option, + version: u8, + payload: *const u8, + payload_len: usize, + signer_handle: *mut SignerHandle, + out_document_id: *mut u8, + out_document_json: *mut *mut c_char, ) -> PlatformWalletFFIResult { check_ptr!(signer_handle); check_ptr!(document_type_name); @@ -346,6 +451,30 @@ pub unsafe extern "C" fn platform_wallet_create_encrypted_document_with_signer( let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, move |wallet| { let identity_wallet = wallet.identity().clone(); + // Resolve the per-document encryptionKeyIndex FIRST, before any key + // material is in scope: the host either supplies it explicitly + // (`Some`, migration / tests) or omits it (`None`), in which case Rust + // allocates the next index from authoritative Platform state, serialized + // under the wallet's allocator mutex (dashpay/platform#4186 follow-up). + // The allocation touches no secrets, so it can run on the worker before + // the master is resolved. + let resolved_index: u32 = match index { + Some(i) => i, + None => { + let iw = identity_wallet.clone(); + let doc_type = document_type_str.clone(); + block_on_worker(async move { + iw.allocate_encryption_key_index( + &owner_id_for_async, + &contract_id_for_async, + &doc_type, + ) + .await + }) + .map_err(PlatformWalletFFIResult::from)? + } + }; + // Key-source selection by wallet capability (may synchronously call // back into the host mnemonic resolver for external-signable // wallets — see `tx_metadata_key_master_for_wallet`). The resolved @@ -366,7 +495,7 @@ pub unsafe extern "C" fn platform_wallet_create_encrypted_document_with_signer( let properties_json = identity_wallet .prepare_encrypted_txmetadata_properties( &owner_id_for_async, - encryption_key_index, + resolved_index, version, &payload_vec, key_source, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index 90a7dcaa82..b06c15d4a4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -35,6 +35,63 @@ use crate::wallet::identity::crypto::tx_metadata::{ use super::*; +/// In-process high-water map for txMetadata `encryptionKeyIndex` allocation, +/// keyed by owner identity id → the NEXT index to hand out for that identity. +/// Wrapped in an `Arc>` so it is shared across every +/// clone of [`IdentityWallet`] and serializes concurrent allocations (see +/// [`reserve_next_index`] / [`IdentityWallet::allocate_encryption_key_index`]). +pub(crate) type EncryptionKeyIndexAllocator = + Arc>>; + +/// The legacy `encryptionKeyIndex` for the NEXT txMetadata document given the +/// count of documents that already exist for the identity — dash-wallet's +/// `1 + countAllRequests()`. +/// +/// `countAllRequests()` was `SELECT COUNT(*) FROM transaction_metadata_platform` +/// (the count of the identity's published txMetadata documents in the app's +/// local cache — see `PlatformSyncService.publishTxMetaData`, +/// `TransactionMetadataDocumentDao.countAllRequests`). Empty state +/// (`count == 0`) → `1`; `n` existing documents → `n + 1`. This is `count + 1`, +/// NOT `max(index) + 1` — it matches the legacy formula byte-for-byte +/// (dashpay/platform#4186). Saturates at `u32::MAX` (an unreachable +/// 4-billion-document wallet) rather than wrapping back to `0`. +pub(crate) fn next_encryption_key_index_from_count(count: u32) -> u32 { + count.saturating_add(1) +} + +/// Atomically reserve the next `encryptionKeyIndex` for `owner` from the shared +/// `allocator`, serializing concurrent callers under its mutex so two creates +/// through the SAME wallet process can never pick the same index. +/// +/// The first allocation for an owner in this process seeds the high-water from +/// `seed` — the Platform-derived `1 + count`, evaluated lazily UNDER the lock so +/// a racing caller blocks on the seed rather than re-computing it — and every +/// subsequent allocation hands out a monotonically increasing index with no +/// further network work. The stored value is always `handed_out + 1`. +/// +/// Cross-DEVICE uniqueness is NOT guaranteed (another device that has not yet +/// reflected its writes on Platform can seed to the same base); see +/// [`IdentityWallet::allocate_encryption_key_index`] for why that stays safe. +pub(crate) async fn reserve_next_index( + allocator: &tokio::sync::Mutex>, + owner: &Identifier, + seed: S, +) -> Result +where + S: std::future::Future>, +{ + // Hold the guard across the (first-time only) seed await: this is exactly + // what serializes racing allocators — a second caller that finds the map + // empty blocks here until the first has seeded and inserted its `next + 1`. + let mut guard = allocator.lock().await; + let next = match guard.get(owner).copied() { + Some(n) => n, + None => seed.await?, + }; + guard.insert(*owner, next.saturating_add(1)); + Ok(next) +} + /// Where one encrypted-document call derives the per-document txMetadata AES /// key from. Selected by the CALLER (the FFI layer) from the wallet's shape — /// the same capability convention as the identity discovery / key-preview @@ -219,6 +276,103 @@ impl IdentityWallet { }) } + /// Count the identity's existing txMetadata-style documents on Platform — + /// the authoritative equivalent of dash-wallet's local + /// `transactionMetadataDocumentDao.countAllRequests()` + /// (`SELECT COUNT(*) FROM transaction_metadata_platform`). Fetches + + /// registers the contract, then runs the owner-scoped scan with + /// `since_ms == 0` (every document, since `$updatedAt >= 0` always holds) + /// and returns the number of documents found. + /// + /// Every returned entry counts, materialized or not: an un-materialized id + /// still denotes an existing document, so the count never under-reports and + /// the next index never re-collides with an existing one. + /// + /// NOTE: this counts by fetching the owned documents (the same paginated + /// query the fetch path uses) rather than a dedicated drive `COUNT` query — + /// a wallet's txMetadata document set is small, so the extra surface a + /// count-only query would add is not worth it here. + async fn count_owned_txmetadata_documents( + &self, + contract_id: &Identifier, + owner_identity_id: &Identifier, + document_type_name: &str, + ) -> Result { + use dash_sdk::platform::{ContextProvider, Fetch}; + + let contract = DataContract::fetch(&self.sdk, *contract_id) + .await + .map_err(PlatformWalletError::Sdk)? + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Data contract {contract_id} not found on Platform; \ + cannot allocate encryptionKeyIndex" + )) + })?; + let contract = Arc::new(contract); + if let Some(provider) = self.sdk.context_provider() { + provider.register_data_contract(Arc::clone(&contract)); + } + let raw = + query_owned_encrypted_documents(&self.sdk, contract, owner_identity_id, document_type_name, 0) + .await?; + Ok(u32::try_from(raw.len()).unwrap_or(u32::MAX)) + } + + /// Allocate the next `encryptionKeyIndex` for an encrypted-document create + /// when the host supplies none — moving the index-selection policy off the + /// Kotlin host and into authoritative Rust/Platform state + /// (dashpay/platform#4186 follow-up: the host-thin rule forbids a key-index + /// policy loop in the host; hosts now provide only the opaque payload). + /// + /// Semantics MATCH the retired dash-wallet counter EXACTLY: the index is + /// `1 + countAllRequests()`, where the count is now + /// [`Self::count_owned_txmetadata_documents`] read from Platform at create + /// time instead of the app's local `transaction_metadata_platform` table. + /// Empty state → `1`; `n` existing documents → `n + 1` (see + /// [`next_encryption_key_index_from_count`]). + /// + /// Allocation is serialized through the wallet's shared + /// [`EncryptionKeyIndexAllocator`] mutex (see [`reserve_next_index`]): two + /// concurrent creates through the SAME wallet process can NEVER pick the + /// same index — the first seeds the in-process high-water from Platform, the + /// second hands out the next value without a second query. + /// + /// ## Cross-device caveat (best-effort per device, NOT data-loss) + /// Uniqueness is guaranteed only PER DEVICE. Two devices sharing an identity + /// can seed to the same base before either's write is visible to the other, + /// so both may write a document at the same `encryptionKeyIndex`. This is + /// SAFE, not lossy: every encrypted document stores its OWN `keyIndex` + + /// `encryptionKeyIndex`, and the reader + /// ([`Self::fetch_encrypted_documents`]) derives each document's key from + /// the document's own stored indices — so two documents sharing an index + /// each carry a fresh random IV, decrypt independently, and are BOTH + /// returned. A duplicate index is not even an extra decrypt attempt (the + /// reader never guesses indices); no document is overwritten or shadowed. + pub async fn allocate_encryption_key_index( + &self, + owner_identity_id: &Identifier, + contract_id: &Identifier, + document_type_name: &str, + ) -> Result { + reserve_next_index(&self.enc_key_index_allocator, owner_identity_id, async { + let count = self + .count_owned_txmetadata_documents( + contract_id, + owner_identity_id, + document_type_name, + ) + .await?; + let index = next_encryption_key_index_from_count(count); + breadcrumb(&format!( + "allocate_encryption_key_index: seeded owner={owner_identity_id} \ + existing_count={count} next_index={index}" + )); + Ok(index) + }) + .await + } + /// Resolve `(identity, identity_index, wallet)` for `owner_identity_id` /// from the in-process wallet manager — the inputs the tx-metadata key /// derivation needs. Errors for a watch-only / out-of-wallet identity (no @@ -633,3 +787,112 @@ pub async fn query_owned_encrypted_documents( )); Ok(raw_docs) } + +#[cfg(test)] +mod allocator_tests { + //! Unit tests for the `encryptionKeyIndex` allocator + //! (dashpay/platform#4186 follow-up). These exercise the index math and the + //! atomic in-process reservation WITHOUT a live SDK: the Platform-derived + //! seed is injected as a plain future, so `1 + count` semantics, per-owner + //! isolation, and the concurrent no-collision guarantee are all pinned here. + use super::*; + + fn empty_allocator() -> EncryptionKeyIndexAllocator { + Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())) + } + + /// The index math is EXACTLY dash-wallet's `1 + countAllRequests()`: + /// empty state → 1, `n` existing → `n + 1` (count+1, not max+1), saturating + /// at the ceiling rather than wrapping to 0. + #[test] + fn next_index_matches_legacy_one_plus_count() { + assert_eq!(next_encryption_key_index_from_count(0), 1); + assert_eq!(next_encryption_key_index_from_count(1), 2); + assert_eq!(next_encryption_key_index_from_count(5), 6); + assert_eq!(next_encryption_key_index_from_count(u32::MAX), u32::MAX); + } + + /// Empty state seeds to `1 + count(0) == 1`, then hands out 2, 3 … WITHOUT + /// re-seeding (the seed future must not be polled again once the high-water + /// is established). + #[tokio::test] + async fn empty_state_seeds_to_one_then_increments() { + let alloc = empty_allocator(); + let owner = Identifier::from([7u8; 32]); + + let first = reserve_next_index(&alloc, &owner, async { + Ok(next_encryption_key_index_from_count(0)) + }) + .await + .expect("seed ok"); + assert_eq!(first, 1, "empty state must allocate index 1"); + + // A seed that panics if awaited proves the second/third allocations + // never re-seed — they read the cached high-water instead. + let must_not_seed = + || async { unreachable!("must not re-seed once the high-water is established") }; + assert_eq!( + reserve_next_index(&alloc, &owner, must_not_seed()).await.unwrap(), + 2 + ); + assert_eq!( + reserve_next_index(&alloc, &owner, must_not_seed()).await.unwrap(), + 3 + ); + } + + /// Distinct owners keep independent high-waters — one identity's allocations + /// never perturb another's. + #[tokio::test] + async fn distinct_owners_seed_independently() { + let alloc = empty_allocator(); + let a = Identifier::from([1u8; 32]); + let b = Identifier::from([2u8; 32]); + + // a: 3 existing docs → 4; b: 0 existing → 1; then a again → 5. + assert_eq!( + reserve_next_index(&alloc, &a, async { Ok(next_encryption_key_index_from_count(3)) }) + .await + .unwrap(), + 4 + ); + assert_eq!( + reserve_next_index(&alloc, &b, async { Ok(next_encryption_key_index_from_count(0)) }) + .await + .unwrap(), + 1 + ); + assert_eq!( + reserve_next_index(&alloc, &a, async { unreachable!("a already seeded") }) + .await + .unwrap(), + 5 + ); + } + + /// The core concurrency guarantee: two allocations racing on the SAME owner + /// through the SAME allocator get DISTINCT indices. The mutex serializes + /// them even though both start from an empty map and both would otherwise + /// seed to 1. `yield_now` inside the seed widens the interleaving window so + /// a broken (non-serialized) allocator would reliably hand out 1 twice. + #[tokio::test] + async fn concurrent_allocations_never_collide() { + let alloc = empty_allocator(); + let owner = Identifier::from([3u8; 32]); + + let seed = || async { + tokio::task::yield_now().await; + Ok(next_encryption_key_index_from_count(0)) + }; + let (r1, r2) = tokio::join!( + reserve_next_index(&alloc, &owner, seed()), + reserve_next_index(&alloc, &owner, seed()), + ); + let (i1, i2) = (r1.expect("task 1"), r2.expect("task 2")); + + assert_ne!(i1, i2, "concurrent allocations must not collide"); + let mut got = [i1, i2]; + got.sort_unstable(); + assert_eq!(got, [1, 2], "the two racing indices must be exactly 1 and 2"); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index 883e4bae99..3e6d7b8601 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -38,6 +38,7 @@ use zeroize::Zeroizing; use crate::broadcaster::{SpvBroadcaster, TransactionBroadcaster}; use crate::error::PlatformWalletError; use crate::wallet::asset_lock::manager::AssetLockManager; +use crate::wallet::identity::network::encrypted_document::EncryptionKeyIndexAllocator; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; /// Default gap limit for identity discovery scanning. @@ -322,6 +323,15 @@ pub struct IdentityWallet { /// signer-generic `PutDocument` trait) behind two by-value methods /// so the call sites stay simple. pub(crate) sdk_writer: Arc, + /// In-process, per-owner-identity high-water map for allocating the + /// txMetadata `encryptionKeyIndex` when the host omits it — the Rust-side + /// index-allocation policy (dashpay/platform#4186 follow-up). Shared across + /// every clone of this handle (an `Arc`), so two concurrent + /// encrypted-document creates through the SAME wallet process serialize + /// under its mutex and can never pick the same index. Best-effort unique + /// PER DEVICE only; see + /// [`IdentityWallet::allocate_encryption_key_index`](crate::wallet::identity::IdentityWallet::allocate_encryption_key_index). + pub(crate) enc_key_index_allocator: EncryptionKeyIndexAllocator, } // Manual `Debug`: the derive would require `B: Debug`, which is not part @@ -345,6 +355,7 @@ impl Clone for IdentityWallet { persister: self.persister.clone(), broadcaster: Arc::clone(&self.broadcaster), sdk_writer: Arc::clone(&self.sdk_writer), + enc_key_index_allocator: Arc::clone(&self.enc_key_index_allocator), } } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index bd797eac60..1ca05f40d7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -3676,6 +3676,7 @@ mod tests { persister: real.persister.clone(), broadcaster: Arc::new(AcceptingBroadcaster), sdk_writer: Arc::clone(&real.sdk_writer), + enc_key_index_allocator: Arc::clone(&real.enc_key_index_allocator), } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 0df9683bfa..809be2f8cc 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -444,6 +444,12 @@ impl PlatformWallet { sdk_writer: Arc::new( crate::wallet::identity::network::sdk_writer::SdkWriter::new(Arc::clone(&sdk)), ), + // Fresh, empty allocator: encryptionKeyIndex high-water is seeded + // lazily per owner-identity from Platform state on the first + // host-omitted create (dashpay/platform#4186 follow-up). + enc_key_index_allocator: Arc::new(tokio::sync::Mutex::new( + std::collections::HashMap::new(), + )), }; let platform = PlatformAddressWallet::new( diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 2bbad7b796..f15a38bc30 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -754,16 +754,22 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do /// Create + broadcast an ENCRYPTED wallet-contract document (the wire- /// compatible `txMetadata` shape) — the JNI bridge over -/// `platform_wallet_create_encrypted_document_with_signer`. +/// `platform_wallet_create_encrypted_document_with_signer` and its +/// Rust-allocated-index sibling. /// /// The SDK derives the identity encryption key, seals `payload` into the /// legacy `version ‖ IV ‖ AES-256-CBC` blob, and writes -/// `{keyIndex, encryptionKeyIndex, encryptedMetadata}`. `encryptionKeyIndex` is -/// the app's per-document index; `version` is the payload version byte -/// (`1` = protobuf); `payload` is the already-serialized opaque plaintext (a -/// protobuf `TxMetadataBatch`) — the SDK does not parse it. Returns the -/// confirmed document's canonical JSON (its 32-byte id is the base58 `$id` -/// field); null after throwing on error. +/// `{keyIndex, encryptionKeyIndex, encryptedMetadata}`. `version` is the payload +/// version byte (`1` = protobuf); `payload` is the already-serialized opaque +/// plaintext (a protobuf `TxMetadataBatch`) — the SDK does not parse it. +/// +/// `encryption_key_index` carries the per-document index OR the `-1` sentinel +/// (dashpay/platform#4186 follow-up): a non-negative value is used verbatim +/// (routed to the explicit-index export, retained for migration / tests), while +/// `-1` means "let the SDK allocate the index from authoritative Platform state" +/// and routes to `platform_wallet_create_encrypted_document_with_signer_auto_index`. +/// Any value `< -1` is rejected. Returns the confirmed document's canonical JSON +/// (its 32-byte id is the base58 `$id` field); null after throwing on error. #[no_mangle] #[allow(clippy::too_many_arguments)] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_documentCreateEncrypted( @@ -789,10 +795,19 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do let Some(doc_type) = read_cstring(env, &document_type, "documentType") else { return ptr::null_mut(); }; - if encryption_key_index < 0 { - throw_sdk_exception(env, 1, "encryptionKeyIndex must be non-negative"); + // encryptionKeyIndex == -1 is the "let Rust allocate" sentinel + // (dashpay/platform#4186 follow-up): the host omits the index and the + // SDK derives the next one from Platform state. A non-negative value is + // an explicit caller-supplied index; anything below -1 is invalid. + if encryption_key_index < -1 { + throw_sdk_exception( + env, + 1, + "encryptionKeyIndex must be >= 0, or -1 to let the SDK allocate it", + ); return ptr::null_mut(); } + let auto_index = encryption_key_index == -1; // Only 0 (CBOR) and 1 (protobuf) are wire-decodable by the legacy dashj // decryptTxMetadata; anything else seals a document the legacy stack // can't read. Fail fast here with the correct bound instead of the stale @@ -825,21 +840,41 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do let mut out_id = [0u8; 32]; let mut out_json: *mut c_char = ptr::null_mut(); - let result = unsafe { - platform_wallet_ffi::platform_wallet_create_encrypted_document_with_signer( - wallet_handle as Handle, - mnemonic_resolver_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, - owner.as_ptr(), - contract.as_ptr(), - doc_type.as_ptr(), - encryption_key_index as u32, - version as u8, - payload_bytes.as_ptr(), - payload_bytes.len(), - signer_handle as *mut SignerHandle, - out_id.as_mut_ptr(), - &mut out_json as *mut *mut c_char, - ) + let result = if auto_index { + // Host omitted the index: route to the ABI-additive sibling that + // takes no encryptionKeyIndex and lets Rust allocate it. + unsafe { + platform_wallet_ffi::platform_wallet_create_encrypted_document_with_signer_auto_index( + wallet_handle as Handle, + mnemonic_resolver_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, + owner.as_ptr(), + contract.as_ptr(), + doc_type.as_ptr(), + version as u8, + payload_bytes.as_ptr(), + payload_bytes.len(), + signer_handle as *mut SignerHandle, + out_id.as_mut_ptr(), + &mut out_json as *mut *mut c_char, + ) + } + } else { + unsafe { + platform_wallet_ffi::platform_wallet_create_encrypted_document_with_signer( + wallet_handle as Handle, + mnemonic_resolver_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, + owner.as_ptr(), + contract.as_ptr(), + doc_type.as_ptr(), + encryption_key_index as u32, + version as u8, + payload_bytes.as_ptr(), + payload_bytes.len(), + signer_handle as *mut SignerHandle, + out_id.as_mut_ptr(), + &mut out_json as *mut *mut c_char, + ) + } }; // The FFI call has returned: the plaintext has been sealed into // ciphertext and the broadcast has already completed. Scrub this copy From db7dc3851866b7ba63687bd76fba7d028bb5afce Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:39:39 -0400 Subject: [PATCH 22/30] docs(keyindex): note allocator serialization and failure-gap trade-offs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 (doc-only): the per-wallet mutex serializes cross-owner allocations during a first-time seed fetch, and a create that fails after allocating leaves an index gap, never a collision — both now stated at the allocator. Co-Authored-By: Claude Fable 5 --- .../src/wallet/identity/network/encrypted_document.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index b06c15d4a4..b5aa3dc93c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -72,6 +72,12 @@ pub(crate) fn next_encryption_key_index_from_count(count: u32) -> u32 { /// Cross-DEVICE uniqueness is NOT guaranteed (another device that has not yet /// reflected its writes on Platform can seed to the same base); see /// [`IdentityWallet::allocate_encryption_key_index`] for why that stays safe. +/// +/// Two deliberate trade-offs of the single per-wallet mutex + optimistic +/// reservation: allocations for OTHER owners in the same wallet serialize +/// behind a first-time seed fetch (benign for the normal one-identity case), +/// and a create that fails after allocating leaves a harmless index GAP — +/// never a collision — since the high-water is not rolled back. pub(crate) async fn reserve_next_index( allocator: &tokio::sync::Mutex>, owner: &Identifier, From 4f2eb06d646b9e7a4b28d68aac8aeb37b7ffe038 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:15:13 -0400 Subject: [PATCH 23/30] fix(platform-wallet): validate txMetadata payload size before allocating encryptionKeyIndex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses shumkov's #4195 review: on the auto-index create path the network count / high-water reservation (`allocate_encryption_key_index` -> `reserve_next_index`) ran BEFORE the deterministic 4063-byte payload gate, so an oversized payload — which must fail — still consumed an index and left a gap. Run the size check first. It is a pure, network-free bound (`ensure_tx_metadata_payload_fits` / `MAX_TX_METADATA_PLAINTEXT_LEN`) and needs no key material: - new `reserve_next_index_checked(allocator, owner, payload_len, seed)` runs `ensure_tx_metadata_payload_fits(payload_len)?` before `reserve_next_index`, so an oversized payload returns the typed `TxMetadataPayloadTooLarge` without polling the seed — the high-water is never seeded or advanced (no consumed index, no gap). - `IdentityWallet::allocate_encryption_key_index` gains a `payload_len` param and routes through the checked variant; the FFI auto-index create path passes `payload_vec.len()`. Explicit-index path is unchanged (it never allocates). - new unit test `oversized_payload_does_not_advance_highwater`: asserts the typed error, that the owner is absent from the allocator map, and that the next well-sized reservation still seeds at 1 (no gap). cargo test (platform-wallet allocator 5/5, platform-wallet-ffi 204/204) + clippy green; Kotlin :sdk:compileDebugKotlin + :sdk:testDebugUnitTest green (JNI/Kotlin ABI unchanged — payload_len plumbing is internal to Rust). Note (source-breaking, positional Kotlin callers): the #4186 stack moved `SDK.Documents.createEncryptedDocument`'s `encryptionKeyIndex` to the last positional slot (`Int? = null`). Positional callers must drop the argument (let Rust allocate) or switch to a named argument; named callers are unaffected. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet-ffi/src/document.rs | 12 +- .../identity/network/encrypted_document.rs | 125 +++++++++++++++--- 2 files changed, 120 insertions(+), 17 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index 0af29535a7..31bb3b29f2 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -397,7 +397,10 @@ pub unsafe extern "C" fn platform_wallet_create_encrypted_document_with_signer_a /// from Platform state via `IdentityWallet::allocate_encryption_key_index` /// (serialized under the wallet's allocator mutex) BEFORE any key material is /// resolved — the allocation touches no secrets and never crosses the broadcast -/// await with the master in scope. +/// await with the master in scope. That allocation first runs the deterministic, +/// network-free payload-size gate, so an oversized payload fails without +/// reserving (and thus without consuming) an index — no allocator gap +/// (dashpay/platform#4186 review). /// /// # Safety /// All pointers must be valid for the duration of the call; `payload` may be @@ -457,17 +460,22 @@ unsafe fn create_encrypted_document_impl( // allocates the next index from authoritative Platform state, serialized // under the wallet's allocator mutex (dashpay/platform#4186 follow-up). // The allocation touches no secrets, so it can run on the worker before - // the master is resolved. + // the master is resolved. `allocate_encryption_key_index` runs the + // deterministic payload-size gate (network-free) BEFORE reserving, so an + // oversized payload fails without consuming an index — no allocator gap + // (dashpay/platform#4186 review). let resolved_index: u32 = match index { Some(i) => i, None => { let iw = identity_wallet.clone(); let doc_type = document_type_str.clone(); + let payload_len = payload_vec.len(); block_on_worker(async move { iw.allocate_encryption_key_index( &owner_id_for_async, &contract_id_for_async, &doc_type, + payload_len, ) .await }) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index b5aa3dc93c..9f9d9e0f7d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -98,6 +98,34 @@ where Ok(next) } +/// [`reserve_next_index`] with the deterministic payload-size gate run FIRST, so +/// an over-large payload — one that MUST fail — never consumes an index. +/// +/// The size check ([`ensure_tx_metadata_payload_fits`]) is a pure, deterministic +/// bound (`payload_len <= MAX_TX_METADATA_PLAINTEXT_LEN`) that needs no network +/// and no key material. Running it before the allocator is touched means an +/// oversized payload returns the typed +/// [`PlatformWalletError::TxMetadataPayloadTooLarge`] WITHOUT seeding the +/// high-water or advancing it — no index is reserved, so the allocator leaves no +/// gap for a request that was always going to be rejected. Only once the payload +/// is known to fit do we (lazily, under the lock) seed/hand out the next index +/// (dashpay/platform#4186 review: validate size before allocating the index). +pub(crate) async fn reserve_next_index_checked( + allocator: &tokio::sync::Mutex>, + owner: &Identifier, + payload_len: usize, + seed: S, +) -> Result +where + S: std::future::Future>, +{ + // Deterministic, network-free size gate BEFORE any allocation: an oversized + // payload fails here, so `seed` is never polled and the high-water is never + // seeded/advanced — no consumed index, no gap. + ensure_tx_metadata_payload_fits(payload_len)?; + reserve_next_index(allocator, owner, seed).await +} + /// Where one encrypted-document call derives the per-document txMetadata AES /// key from. Selected by the CALLER (the FFI layer) from the wallet's shape — /// the same capability convention as the identity discovery / key-preview @@ -355,27 +383,44 @@ impl IdentityWallet { /// each carry a fresh random IV, decrypt independently, and are BOTH /// returned. A duplicate index is not even an extra decrypt attempt (the /// reader never guesses indices); no document is overwritten or shadowed. + /// + /// ## Size validated BEFORE allocating (no index consumed on failure) + /// `payload_len` is the plaintext length of the document about to be sealed. + /// It is checked against + /// [`MAX_TX_METADATA_PLAINTEXT_LEN`](crate::wallet::identity::crypto::tx_metadata::MAX_TX_METADATA_PLAINTEXT_LEN) + /// up front — a pure, + /// network-free bound — via [`reserve_next_index_checked`], so an oversized + /// payload (which the deterministic 4063-byte limit MUST reject) fails with + /// [`PlatformWalletError::TxMetadataPayloadTooLarge`] WITHOUT ever counting on + /// Platform or advancing the allocator's high-water. An always-doomed request + /// therefore leaves no index gap (dashpay/platform#4186 review). pub async fn allocate_encryption_key_index( &self, owner_identity_id: &Identifier, contract_id: &Identifier, document_type_name: &str, + payload_len: usize, ) -> Result { - reserve_next_index(&self.enc_key_index_allocator, owner_identity_id, async { - let count = self - .count_owned_txmetadata_documents( - contract_id, - owner_identity_id, - document_type_name, - ) - .await?; - let index = next_encryption_key_index_from_count(count); - breadcrumb(&format!( - "allocate_encryption_key_index: seeded owner={owner_identity_id} \ - existing_count={count} next_index={index}" - )); - Ok(index) - }) + reserve_next_index_checked( + &self.enc_key_index_allocator, + owner_identity_id, + payload_len, + async { + let count = self + .count_owned_txmetadata_documents( + contract_id, + owner_identity_id, + document_type_name, + ) + .await?; + let index = next_encryption_key_index_from_count(count); + breadcrumb(&format!( + "allocate_encryption_key_index: seeded owner={owner_identity_id} \ + existing_count={count} next_index={index}" + )); + Ok(index) + }, + ) .await } @@ -901,4 +946,54 @@ mod allocator_tests { got.sort_unstable(); assert_eq!(got, [1, 2], "the two racing indices must be exactly 1 and 2"); } + + /// An oversized payload on the auto-index path fails with the typed + /// `TxMetadataPayloadTooLarge` BEFORE the allocator is touched: the seed is + /// never polled, so the high-water is neither seeded nor advanced — no index + /// is consumed and no gap is left (dashpay/platform#4186 review). A + /// subsequent well-sized reservation for the same owner still starts at the + /// legacy `1 + count(0) == 1`, proving nothing was reserved by the doomed + /// request. + #[tokio::test] + async fn oversized_payload_does_not_advance_highwater() { + use crate::wallet::identity::crypto::tx_metadata::MAX_TX_METADATA_PLAINTEXT_LEN; + + let alloc = empty_allocator(); + let owner = Identifier::from([8u8; 32]); + + // 4064 bytes (MAX + 1) is the first rejected length. The seed panics if + // polled — proving the size gate short-circuits before any allocation. + let result = reserve_next_index_checked( + &alloc, + &owner, + MAX_TX_METADATA_PLAINTEXT_LEN + 1, + async { unreachable!("seed must not run when the payload is oversized") }, + ) + .await; + match result { + Err(PlatformWalletError::TxMetadataPayloadTooLarge { len, max }) => { + assert_eq!(len, MAX_TX_METADATA_PLAINTEXT_LEN + 1); + assert_eq!(max, MAX_TX_METADATA_PLAINTEXT_LEN); + } + other => panic!("expected TxMetadataPayloadTooLarge, got {other:?}"), + } + + // High-water NOT advanced: the owner was never inserted into the map. + assert!( + alloc.lock().await.get(&owner).is_none(), + "an oversized payload must not seed/advance the allocator high-water" + ); + + // The next well-sized reservation still seeds fresh at 1 — no gap was + // left by the rejected oversized request. + let index = reserve_next_index_checked(&alloc, &owner, 0, async { + Ok(next_encryption_key_index_from_count(0)) + }) + .await + .expect("well-sized reservation seeds ok"); + assert_eq!( + index, 1, + "the first index after a rejected oversized payload must still be 1 (no gap)" + ); + } } From 9efc0b7e3a1ff0deb70f09a8b00445678ad6e52b Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:12:29 -0400 Subject: [PATCH 24/30] feat(swift-sdk): Swift wrappers for encrypted-txMetadata FFI exports Mechanical Swift wrappers for the encrypted-document C-ABI exports added by dashpay/platform#4091 (port/v4.1/encrypted-documents). Re-stacked onto dashpay/platform#4195 (followup/v4.1/keyindex-rust-allocation) per the reviewer sequencing decision so the Rust-side auto-index export is available: - platform_wallet_create_encrypted_document_with_signer_auto_index - platform_wallet_fetch_encrypted_documents Adds ManagedPlatformWallet.createEncryptedDocument(...) and .fetchEncryptedDocuments(...) to Sources/SwiftDashSDK/PlatformWallet, mirroring the existing createDocument (signer + byte-buffer marshalling, withExtendedLifetime pinning, result-code .check(), string_free) and previewIdentityRegistrationKeys (internal MnemonicResolver construction + pinning) patterns. The plaintext payload is handed straight to Rust's Zeroizing buffer with no extra Swift-side copy, as the neighboring seed path does. createEncryptedDocument now calls the AUTO-INDEX export and DROPS the host-supplied encryptionKeyIndex parameter: Rust allocates the per-document encryptionKeyIndex from authoritative Platform state (dashpay/platform#4195), so hosts no longer assign it (host-side assignment risked cross-device collisions). This matches the Android auto-index path, where Kotlin's createEncryptedDocument omits the index (encryptionKeyIndex = null). The version-byte {0,1} guard and argument-order/nullability parity with the Kotlin counterpart are preserved; fetchEncryptedDocuments is unchanged. Updates EncryptedDocumentVersionValidationTests (the Swift mirror of the Kotlin DocumentTransactionsVersionValidationTest) to the new signature: the wire-meaningless version bytes (2/3/127/255) are rejected before any FFI dispatch. Verified: cbindgen regenerates the platform-wallet-ffi header with the auto-index export at the expected signature (no encryption_key_index arg); `swift build` of SwiftDashSDK type-checks the reworked call site against that header. `swift test` currently fails only at link because the checked-in prebuilt DashSDKFFI.xcframework static archive predates #4195 and lacks the auto-index symbol; a framework rebuild against #4195 resolves it. Co-Authored-By: Claude Opus 4.8 --- .../ManagedPlatformWallet.swift | 229 ++++++++++++++++++ ...ryptedDocumentVersionValidationTests.swift | 68 ++++++ 2 files changed, 297 insertions(+) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/EncryptedDocumentVersionValidationTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 4f5226ac82..bbae735e3e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3415,6 +3415,235 @@ extension ManagedPlatformWallet { }.value } + /// Create + broadcast an ENCRYPTED wallet-contract document (the + /// wire-compatible `txMetadata` shape) on `contractId`'s + /// `documentType`, owned by `ownerIdentityId`, signed via `signer`. + /// Returns the 32-byte document id and the confirmed document's + /// canonical query-side JSON once Platform confirms the transition. + /// + /// Sibling to `createDocument` — the encrypted counterpart that + /// bridges `platform_wallet_create_encrypted_document_with_signer_auto_index`. + /// The Rust side selects the identity's ENCRYPTION key id (the + /// `keyIndex` field), ALLOCATES the per-document `encryptionKeyIndex` + /// from authoritative Platform state, derives the AES key from the + /// wallet HD tree, and seals `payload` into the legacy + /// `version ‖ IV ‖ AES-256-CBC` blob, then broadcasts + /// `{keyIndex, encryptionKeyIndex, encryptedMetadata}` via the generic + /// create-with-signer path. The written document is decryptable by the + /// legacy `org.dashj.platform` stack and vice versa. The resolved master + /// xprv is wiped BETWEEN the (synchronous) derivation and the (async) + /// broadcast, so no key material crosses the network `.await` + /// (dashpay/platform#4091). + /// + /// The `encryptionKeyIndex` is no longer a host parameter: Rust allocates + /// it (dashpay/platform#4195), matching the Android auto-index path where + /// Kotlin's `createEncryptedDocument` omits it (`encryptionKeyIndex = + /// null`). Host-side index assignment risked cross-device collisions, so + /// both platforms now defer to the Rust-side allocator. + /// + /// Batching stays app-side: the caller serializes its items into + /// `payload` (a protobuf `TxMetadataBatch` for `version == 1`). The + /// plaintext `payload` is copied directly into a Rust-owned `Zeroizing` + /// buffer + /// (scrubbed on drop, before the broadcast await) — this wrapper keeps + /// no extra Swift-side copy, the same handling as the seed bytes that + /// flow through `MnemonicResolver`. Callers that hold sensitive + /// plaintext should scrub their own buffer after the call returns. + /// + /// `version` MUST be `0` (CBOR) or `1` (protobuf): `seal_tx_metadata` + /// writes the byte verbatim and the legacy dashj `decryptTxMetadata` + /// switches on exactly those two values, so an out-of-range byte would + /// silently seal a document the legacy stack can't decode. The guard + /// runs before any FFI call (mirrors the Kotlin + /// `DocumentTransactions.createEncryptedDocument` `require`). + /// + /// # Key source: chosen by wallet capability (Rust-side) + /// + /// A `MnemonicResolver` is always passed, but Rust decides whether to + /// use it: a key-resident wallet derives the AES key in-process; an + /// external-signable / Keychain-backed wallet (the app's shape) + /// derives on demand through the resolver. The resolver is pinned + /// across the synchronous FFI call with `withExtendedLifetime`, same as + /// `previewIdentityRegistrationKeys`. + /// + /// Lifetime contract: the `signer` instance MUST stay alive for the + /// duration of the synchronous FFI call (Rust holds a `passUnretained` + /// ctx pointer). It is pinned with `withExtendedLifetime` around the + /// full marshalling chain, matching the other `*_with_signer` wrappers. + public func createEncryptedDocument( + ownerIdentityId: Identifier, + contractId: Identifier, + documentType: String, + version: UInt8, + payload: Data, + signer: KeychainSigner, + storage: WalletStorage = WalletStorage() + ) async throws -> (Identifier, String) { + // Reject wire-meaningless version bytes before touching the FFI so + // a bad byte never seals a document the legacy stack can't decode + // (dashpay/platform#4091). Mirrors the Kotlin `require`. + guard version == 0 || version == 1 else { + throw PlatformWalletError.invalidParameter( + "version must be 0 (CBOR) or 1 (protobuf), got \(version)" + ) + } + + let handle = self.handle + let signerHandle = signer.handle + // Rust pulls the BIP-39 mnemonic on demand for external-signable + // wallets (the seed never round-trips into a Swift `String`); a + // key-resident wallet ignores it. Pinned below across the FFI call. + let resolver = MnemonicResolver(storage: storage) + let resolverHandle = resolver.handle + let ownerBytes: [UInt8] = ownerIdentityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let contractBytes: [UInt8] = contractId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + return try await Task.detached(priority: .userInitiated) { + var documentIdBytes = [UInt8](repeating: 0, count: 32) + // Receives an owned canonical-document JSON C string on + // success; freed with `platform_wallet_string_free` below. + var documentJsonPtr: UnsafeMutablePointer? = nil + + // Pin BOTH the signer and the resolver for the whole FFI call + // (see `createDocument` / `previewIdentityRegistrationKeys` for + // why a bare `_ = signer` is unreliable under -O). Rust + // dereferences both ctx pointers synchronously inside + // `block_on_worker`. + let result = withExtendedLifetime(resolver) { + withExtendedLifetime(signer) { + ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in + contractBytes.withUnsafeBufferPointer { contractBp -> PlatformWalletFFIResult in + documentType.withCString { typePtr -> PlatformWalletFFIResult in + // Borrow the plaintext bytes in place — no + // extra Swift copy. `baseAddress` is nil for + // an empty payload, which the FFI accepts + // only when `payload_len == 0`. + payload.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + let payloadPtr = raw.bindMemory(to: UInt8.self).baseAddress + return documentIdBytes.withUnsafeMutableBufferPointer { outBp in + // Auto-index export: Rust allocates the + // per-document `encryptionKeyIndex` from + // Platform state, so no index argument is + // passed (dashpay/platform#4195). + platform_wallet_create_encrypted_document_with_signer_auto_index( + handle, + resolverHandle, + ownerBp.baseAddress!, + contractBp.baseAddress!, + typePtr, + version, + payloadPtr, + UInt(payload.count), + signerHandle, + outBp.baseAddress!, + &documentJsonPtr + ) + } + } + } + } + } + } + } + + try result.check() + // Take ownership of the JSON and release the Rust allocation. + defer { if let p = documentJsonPtr { platform_wallet_string_free(p) } } + // On a successful broadcast the Rust side always writes the + // canonical JSON; a null pointer here is an FFI/ABI contract + // violation. Fail loudly rather than persist an empty body. + guard let jsonPtr = documentJsonPtr else { + throw PlatformWalletError.walletOperation( + "create_encrypted_document_with_signer returned no canonical document JSON" + ) + } + let canonicalJSON = String(cString: jsonPtr) + return (Data(documentIdBytes), canonicalJSON) + }.value + } + + /// Fetch + DECRYPT every encrypted wallet-contract document owned by + /// `ownerIdentityId` on `contractId`'s `documentType` updated at or + /// after `sinceMs` (epoch-millis). Returns an owned JSON array string. + /// + /// The wire-compatible read counterpart of the legacy + /// `getTxMetaData(since, key)` — bridges + /// `platform_wallet_fetch_encrypted_documents`. Each document's + /// `encryptedMetadata` blob is decrypted with the identity's derived + /// key; documents that can't be derived/decrypted are skipped Rust-side + /// (a bad document never aborts the fetch). + /// + /// Each element of the returned array is + /// `{ "id": base58, "ownerId": base58, "keyIndex": UInt32, + /// "encryptionKeyIndex": UInt32, "version": UInt8, + /// "updatedAt": UInt64|null, "payload": base64 }`, where `payload` is + /// the decrypted opaque plaintext the caller parses itself (a protobuf + /// `TxMetadataBatch` for `version == 1`). + /// + /// # Key source: chosen by wallet capability (Rust-side) + /// + /// A `MnemonicResolver` is always passed, but Rust consults it only + /// when the in-process wallet lacks resident keys (the app's + /// external-signable shape). The resolver is pinned across the + /// synchronous FFI call with `withExtendedLifetime`, same as + /// `previewIdentityRegistrationKeys`. + public func fetchEncryptedDocuments( + ownerIdentityId: Identifier, + contractId: Identifier, + documentType: String, + sinceMs: UInt64, + storage: WalletStorage = WalletStorage() + ) async throws -> String { + let handle = self.handle + let resolver = MnemonicResolver(storage: storage) + let resolverHandle = resolver.handle + let ownerBytes: [UInt8] = ownerIdentityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let contractBytes: [UInt8] = contractId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + return try await Task.detached(priority: .userInitiated) { + // Receives an owned JSON-array C string on success; freed with + // `platform_wallet_string_free` below. + var documentsJsonPtr: UnsafeMutablePointer? = nil + + // Pin the resolver for the whole FFI call — Rust dereferences + // its ctx pointer synchronously inside `block_on_worker`. + let result = withExtendedLifetime(resolver) { + ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in + contractBytes.withUnsafeBufferPointer { contractBp -> PlatformWalletFFIResult in + documentType.withCString { typePtr in + platform_wallet_fetch_encrypted_documents( + handle, + resolverHandle, + ownerBp.baseAddress!, + contractBp.baseAddress!, + typePtr, + sinceMs, + &documentsJsonPtr + ) + } + } + } + } + + try result.check() + defer { if let p = documentsJsonPtr { platform_wallet_string_free(p) } } + // On success the Rust side always writes a JSON array (even + // `"[]"`); a null pointer here is an FFI/ABI contract violation. + guard let jsonPtr = documentsJsonPtr else { + throw PlatformWalletError.walletOperation( + "fetch_encrypted_documents returned no JSON array" + ) + } + return String(cString: jsonPtr) + }.value + } + /// Replace + broadcast `documentId`'s properties on `contractId`'s /// `documentType`, owned by `ownerIdentityId`, signed with the /// explicit AUTHENTICATION + ECDSA key `signingKeyId`. Returns the diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/EncryptedDocumentVersionValidationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/EncryptedDocumentVersionValidationTests.swift new file mode 100644 index 0000000000..e0f100918f --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/EncryptedDocumentVersionValidationTests.swift @@ -0,0 +1,68 @@ +import XCTest +@testable import SwiftDashSDK + +/// Version-byte validation for +/// `ManagedPlatformWallet.createEncryptedDocument` (dashpay/platform#4091). +/// Only `0` (CBOR) and `1` (protobuf) are wire-meaningful — `seal_tx_metadata` +/// writes the byte verbatim and the legacy dashj `decryptTxMetadata` switches +/// on exactly those two values, so an out-of-range byte would silently seal a +/// document the legacy stack can't decode. +/// +/// The `guard` runs before any FFI call (no `platform_wallet_*` symbol is +/// dereferenced and the wallet `handle` is never used), so the REJECTION paths +/// are exercised with a dummy handle and no live wallet — the Swift mirror of +/// the Kotlin `DocumentTransactionsVersionValidationTest` +/// (`walletHandle = 0L`). The accepted values `0` / `1` would proceed into +/// native and can't be unit-tested here. +final class EncryptedDocumentVersionValidationTests: XCTestCase { + + /// A dummy, never-dispatched wallet handle (matches Kotlin's `0L`). The + /// version guard throws before the handle is read, so no FFI dispatch + /// occurs on the rejection paths under test. + private func makeWallet() -> ManagedPlatformWallet { + ManagedPlatformWallet(handle: 0, walletId: Data(count: 32)) + } + + private let id32 = Data(count: 32) + private let payload = Data([0, 1, 2, 3]) + + /// A signer is a required argument, but the version guard throws before it + /// is ever dereferenced — an in-memory-backed instance is enough to + /// satisfy the type. Built per-test. + private func makeSigner() throws -> KeychainSigner { + let container = try DashModelContainer.createInMemory() + return KeychainSigner(modelContainer: container, network: .testnet) + } + + /// Bytes `2...255` (every value the legacy `0..=255` range once accepted + /// beyond the two wire-meaningful ones) are rejected with a message that + /// names them. + func testRejectsVersionBytesTheLegacyStackCannotDecode() async throws { + let wallet = makeWallet() + let signer = try makeSigner() + for version: UInt8 in [2, 3, 127, 255] { + do { + _ = try await wallet.createEncryptedDocument( + ownerIdentityId: id32, + contractId: id32, + documentType: "txMetadata", + version: version, + payload: payload, + signer: signer + ) + XCTFail("version=\(version) must be rejected") + } catch let error as PlatformWalletError { + guard case let .invalidParameter(message) = error else { + XCTFail("expected .invalidParameter for version=\(version), got \(error)") + continue + } + XCTAssertTrue( + message.contains("0 (CBOR) or 1 (protobuf)"), + "message should name the wire-meaningful versions, got: \(message)" + ) + } catch { + XCTFail("expected PlatformWalletError for version=\(version), got \(error)") + } + } + } +} From 7616365d53017911e987f09156b22d5823154150 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 28 Jul 2026 12:14:13 +0700 Subject: [PATCH 25/30] fix(sdk): zeroize txMetadata decrypt buffers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep decrypted txMetadata payloads in zeroizing owners through AES, wallet, and FFI serialization, then release the shared C result through a dedicated zeroizing free on Kotlin/JNI and Swift. Preserve the existing JSON and host String APIs while documenting that runtime-managed host strings and parsed copies cannot be reliably scrubbed. Test would have caught this in CI: ✖ baseline lifetime assertions did not compile because decrypted payloads were plain Vec values and no sensitive decrypt primitive existed; ✔ the unchanged assertions and focused encryption, wallet, FFI, and JNI suites pass with zeroizing owners and sensitive release coverage. --- Cargo.lock | 1 + ...ETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md | 202 +++++++++ .../dashsdk/documents/DocumentTransactions.kt | 6 + .../dashsdk/ffi/TransactionsNative.kt | 6 + packages/rs-platform-encryption/Cargo.toml | 1 + packages/rs-platform-encryption/src/aes.rs | 62 ++- packages/rs-platform-encryption/src/lib.rs | 2 +- .../rs-platform-wallet-ffi/src/document.rs | 51 +-- packages/rs-platform-wallet-ffi/src/lib.rs | 1 + .../src/tx_metadata_json.rs | 386 ++++++++++++++++++ packages/rs-platform-wallet-ffi/src/types.rs | 56 +++ .../src/wallet/identity/crypto/tx_metadata.rs | 38 +- .../identity/network/encrypted_document.rs | 29 +- .../rs-unified-sdk-jni/src/transactions.rs | 86 +++- .../ManagedPlatformWallet.swift | 14 +- 15 files changed, 874 insertions(+), 67 deletions(-) create mode 100644 docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md create mode 100644 packages/rs-platform-wallet-ffi/src/tx_metadata_json.rs diff --git a/Cargo.lock b/Cargo.lock index 91410a321d..b14a2043bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5121,6 +5121,7 @@ dependencies = [ "secp256k1", "sha2", "thiserror 1.0.69", + "zeroize", ] [[package]] diff --git a/docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md b/docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md new file mode 100644 index 0000000000..18214ab0a7 --- /dev/null +++ b/docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md @@ -0,0 +1,202 @@ +# txMetadata decrypt-path plaintext lifetime + +**Status:** IMPLEMENTED — REVIEWED +**Date:** 2026-07-23 +**Work unit:** iOS/Kotlin parity U7 +**Baseline:** PR #4194 head `9efc0b7e3a`, including PR #4195 head `4f2eb06d64` +**Scope:** `rs-platform-encryption`, `rs-platform-wallet`, `rs-platform-wallet-ffi`, `rs-unified-sdk-jni`, Kotlin SDK fetch documentation, and the incoming Swift fetch wrapper + +## Problem + +The txMetadata create direction is already scrubbed after PR #4186. The decrypt direction is not. + +`platform_encryption::decrypt_aes_256_cbc` first decrypts in place into a plain `Vec`, clones the unpadded plaintext into a second plain `Vec`, and drops the first allocation without zeroizing it. `open_tx_metadata` stores the clone in `OpenedTxMetadata.payload`, and `IdentityWallet::fetch_encrypted_documents` moves it into `DecryptedEncryptedDocument.payload`; both fields are also plain `Vec`. The FFI then base64-encodes each payload into a `serde_json::Value`, serializes the array into a plain `String`, converts that into a `CString`, and returns the raw pointer. `platform_wallet_string_free` deallocates that `CString` without zeroizing it. JNI additionally copies the C string into a Rust `String` before creating the JVM `String`. Swift copies the same C string into a Swift `String`. + +Consequently, decrypted financial metadata can remain in reclaimed native heap allocations after the fetch returns. Removing every residual is impossible without changing the public host return type: JVM and Swift `String` storage and runtime-created copies cannot be reliably overwritten by the SDK. U7 must close the controlled Rust/C allocations and document that host-runtime ceiling without claiming complete process-memory erasure. + +## Requirements + +1. The in-place AES decrypt buffer and every later SDK-owned plaintext allocation must zeroize on every normal, recoverable-error, and unwinding drop path. +2. Base64 and JSON construction must not create plain, later-unscrubbed native allocations containing the payload. +3. The returned C allocation must have an explicit ownership contract that zeroizes its complete backing allocation before deallocation. +4. Kotlin/JNI and Swift must consume the same FFI result and free it through the same sensitive ownership contract; public fetch signatures and JSON shape remain unchanged. +5. Kotlin and Swift public documentation must state the same limitation: the returned host `String` is plaintext-equivalent, cannot be reliably scrubbed, and must be parsed promptly and never logged or persisted unnecessarily. +6. The create direction, PR #4195 encryption-key-index allocation, wire format, query behavior, skip-on-decrypt-failure behavior, JNI descriptor, and Swift/Kotlin public signatures remain unchanged. + +## Chosen design + +Keep the existing JSON/base64 and host `String` API, but make the complete native decrypt-to-host-copy path sensitive by construction. + +### Zeroizing decryption and plaintext owners + +- Add an ABI-additive `decrypt_aes_256_cbc_zeroizing` primitive in `rs-platform-encryption`. It wraps the ciphertext copy in `Zeroizing>` before decrypting in place, obtains the unpadded length, truncates the same allocation, and returns it without copying. Invalid padding and unwinding therefore drop a guard that may contain partially decrypted bytes. +- Keep the existing `decrypt_aes_256_cbc -> Result, _>` interface for unrelated callers by having it copy from the zeroizing primitive. That preserves its source API and existing plaintext-lifetime contract while also scrubbing its in-place working allocation. txMetadata alone calls the new zeroizing primitive and avoids that final plain copy. +- Change `OpenedTxMetadata.payload` to `Zeroizing>`. It receives the zeroizing AES allocation directly. +- Change `DecryptedEncryptedDocument.payload` to `Zeroizing>`. Moving from the opened result remains allocation-preserving; cloning the document produces another zeroizing owner. +- Keep the handwritten redacted `Debug` implementations. Do not derive or log payload content. + +### Sensitive JSON construction + +Replace the `Vec -> String -> CString` chain in `platform_wallet_fetch_encrypted_documents` with one focused sensitive serializer: + +- A checked counting pass computes the exact JSON byte length plus the final NUL. Payload contribution uses `base64::encoded_len`; it does not encode or copy plaintext. Identifier and integer sizing uses fixed stack storage or allocation-free length helpers. +- Before sensitive writing starts, allocate an exact-length boxed byte slice filled with a non-NUL ASCII sentinel plus a final NUL and wrap it in a private `SensitiveCString` owner. The owner exposes the non-terminator region as an ordinary mutable slice; it does not cast a read-only `CString::as_ptr` into writable memory. Its `Drop` zeroizes the complete owned slice including the terminator before deallocation. +- A bounded writer borrows that fixed allocation exclusively and cannot grow it. It writes JSON directly into the allocation and uses `base64::Engine::encode_slice` to encode each payload directly into its final output range. There is no payload-bearing base64 scratch, Serde `Value`, JSON `String`, or growable sensitive buffer. +- The writer rejects overflow or underfill, then validates the complete buffer for ASCII, interior NUL, and its final terminator. On any error, `SensitiveCString` remains armed and wipes the partially written allocation. +- After exact completion is validated, the boxed slice is converted to an exact-capacity `Vec` and then a `CString`; both conversions retain the allocation. `SensitiveCString::into_raw` disarms its guard and transfers that same allocation to the caller. All potentially reallocating construction occurred while the allocation held only non-sensitive sentinel bytes. +- Preserve the existing object field order as an output-compatibility precaution, as well as the exact field names, null handling, base58 identifiers, standard padded base64, array order, and successful empty result `"[]"`. JSON object order is not promoted to a new public contract. + +This deliberately bounded writer makes a wrong size estimate fail closed instead of silently reallocating and stranding plaintext. + +### Sensitive C-string ownership + +Add an ABI-additive `platform_wallet_sensitive_string_free(*mut c_char)` export: + +- Null is a no-op. +- A non-null pointer must have been returned by a function whose documentation names this free contract. +- The function reconstructs the original `CString`, converts it with `into_bytes_with_nul`, zeroizes the resulting allocation including the terminator, and then deallocates it. +- Callers treat the returned allocation as read-only and pass the original pointer without altering any byte or moving, adding, or removing the terminator; `CString::from_raw` requires its original length. +- `platform_wallet_fetch_encrypted_documents` documents that its output contains decrypted, plaintext-equivalent data and must be released with this function. +- `platform_wallet_string_free` remains the ordinary non-sensitive contract. Existing secret-bearing FFI precedent stays unchanged, including `platform_wallet_address_private_key_free`, which already uses a dedicated zeroizing release path. + +The fetch export signature itself does not change, so there is no C layout change and no JNI descriptor or Swift method-signature change. cbindgen adds only the new free symbol to the generated platform-wallet header. An old binary that calls `platform_wallet_string_free` remains memory-safe because the allocation is still a `CString`, but it does not receive U7's final-allocation zeroization guarantee. + +### Host copies and the documented ceiling + +JNI installs a nullable sensitive-pointer RAII guard immediately after the FFI call, before result/null/JNI error handling. Because the serializer enforces ASCII with no interior NUL, JNI passes the existing C buffer directly to raw `NewStringUTF` rather than using `JNIEnv::new_string`, whose `JNIString` conversion would create another unsanitized native allocation. The guard invokes `platform_wallet_sensitive_string_free` after the JVM copy or on any early return/unwind. The returned Java/Kotlin `String` remains runtime-managed and unsrubbable. + +Swift installs its nullable-pointer `defer` immediately after the FFI call, before `result.check()`, and uses `platform_wallet_sensitive_string_free`. It keeps its current `String(cString:)` copy. The returned Swift `String` remains runtime-managed and may share or copy storage. + +Both Kotlin public entry points (`DocumentTransactions.kt` and `TransactionsNative.kt`) and the Swift `ManagedPlatformWallet.fetchEncryptedDocuments` wrapper use equivalent wording: + +> SDK-owned Rust/C decrypted payload and JSON buffers are zeroized before deallocation. The returned host `String` is plaintext-equivalent; its runtime-managed storage, copies, and parsed-object copies cannot be reliably overwritten by the SDK. Parse it promptly, do not log it, and do not retain or persist it longer than required. + +This is an honest boundary, not a guarantee that all plaintext has disappeared from the process. + +## Interface and data flow + +```mermaid +flowchart TB + A[Encrypted Platform document] --> B[Zeroizing in-place AES buffer] + B --> C[OpenedTxMetadata payload
same Zeroizing Vec] + C --> D[DecryptedEncryptedDocument payload
Zeroizing Vec] + D --> E[Bounded base64 + JSON writer] + E --> F[Sensitive boxed-byte allocation] + F --> G[Raw caller-owned pointer] + G --> H{Host bridge copies} + H --> I[JVM String] + H --> J[Swift String] + G --> K[Sensitive free zeroizes native allocation] + I --> L[Documented unsrubbable host residual] + J --> L +``` + +| Interface | Before U7 | After U7 | +| --- | --- | --- | +| `decrypt_aes_256_cbc_zeroizing` | Absent | Additive primitive returning `Zeroizing>` | +| `OpenedTxMetadata.payload` | `Vec` | `Zeroizing>` | +| `DecryptedEncryptedDocument.payload` | `Vec` | `Zeroizing>` | +| `platform_wallet_fetch_encrypted_documents` | JSON `CString`; ordinary free | Same ABI/JSON; sensitive-free contract | +| C release API | `platform_wallet_string_free` | New `platform_wallet_sensitive_string_free` for this result | +| JNI/Kotlin fetch | `String`; plain Rust copy before JVM copy | Same descriptor/return type; direct guarded JVM copy | +| Swift fetch | `async throws -> String`; ordinary free | Same signature; sensitive free | + +The two public Rust payload-field type changes are source incompatible for code that constructs, destructures, or moves those fields as `Vec`. Workspace call sites must be updated explicitly. No C/JNI/Swift/Kotlin signature changes. + +## Alternatives rejected + +### Structured binary out-buffer + +A C array of document structs with `(payload_ptr, payload_len)` fields and a zeroizing array free would avoid base64 in the shared FFI. It is the stronger foundation for a future typed host API returning `ByteArray`/`Data`. + +It is rejected for U7 because the public Kotlin and incoming Swift wrappers return the existing JSON `String`. Preserving that API would move JSON/base64 construction into JNI and Swift, duplicating sensitive serialization and ownership logic across hosts. Changing both public APIs to typed results would be a larger source/API redesign with C layout pins, generated-header changes, Kotlin models, Swift models, and new caller migration. That work should be considered separately if eliminating the terminal host `String` becomes a requirement. + +### Zeroize every `platform_wallet_string_free` + +Changing the general free function would require fewer symbols and would make old callers scrub this final allocation automatically. The serializer is what removes the earlier base64/JSON intermediates under either free design. + +It is rejected because it changes the cost and behavior of every ordinary platform-wallet string release and obscures which outputs carry a sensitive ownership obligation. The dedicated export follows the repository's private-key precedent and limits the behavioral blast radius, at the cost of one additive symbol, two host release-call changes, and a documented free-function mismatch risk. + +### Wrap only the final payload or final JSON string + +Wrapping only `DecryptedEncryptedDocument.payload`, or only the final JSON `String`, leaves earlier decrypt owners, base64 strings, `serde_json::Value` strings, and reallocations unsanitized. It does not close the reported lifetime path and is rejected. + +## Failure modes and handling + +| Failure | Required behavior | +| --- | --- | +| Key derivation, AES padding validation, or decryption fails for one document | Preserve the current skip-and-warn behavior; the in-place AES buffer and every later temporary key/plaintext owner drop and zeroize. | +| Length arithmetic, bounded writing, or output validation fails | Return an error with `*out_documents_json` still null; zeroize every payload and the partial CString allocation on drop. | +| Fetch succeeds with no documents | Return `"[]"` through the sensitive contract; both hosts still call the sensitive free. | +| JNI UTF/JVM allocation fails or a panic occurs after receiving the pointer | RAII guard zeroizes and releases the C allocation before returning null or unwinding into the outer JNI guard. | +| Swift conversion or later validation throws | `defer` zeroizes and releases the C allocation. | +| New caller uses the ordinary string free | It remains memory-safe but violates the sensitive ownership contract and skips final-allocation zeroization; updated Kotlin/JNI and Swift wrappers must never do this for fetch output. | +| Future serializer change introduces reallocation or a plain base64/JSON tree | Focused regression tests must fail; code review must treat this as a plaintext-lifetime regression. | +| Host caller retains/logs the returned `String` | Native guarantees no longer apply; public docs prohibit logging and unnecessary retention but cannot enforce erasure. | +| Allocator abort or `panic=abort` terminates the process | No cleanup promise is made because Rust destructors do not run. The guarantee covers normal returns, recoverable errors, and unwinding paths where drops execute. | + +## Verification plan + +Implementation follows a failing-then-passing sequence. + +1. Before production changes, add a compile-red decrypt-lifetime regression test. A typed assertion helper accepts only `&Zeroizing>`; pass it the new sensitive AES result, `OpenedTxMetadata.payload`, and `DecryptedEncryptedDocument.payload` built from the existing concrete txMetadata plaintext vector. On the baseline, the test target must fail to compile because the sensitive AES primitive is absent and both fields are `Vec`. Record those compiler errors, then run the unchanged test after the fix and require it to compile and pass. This intentionally tests the storage-type security invariant rather than using brittle runtime type-name strings. +2. Add `rs-platform-encryption` unit coverage for successful sensitive decrypt without a plaintext clone and invalid-padding/error handling while the in-place buffer is guard-owned. Keep the existing ordinary decrypt API tests passing. +3. Add focused FFI tests for the private bounded writer, `SensitiveCString`, and release seam: + - one document preserves the current emitted bytes, every field, and exact padded base64 payload; + - multiple and empty results preserve field/array order and `"[]"`; + - the final output is ASCII, contains no interior NUL, and exactly consumes the fixed output region; + - undersized capacity is rejected without growth, and a deliberately failing writer leaves the FFI out-pointer null; + - the shared wipe primitive overwrites a still-live byte slice, including a NUL terminator, before deallocation; tests never inspect freed memory; + - null release is a no-op; + - error/unwind ownership is exercised at the safe internal owner seam. +4. Run the original compile-red decrypt-lifetime test unchanged and record its red-to-green transition. +5. Run targeted and crate-level Rust tests for `platform-encryption`, `platform-wallet`, `platform-wallet-ffi`, and `rs-unified-sdk-jni`, plus formatting and clippy for the changed crates. +6. Regenerate the cbindgen header and verify the fetch signature is unchanged and the only new relevant ABI surface is `platform_wallet_sensitive_string_free`. +7. Add JNI coverage or a focused seam test proving the guard uses sensitive free on success and JNI failure, and that the direct `NewStringUTF` input satisfies the ASCII/no-interior-NUL precondition. +8. Run Kotlin SDK JVM tests under JDK 17 and build the Android native library so the unchanged JNI descriptor/symbol path is exercised. +9. Rebuild the iOS framework from this branch before Swift validation, then run Swift package tests/build. The prebuilt framework at PR #4194 head predates PR #4195's auto-index symbol and is not a valid link artifact for this verification. +10. Run `git diff --check` and inspect the final diff to confirm no create-path, allocator-policy, query, wire-format, or unrelated host cleanup entered U7. + +Memory inspection after deallocation is undefined behavior, so tests prove the security contract at safe seams: zeroizing owner types, in-place overwrite before release, guarded ownership on every exit, unchanged serialized output, and generated ABI use. + +## Coordination with PRs #4194 and #4195 + +- PR #4195 remains the owner of Rust-side `encryptionKeyIndex` allocation and create-path size-before-allocation behavior. U7 does not edit those decisions or their host documentation. +- PR #4194 remains the owner of the incoming Swift create/fetch wrappers. U7 changes only the fetch result's release call and lifetime documentation in that wrapper. +- Both PRs are open as of 2026-07-23, and this branch already contains both current heads. Immediately before implementation, refetch and compare their final heads or merge commits with this baseline. Sync only any new upstream delta; do not replay #4195 or duplicate its create/allocator changes. Preserve #4194's final host behavior, then apply only U7's fetch-path lifetime deltas. +- No commit, push, or PR creation is part of this work unless Ivan asks. + +## Review record + +Three independent reviews were completed before implementation: + +- the required Swift/Rust FFI reviewer checked ownership transfer, generated-header/XCFramework impact, JNI copying, and Swift cleanup; +- a security/failure-mode reviewer traced plaintext back through AES error paths and challenged allocation, NUL, unwinding, and release guarantees; +- a simplicity/TDD reviewer checked source compatibility, the dedicated-versus-global free trade-off, executable red-to-green seams, host documentation placement, and #4194/#4195 overlap. + +Their must-fixes are incorporated above: the AES working allocation is now in scope, the sensitive CString uses the repository-compatible byte-vector wipe, the writer is fixed-size and fail-closed, Rust source incompatibilities and old-binary behavior are explicit, host guards are installed before result handling, and the verification plan names concrete compile-red and safe pre-deallocation seams. + +## Expected implementation surface + +| Area | Planned change | +| --- | --- | +| `rs-platform-encryption` | Add the zeroizing AES decrypt primitive, dependency, export, and success/error tests. | +| `rs-platform-wallet` | Use the sensitive primitive for txMetadata and change the two payload owners. | +| `rs-platform-wallet-ffi` | Add the bounded sensitive serializer/owner, dedicated free, docs, and focused tests. | +| `rs-unified-sdk-jni` | Add the immediate pointer guard and direct `NewStringUTF`; remove the native Rust JSON copy. | +| Kotlin SDK | Add matching limitation KDoc at both public entry points; no behavior/signature change. | +| Swift SDK/generated header | Use the immediate sensitive defer, add matching docs, regenerate/rebuild the header/framework. | + +## Sources + +- Existing zeroizing C-string precedent: `packages/rs-platform-wallet-ffi/src/address_private_key.rs` +- Earliest in-place decrypt buffer: `packages/rs-platform-encryption/src/aes.rs` +- Current decrypt owners: `packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs` and `packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs` +- Current FFI serialization/free contracts: `packages/rs-platform-wallet-ffi/src/document.rs` and `packages/rs-platform-wallet-ffi/src/types.rs` +- Current host bridges: `packages/rs-unified-sdk-jni/src/transactions.rs`, `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt`, and `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift` +- [`zeroize::Zeroizing`](https://docs.rs/zeroize/1.8.2/zeroize/struct.Zeroizing.html) and [`Zeroize` for allocated buffers](https://docs.rs/zeroize/1.8.2/zeroize/trait.Zeroize.html) +- [Rust `CString` ownership and raw-pointer contract](https://doc.rust-lang.org/std/ffi/struct.CString.html) +- [JNI `NewStringUTF`](https://docs.oracle.com/en/java/javase/26/docs/specs/jni/functions.html#newstringutf) +- [Java `String` values are unchanging](https://docs.oracle.com/javase/specs/jls/se25/html/jls-4.html#jls-4.3.3) +- [Swift `String(cString:)` copies the C bytes](https://developer.apple.com/documentation/swift/string/init(cstring:encoding:)) +- [Swift strings are value types with runtime copy optimizations](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/stringsandcharacters/#Strings-Are-Value-Types) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt index 5ff637c020..274145658a 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt @@ -351,6 +351,12 @@ class DocumentTransactions internal constructor( * `version == 1`) and reconciles memo / taxCategory / exchangeRate / * service / giftCard fields into its local store. * + * SDK-owned Rust/C decrypted payload and JSON buffers are zeroized before + * deallocation. The returned host `String` is plaintext-equivalent; its + * runtime-managed storage, copies, and parsed-object copies cannot be + * reliably overwritten by the SDK. Parse it promptly, do not log it, and + * do not retain or persist it longer than required. + * * [mnemonicResolverHandle] is the host mnemonic-resolver handle * ([org.dashfoundation.dashsdk.wallet.PlatformWalletManager.mnemonicResolverHandle]): * required for external-signable wallets (the app's shape — the AES key diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index bfc00cbaff..8fbaca1e83 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -219,6 +219,12 @@ internal object TransactionsNative { * "keyIndex", "encryptionKeyIndex", "version", "updatedAt" (number|null), * "payload" (base64 of the decrypted opaque plaintext) }`. Documents that * fail to decrypt are skipped Rust-side. + * + * SDK-owned Rust/C decrypted payload and JSON buffers are zeroized before + * deallocation. The returned host `String` is plaintext-equivalent; its + * runtime-managed storage, copies, and parsed-object copies cannot be + * reliably overwritten by the SDK. Parse it promptly, do not log it, and + * do not retain or persist it longer than required. */ external fun documentFetchEncrypted( walletHandle: Long, diff --git a/packages/rs-platform-encryption/Cargo.toml b/packages/rs-platform-encryption/Cargo.toml index 7c6757d23a..565549226b 100644 --- a/packages/rs-platform-encryption/Cargo.toml +++ b/packages/rs-platform-encryption/Cargo.toml @@ -17,6 +17,7 @@ cbc = "0.1" hmac = "0.12" sha2 = "0.10" thiserror = "1.0" +zeroize = "1" [dev-dependencies] # Tests generate keypairs via secp256k1's RNG helpers (`generate_keypair`, diff --git a/packages/rs-platform-encryption/src/aes.rs b/packages/rs-platform-encryption/src/aes.rs index c1609592fd..00a5fa14b0 100644 --- a/packages/rs-platform-encryption/src/aes.rs +++ b/packages/rs-platform-encryption/src/aes.rs @@ -2,6 +2,7 @@ use aes::cipher::{block_padding::Pkcs7, KeyIvInit}; use aes::Aes256; +use zeroize::Zeroizing; use crate::error::CryptoError; @@ -34,7 +35,7 @@ pub fn encrypt_aes_256_cbc(key: &[u8; 32], iv: &[u8; 16], data: &[u8]) -> Vec Vec Result, CryptoError> { +) -> Result>, CryptoError> { use aes::cipher::BlockDecryptMut; let cipher = Aes256CbcDec::new(key.into(), iv.into()); - let mut buffer = ciphertext.to_vec(); + let mut buffer = Zeroizing::new(ciphertext.to_vec()); - let decrypted = cipher + let plaintext_len = cipher .decrypt_padded_mut::(&mut buffer) - .map_err(|_| CryptoError::DecryptionFailed)?; + .map_err(|_| CryptoError::DecryptionFailed)? + .len(); + + buffer.truncate(plaintext_len); + Ok(buffer) +} + +/// Decrypt data using CBC-AES-256. +/// +/// This compatibility wrapper keeps the original `Vec` return type. Its +/// in-place working allocation is still zeroized before drop; callers that +/// retain sensitive plaintext should prefer [`decrypt_aes_256_cbc_zeroizing`]. +pub fn decrypt_aes_256_cbc( + key: &[u8; 32], + iv: &[u8; 16], + ciphertext: &[u8], +) -> Result, CryptoError> { + let decrypted = decrypt_aes_256_cbc_zeroizing(key, iv, ciphertext)?; Ok(decrypted.to_vec()) } @@ -78,4 +97,33 @@ mod tests { assert_eq!(plaintext, decrypted.as_slice()); } + + #[test] + fn should_use_zeroizing_storage_for_decrypted_plaintext() { + let key = [0x31; 32]; + let iv = [0x42; 16]; + let plaintext = b"txMetadata plaintext"; + let ciphertext = encrypt_aes_256_cbc(&key, &iv, plaintext); + + let decrypted = decrypt_aes_256_cbc_zeroizing(&key, &iv, &ciphertext).expect("decrypt"); + + fn assert_zeroizing(_: &Zeroizing>) {} + assert_zeroizing(&decrypted); + assert_eq!(decrypted.as_slice(), plaintext); + } + + #[test] + fn should_fail_invalid_padding_after_in_place_decryption() { + let key = [0x53; 32]; + let iv = [0x64; 16]; + let plaintext = [0x75; 15]; + let ciphertext = encrypt_aes_256_cbc(&key, &iv, &plaintext); + let mut invalid_iv = iv; + invalid_iv[15] ^= 1; + + assert!(matches!( + decrypt_aes_256_cbc_zeroizing(&key, &invalid_iv, &ciphertext), + Err(CryptoError::DecryptionFailed) + )); + } } diff --git a/packages/rs-platform-encryption/src/lib.rs b/packages/rs-platform-encryption/src/lib.rs index 6a6f9c4cc9..e78a8ecf6f 100644 --- a/packages/rs-platform-encryption/src/lib.rs +++ b/packages/rs-platform-encryption/src/lib.rs @@ -25,7 +25,7 @@ mod error; pub use account_label::{decrypt_account_label, encrypt_account_label}; pub use account_reference::{calculate_account_reference, unmask_account_reference}; -pub use aes::{decrypt_aes_256_cbc, encrypt_aes_256_cbc}; +pub use aes::{decrypt_aes_256_cbc, decrypt_aes_256_cbc_zeroizing, encrypt_aes_256_cbc}; pub use compact_xpub::{ compact_xpub_bytes, decrypt_extended_public_key, encrypt_extended_public_key, parse_compact_xpub, CompactXpub, COMPACT_XPUB_LEN, diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index 31bb3b29f2..406f7a5da7 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -10,14 +10,15 @@ use dpp::prelude::Identifier; use dpp::serialization::ValueConvertible; use key_wallet::bip32::ExtendedPrivKey; use platform_wallet::{PlatformWalletError, TxMetadataKeySource}; -use zeroize::Zeroizing; use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle, VTableSigner}; +use zeroize::Zeroizing; use crate::check_ptr; use crate::error::*; use crate::handle::*; use crate::identity_keys_from_mnemonic::resolve_master_from_resolver; use crate::runtime::block_on_worker; +use crate::tx_metadata_json::serialize_decrypted_documents; use crate::types::read_identifier; use crate::{unwrap_option_or_return, unwrap_result_or_return}; @@ -91,7 +92,11 @@ unsafe fn tx_metadata_key_master_for_wallet( // caller's safety contract guarantees it came from // `dash_sdk_mnemonic_resolver_create`. let master = unsafe { - resolve_master_from_resolver(mnemonic_resolver_handle, &wallet_id, wallet.network())? + resolve_master_from_resolver( + mnemonic_resolver_handle, + &wallet_id, + wallet.network(), + )? }; Ok(Some(master)) } @@ -487,10 +492,9 @@ unsafe fn create_encrypted_document_impl( // back into the host mnemonic resolver for external-signable // wallets — see `tx_metadata_key_master_for_wallet`). The resolved // master is wrapped in a Drop-wiping guard. - let master_opt = unsafe { - tx_metadata_key_master_for_wallet(wallet, mnemonic_resolver_handle) - }? - .map(WipingMaster); + let master_opt = + unsafe { tx_metadata_key_master_for_wallet(wallet, mnemonic_resolver_handle) }? + .map(WipingMaster); // Derive the AES key + seal the wire blob SYNCHRONOUSLY, then wipe the // master BEFORE any network `.await`: the master xprv never crosses the @@ -566,8 +570,10 @@ unsafe fn create_encrypted_document_impl( /// `tx_metadata_key_master_for_wallet`). /// /// On success `*out_documents_json` receives an owned NUL-terminated JSON array -/// (release with `platform_wallet_string_free`; left null on any error). Each -/// element is +/// containing decrypted, plaintext-equivalent data (release with +/// `platform_wallet_sensitive_string_free`; left null on any error). Treat the +/// allocation as read-only and pass its original, unmodified pointer to that +/// release function. Each element is /// `{ "id": base58, "ownerId": base58, "keyIndex": u32, "encryptionKeyIndex": /// u32, "version": u8, "updatedAt": u64|null, "payload": base64 }`, where /// `payload` is the decrypted, opaque plaintext the caller parses (a protobuf @@ -582,8 +588,6 @@ pub unsafe extern "C" fn platform_wallet_fetch_encrypted_documents( since_ms: u64, out_documents_json: *mut *mut c_char, ) -> PlatformWalletFFIResult { - use base64::Engine; - check_ptr!(document_type_name); check_ptr!(out_documents_json); @@ -604,10 +608,9 @@ pub unsafe extern "C" fn platform_wallet_fetch_encrypted_documents( // back into the host mnemonic resolver for external-signable // wallets — see `tx_metadata_key_master_for_wallet`). The resolved // master is wrapped in a Drop-wiping guard. - let master_opt = unsafe { - tx_metadata_key_master_for_wallet(wallet, mnemonic_resolver_handle) - }? - .map(WipingMaster); + let master_opt = + unsafe { tx_metadata_key_master_for_wallet(wallet, mnemonic_resolver_handle) }? + .map(WipingMaster); let result: Result, PlatformWalletError> = block_on_worker(async move { @@ -641,24 +644,8 @@ pub unsafe extern "C" fn platform_wallet_fetch_encrypted_documents( let result = unwrap_option_or_return!(option); let docs = unwrap_result_or_return!(result); - let json_array: Vec = docs - .iter() - .map(|d| { - serde_json::json!({ - "id": bs58::encode(d.document_id.to_buffer()).into_string(), - "ownerId": bs58::encode(d.owner_id.to_buffer()).into_string(), - "keyIndex": d.key_index, - "encryptionKeyIndex": d.encryption_key_index, - "version": d.version, - "updatedAt": d.updated_at_ms, - "payload": base64::engine::general_purpose::STANDARD.encode(&d.payload), - }) - }) - .collect(); - let json_string = - unwrap_result_or_return!(serde_json::to_string(&serde_json::Value::Array(json_array))); - let json_cstring = unwrap_result_or_return!(CString::new(json_string)); - *out_documents_json = json_cstring.into_raw(); + let sensitive_json = unwrap_result_or_return!(serialize_decrypted_documents(&docs)); + *out_documents_json = sensitive_json.into_raw(); PlatformWalletFFIResult::ok() } diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 5d80c33ded..0d8318690b 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -74,6 +74,7 @@ pub mod sign_with_mnemonic_resolver; pub mod spv; pub mod token_persistence; pub mod tokens; +mod tx_metadata_json; pub mod types; pub mod utils; pub mod wallet; diff --git a/packages/rs-platform-wallet-ffi/src/tx_metadata_json.rs b/packages/rs-platform-wallet-ffi/src/tx_metadata_json.rs new file mode 100644 index 0000000000..2356c417b0 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/tx_metadata_json.rs @@ -0,0 +1,386 @@ +use std::ffi::CString; +use std::os::raw::c_char; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use platform_wallet::DecryptedEncryptedDocument; + +use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; +use crate::types::zeroize_sensitive_bytes; + +const IDENTIFIER_BASE58_CAPACITY: usize = 48; + +fn serialization_error(message: &'static str) -> PlatformWalletFFIResult { + PlatformWalletFFIResult::err(PlatformWalletFFIResultCode::ErrorSerialization, message) +} + +fn arithmetic_error() -> PlatformWalletFFIResult { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorArithmeticOverflow, + "decrypted document JSON length overflow", + ) +} + +fn validate_ascii(bytes: &[u8]) -> Result<(), PlatformWalletFFIResult> { + if !bytes.is_ascii() || bytes.contains(&0) { + return Err(serialization_error( + "decrypted document JSON contains non-ASCII or NUL bytes", + )); + } + Ok(()) +} + +trait JsonWriter { + fn write_ascii(&mut self, bytes: &[u8]) -> Result<(), PlatformWalletFFIResult>; + fn write_payload(&mut self, payload: &[u8]) -> Result<(), PlatformWalletFFIResult>; +} + +struct CountingWriter { + written: usize, +} + +impl CountingWriter { + fn new() -> Self { + Self { written: 0 } + } + + fn written(&self) -> usize { + self.written + } +} + +impl JsonWriter for CountingWriter { + fn write_ascii(&mut self, bytes: &[u8]) -> Result<(), PlatformWalletFFIResult> { + validate_ascii(bytes)?; + self.written = self + .written + .checked_add(bytes.len()) + .ok_or_else(arithmetic_error)?; + Ok(()) + } + + fn write_payload(&mut self, payload: &[u8]) -> Result<(), PlatformWalletFFIResult> { + let encoded_len = base64::encoded_len(payload.len(), true).ok_or_else(arithmetic_error)?; + self.written = self + .written + .checked_add(encoded_len) + .ok_or_else(arithmetic_error)?; + Ok(()) + } +} + +struct FixedAsciiWriter<'a> { + output: &'a mut [u8], + written: usize, +} + +impl<'a> FixedAsciiWriter<'a> { + fn new(output: &'a mut [u8]) -> Self { + Self { output, written: 0 } + } + + fn written(&self) -> usize { + self.written + } + + fn remaining_mut(&mut self, len: usize) -> Result<&mut [u8], PlatformWalletFFIResult> { + let end = self.written.checked_add(len).ok_or_else(arithmetic_error)?; + if end > self.output.len() { + return Err(serialization_error( + "decrypted document JSON exceeded its fixed output buffer", + )); + } + Ok(&mut self.output[self.written..end]) + } +} + +impl JsonWriter for FixedAsciiWriter<'_> { + fn write_ascii(&mut self, bytes: &[u8]) -> Result<(), PlatformWalletFFIResult> { + validate_ascii(bytes)?; + self.remaining_mut(bytes.len())?.copy_from_slice(bytes); + self.written += bytes.len(); + Ok(()) + } + + fn write_payload(&mut self, payload: &[u8]) -> Result<(), PlatformWalletFFIResult> { + let encoded_len = base64::encoded_len(payload.len(), true).ok_or_else(arithmetic_error)?; + let written = STANDARD + .encode_slice(payload, self.remaining_mut(encoded_len)?) + .map_err(|_| { + serialization_error("base64 payload did not fit its fixed output range") + })?; + if written != encoded_len { + return Err(serialization_error( + "base64 payload length differed from its counted length", + )); + } + self.written += written; + Ok(()) + } +} + +fn write_identifier( + writer: &mut impl JsonWriter, + identifier: &[u8; 32], +) -> Result<(), PlatformWalletFFIResult> { + let mut encoded = [0u8; IDENTIFIER_BASE58_CAPACITY]; + let len = bs58::encode(identifier) + .onto(&mut encoded[..]) + .map_err(|_| serialization_error("identifier did not fit its base58 stack buffer"))?; + writer.write_ascii(&encoded[..len]) +} + +fn write_u64(writer: &mut impl JsonWriter, mut value: u64) -> Result<(), PlatformWalletFFIResult> { + let mut digits = [0u8; 20]; + let mut cursor = digits.len(); + loop { + cursor -= 1; + digits[cursor] = b'0' + (value % 10) as u8; + value /= 10; + if value == 0 { + break; + } + } + writer.write_ascii(&digits[cursor..]) +} + +fn write_documents( + writer: &mut impl JsonWriter, + documents: &[DecryptedEncryptedDocument], +) -> Result<(), PlatformWalletFFIResult> { + writer.write_ascii(b"[")?; + for (index, document) in documents.iter().enumerate() { + if index > 0 { + writer.write_ascii(b",")?; + } + writer.write_ascii(b"{\"id\":\"")?; + write_identifier(writer, &document.document_id.to_buffer())?; + writer.write_ascii(b"\",\"ownerId\":\"")?; + write_identifier(writer, &document.owner_id.to_buffer())?; + writer.write_ascii(b"\",\"keyIndex\":")?; + write_u64(writer, u64::from(document.key_index))?; + writer.write_ascii(b",\"encryptionKeyIndex\":")?; + write_u64(writer, u64::from(document.encryption_key_index))?; + writer.write_ascii(b",\"version\":")?; + write_u64(writer, u64::from(document.version))?; + writer.write_ascii(b",\"updatedAt\":")?; + if let Some(updated_at_ms) = document.updated_at_ms { + write_u64(writer, updated_at_ms)?; + } else { + writer.write_ascii(b"null")?; + } + writer.write_ascii(b",\"payload\":\"")?; + writer.write_payload(&document.payload)?; + writer.write_ascii(b"\"}")?; + } + writer.write_ascii(b"]") +} + +pub(crate) struct SensitiveCString { + inner: Option>, +} + +impl SensitiveCString { + fn new(content_len: usize) -> Result { + let allocation_len = content_len.checked_add(1).ok_or_else(arithmetic_error)?; + let mut bytes = vec![b' '; allocation_len]; + bytes[content_len] = 0; + Ok(Self { + inner: Some(bytes.into_boxed_slice()), + }) + } + + fn content_mut(&mut self) -> &mut [u8] { + let inner = self + .inner + .as_mut() + .expect("sensitive bytes are owned until consuming transfer"); + let content_len = inner + .len() + .checked_sub(1) + .expect("sensitive bytes include a NUL terminator"); + &mut inner[..content_len] + } + + fn validate(&self) -> Result<(), PlatformWalletFFIResult> { + let inner = self + .inner + .as_ref() + .expect("sensitive bytes are owned until consuming transfer"); + let Some((&terminator, content)) = inner.split_last() else { + return Err(serialization_error( + "decrypted document JSON output buffer was empty", + )); + }; + if terminator != 0 { + return Err(serialization_error( + "decrypted document JSON lost its NUL terminator", + )); + } + validate_ascii(content) + } + + #[cfg(test)] + fn as_c_str(&self) -> &std::ffi::CStr { + let inner = self + .inner + .as_deref() + .expect("test observes sensitive bytes before ownership transfer"); + std::ffi::CStr::from_bytes_with_nul(inner) + .expect("validated sensitive bytes form a C string") + } + + pub(crate) fn into_raw(mut self) -> *mut c_char { + let bytes = self + .inner + .take() + .expect("sensitive bytes are owned until consuming transfer") + .into_vec(); + // SAFETY: serialization validates that the final byte remains the sole + // NUL terminator. Converting an exact-length boxed slice into a Vec + // gives it capacity equal to its length, so CString adopts the same + // allocation without shrinking it. + unsafe { CString::from_vec_with_nul_unchecked(bytes) }.into_raw() + } +} + +impl Drop for SensitiveCString { + fn drop(&mut self) { + if let Some(mut inner) = self.inner.take() { + zeroize_sensitive_bytes(&mut inner); + } + } +} + +pub(crate) fn serialize_decrypted_documents( + documents: &[DecryptedEncryptedDocument], +) -> Result { + let mut counter = CountingWriter::new(); + write_documents(&mut counter, documents)?; + let expected_len = counter.written(); + + let mut output = SensitiveCString::new(expected_len)?; + let mut writer = FixedAsciiWriter::new(output.content_mut()); + write_documents(&mut writer, documents)?; + if writer.written() != expected_len { + return Err(serialization_error( + "decrypted document JSON did not fill its fixed output buffer", + )); + } + output.validate()?; + + Ok(output) +} + +#[cfg(test)] +mod tests { + use dpp::prelude::Identifier; + use platform_wallet::DecryptedEncryptedDocument; + + use super::*; + + fn document(payload: &[u8]) -> DecryptedEncryptedDocument { + DecryptedEncryptedDocument { + document_id: Identifier::from([1; 32]), + owner_id: Identifier::from([2; 32]), + key_index: 3, + encryption_key_index: 4, + version: 1, + updated_at_ms: Some(5), + payload: payload.to_vec().into(), + } + } + + #[test] + fn should_preserve_the_existing_sensitive_json_wire_shape() { + let serialized = + serialize_decrypted_documents(&[document(b"\x00\x01secret")]).expect("serialize"); + let id = bs58::encode([1; 32]).into_string(); + let owner_id = bs58::encode([2; 32]).into_string(); + let expected = format!( + r#"[{{"id":"{id}","ownerId":"{owner_id}","keyIndex":3,"encryptionKeyIndex":4,"version":1,"updatedAt":5,"payload":"AAFzZWNyZXQ="}}]"# + ); + + assert_eq!(serialized.as_c_str().to_bytes(), expected.as_bytes()); + } + + #[test] + fn should_serialize_empty_sensitive_json_as_an_ascii_array() { + let serialized = serialize_decrypted_documents(&[]).expect("serialize"); + + assert_eq!(serialized.as_c_str().to_bytes(), b"[]"); + assert!(serialized.as_c_str().to_bytes().is_ascii()); + assert!(!serialized.as_c_str().to_bytes().contains(&0)); + } + + #[test] + fn should_preserve_sensitive_json_array_order_and_null_timestamps() { + let first = document(b"first"); + let mut second = document(b"second"); + second.document_id = Identifier::from([9; 32]); + second.updated_at_ms = None; + + let serialized = + serialize_decrypted_documents(&[first, second]).expect("serialize documents"); + let json: serde_json::Value = + serde_json::from_slice(serialized.as_c_str().to_bytes()).expect("valid JSON"); + + assert_eq!( + json[0]["id"], + bs58::encode([1; 32]).into_string(), + "fetch order must be preserved" + ); + assert_eq!( + json[1]["id"], + bs58::encode([9; 32]).into_string(), + "fetch order must be preserved" + ); + assert!(json[1]["updatedAt"].is_null()); + assert_eq!(json[0]["payload"], "Zmlyc3Q="); + assert_eq!(json[1]["payload"], "c2Vjb25k"); + assert!(serialized.as_c_str().to_bytes().is_ascii()); + assert!(!serialized.as_c_str().to_bytes().contains(&0)); + } + + #[test] + fn should_reject_bounded_writer_overflow_without_growing() { + let mut storage = [b' '; 3]; + let mut writer = FixedAsciiWriter::new(&mut storage); + + assert!(writer.write_ascii(b"four").is_err()); + assert_eq!(writer.written(), 0); + assert_eq!(storage, [b' '; 3]); + } + + #[test] + fn should_zeroize_raw_pointer_bytes_before_release() { + let serialized = serialize_decrypted_documents(&[document(b"secret")]).expect("serialize"); + let expected_len = serialized.as_c_str().to_bytes_with_nul().len(); + let raw = serialized.into_raw(); + + let zeroized = unsafe { crate::types::zeroize_sensitive_string_into_bytes(raw) }; + + assert_eq!(zeroized.len(), expected_len); + assert!(zeroized.iter().all(|byte| *byte == 0)); + } + + #[test] + fn should_write_into_mutable_owned_bytes_before_cstring_transfer() { + fn assert_mutable_byte_owner(_: &Box<[u8]>) {} + + let mut serialized = SensitiveCString::new(6).expect("allocate"); + let owned_bytes = serialized + .inner + .as_ref() + .expect("sensitive bytes remain owned before transfer"); + assert_mutable_byte_owner(owned_bytes); + let allocation_ptr = owned_bytes.as_ptr(); + serialized.content_mut().copy_from_slice(b"secret"); + + let raw = serialized.into_raw(); + + assert_eq!(raw.cast::().cast_const(), allocation_ptr); + let zeroized = unsafe { crate::types::zeroize_sensitive_string_into_bytes(raw) }; + assert!(zeroized.iter().all(|byte| *byte == 0)); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/types.rs b/packages/rs-platform-wallet-ffi/src/types.rs index f53b0df033..bd7e6ab1d7 100644 --- a/packages/rs-platform-wallet-ffi/src/types.rs +++ b/packages/rs-platform-wallet-ffi/src/types.rs @@ -1,5 +1,7 @@ use std::os::raw::c_char; +use zeroize::Zeroize; + // Single source of truth for the network type across the Rust-side // wallet stack and the FFI boundary. `Network` is the typed enum; // `FFINetwork` is the `#[repr(C)]` mirror cbindgen emits for callers. @@ -169,6 +171,44 @@ pub unsafe extern "C" fn platform_wallet_string_free(s: *mut c_char) { } } +pub(crate) fn zeroize_sensitive_bytes(bytes: &mut [u8]) { + bytes.zeroize(); +} + +fn zeroize_cstring_into_bytes(string: std::ffi::CString) -> Vec { + let mut bytes = string.into_bytes_with_nul(); + zeroize_sensitive_bytes(&mut bytes); + bytes +} + +/// Reclaim and zeroize an owned sensitive C string while leaving its bytes live. +/// +/// # Safety +/// `s` must be a non-null pointer produced by [`std::ffi::CString::into_raw`]. +/// Ownership must not already have been reclaimed, and the C-string length and +/// terminating NUL must be unchanged. +pub(crate) unsafe fn zeroize_sensitive_string_into_bytes(s: *mut c_char) -> Vec { + let string = unsafe { std::ffi::CString::from_raw(s) }; + zeroize_cstring_into_bytes(string) +} + +/// Free a C string containing plaintext-equivalent sensitive data. +/// +/// The complete NUL-terminated allocation is zeroized before deallocation. +/// Null is a no-op. +/// +/// # Safety +/// `s` must be null or a pointer returned by an API that explicitly names +/// `platform_wallet_sensitive_string_free` as its release function. Callers +/// must pass the original pointer without modifying the allocation, including +/// its terminating NUL, and must not already have freed it. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_sensitive_string_free(s: *mut c_char) { + if !s.is_null() { + drop(unsafe { zeroize_sensitive_string_into_bytes(s) }); + } +} + #[cfg(test)] mod tests { use super::*; @@ -232,4 +272,20 @@ mod tests { assert!(res.is_err()); } } + + #[test] + fn should_clear_sensitive_string_bytes_including_terminator() { + let mut bytes = *b"plaintext\0"; + + zeroize_sensitive_bytes(&mut bytes); + + assert_eq!(bytes, [0; 10]); + } + + #[test] + fn should_accept_null_sensitive_string_free() { + unsafe { + platform_wallet_sensitive_string_free(std::ptr::null_mut()); + } + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs index f3f3f729eb..e0226c2084 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs @@ -323,7 +323,7 @@ pub struct OpenedTxMetadata { /// dispatches its payload parse on this. pub version: u8, /// The decrypted, PKCS7-unpadded payload bytes — opaque to this crate. - pub payload: Vec, + pub payload: Zeroizing>, } impl std::fmt::Debug for OpenedTxMetadata { @@ -331,7 +331,10 @@ impl std::fmt::Debug for OpenedTxMetadata { f.debug_struct("OpenedTxMetadata") .field("version", &self.version) // Redacted: never render the decrypted plaintext. - .field("payload", &format_args!("<{} bytes redacted>", self.payload.len())) + .field( + "payload", + &format_args!("<{} bytes redacted>", self.payload.len()), + ) .finish() } } @@ -368,9 +371,10 @@ pub fn open_tx_metadata( .try_into() .expect("slice [1..17) is exactly 16 bytes"); - let payload = platform_encryption::decrypt_aes_256_cbc(key, &iv, ciphertext).map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!("txMetadata decrypt failed: {e}")) - })?; + let payload = platform_encryption::decrypt_aes_256_cbc_zeroizing(key, &iv, ciphertext) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("txMetadata decrypt failed: {e}")) + })?; Ok(OpenedTxMetadata { version, payload }) } @@ -417,8 +421,10 @@ mod tests { assert_eq!(blob[0], version); assert_eq!(&blob[1..17], &iv); let opened = open_tx_metadata(&key, &blob).expect("open"); + fn assert_zeroizing(_: &Zeroizing>) {} + assert_zeroizing(&opened.payload); assert_eq!(opened.version, version); - assert_eq!(opened.payload, payload); + assert_eq!(opened.payload.as_slice(), payload.as_slice()); } } @@ -477,7 +483,7 @@ mod tests { "the max-payload blob must fit the encryptedMetadata field" ); let opened = open_tx_metadata(&key, &blob).expect("max-size blob round-trips"); - assert_eq!(opened.payload, max_payload); + assert_eq!(opened.payload.as_slice(), max_payload.as_slice()); // 4064 bytes: rejected up front with the typed error, before any cipher // work — it would frame to a 4097-byte blob and be refused at broadcast. @@ -530,7 +536,8 @@ mod tests { match open_tx_metadata(&wrong, &blob) { Err(_) => {} Ok(opened) => assert_ne!( - opened.payload, payload, + opened.payload.as_slice(), + payload.as_slice(), "a wrong key must not recover the original plaintext" ), } @@ -668,13 +675,13 @@ mod tests { seal_tx_metadata(&resident_key, VERSION_PROTOBUF, &iv, &payload).expect("valid version"); let opened_by_master = open_tx_metadata(&master_key, &sealed_by_resident).expect("master key opens"); - assert_eq!(opened_by_master.payload, payload); + assert_eq!(opened_by_master.payload.as_slice(), payload.as_slice()); let sealed_by_master = seal_tx_metadata(&master_key, VERSION_PROTOBUF, &iv, &payload).expect("valid version"); let opened_by_resident = open_tx_metadata(&resident_key, &sealed_by_master).expect("resident key opens"); - assert_eq!(opened_by_resident.payload, payload); + assert_eq!(opened_by_resident.payload.as_slice(), payload.as_slice()); } /// Secondary cross-stack check of the AES-256-CBC core + blob framing, @@ -715,7 +722,7 @@ mod tests { // And the framing round-trips back to the original block. let opened = open_tx_metadata(&key, &blob).expect("open"); assert_eq!(opened.version, VERSION_PROTOBUF); - assert_eq!(opened.payload, plaintext_block); + assert_eq!(opened.payload.as_slice(), plaintext_block.as_slice()); } /// Tiny fixed-size hex decoder for the test vectors (no extra dep). @@ -848,7 +855,8 @@ mod tests { let opened = open_tx_metadata(&key, &legacy_blob).expect("open legacy blob"); assert_eq!(opened.version, VERSION_PROTOBUF, "version byte"); assert_eq!( - opened.payload, expected_plaintext, + opened.payload.as_slice(), + expected_plaintext.as_slice(), "Rust must decrypt a dashj-produced txMetadata blob to the original plaintext" ); } @@ -973,7 +981,8 @@ mod tests { "the legacy install published a protobuf (version 1) txMetadata blob" ); assert_eq!( - opened.payload, expected_plaintext, + opened.payload.as_slice(), + expected_plaintext.as_slice(), "the new Rust crypto must decrypt a real dash-wallet 11.9 install's testnet \ txMetadata blob to its exact published plaintext, byte-for-byte" ); @@ -1093,7 +1102,8 @@ mod tests { let opened = open_tx_metadata(&key, &slot1_blob).expect("open slot-1 blob"); assert_eq!(opened.version, VERSION_PROTOBUF, "version byte"); assert_eq!( - opened.payload, expected_plaintext, + opened.payload.as_slice(), + expected_plaintext.as_slice(), "Rust must decrypt a blob sealed at identity_index=1 to the original plaintext" ); } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index 9f9d9e0f7d..981229417a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -26,6 +26,7 @@ use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV use dpp::identity::{KeyType, Purpose, SecurityLevel}; use dpp::platform_value::Value; use dpp::prelude::{DataContract, Identifier}; +use zeroize::Zeroizing; use crate::error::PlatformWalletError; use crate::wallet::identity::crypto::tx_metadata::{ @@ -258,7 +259,7 @@ pub struct DecryptedEncryptedDocument { /// this as its since-timestamp high-water mark for the next fetch. pub updated_at_ms: Option, /// The decrypted, opaque payload bytes. - pub payload: Vec, + pub payload: Zeroizing>, } impl std::fmt::Debug for DecryptedEncryptedDocument { @@ -271,11 +272,35 @@ impl std::fmt::Debug for DecryptedEncryptedDocument { .field("version", &self.version) .field("updated_at_ms", &self.updated_at_ms) // Redacted: never render the decrypted financial plaintext. - .field("payload", &format_args!("<{} bytes redacted>", self.payload.len())) + .field( + "payload", + &format_args!("<{} bytes redacted>", self.payload.len()), + ) .finish() } } +#[cfg(test)] +mod decrypted_document_tests { + use super::*; + + #[test] + fn should_use_zeroizing_storage_for_decrypted_payload() { + let document = DecryptedEncryptedDocument { + document_id: Identifier::from([1; 32]), + owner_id: Identifier::from([2; 32]), + key_index: 3, + encryption_key_index: 4, + version: 1, + updated_at_ms: Some(5), + payload: b"txMetadata plaintext".to_vec().into(), + }; + + fn assert_zeroizing(_: &Zeroizing>) {} + assert_zeroizing(&document.payload); + } +} + impl IdentityWallet { /// Select the identity's encryption key id (the document's `keyIndex` /// field): an `ECDSA_SECP256K1` `Purpose::ENCRYPTION` / `MEDIUM` key, falling diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index f15a38bc30..3f123c142c 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -50,6 +50,65 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; use std::ptr; +/// Nullable owner for plaintext-equivalent strings returned by +/// `platform_wallet_fetch_encrypted_documents`. +/// +/// Install this immediately after the FFI call so every later result, JNI +/// allocation, and unwind path releases the allocation through the sensitive +/// zeroizing contract. +struct SensitivePlatformWalletString(*mut c_char); + +impl SensitivePlatformWalletString { + fn as_c_str(&self) -> Option<&CStr> { + if self.0.is_null() { + None + } else { + // SAFETY: a non-null pointer came from the platform-wallet FFI + // CString result and remains owned by this guard. + Some(unsafe { CStr::from_ptr(self.0) }) + } + } +} + +impl Drop for SensitivePlatformWalletString { + fn drop(&mut self) { + // SAFETY: this guard is the sole owner of the nullable pointer, and the + // fetch contract names the sensitive free as its release function. + unsafe { + platform_wallet_ffi::platform_wallet_sensitive_string_free(self.0); + } + } +} + +/// Copy an ASCII C string directly into a JVM string without constructing +/// jni-rs's intermediate owned `JNIString`. +/// +/// `JNIEnv::new_string` re-encodes through a native allocation. The encrypted +/// document serializer instead guarantees ASCII JSON with no interior NUL, so +/// it is already valid modified UTF-8 for `NewStringUTF`. +/// +/// Returns null if the JNI interface/table is unavailable or the JVM cannot +/// allocate the string. The JVM normally leaves an exception pending for the +/// allocation-failure case. +unsafe fn new_string_utf_from_ascii(env: &JNIEnv, ascii: &CStr) -> jstring { + let raw_env = env.get_native_interface(); + if raw_env.is_null() { + log::error!("documentFetchEncrypted: JNI environment pointer is null"); + return ptr::null_mut(); + } + let function_table = unsafe { *raw_env }; + if function_table.is_null() { + log::error!("documentFetchEncrypted: JNI function table is null"); + return ptr::null_mut(); + } + let Some(new_string_utf) = (unsafe { (*function_table).NewStringUTF }) else { + log::error!("documentFetchEncrypted: JNI NewStringUTF function is unavailable"); + return ptr::null_mut(); + }; + + unsafe { new_string_utf(raw_env, ascii.as_ptr()) } +} + /// Read a required 32-byte id from a Java `byte[]`; throws + returns None /// on the wrong length or a JNI error. Mirrors `identity::read_id32` — kept /// local so this module stays a self-contained marshaling unit. @@ -912,6 +971,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do /// "updatedAt" (u64|null), "payload" (base64 of the decrypted opaque plaintext)}`. /// The caller parses each `payload` itself (a protobuf `TxMetadataBatch` for /// `version == 1`). Documents that can't be decrypted are skipped Rust-side. +/// SDK-owned native plaintext allocations are zeroized before release; the +/// returned JVM string remains runtime-managed and cannot be reliably wiped. /// Null after throwing on error. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_documentFetchEncrypted( @@ -976,10 +1037,11 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do &mut out_json as *mut *mut c_char, ) }; + let out_json = SensitivePlatformWalletString(out_json); if take_pwffi_error(env, result) { return ptr::null_mut(); } - if out_json.is_null() { + let Some(json) = out_json.as_c_str() else { log::warn!("documentFetchEncrypted: success code but null JSON; throwing"); throw_sdk_exception( env, @@ -987,19 +1049,25 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do "encrypted document fetch returned success but no JSON", ); return ptr::null_mut(); + }; + let json_bytes = json.to_bytes(); + if !json_bytes.is_ascii() { + log::warn!("documentFetchEncrypted: serializer returned non-ASCII JSON; throwing"); + throw_sdk_exception(env, 99, "encrypted document fetch returned non-ASCII JSON"); + return ptr::null_mut(); + } + let json_len = json_bytes.len(); + let java_string = unsafe { new_string_utf_from_ascii(env, json) }; + if java_string.is_null() { + log::warn!("documentFetchEncrypted: NewStringUTF returned null"); + return ptr::null_mut(); } - let json = unsafe { CStr::from_ptr(out_json) } - .to_string_lossy() - .into_owned(); - unsafe { platform_wallet_ffi::platform_wallet_string_free(out_json) }; log::debug!( "documentFetchEncrypted: success, returning {} chars of JSON to Kotlin", - json.len() + json_len ); - env.new_string(json) - .map(|s| s.into_raw()) - .unwrap_or(ptr::null_mut()) + java_string }) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index bbae735e3e..5ff3665be9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3583,6 +3583,12 @@ extension ManagedPlatformWallet { /// the decrypted opaque plaintext the caller parses itself (a protobuf /// `TxMetadataBatch` for `version == 1`). /// + /// SDK-owned Rust/C decrypted payload and JSON buffers are zeroized before + /// deallocation. The returned host `String` is plaintext-equivalent; its + /// runtime-managed storage, copies, and parsed-object copies cannot be + /// reliably overwritten by the SDK. Parse it promptly, do not log it, and + /// do not retain or persist it longer than required. + /// /// # Key source: chosen by wallet capability (Rust-side) /// /// A `MnemonicResolver` is always passed, but Rust consults it only @@ -3608,7 +3614,7 @@ extension ManagedPlatformWallet { } return try await Task.detached(priority: .userInitiated) { // Receives an owned JSON-array C string on success; freed with - // `platform_wallet_string_free` below. + // `platform_wallet_sensitive_string_free` below. var documentsJsonPtr: UnsafeMutablePointer? = nil // Pin the resolver for the whole FFI call — Rust dereferences @@ -3631,8 +3637,12 @@ extension ManagedPlatformWallet { } } + defer { + if let p = documentsJsonPtr { + platform_wallet_sensitive_string_free(p) + } + } try result.check() - defer { if let p = documentsJsonPtr { platform_wallet_string_free(p) } } // On success the Rust side always writes a JSON array (even // `"[]"`); a null pointer here is an FFI/ABI contract violation. guard let jsonPtr = documentsJsonPtr else { From 5d99b88d920168a48e715bcd19d2b80dfad4e81a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 28 Jul 2026 13:21:44 +0700 Subject: [PATCH 26/30] docs(sdk): fix plaintext lifetime wording --- docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md b/docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md index 18214ab0a7..9428132e59 100644 --- a/docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md +++ b/docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md @@ -63,7 +63,7 @@ The fetch export signature itself does not change, so there is no C layout chang ### Host copies and the documented ceiling -JNI installs a nullable sensitive-pointer RAII guard immediately after the FFI call, before result/null/JNI error handling. Because the serializer enforces ASCII with no interior NUL, JNI passes the existing C buffer directly to raw `NewStringUTF` rather than using `JNIEnv::new_string`, whose `JNIString` conversion would create another unsanitized native allocation. The guard invokes `platform_wallet_sensitive_string_free` after the JVM copy or on any early return/unwind. The returned Java/Kotlin `String` remains runtime-managed and unsrubbable. +JNI installs a nullable sensitive-pointer RAII guard immediately after the FFI call, before result/null/JNI error handling. Because the serializer enforces ASCII with no interior NUL, JNI passes the existing C buffer directly to raw `NewStringUTF` rather than using `JNIEnv::new_string`, whose `JNIString` conversion would create another unsanitized native allocation. The guard invokes `platform_wallet_sensitive_string_free` after the JVM copy or on any early return/unwind. The returned Java/Kotlin `String` remains runtime-managed and unscrubbable. Swift installs its nullable-pointer `defer` immediately after the FFI call, before `result.check()`, and uses `platform_wallet_sensitive_string_free`. It keeps its current `String(cString:)` copy. The returned Swift `String` remains runtime-managed and may share or copy storage. @@ -87,7 +87,7 @@ flowchart TB H --> I[JVM String] H --> J[Swift String] G --> K[Sensitive free zeroizes native allocation] - I --> L[Documented unsrubbable host residual] + I --> L[Documented unscrubbable host residual] J --> L ``` From 9870f4f5d79355b09f97ab56d3b1bee64f1187a8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 28 Jul 2026 19:22:21 +0700 Subject: [PATCH 27/30] style(platform-wallet): apply current rustfmt output Rust 1.92 formatting rejected dependency-stack code before the macOS workspace job could reach its tests. CI transition: cargo fmt --check --all failed before formatting and passes afterward. --- packages/rs-platform-wallet/src/lib.rs | 7 +-- .../src/wallet/identity/crypto/mod.rs | 6 +- .../src/wallet/identity/crypto/tx_metadata.rs | 38 ++++++------ .../identity/network/encrypted_document.rs | 59 ++++++++++++------- .../src/wallet/identity/network/mod.rs | 8 +-- .../tests/txmetadata_fetch.rs | 10 +++- 6 files changed, 74 insertions(+), 54 deletions(-) diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 94818af08c..0e6f90c8d7 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -63,10 +63,9 @@ pub use wallet::core::{CoreWallet, SignedCoreTransaction}; // `identity::crypto::*` internally). pub use wallet::core_address_key::CoreAddressPrivateKey; pub use wallet::identity::network::{ - derive_identity_auth_keypair, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, - ContactInfoPublishOutcome, ContactInfoSealed, DecryptedEncryptedDocument, - query_owned_encrypted_documents, TxMetadataKeySource, SeedBindingVerification, - IDENTITY_GAP_LIMIT, + derive_identity_auth_keypair, query_owned_encrypted_documents, AutoAcceptProofSource, + ContactCryptoProvider, ContactInfoOpened, ContactInfoPublishOutcome, ContactInfoSealed, + DecryptedEncryptedDocument, SeedBindingVerification, TxMetadataKeySource, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; pub use wallet::identity::{ diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs index d299baf148..bd72b8fe40 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs @@ -24,8 +24,8 @@ pub use invitation::{ InviterInfo, ParsedInvitation, }; pub use tx_metadata::{ - derive_tx_metadata_key, derive_tx_metadata_key_from_master, open_tx_metadata, - seal_tx_metadata, tx_metadata_derivation_path, OpenedTxMetadata, - TX_METADATA_ENCRYPTION_CHILD, VERSION_CBOR, VERSION_PROTOBUF, + derive_tx_metadata_key, derive_tx_metadata_key_from_master, open_tx_metadata, seal_tx_metadata, + tx_metadata_derivation_path, OpenedTxMetadata, TX_METADATA_ENCRYPTION_CHILD, VERSION_CBOR, + VERSION_PROTOBUF, }; pub use validation::pubkey_binds_expected_key_data; diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs index e0226c2084..1078212069 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs @@ -123,8 +123,8 @@ const ENCRYPTED_METADATA_FIELD_MAX: usize = 4096; pub const MAX_TX_METADATA_PLAINTEXT_LEN: usize = { // Largest whole ciphertext (a multiple of the AES block) that still fits // the field alongside the version+IV header. - let max_ciphertext = ((ENCRYPTED_METADATA_FIELD_MAX - BLOB_HEADER_LEN) / AES_BLOCK_LEN) - * AES_BLOCK_LEN; + let max_ciphertext = + ((ENCRYPTED_METADATA_FIELD_MAX - BLOB_HEADER_LEN) / AES_BLOCK_LEN) * AES_BLOCK_LEN; // PKCS7 always consumes ≥ 1 byte of the final block for padding, so the // plaintext is at most one byte short of that ciphertext length. max_ciphertext - 1 @@ -632,11 +632,8 @@ mod tests { // The device shape: an external-signable wallet with no in-process // private keys. - let external_wallet = Wallet::new_external_signable( - Network::Testnet, - [0x42u8; 32], - AccountCollection::new(), - ); + let external_wallet = + Wallet::new_external_signable(Network::Testnet, [0x42u8; 32], AccountCollection::new()); let err = derive_tx_metadata_key(&external_wallet, Network::Testnet, 0, 2, 1) .expect_err("an external-signable wallet has no in-process key to derive from"); assert!( @@ -671,8 +668,8 @@ mod tests { let payload = b"external-signable round-trip".to_vec(); let iv = [0x66u8; 16]; - let sealed_by_resident = - seal_tx_metadata(&resident_key, VERSION_PROTOBUF, &iv, &payload).expect("valid version"); + let sealed_by_resident = seal_tx_metadata(&resident_key, VERSION_PROTOBUF, &iv, &payload) + .expect("valid version"); let opened_by_master = open_tx_metadata(&master_key, &sealed_by_resident).expect("master key opens"); assert_eq!(opened_by_master.payload.as_slice(), payload.as_slice()); @@ -804,8 +801,12 @@ mod tests { Language::English, ) .expect("valid test mnemonic"); - let wallet = Wallet::from_mnemonic(mnemonic, Network::Testnet, WalletAccountCreationOptions::None) - .expect("wallet from mnemonic"); + let wallet = Wallet::from_mnemonic( + mnemonic, + Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("wallet from mnemonic"); // identity_index 0 (the wallet's single identity), key_index 2 (the // ENCRYPTION/MEDIUM key id), encryptionKeyIndex 1 (first document). @@ -833,9 +834,8 @@ mod tests { .to_seed(""), ) .expect("master from seed"); - let key_via_master = - derive_tx_metadata_key_from_master(&master, Network::Testnet, 0, 2, 1) - .expect("master derive"); + let key_via_master = derive_tx_metadata_key_from_master(&master, Network::Testnet, 0, 2, 1) + .expect("master derive"); assert_eq!( *key_via_master, legacy_key, "resolver-master tx-metadata derivation must match the legacy dashj stack too" @@ -944,9 +944,8 @@ mod tests { .to_seed(""), ) .expect("master from seed"); - let key_via_master = - derive_tx_metadata_key_from_master(&master, Network::Testnet, 0, 2, 1) - .expect("master derive"); + let key_via_master = derive_tx_metadata_key_from_master(&master, Network::Testnet, 0, 2, 1) + .expect("master derive"); assert_eq!( *key, *key_via_master, "resident and resolver-master derivation must agree for the legacy-install document" @@ -1081,9 +1080,8 @@ mod tests { .to_seed(""), ) .expect("master from seed"); - let key_via_master = - derive_tx_metadata_key_from_master(&master, Network::Testnet, 1, 2, 1) - .expect("master derive"); + let key_via_master = derive_tx_metadata_key_from_master(&master, Network::Testnet, 1, 2, 1) + .expect("master derive"); assert_eq!( *key_via_master, slot1_key, "resolver-master derivation must match the resident derivation at identity_index=1" diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index 981229417a..75fd09b17c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -372,9 +372,14 @@ impl IdentityWallet { if let Some(provider) = self.sdk.context_provider() { provider.register_data_contract(Arc::clone(&contract)); } - let raw = - query_owned_encrypted_documents(&self.sdk, contract, owner_identity_id, document_type_name, 0) - .await?; + let raw = query_owned_encrypted_documents( + &self.sdk, + contract, + owner_identity_id, + document_type_name, + 0, + ) + .await?; Ok(u32::try_from(raw.len()).unwrap_or(u32::MAX)) } @@ -457,7 +462,8 @@ impl IdentityWallet { async fn resolve_encryption_context( &self, owner_identity_id: &Identifier, - ) -> Result<(dpp::identity::Identity, u32, key_wallet::wallet::Wallet), PlatformWalletError> { + ) -> Result<(dpp::identity::Identity, u32, key_wallet::wallet::Wallet), PlatformWalletError> + { let wm = self.wallet_manager.read().await; let info = wm .get_wallet_info(&self.wallet_id) @@ -489,7 +495,8 @@ impl IdentityWallet { fn resolve_encryption_context_blocking( &self, owner_identity_id: &Identifier, - ) -> Result<(dpp::identity::Identity, u32, key_wallet::wallet::Wallet), PlatformWalletError> { + ) -> Result<(dpp::identity::Identity, u32, key_wallet::wallet::Wallet), PlatformWalletError> + { let wm = self.wallet_manager.blocking_read(); let info = wm .get_wallet_info(&self.wallet_id) @@ -908,11 +915,15 @@ mod allocator_tests { let must_not_seed = || async { unreachable!("must not re-seed once the high-water is established") }; assert_eq!( - reserve_next_index(&alloc, &owner, must_not_seed()).await.unwrap(), + reserve_next_index(&alloc, &owner, must_not_seed()) + .await + .unwrap(), 2 ); assert_eq!( - reserve_next_index(&alloc, &owner, must_not_seed()).await.unwrap(), + reserve_next_index(&alloc, &owner, must_not_seed()) + .await + .unwrap(), 3 ); } @@ -927,15 +938,19 @@ mod allocator_tests { // a: 3 existing docs → 4; b: 0 existing → 1; then a again → 5. assert_eq!( - reserve_next_index(&alloc, &a, async { Ok(next_encryption_key_index_from_count(3)) }) - .await - .unwrap(), + reserve_next_index(&alloc, &a, async { + Ok(next_encryption_key_index_from_count(3)) + }) + .await + .unwrap(), 4 ); assert_eq!( - reserve_next_index(&alloc, &b, async { Ok(next_encryption_key_index_from_count(0)) }) - .await - .unwrap(), + reserve_next_index(&alloc, &b, async { + Ok(next_encryption_key_index_from_count(0)) + }) + .await + .unwrap(), 1 ); assert_eq!( @@ -969,7 +984,11 @@ mod allocator_tests { assert_ne!(i1, i2, "concurrent allocations must not collide"); let mut got = [i1, i2]; got.sort_unstable(); - assert_eq!(got, [1, 2], "the two racing indices must be exactly 1 and 2"); + assert_eq!( + got, + [1, 2], + "the two racing indices must be exactly 1 and 2" + ); } /// An oversized payload on the auto-index path fails with the typed @@ -988,13 +1007,11 @@ mod allocator_tests { // 4064 bytes (MAX + 1) is the first rejected length. The seed panics if // polled — proving the size gate short-circuits before any allocation. - let result = reserve_next_index_checked( - &alloc, - &owner, - MAX_TX_METADATA_PLAINTEXT_LEN + 1, - async { unreachable!("seed must not run when the payload is oversized") }, - ) - .await; + let result = + reserve_next_index_checked(&alloc, &owner, MAX_TX_METADATA_PLAINTEXT_LEN + 1, async { + unreachable!("seed must not run when the payload is oversized") + }) + .await; match result { Err(PlatformWalletError::TxMetadataPayloadTooLarge { len, max }) => { assert_eq!(len, MAX_TX_METADATA_PLAINTEXT_LEN + 1); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index ee4326a95f..10f6e96927 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -23,8 +23,8 @@ mod contract; mod discovery; mod document; -mod encrypted_document; mod dpns; +mod encrypted_document; mod identity_handle; mod loading; mod register_from_addresses; @@ -65,15 +65,15 @@ pub use seed_binding::SeedBindingVerification; mod tokens; pub use contact_info::ContactInfoPublishOutcome; -pub use encrypted_document::{ - query_owned_encrypted_documents, DecryptedEncryptedDocument, TxMetadataKeySource, -}; pub use contact_requests::{ AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, ContactInfoSealed, }; pub use dashpay_view::DashPayView; pub use discovery::IdentityDiscoveryOptions; pub use dpns::{ContestContender, ContestVoteState, ContestWinner}; +pub use encrypted_document::{ + query_owned_encrypted_documents, DecryptedEncryptedDocument, TxMetadataKeySource, +}; pub use identity_handle::{ derive_ecdsa_identity_auth_keypair_from_master, derive_identity_auth_key_hash_from_master, derive_identity_auth_keypair, identity_auth_derivation_path_for_type, DerivedIdentityAuthKey, diff --git a/packages/rs-platform-wallet/tests/txmetadata_fetch.rs b/packages/rs-platform-wallet/tests/txmetadata_fetch.rs index efea86e3db..dd3885ebc4 100644 --- a/packages/rs-platform-wallet/tests/txmetadata_fetch.rs +++ b/packages/rs-platform-wallet/tests/txmetadata_fetch.rs @@ -238,8 +238,14 @@ async fn capture_legacy_yabba2_txmetadata_blobs() { let created_at = doc.created_at(); let updated_at = doc.updated_at(); - let aes_key = derive_tx_metadata_key(&wallet, Network::Testnet, 0, key_index, encryption_key_index) - .expect("derive txMetadata key at identity_index 0"); + let aes_key = derive_tx_metadata_key( + &wallet, + Network::Testnet, + 0, + key_index, + encryption_key_index, + ) + .expect("derive txMetadata key at identity_index 0"); let opened = open_tx_metadata(&aes_key, &blob).expect("open legacy blob"); println!("---- DOCUMENT {i} ----"); From 4868eca8daf956ea7e8ede518eb29dd42dc57e06 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 30 Jul 2026 19:27:15 +0700 Subject: [PATCH 28/30] docs(sdk): remove internal plaintext lifetime spec --- ...ETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md | 202 ------------------ 1 file changed, 202 deletions(-) delete mode 100644 docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md diff --git a/docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md b/docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md deleted file mode 100644 index 9428132e59..0000000000 --- a/docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md +++ /dev/null @@ -1,202 +0,0 @@ -# txMetadata decrypt-path plaintext lifetime - -**Status:** IMPLEMENTED — REVIEWED -**Date:** 2026-07-23 -**Work unit:** iOS/Kotlin parity U7 -**Baseline:** PR #4194 head `9efc0b7e3a`, including PR #4195 head `4f2eb06d64` -**Scope:** `rs-platform-encryption`, `rs-platform-wallet`, `rs-platform-wallet-ffi`, `rs-unified-sdk-jni`, Kotlin SDK fetch documentation, and the incoming Swift fetch wrapper - -## Problem - -The txMetadata create direction is already scrubbed after PR #4186. The decrypt direction is not. - -`platform_encryption::decrypt_aes_256_cbc` first decrypts in place into a plain `Vec`, clones the unpadded plaintext into a second plain `Vec`, and drops the first allocation without zeroizing it. `open_tx_metadata` stores the clone in `OpenedTxMetadata.payload`, and `IdentityWallet::fetch_encrypted_documents` moves it into `DecryptedEncryptedDocument.payload`; both fields are also plain `Vec`. The FFI then base64-encodes each payload into a `serde_json::Value`, serializes the array into a plain `String`, converts that into a `CString`, and returns the raw pointer. `platform_wallet_string_free` deallocates that `CString` without zeroizing it. JNI additionally copies the C string into a Rust `String` before creating the JVM `String`. Swift copies the same C string into a Swift `String`. - -Consequently, decrypted financial metadata can remain in reclaimed native heap allocations after the fetch returns. Removing every residual is impossible without changing the public host return type: JVM and Swift `String` storage and runtime-created copies cannot be reliably overwritten by the SDK. U7 must close the controlled Rust/C allocations and document that host-runtime ceiling without claiming complete process-memory erasure. - -## Requirements - -1. The in-place AES decrypt buffer and every later SDK-owned plaintext allocation must zeroize on every normal, recoverable-error, and unwinding drop path. -2. Base64 and JSON construction must not create plain, later-unscrubbed native allocations containing the payload. -3. The returned C allocation must have an explicit ownership contract that zeroizes its complete backing allocation before deallocation. -4. Kotlin/JNI and Swift must consume the same FFI result and free it through the same sensitive ownership contract; public fetch signatures and JSON shape remain unchanged. -5. Kotlin and Swift public documentation must state the same limitation: the returned host `String` is plaintext-equivalent, cannot be reliably scrubbed, and must be parsed promptly and never logged or persisted unnecessarily. -6. The create direction, PR #4195 encryption-key-index allocation, wire format, query behavior, skip-on-decrypt-failure behavior, JNI descriptor, and Swift/Kotlin public signatures remain unchanged. - -## Chosen design - -Keep the existing JSON/base64 and host `String` API, but make the complete native decrypt-to-host-copy path sensitive by construction. - -### Zeroizing decryption and plaintext owners - -- Add an ABI-additive `decrypt_aes_256_cbc_zeroizing` primitive in `rs-platform-encryption`. It wraps the ciphertext copy in `Zeroizing>` before decrypting in place, obtains the unpadded length, truncates the same allocation, and returns it without copying. Invalid padding and unwinding therefore drop a guard that may contain partially decrypted bytes. -- Keep the existing `decrypt_aes_256_cbc -> Result, _>` interface for unrelated callers by having it copy from the zeroizing primitive. That preserves its source API and existing plaintext-lifetime contract while also scrubbing its in-place working allocation. txMetadata alone calls the new zeroizing primitive and avoids that final plain copy. -- Change `OpenedTxMetadata.payload` to `Zeroizing>`. It receives the zeroizing AES allocation directly. -- Change `DecryptedEncryptedDocument.payload` to `Zeroizing>`. Moving from the opened result remains allocation-preserving; cloning the document produces another zeroizing owner. -- Keep the handwritten redacted `Debug` implementations. Do not derive or log payload content. - -### Sensitive JSON construction - -Replace the `Vec -> String -> CString` chain in `platform_wallet_fetch_encrypted_documents` with one focused sensitive serializer: - -- A checked counting pass computes the exact JSON byte length plus the final NUL. Payload contribution uses `base64::encoded_len`; it does not encode or copy plaintext. Identifier and integer sizing uses fixed stack storage or allocation-free length helpers. -- Before sensitive writing starts, allocate an exact-length boxed byte slice filled with a non-NUL ASCII sentinel plus a final NUL and wrap it in a private `SensitiveCString` owner. The owner exposes the non-terminator region as an ordinary mutable slice; it does not cast a read-only `CString::as_ptr` into writable memory. Its `Drop` zeroizes the complete owned slice including the terminator before deallocation. -- A bounded writer borrows that fixed allocation exclusively and cannot grow it. It writes JSON directly into the allocation and uses `base64::Engine::encode_slice` to encode each payload directly into its final output range. There is no payload-bearing base64 scratch, Serde `Value`, JSON `String`, or growable sensitive buffer. -- The writer rejects overflow or underfill, then validates the complete buffer for ASCII, interior NUL, and its final terminator. On any error, `SensitiveCString` remains armed and wipes the partially written allocation. -- After exact completion is validated, the boxed slice is converted to an exact-capacity `Vec` and then a `CString`; both conversions retain the allocation. `SensitiveCString::into_raw` disarms its guard and transfers that same allocation to the caller. All potentially reallocating construction occurred while the allocation held only non-sensitive sentinel bytes. -- Preserve the existing object field order as an output-compatibility precaution, as well as the exact field names, null handling, base58 identifiers, standard padded base64, array order, and successful empty result `"[]"`. JSON object order is not promoted to a new public contract. - -This deliberately bounded writer makes a wrong size estimate fail closed instead of silently reallocating and stranding plaintext. - -### Sensitive C-string ownership - -Add an ABI-additive `platform_wallet_sensitive_string_free(*mut c_char)` export: - -- Null is a no-op. -- A non-null pointer must have been returned by a function whose documentation names this free contract. -- The function reconstructs the original `CString`, converts it with `into_bytes_with_nul`, zeroizes the resulting allocation including the terminator, and then deallocates it. -- Callers treat the returned allocation as read-only and pass the original pointer without altering any byte or moving, adding, or removing the terminator; `CString::from_raw` requires its original length. -- `platform_wallet_fetch_encrypted_documents` documents that its output contains decrypted, plaintext-equivalent data and must be released with this function. -- `platform_wallet_string_free` remains the ordinary non-sensitive contract. Existing secret-bearing FFI precedent stays unchanged, including `platform_wallet_address_private_key_free`, which already uses a dedicated zeroizing release path. - -The fetch export signature itself does not change, so there is no C layout change and no JNI descriptor or Swift method-signature change. cbindgen adds only the new free symbol to the generated platform-wallet header. An old binary that calls `platform_wallet_string_free` remains memory-safe because the allocation is still a `CString`, but it does not receive U7's final-allocation zeroization guarantee. - -### Host copies and the documented ceiling - -JNI installs a nullable sensitive-pointer RAII guard immediately after the FFI call, before result/null/JNI error handling. Because the serializer enforces ASCII with no interior NUL, JNI passes the existing C buffer directly to raw `NewStringUTF` rather than using `JNIEnv::new_string`, whose `JNIString` conversion would create another unsanitized native allocation. The guard invokes `platform_wallet_sensitive_string_free` after the JVM copy or on any early return/unwind. The returned Java/Kotlin `String` remains runtime-managed and unscrubbable. - -Swift installs its nullable-pointer `defer` immediately after the FFI call, before `result.check()`, and uses `platform_wallet_sensitive_string_free`. It keeps its current `String(cString:)` copy. The returned Swift `String` remains runtime-managed and may share or copy storage. - -Both Kotlin public entry points (`DocumentTransactions.kt` and `TransactionsNative.kt`) and the Swift `ManagedPlatformWallet.fetchEncryptedDocuments` wrapper use equivalent wording: - -> SDK-owned Rust/C decrypted payload and JSON buffers are zeroized before deallocation. The returned host `String` is plaintext-equivalent; its runtime-managed storage, copies, and parsed-object copies cannot be reliably overwritten by the SDK. Parse it promptly, do not log it, and do not retain or persist it longer than required. - -This is an honest boundary, not a guarantee that all plaintext has disappeared from the process. - -## Interface and data flow - -```mermaid -flowchart TB - A[Encrypted Platform document] --> B[Zeroizing in-place AES buffer] - B --> C[OpenedTxMetadata payload
same Zeroizing Vec] - C --> D[DecryptedEncryptedDocument payload
Zeroizing Vec] - D --> E[Bounded base64 + JSON writer] - E --> F[Sensitive boxed-byte allocation] - F --> G[Raw caller-owned pointer] - G --> H{Host bridge copies} - H --> I[JVM String] - H --> J[Swift String] - G --> K[Sensitive free zeroizes native allocation] - I --> L[Documented unscrubbable host residual] - J --> L -``` - -| Interface | Before U7 | After U7 | -| --- | --- | --- | -| `decrypt_aes_256_cbc_zeroizing` | Absent | Additive primitive returning `Zeroizing>` | -| `OpenedTxMetadata.payload` | `Vec` | `Zeroizing>` | -| `DecryptedEncryptedDocument.payload` | `Vec` | `Zeroizing>` | -| `platform_wallet_fetch_encrypted_documents` | JSON `CString`; ordinary free | Same ABI/JSON; sensitive-free contract | -| C release API | `platform_wallet_string_free` | New `platform_wallet_sensitive_string_free` for this result | -| JNI/Kotlin fetch | `String`; plain Rust copy before JVM copy | Same descriptor/return type; direct guarded JVM copy | -| Swift fetch | `async throws -> String`; ordinary free | Same signature; sensitive free | - -The two public Rust payload-field type changes are source incompatible for code that constructs, destructures, or moves those fields as `Vec`. Workspace call sites must be updated explicitly. No C/JNI/Swift/Kotlin signature changes. - -## Alternatives rejected - -### Structured binary out-buffer - -A C array of document structs with `(payload_ptr, payload_len)` fields and a zeroizing array free would avoid base64 in the shared FFI. It is the stronger foundation for a future typed host API returning `ByteArray`/`Data`. - -It is rejected for U7 because the public Kotlin and incoming Swift wrappers return the existing JSON `String`. Preserving that API would move JSON/base64 construction into JNI and Swift, duplicating sensitive serialization and ownership logic across hosts. Changing both public APIs to typed results would be a larger source/API redesign with C layout pins, generated-header changes, Kotlin models, Swift models, and new caller migration. That work should be considered separately if eliminating the terminal host `String` becomes a requirement. - -### Zeroize every `platform_wallet_string_free` - -Changing the general free function would require fewer symbols and would make old callers scrub this final allocation automatically. The serializer is what removes the earlier base64/JSON intermediates under either free design. - -It is rejected because it changes the cost and behavior of every ordinary platform-wallet string release and obscures which outputs carry a sensitive ownership obligation. The dedicated export follows the repository's private-key precedent and limits the behavioral blast radius, at the cost of one additive symbol, two host release-call changes, and a documented free-function mismatch risk. - -### Wrap only the final payload or final JSON string - -Wrapping only `DecryptedEncryptedDocument.payload`, or only the final JSON `String`, leaves earlier decrypt owners, base64 strings, `serde_json::Value` strings, and reallocations unsanitized. It does not close the reported lifetime path and is rejected. - -## Failure modes and handling - -| Failure | Required behavior | -| --- | --- | -| Key derivation, AES padding validation, or decryption fails for one document | Preserve the current skip-and-warn behavior; the in-place AES buffer and every later temporary key/plaintext owner drop and zeroize. | -| Length arithmetic, bounded writing, or output validation fails | Return an error with `*out_documents_json` still null; zeroize every payload and the partial CString allocation on drop. | -| Fetch succeeds with no documents | Return `"[]"` through the sensitive contract; both hosts still call the sensitive free. | -| JNI UTF/JVM allocation fails or a panic occurs after receiving the pointer | RAII guard zeroizes and releases the C allocation before returning null or unwinding into the outer JNI guard. | -| Swift conversion or later validation throws | `defer` zeroizes and releases the C allocation. | -| New caller uses the ordinary string free | It remains memory-safe but violates the sensitive ownership contract and skips final-allocation zeroization; updated Kotlin/JNI and Swift wrappers must never do this for fetch output. | -| Future serializer change introduces reallocation or a plain base64/JSON tree | Focused regression tests must fail; code review must treat this as a plaintext-lifetime regression. | -| Host caller retains/logs the returned `String` | Native guarantees no longer apply; public docs prohibit logging and unnecessary retention but cannot enforce erasure. | -| Allocator abort or `panic=abort` terminates the process | No cleanup promise is made because Rust destructors do not run. The guarantee covers normal returns, recoverable errors, and unwinding paths where drops execute. | - -## Verification plan - -Implementation follows a failing-then-passing sequence. - -1. Before production changes, add a compile-red decrypt-lifetime regression test. A typed assertion helper accepts only `&Zeroizing>`; pass it the new sensitive AES result, `OpenedTxMetadata.payload`, and `DecryptedEncryptedDocument.payload` built from the existing concrete txMetadata plaintext vector. On the baseline, the test target must fail to compile because the sensitive AES primitive is absent and both fields are `Vec`. Record those compiler errors, then run the unchanged test after the fix and require it to compile and pass. This intentionally tests the storage-type security invariant rather than using brittle runtime type-name strings. -2. Add `rs-platform-encryption` unit coverage for successful sensitive decrypt without a plaintext clone and invalid-padding/error handling while the in-place buffer is guard-owned. Keep the existing ordinary decrypt API tests passing. -3. Add focused FFI tests for the private bounded writer, `SensitiveCString`, and release seam: - - one document preserves the current emitted bytes, every field, and exact padded base64 payload; - - multiple and empty results preserve field/array order and `"[]"`; - - the final output is ASCII, contains no interior NUL, and exactly consumes the fixed output region; - - undersized capacity is rejected without growth, and a deliberately failing writer leaves the FFI out-pointer null; - - the shared wipe primitive overwrites a still-live byte slice, including a NUL terminator, before deallocation; tests never inspect freed memory; - - null release is a no-op; - - error/unwind ownership is exercised at the safe internal owner seam. -4. Run the original compile-red decrypt-lifetime test unchanged and record its red-to-green transition. -5. Run targeted and crate-level Rust tests for `platform-encryption`, `platform-wallet`, `platform-wallet-ffi`, and `rs-unified-sdk-jni`, plus formatting and clippy for the changed crates. -6. Regenerate the cbindgen header and verify the fetch signature is unchanged and the only new relevant ABI surface is `platform_wallet_sensitive_string_free`. -7. Add JNI coverage or a focused seam test proving the guard uses sensitive free on success and JNI failure, and that the direct `NewStringUTF` input satisfies the ASCII/no-interior-NUL precondition. -8. Run Kotlin SDK JVM tests under JDK 17 and build the Android native library so the unchanged JNI descriptor/symbol path is exercised. -9. Rebuild the iOS framework from this branch before Swift validation, then run Swift package tests/build. The prebuilt framework at PR #4194 head predates PR #4195's auto-index symbol and is not a valid link artifact for this verification. -10. Run `git diff --check` and inspect the final diff to confirm no create-path, allocator-policy, query, wire-format, or unrelated host cleanup entered U7. - -Memory inspection after deallocation is undefined behavior, so tests prove the security contract at safe seams: zeroizing owner types, in-place overwrite before release, guarded ownership on every exit, unchanged serialized output, and generated ABI use. - -## Coordination with PRs #4194 and #4195 - -- PR #4195 remains the owner of Rust-side `encryptionKeyIndex` allocation and create-path size-before-allocation behavior. U7 does not edit those decisions or their host documentation. -- PR #4194 remains the owner of the incoming Swift create/fetch wrappers. U7 changes only the fetch result's release call and lifetime documentation in that wrapper. -- Both PRs are open as of 2026-07-23, and this branch already contains both current heads. Immediately before implementation, refetch and compare their final heads or merge commits with this baseline. Sync only any new upstream delta; do not replay #4195 or duplicate its create/allocator changes. Preserve #4194's final host behavior, then apply only U7's fetch-path lifetime deltas. -- No commit, push, or PR creation is part of this work unless Ivan asks. - -## Review record - -Three independent reviews were completed before implementation: - -- the required Swift/Rust FFI reviewer checked ownership transfer, generated-header/XCFramework impact, JNI copying, and Swift cleanup; -- a security/failure-mode reviewer traced plaintext back through AES error paths and challenged allocation, NUL, unwinding, and release guarantees; -- a simplicity/TDD reviewer checked source compatibility, the dedicated-versus-global free trade-off, executable red-to-green seams, host documentation placement, and #4194/#4195 overlap. - -Their must-fixes are incorporated above: the AES working allocation is now in scope, the sensitive CString uses the repository-compatible byte-vector wipe, the writer is fixed-size and fail-closed, Rust source incompatibilities and old-binary behavior are explicit, host guards are installed before result handling, and the verification plan names concrete compile-red and safe pre-deallocation seams. - -## Expected implementation surface - -| Area | Planned change | -| --- | --- | -| `rs-platform-encryption` | Add the zeroizing AES decrypt primitive, dependency, export, and success/error tests. | -| `rs-platform-wallet` | Use the sensitive primitive for txMetadata and change the two payload owners. | -| `rs-platform-wallet-ffi` | Add the bounded sensitive serializer/owner, dedicated free, docs, and focused tests. | -| `rs-unified-sdk-jni` | Add the immediate pointer guard and direct `NewStringUTF`; remove the native Rust JSON copy. | -| Kotlin SDK | Add matching limitation KDoc at both public entry points; no behavior/signature change. | -| Swift SDK/generated header | Use the immediate sensitive defer, add matching docs, regenerate/rebuild the header/framework. | - -## Sources - -- Existing zeroizing C-string precedent: `packages/rs-platform-wallet-ffi/src/address_private_key.rs` -- Earliest in-place decrypt buffer: `packages/rs-platform-encryption/src/aes.rs` -- Current decrypt owners: `packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs` and `packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs` -- Current FFI serialization/free contracts: `packages/rs-platform-wallet-ffi/src/document.rs` and `packages/rs-platform-wallet-ffi/src/types.rs` -- Current host bridges: `packages/rs-unified-sdk-jni/src/transactions.rs`, `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt`, and `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift` -- [`zeroize::Zeroizing`](https://docs.rs/zeroize/1.8.2/zeroize/struct.Zeroizing.html) and [`Zeroize` for allocated buffers](https://docs.rs/zeroize/1.8.2/zeroize/trait.Zeroize.html) -- [Rust `CString` ownership and raw-pointer contract](https://doc.rust-lang.org/std/ffi/struct.CString.html) -- [JNI `NewStringUTF`](https://docs.oracle.com/en/java/javase/26/docs/specs/jni/functions.html#newstringutf) -- [Java `String` values are unchanging](https://docs.oracle.com/javase/specs/jls/se25/html/jls-4.html#jls-4.3.3) -- [Swift `String(cString:)` copies the C bytes](https://developer.apple.com/documentation/swift/string/init(cstring:encoding:)) -- [Swift strings are value types with runtime copy optimizations](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/stringsandcharacters/#Strings-Are-Value-Types) From 188549cdf708ea00611ed86700bbc5ba0a6e43d4 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 3 Aug 2026 00:56:23 +0700 Subject: [PATCH 29/30] fix(sdk): complete encrypted txMetadata parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the shared encrypted-document flow for Rust, Kotlin/JNI, and Swift, including wire compatibility, automatic key-index allocation, deferred JNI payload ownership, and zeroizing FFI output ownership. Test would have caught this in CI:\n✖ unsupported envelope versions, early resolver calls, copied oversized plaintext, and non-zeroizing cipher state failed the new regression checks before the fixes.\n✔ the same regressions, legacy vectors and live fixture, host suites, Android ABI matrix, and Apple device/simulator builds now pass. Document the unavoidable terminal host String lifetime limitation symmetrically on both platforms. --- Cargo.lock | 1 + .../dashsdk/documents/DocumentTransactions.kt | 97 +- .../dashsdk/ffi/TransactionsNative.kt | 35 +- ...umentTransactionsEncryptionKeyIndexTest.kt | 129 + ...cumentTransactionsVersionValidationTest.kt | 127 - packages/rs-platform-encryption/Cargo.toml | 4 +- packages/rs-platform-encryption/src/aes.rs | 8 + .../rs-platform-wallet-ffi/src/document.rs | 2274 ++++++++++++++-- packages/rs-platform-wallet-ffi/src/error.rs | 19 + packages/rs-platform-wallet-ffi/src/lib.rs | 4 + .../rs-platform-wallet-ffi/src/runtime.rs | 178 +- .../src/tx_metadata_json.rs | 5 + packages/rs-platform-wallet/src/error.rs | 55 + .../src/wallet/identity/crypto/tx_metadata.rs | 474 +++- .../identity/network/encrypted_document.rs | 2404 ++++++++++++++--- .../identity/network/identity_handle.rs | 79 +- .../src/wallet/platform_wallet.rs | 6 +- .../LegacyDerivationPathCheck.java | 5 +- .../tests/legacy_wire_compat/README.md | 28 +- .../tests/txmetadata_fetch.rs | 210 +- packages/rs-unified-sdk-jni/src/support.rs | 110 +- .../rs-unified-sdk-jni/src/transactions.rs | 654 ++++- .../Core/Wallet/WalletStorage.swift | 8 + .../FFI/MnemonicResolverAndPersister.swift | 25 +- .../ManagedPlatformWallet.swift | 148 +- ...ryptedDocumentVersionValidationTests.swift | 108 +- 26 files changed, 5982 insertions(+), 1213 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsEncryptionKeyIndexTest.kt delete mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt diff --git a/Cargo.lock b/Cargo.lock index 2af61f4707..a8c8ce1f8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,7 @@ dependencies = [ "cfg-if", "cipher", "cpufeatures 0.2.17", + "zeroize", ] [[package]] diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt index 274145658a..89bacc4369 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt @@ -253,36 +253,50 @@ class DocumentTransactions internal constructor( * Create + broadcast an ENCRYPTED wallet-contract document (the wire- * compatible `txMetadata` shape) on [contractId]'s [documentType], owned by * [ownerId] — signed via [signerHandle]. Implements the create half of the - * legacy `BlockchainIdentity.publishTxMetaData` retirement - * (dashpay/platform#4086): the SDK derives the identity encryption key, - * seals [payload] into the legacy `version ‖ IV ‖ AES-256-CBC` blob, and - * writes `{keyIndex, encryptionKeyIndex, encryptedMetadata}`. + * legacy `BlockchainIdentity.publishTxMetaData` retirement: the SDK derives + * the identity encryption key, seals [payload] into the legacy + * `version ‖ IV ‖ AES-256-CBC` blob, and writes + * `{keyIndex, encryptionKeyIndex, encryptedMetadata}`. * * Batching stays app-side: the caller serializes its items into [payload] * (a protobuf `TxMetadataBatch`). The identity encryption key id (the * `keyIndex` field) is chosen SDK-side to match the legacy stack, so the key * never crosses the FFI boundary. * - * ### `encryptionKeyIndex` allocation (dashpay/platform#4186 follow-up) + * ### `encryptionKeyIndex` allocation * Leave [encryptionKeyIndex] `null` (the default) to let the SDK allocate * the per-document index in Rust from authoritative Platform state — the - * host-thin path. Rust counts the identity's existing txMetadata documents - * on Platform and uses `1 + count` (matching dash-wallet's retired - * `1 + countAllRequests()` semantics EXACTLY), serialized under the wallet's - * allocator mutex so concurrent creates through the same process never pick - * the same index. The index is best-effort unique PER DEVICE; a cross-device - * duplicate is not data-loss (each document stores its own index and the - * reader derives that document's key from it, so both decrypt independently). + * host-thin path. Rust counts the identity's existing documents of this + * contract and document type on Platform and uses `1 + count`, the same + * series the legacy stack produced, serialized in process so concurrent + * creates never pick the same index. The index is best-effort unique PER + * DEVICE; a cross-device duplicate is not data loss, because each document + * stores its own index and the reader derives that document's key from it, + * so both decrypt independently. It follows that the index is an + * encryption-key selector, NOT a document sequence number: do not order, + * count, address, or gap-check documents by it. * * Passing an explicit non-negative [encryptionKeyIndex] is retained ONLY for - * migration / tests and is discouraged: the host must NOT reintroduce a - * caller-supplied `1 + countAllRequests()` counter (concurrent callers / - * devices could collide, and it violates the host-thin key-index rule). + * migration / tests and is discouraged: a caller-supplied counter can + * collide across concurrent callers and devices, and choosing the index is + * the SDK's job. + * + * ### What is not scrubbed + * The SDK zeroizes the native copies it makes of [payload]. It cannot + * scrub [payload] itself: that is a JVM `ByteArray` the caller owns, as are + * any buffers that produced it, and the runtime may have copied it while + * compacting the heap. Treat it and every JVM copy as plaintext-equivalent + * for as long as they are reachable: keep them short-lived, never log them, + * and overwrite your own array once this call returns where that is + * feasible. Overwriting the array you hold does not reach any copy the + * runtime made of it, so this reduces exposure rather than eliminating it. * * @param encryptionKeyIndex `null` to let the SDK allocate the index * (preferred); or an explicit non-negative per-document index * (migration / tests only). - * @param version payload version byte (`1` = protobuf, as the wallet writes). + * @param version payload version byte. Which values are meaningful is + * decided by the wallet core; an unsupported one is rejected there and + * surfaced as a platform-wallet invalid-parameter error. * @param payload already-serialized opaque plaintext; the SDK does not * parse it. * [mnemonicResolverHandle] is the host mnemonic-resolver handle @@ -310,14 +324,6 @@ class DocumentTransactions internal constructor( require(encryptionKeyIndex == null || encryptionKeyIndex >= 0) { "encryptionKeyIndex, when supplied, must be non-negative, got $encryptionKeyIndex" } - // Only 0 (CBOR) and 1 (protobuf) are wire-meaningful: `seal_tx_metadata` - // writes this byte verbatim into the envelope and the legacy dashj stack - // (decryptTxMetadata) switches on exactly those two values. Accepting 2..255 - // would silently seal a document the legacy stack can't decode, breaking the - // bidirectional wire-compat guarantee (dashpay/platform#4091). - require(version == 0 || version == 1) { - "version must be 0 (CBOR) or 1 (protobuf), got $version" - } mapNativeErrors { TransactionsNative.documentCreateEncrypted( walletHandle, @@ -338,24 +344,41 @@ class DocumentTransactions internal constructor( * Fetch + DECRYPT every encrypted wallet-contract document owned by * [ownerId] on [contractId]'s [documentType] updated at or after [sinceMs] * (epoch-millis). Implements the read half of the legacy - * `BlockchainIdentity.getTxMetaData(since, key)` retirement - * (dashpay/platform#4087): the SDK fetches the owner-scoped, since-timestamp - * documents and decrypts each with the identity's derived key. Documents - * that fail to decrypt are skipped Rust-side (a bad document never aborts - * the fetch). + * `BlockchainIdentity.getTxMetaData(since, key)` retirement: the SDK fetches + * the owner-scoped, since-timestamp documents and decrypts each with the + * identity's derived key. Documents that fail to decrypt, and documents + * carrying an unsupported wire version, are skipped Rust-side — a bad + * document never aborts the fetch. + * + * ### Decryption is not authentication + * The envelope is AES-256-CBC with PKCS7 and carries no integrity tag, so a + * successful decrypt does not mean the bytes are genuine. A wrong key or a + * modified ciphertext usually fails the unpad and is skipped, but PKCS7 + * accepts a wrong plaintext often enough that an element can carry opaque + * garbage. Parse every `payload` strictly — CBOR for `version` 0, protobuf + * for 1 — and discard anything that does not parse, rather than trusting it + * because it appeared in the array. * * @return a JSON array; each element is `{ "id", "ownerId" (base58), * "keyIndex", "encryptionKeyIndex", "version", "updatedAt" (number|null), * "payload" (base64 of the decrypted opaque plaintext) }`. The caller - * parses each `payload` itself (a protobuf `TxMetadataBatch` for - * `version == 1`) and reconciles memo / taxCategory / exchangeRate / - * service / giftCard fields into its local store. + * parses each `payload` itself and MUST dispatch on `version`: `0` is a + * CBOR payload, `1` a protobuf `TxMetadataBatch`. Those are the only + * versions the legacy format defines; a document carrying anything else + * is skipped by the SDK and never reaches this array. Reconcile memo / + * taxCategory / exchangeRate / service / giftCard fields into the local + * store from the parsed payload. * - * SDK-owned Rust/C decrypted payload and JSON buffers are zeroized before - * deallocation. The returned host `String` is plaintext-equivalent; its - * runtime-managed storage, copies, and parsed-object copies cannot be - * reliably overwritten by the SDK. Parse it promptly, do not log it, and - * do not retain or persist it longer than required. + * ### What is not scrubbed + * The SDK zeroizes the native decrypted-payload and JSON buffers it owns. + * It cannot scrub the returned `String`: that is a JVM object the runtime + * manages, as are every copy of it and every object parsed out of it, and + * the runtime may have copied it while compacting the heap. Treat it and + * everything derived from it as plaintext-equivalent for as long as they + * are reachable: parse promptly, never log them, and do not retain or + * persist them longer than required. Unlike a `ByteArray` there is no + * overwrite to attempt here at all, so short retention is the only control + * the caller has. * * [mnemonicResolverHandle] is the host mnemonic-resolver handle * ([org.dashfoundation.dashsdk.wallet.PlatformWalletManager.mnemonicResolverHandle]): diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index 8fbaca1e83..f7f6ef1ea7 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -168,7 +168,8 @@ internal object TransactionsNative { * Create + broadcast an ENCRYPTED wallet-contract document (the wire- * compatible `txMetadata` shape) on [contractId]'s [documentType], owned by * [ownerId], signed via [signerHandle]. Bridges - * `platform_wallet_create_encrypted_document_with_signer`. + * the Rust-ABI composite + * `create_encrypted_document_with_deferred_payload`. * * The Rust side selects the identity's ENCRYPTION key id (the `keyIndex` * field), derives the AES key from the wallet HD tree, and seals [payload] @@ -180,15 +181,24 @@ internal object TransactionsNative { * required (non-zero) for external-signable wallets — the app's shape — * whose txMetadata AES key derives on demand through the resolver. * Ignored for wallets with resident private keys. - * @param encryptionKeyIndex the per-document index, OR `-1` to let the SDK - * allocate it in Rust from authoritative Platform state - * (dashpay/platform#4186 follow-up). A non-negative value routes to the - * explicit-index FFI export (migration / tests); `-1` routes to - * `platform_wallet_create_encrypted_document_with_signer_auto_index`, which - * omits the index. Values `< -1` are rejected. - * @param version payload version byte (`1` = protobuf, as the wallet writes). + * @param encryptionKeyIndex an explicit per-document index (migration / + * tests), OR `-1` to let the SDK allocate one from authoritative Platform + * state. Both forms enter one Rust operation. For `-1`, Rust settles the + * index before asking JNI to copy this array into native memory — the + * allocation query has no request timeout, so copying first would retain + * plaintext throughout an unbounded wait. Rust then takes ownership of + * the native copy and scrubs it as soon as the properties are sealed, + * before broadcast. + * Values `< -1` are rejected. `-1` rather than a boxed `Integer?` keeps + * this signature on primitives. + * @param version payload version byte. This layer narrows it to a byte and + * nothing more: which values are meaningful is decided by the wallet core, + * which rejects an unsupported one before anything is sealed. * @param payload the already-serialized opaque plaintext (a protobuf - * `TxMetadataBatch`); the SDK does not parse it. + * `TxMetadataBatch`); the SDK does not parse it. The native copies made of + * it are zeroized, but this `ByteArray` and any JVM copies of it are + * plaintext-equivalent and cannot be scrubbed by the SDK — see + * [org.dashfoundation.dashsdk.documents.DocumentTransactions.createEncryptedDocument]. * @return the confirmed document's canonical JSON (its 32-byte id is the * base58 `$id` field). */ @@ -218,7 +228,12 @@ internal object TransactionsNative { * @return a JSON array; each element is `{ "id", "ownerId" (base58), * "keyIndex", "encryptionKeyIndex", "version", "updatedAt" (number|null), * "payload" (base64 of the decrypted opaque plaintext) }`. Documents that - * fail to decrypt are skipped Rust-side. + * fail to decrypt, and documents carrying an unsupported wire version, + * are skipped Rust-side. A payload that IS returned is not authenticated: + * the envelope is AES-256-CBC with PKCS7 and no integrity tag, so a wrong + * key or modified ciphertext usually fails the unpad but can occasionally + * unpad cleanly and surface opaque garbage. Parse each payload strictly + * and discard what does not parse. * * SDK-owned Rust/C decrypted payload and JSON buffers are zeroized before * deallocation. The returned host `String` is plaintext-equivalent; its diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsEncryptionKeyIndexTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsEncryptionKeyIndexTest.kt new file mode 100644 index 0000000000..a4400b15fd --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsEncryptionKeyIndexTest.kt @@ -0,0 +1,129 @@ +package org.dashfoundation.dashsdk.documents + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Argument handling for [DocumentTransactions.createEncryptedDocument]. + * + * This wrapper owns exactly one decision about the encrypted-document + * arguments: `null` is how a caller says "no index supplied", and an explicit + * index must therefore be non-negative, because a negative value is neither an + * index nor the omission. Everything else — which wire versions are meaningful, + * how large a payload may be, which indices have a derivable key — is decided by + * the wallet core, so this layer must not reject those values on its own. + * + * Rejections happen before any native call, so they are testable on the JVM with + * no JNI library loaded. Arguments that PASS proceed into native, which a JVM + * unit test cannot complete; those cases assert only that the failure is NOT an + * [IllegalArgumentException] from this wrapper. + */ +class DocumentTransactionsEncryptionKeyIndexTest { + + private val id32 = ByteArray(32) + private val payload = ByteArray(4) { it.toByte() } + + /** + * Call the wrapper and return whatever it threw, or `null` on success. + * + * A JVM unit test has no JNI library, so a call that gets past this + * wrapper's guards fails inside the native layer instead. Returning the + * throwable lets each case assert on which layer refused. + */ + private fun createReturningFailure( + version: Int, + encryptionKeyIndex: Int? = null, + ): Throwable? = runCatching { + runBlocking { + DocumentTransactions().createEncryptedDocument( + walletHandle = 0L, + mnemonicResolverHandle = 0L, + ownerId = id32, + contractId = id32, + documentType = "txMetadata", + version = version, + payload = payload, + signerHandle = 0L, + encryptionKeyIndex = encryptionKeyIndex, + ) + } + }.exceptionOrNull() + + /** + * The wrapper does not decide which wire versions are meaningful. + * + * Only the wallet core knows which version bytes the legacy stack can + * decode, and it rejects an unsupported one before anything is sealed. A + * guard here would be a second place where that set is written down, free to + * drift from the core and to reject a value a later core accepts. So every + * value a caller can pass must get past this layer — including ones the core + * will refuse. + */ + @Test + fun doesNotRejectAnyVersionLocally() { + for (version in intArrayOf(-1, 0, 1, 2, 3, 127, 255, 256, Int.MAX_VALUE, Int.MIN_VALUE)) { + val failure = createReturningFailure(version = version, encryptionKeyIndex = 0) + assertFalse( + "version=$version must not be rejected by the Kotlin wrapper; " + + "representation narrowing and version policy belong to the " + + "native layers, got: $failure", + failure is IllegalArgumentException, + ) + } + } + + /** + * An explicit NEGATIVE index is a caller error this layer does own. + * + * `null` is how the API expresses "no index supplied", so a negative number + * denotes neither an index nor the omission and cannot be forwarded as + * either. + */ + @Test + fun rejectsAnExplicitNegativeIndex() { + for (index in intArrayOf(-1, -5, Int.MIN_VALUE)) { + val failure = createReturningFailure(version = 1, encryptionKeyIndex = index) + val rejected = assertThrows( + "encryptionKeyIndex=$index must be rejected by the wrapper", + IllegalArgumentException::class.java, + ) { throw failure!! } + assertTrue( + "the message should name the offending argument, got: ${rejected.message}", + rejected.message!!.contains("encryptionKeyIndex"), + ) + } + } + + /** + * Omitting the index is valid and must reach native. + * + * This is the preferred path: the SDK allocates the index from Platform + * state. It must not be mistaken for a missing argument. + */ + @Test + fun acceptsAnOmittedIndex() { + val failure = createReturningFailure(version = 1, encryptionKeyIndex = null) + assertFalse( + "the omitted-index path must not be rejected as an argument error, got: $failure", + failure is IllegalArgumentException, + ) + } + + /** + * Zero is an ordinary explicit index, not a stand-in for absence. + * + * Guards the boundary between the two representations: only `null` means + * omitted, so `0` must pass through as a real index. + */ + @Test + fun acceptsZeroAsAnExplicitIndex() { + val failure = createReturningFailure(version = 1, encryptionKeyIndex = 0) + assertFalse( + "an explicit zero index must not be rejected as an argument error, got: $failure", + failure is IllegalArgumentException, + ) + } +} diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt deleted file mode 100644 index cbc2e05073..0000000000 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -package org.dashfoundation.dashsdk.documents - -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertFalse -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * Input validation for [DocumentTransactions.createEncryptedDocument]. - * - * Two independent guards are exercised here, both of which run BEFORE any - * native call (`TransactionsNative`), so the REJECTION paths are testable on the - * JVM without the JNI library loaded: - * - * 1. Version byte (dashpay/platform#4091): only 0 (CBOR) and 1 (protobuf) are - * wire-meaningful — `seal_tx_metadata` writes the byte verbatim and the - * legacy dashj `decryptTxMetadata` switches on exactly those two values, so - * an out-of-range byte would silently seal a document the legacy stack can't - * decode. - * 2. `encryptionKeyIndex` (dashpay/platform#4186 follow-up): `null` is the - * preferred path (Rust allocates the index from Platform state); an explicit - * value, when supplied, must be non-negative. - * - * Paths that PASS validation proceed into native and can't be fully unit-tested - * here (no JNI library); [noIndexPathPassesValidation] asserts only that the - * `null` index is accepted by the guard, not rejected as an argument error. - */ -class DocumentTransactionsVersionValidationTest { - - private val id32 = ByteArray(32) - private val payload = ByteArray(4) { it.toByte() } - - private fun createWithVersion(version: Int) = runBlocking { - DocumentTransactions().createEncryptedDocument( - walletHandle = 0L, - mnemonicResolverHandle = 0L, - ownerId = id32, - contractId = id32, - documentType = "txMetadata", - version = version, - payload = payload, - signerHandle = 0L, - encryptionKeyIndex = 0, - ) - } - - /** Bytes 2..255 (previously accepted by the `0..255` range) are now rejected. */ - @Test - fun rejectsVersionBytesTheLegacyStackCannotDecode() { - for (version in intArrayOf(2, 3, 127, 255)) { - val e = assertThrows( - "version=$version must be rejected", - IllegalArgumentException::class.java, - ) { createWithVersion(version) } - assertTrue( - "message should name the wire-meaningful versions, got: ${e.message}", - e.message!!.contains("0 (CBOR) or 1 (protobuf)"), - ) - } - } - - /** A negative version byte is likewise rejected. */ - @Test - fun rejectsNegativeVersion() { - assertThrows(IllegalArgumentException::class.java) { createWithVersion(-1) } - } - - /** - * An explicit NEGATIVE index (the migration/test-only path) is rejected by - * the `require`. `null` (the allocate-in-Rust path) is the only way to omit - * an index; a negative explicit value is a caller error. - */ - @Test - fun rejectsExplicitNegativeIndex() { - val e = assertThrows(IllegalArgumentException::class.java) { - runBlocking { - DocumentTransactions().createEncryptedDocument( - walletHandle = 0L, - mnemonicResolverHandle = 0L, - ownerId = id32, - contractId = id32, - documentType = "txMetadata", - version = 1, - payload = payload, - signerHandle = 0L, - encryptionKeyIndex = -5, - ) - } - } - assertTrue( - "message should name encryptionKeyIndex, got: ${e.message}", - e.message!!.contains("encryptionKeyIndex"), - ) - } - - /** - * The no-index path (`encryptionKeyIndex` omitted → `null`, the default and - * preferred allocate-in-Rust route) must PASS the argument guards. With all - * other inputs valid, the only failure that can surface is the native call - * itself (no JNI library in a JVM unit test), NOT an - * [IllegalArgumentException] from our `require`s — proving `null` is a valid - * argument rather than a rejected one. - */ - @Test - fun noIndexPathPassesValidation() { - val t = runCatching { - runBlocking { - DocumentTransactions().createEncryptedDocument( - walletHandle = 0L, - mnemonicResolverHandle = 0L, - ownerId = id32, - contractId = id32, - documentType = "txMetadata", - version = 1, - payload = payload, - signerHandle = 0L, - // encryptionKeyIndex omitted → null → allocate in Rust. - ) - } - }.exceptionOrNull() - assertFalse( - "the null-index path must not be rejected as an argument error, got: $t", - t is IllegalArgumentException, - ) - } -} diff --git a/packages/rs-platform-encryption/Cargo.toml b/packages/rs-platform-encryption/Cargo.toml index 565549226b..a650e99314 100644 --- a/packages/rs-platform-encryption/Cargo.toml +++ b/packages/rs-platform-encryption/Cargo.toml @@ -12,8 +12,8 @@ description = "Cryptographic utilities for Dash Platform (DIP-15 DashPay encrypt # 0.30 dashcore re-exports, so the public `SecretKey`/`PublicKey` types unify # with dashcore-typed callers (platform-wallet, rs-sdk-ffi). secp256k1 = { version = "0.30.0", features = ["std"] } -aes = "0.8" -cbc = "0.1" +aes = { version = "0.8", features = ["zeroize"] } +cbc = { version = "0.1", features = ["zeroize"] } hmac = "0.12" sha2 = "0.10" thiserror = "1.0" diff --git a/packages/rs-platform-encryption/src/aes.rs b/packages/rs-platform-encryption/src/aes.rs index 00a5fa14b0..44e2afd9ef 100644 --- a/packages/rs-platform-encryption/src/aes.rs +++ b/packages/rs-platform-encryption/src/aes.rs @@ -84,6 +84,14 @@ mod tests { use super::*; use secp256k1::rand::{thread_rng, RngCore}; + #[test] + fn should_zeroize_encryptor_and_decryptor_state_on_drop() { + fn assert_zeroize_on_drop() {} + + assert_zeroize_on_drop::(); + assert_zeroize_on_drop::(); + } + #[test] fn test_aes_encryption_decryption() { let key = [0u8; 32]; diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index 406f7a5da7..fda93cd263 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -1,6 +1,7 @@ //! FFI bindings for document create operations on `IdentityWallet`. use std::ffi::{CStr, CString}; +use std::marker::PhantomData; use std::os::raw::c_char; use std::ptr; use std::slice; @@ -9,6 +10,7 @@ use dpp::document::{Document, DocumentV0Getters}; use dpp::prelude::Identifier; use dpp::serialization::ValueConvertible; use key_wallet::bip32::ExtendedPrivKey; +use platform_wallet::wallet::identity::crypto::tx_metadata::ensure_tx_metadata_create_inputs_valid; use platform_wallet::{PlatformWalletError, TxMetadataKeySource}; use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle, VTableSigner}; use zeroize::Zeroizing; @@ -17,17 +19,23 @@ use crate::check_ptr; use crate::error::*; use crate::handle::*; use crate::identity_keys_from_mnemonic::resolve_master_from_resolver; -use crate::runtime::block_on_worker; +use crate::runtime::{block_on_worker, try_block_on_worker}; use crate::tx_metadata_json::serialize_decrypted_documents; use crate::types::read_identifier; use crate::{unwrap_option_or_return, unwrap_result_or_return}; /// RAII guard scrubbing a resolved master xprv's secret scalar on drop. -/// `ExtendedPrivKey` has no `Drop`/`Zeroize` of its own, so a resolved master -/// would otherwise linger on the stack past its use — and a manual -/// `non_secure_erase()` placed after an `.await` is skipped on panic / early -/// return. Wrapping the master here scrubs it on EVERY exit path -/// (dashpay/platform#4091). Mirrors `WipingSecretKey` in `utils.rs`. +/// +/// The pinned `ExtendedPrivKey` zeroizes itself on drop. This guard narrows the +/// private scalar's lifetime to the explicit operation boundary and keeps that +/// boundary stable across later lexical refactors: ordinary return, error +/// return, and unwinding panic all erase it here before the value's own full +/// zeroizing drop runs. +/// +/// It does NOT cover `panic = "abort"`, which the iOS profiles use: an abort +/// runs no destructor, so nothing scrubs the master there. Nor is the write +/// itself absolute — it cannot reach a register copy or one the optimizer +/// already made. Mirrors `WipingSecretKey` in `utils.rs`. struct WipingMaster(ExtendedPrivKey); impl Drop for WipingMaster { @@ -48,8 +56,9 @@ impl Drop for WipingMaster { /// resolver: the wallet's mnemonic is resolved on demand (keyed by the /// wallet's own id) and returned as a master xprv (`Ok(Some(master))`). /// The CALLER must wipe it once the derive is done — wrap it in -/// [`WipingMaster`] so its scalar is scrubbed on every exit path (normal, -/// early return, panic), not only after a manual `non_secure_erase()`. When +/// [`WipingMaster`] so its scalar is scrubbed on ordinary return, on an error +/// return and on an unwinding panic, rather than only after a manual +/// `non_secure_erase()`. An abort runs no destructor and is not covered. When /// the resolver handle is null for this shape, errors with a hint naming the /// requirement. /// @@ -103,10 +112,257 @@ unsafe fn tx_metadata_key_master_for_wallet( } } +/// The whole txMetadata create-argument policy as an FFI result. +/// +/// A Rust helper, not a C symbol, shared by the C exports, standalone allocator, +/// deferred-payload composite, and their tests so every entry point applies one +/// implementation of what makes a create request valid. +/// +/// Every caller runs this BEFORE copying plaintext, consulting the host key +/// resolver, reaching the network, or reserving an index — including the index +/// allocator, which must not spend an index on a request that a later stage will +/// reject anyway. `encryption_key_index` is `None` when an index is about to be +/// allocated. +/// +/// `signer_present` carries the one precondition that is not wallet-protocol +/// policy: a create broadcasts through a signer, so a request without one cannot +/// succeed no matter what the other arguments say. It lives here rather than +/// only at a C wrapper so the C exports, standalone allocator, and Rust-ABI +/// deferred-payload composite all reject the same requests before materializing +/// plaintext or reserving an index. +pub fn tx_metadata_create_preflight_result( + payload_len: usize, + version: u8, + encryption_key_index: Option, + signer_present: bool, +) -> PlatformWalletFFIResult { + if !signer_present { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorNullPointer, + "signer_handle ptr is null", + ); + } + match ensure_tx_metadata_create_inputs_valid(payload_len, version, encryption_key_index) { + Ok(()) => PlatformWalletFFIResult::ok(), + Err(error) => error.into(), + } +} + +/// Sequence an encrypted create's index resolution ahead of its plaintext copy. +/// +/// Resolving the index can wait on the network (the SDK-allocated path counts +/// the identity's documents on Platform) and needs only the payload's length, +/// while materializing produces an owned copy of the caller's plaintext. Running +/// them in this order is what keeps a native plaintext copy from existing while +/// that round trip is in flight — a window with no bound, since the SDK sets no +/// request timeout. A failed resolution returns before `materialize` runs at all, +/// so a doomed request never copies the plaintext either. +/// +/// The order is expressed as a function rather than as adjacent statements +/// because it is a security property, not a stylistic one: a later edit that +/// reorders it has to change this call, and the ordering test pinning it. +/// +/// This private seam is used by the shared create orchestration for both +/// borrowed C input and deferred host materialization, so neither host bridge +/// decides the ordering. +fn allocate_before_materializing( + resolve_index: impl FnOnce() -> Result, + materialize: impl FnOnce() -> T, +) -> Result<(u32, T), PlatformWalletFFIResult> { + let resolved_index = resolve_index()?; + Ok((resolved_index, materialize())) +} + +/// Resolve an index, materialize the native plaintext exactly once, and verify +/// that the callback honored the declared-length contract. +fn settle_index_and_materialize_payload( + declared_len: usize, + resolve_index: impl FnOnce() -> Result, + materialize: impl FnOnce() -> Result>, PlatformWalletFFIResult>, +) -> Result<(u32, Zeroizing>), PlatformWalletFFIResult> { + let (resolved_index, materialized) = allocate_before_materializing(resolve_index, materialize)?; + let payload = materialized?; + if payload.len() != declared_len { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!( + "materialized payload length {} did not match declared length {declared_len}", + payload.len() + ), + )); + } + Ok((resolved_index, payload)) +} + +/// Seal the plaintext, release every SDK-owned secret, and only THEN broadcast. +/// +/// Broadcasting is an unbounded network wait — the SDK sets no request timeout — +/// so whatever is still alive when it starts stays alive for however long the +/// network takes. Sealing needs the plaintext and the resolved key material; +/// broadcasting needs neither, only the ciphertext-bearing properties. Ending +/// both lifetimes strictly between the two stages is what keeps them off that +/// wait, and a seal that fails releases them without reaching the network at +/// all. +/// +/// The order is expressed as a function rather than as adjacent statements +/// because it is a security property, not a stylistic one: a later edit that +/// reorders it has to change this call, and the ordering tests pinning it. +/// +/// It is also what carries the guarantee across the SDK's own boundary. A host +/// that cannot lend its plaintext hands over a bridge-created native copy +/// instead; that copy becomes `payload` here, so the release below erases that +/// native copy rather than merely erasing a second copy made inside the SDK. +/// Any runtime-managed host object remains outside Rust's control. +fn seal_and_release_before_broadcasting( + payload: Payload, + secret: Secret, + seal: impl FnOnce(&Payload, &Secret) -> Result, + broadcast: impl FnOnce(Sealed) -> Broadcast, +) -> Result { + // Released explicitly on BOTH paths rather than left to end-of-scope, so + // the point at which each lifetime ends is stated here rather than implied + // by declaration order. + let sealed = match seal(&payload, &secret) { + Ok(sealed) => sealed, + Err(failure) => { + drop(payload); + drop(secret); + return Err(failure); + } + }; + drop(payload); + drop(secret); + Ok(broadcast(sealed)) +} + +/// Where an encrypted create's plaintext comes from and — inseparably — how its +/// `encryptionKeyIndex` was settled. +/// +/// The two shapes exist because hosts differ in what they can lend. A host that +/// owns its plaintext outright lends a pointer to it for the synchronous call +/// (Swift's `Data.withUnsafeBytes`), so the SDK copies it internally and is free +/// to settle the index itself first. A host whose plaintext lives in a +/// runtime-managed object that cannot be pinned across a network round trip (a +/// JVM `byte[]`) instead gives Rust a deferred materializer. Rust settles the +/// index first and invokes that callback only when it is ready to take ownership +/// of the native plaintext copy. +/// +/// Pairing the declared length, optional index, and materializer keeps the +/// ordering in this shared Rust operation rather than in either host bridge. +type DeferredPayloadMaterializer<'a> = + Box Result>, PlatformWalletFFIResult> + 'a>; + +enum PayloadSource<'a> { + /// Caller memory borrowed for the synchronous call, copied into an owned + /// zeroizing buffer only once the index is settled — either by using the + /// index the host supplied (`Some`) or by allocating one (`None`). + Borrowed { + ptr: *const u8, + len: usize, + index: Option, + borrow: PhantomData<&'a [u8]>, + }, + /// A native copy that Rust asks the host bridge to make only after the + /// index is settled. The callback runs synchronously on the thread that + /// entered this Rust-ABI helper and returns ownership of the copy. + Deferred { + len: usize, + index: Option, + materialize: DeferredPayloadMaterializer<'a>, + }, +} + +impl PayloadSource<'_> { + /// The plaintext length. On the borrowed shape this is the DECLARED length, + /// which is what lets an over-large request be refused without the pointer + /// ever being read. + fn len(&self) -> usize { + match self { + PayloadSource::Borrowed { len, .. } => *len, + PayloadSource::Deferred { len, .. } => *len, + } + } + + /// The caller-supplied index, or `None` when the SDK is about to allocate + /// one before materialization. + fn settled_index(&self) -> Option { + match self { + PayloadSource::Borrowed { index, .. } => *index, + PayloadSource::Deferred { index, .. } => *index, + } + } +} + +/// Run an encrypted export's Rust-ABI inner function so a panic in it cannot +/// reach the `extern "C"` frame. +/// +/// Where unwinding exists, an escaping panic would unwind into a frame declared +/// with the non-unwinding C ABI; the compiler stops that with a forced abort, +/// killing the host. Catching here turns it into an ordinary result instead. +/// +/// Under `panic = "abort"` a panic aborts where it is raised, so no catch is +/// possible and the inner function is called directly. What that profile gains +/// is narrower and comes from elsewhere: the known runtime and worker-join +/// failures are handled as VALUES (see [`crate::runtime::WorkerFailure`]) and +/// so never become panics at all. Arbitrary panics remain fatal there. +/// +/// The caught payload is deliberately dropped: an FFI message must stay bounded +/// and free of anything caller-derived. +fn contain_panics(inner: impl FnOnce() -> PlatformWalletFFIResult) -> PlatformWalletFFIResult { + #[cfg(panic = "unwind")] + { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(inner)) { + Ok(result) => result, + Err(_) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUnknown, + "encrypted document operation failed unexpectedly", + ), + } + } + #[cfg(not(panic = "unwind"))] + { + inner() + } +} + +/// Map a shared-runtime failure to an FFI result. +/// +/// Neither stage is the caller's fault, so both surface as the unknown-failure +/// code carrying the failure's own fixed, stage-only text. +fn worker_failure_result(failure: crate::runtime::WorkerFailure) -> PlatformWalletFFIResult { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUnknown, + failure.to_string(), + ) +} + +// One-shot, thread-scoped panic injection for the encrypted create inner +// function, used to prove the containment above. Consumed by the first check on +// the calling thread, leaving no state behind. +#[cfg(test)] +thread_local! { + static FORCED_INNER_PANIC: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Make the next encrypted create inner call on THIS thread panic. +#[cfg(test)] +pub(crate) fn force_inner_panic_once() { + FORCED_INNER_PANIC.with(|flag| flag.set(true)); +} + +#[cfg(test)] +fn take_forced_inner_panic() { + if FORCED_INNER_PANIC.with(|flag| flag.replace(false)) { + panic!("forced inner panic"); + } +} + +#[cfg(not(test))] +fn take_forced_inner_panic() {} + /// The key-source outcome of the capability + resolver-handle check, factored /// out of [`tx_metadata_key_master_for_wallet`] as a pure decision so the -/// dispatch is unit-testable without a live `PlatformWallet` -/// (dashpay/platform#4091). +/// dispatch is decidable without a live `PlatformWallet`. #[derive(Debug, PartialEq, Eq)] enum KeySourceDecision { /// Resident-key wallet — derive in-process; the resolver handle is ignored @@ -283,7 +539,7 @@ fn confirmed_document_to_json(document: &Document) -> Result Result PlatformWalletFFIResult { - // ABI-stable explicit-index entry point: the host supplies the per-document - // encryptionKeyIndex (migration / tests). Delegates to the shared impl with - // `Some(index)`. - create_encrypted_document_impl( - wallet_handle, - mnemonic_resolver_handle, - owner_identity_id, - contract_id, - document_type_name, - Some(encryption_key_index), - version, - payload, - payload_len, - signer_handle, - out_document_id, - out_document_json, - ) + // Validate the output-parameter ADDRESS and publish the documented null + // sentinel before any other fallible input or lookup, so every later + // rejection leaves the caller holding null rather than whatever the + // variable happened to contain. A caller that follows the documented + // contract would otherwise free a pointer this call never owned. This runs + // in the `extern "C"` frame itself, so the sentinel is published even if + // the inner function later fails in any way. + check_ptr!(out_document_json); + *out_document_json = ptr::null_mut(); + + contain_panics(|| { + create_encrypted_document_inner( + wallet_handle, + mnemonic_resolver_handle, + owner_identity_id, + contract_id, + document_type_name, + version, + PayloadSource::Borrowed { + ptr: payload, + len: payload_len, + index: Some(encryption_key_index), + borrow: PhantomData, + }, + signer_handle, + out_document_id, + out_document_json, + ) + }) } /// Create + broadcast an encrypted `txMetadata` document, letting RUST allocate /// the per-document `encryptionKeyIndex` from authoritative Platform state -/// (dashpay/platform#4186 follow-up). ABI-additive sibling of +///. ABI-additive sibling of /// [`platform_wallet_create_encrypted_document_with_signer`] — IDENTICAL /// parameters minus `encryption_key_index`. /// @@ -374,160 +672,431 @@ pub unsafe extern "C" fn platform_wallet_create_encrypted_document_with_signer_a out_document_id: *mut u8, out_document_json: *mut *mut c_char, ) -> PlatformWalletFFIResult { - // Rust-allocated-index entry point: the host omits encryptionKeyIndex, so - // the shared impl allocates it from Platform state (`None`). - create_encrypted_document_impl( - wallet_handle, - mnemonic_resolver_handle, - owner_identity_id, - contract_id, - document_type_name, - None, - version, - payload, - payload_len, - signer_handle, - out_document_id, - out_document_json, - ) + // Same out-pointer contract as the explicit-index export: the address is + // validated and its null sentinel published in the `extern "C"` frame, + // before any other fallible input. + check_ptr!(out_document_json); + *out_document_json = ptr::null_mut(); + + contain_panics(|| { + create_encrypted_document_inner( + wallet_handle, + mnemonic_resolver_handle, + owner_identity_id, + contract_id, + document_type_name, + version, + PayloadSource::Borrowed { + ptr: payload, + len: payload_len, + index: None, + borrow: PhantomData, + }, + signer_handle, + out_document_id, + out_document_json, + ) + }) } -/// Shared implementation behind the explicit-index -/// ([`platform_wallet_create_encrypted_document_with_signer`], `Some`) and -/// Rust-allocated -/// ([`platform_wallet_create_encrypted_document_with_signer_auto_index`], -/// `None`) encrypted-document create exports. -/// -/// When `index` is `None` the per-document `encryptionKeyIndex` is allocated -/// from Platform state via `IdentityWallet::allocate_encryption_key_index` -/// (serialized under the wallet's allocator mutex) BEFORE any key material is -/// resolved — the allocation touches no secrets and never crosses the broadcast -/// await with the master in scope. That allocation first runs the deterministic, -/// network-free payload-size gate, so an oversized payload fails without -/// reserving (and thus without consuming) an index — no allocator gap -/// (dashpay/platform#4186 review). +/// Create + broadcast an encrypted `txMetadata` document while deferring the +/// caller's native plaintext copy until Rust has settled the index. +/// +/// A Rust-ABI helper, not a C symbol: the JNI layer links this crate as an rlib +/// and calls it directly, so this adds no export to the C header and no second +/// implementation of the create — it converges on the same orchestration the +/// two C exports run. +/// +/// It exists because a JVM `byte[]` cannot be pinned across the automatic index +/// query. JNI supplies only its declared length and a synchronous callback. +/// Rust validates the request, settles the explicit or automatic index, and +/// only then invokes `materialize_payload` exactly once. The returned +/// `Zeroizing>` is consumed by the shared create path and scrubbed as +/// soon as the encrypted properties are sealed, before broadcast begins. +/// +/// Keeping that sequence in one Rust operation makes JNI a marshaling layer: +/// it never calls the allocator separately and never owns a native plaintext +/// copy while a network allocation query is in flight. /// /// # Safety -/// All pointers must be valid for the duration of the call; `payload` may be -/// null only when `payload_len == 0`. +/// Same pointer contract as +/// [`platform_wallet_create_encrypted_document_with_signer`], minus the payload: +/// `materialize_payload` must return exactly `payload_len` bytes. The helper +/// rejects a mismatch and drops the returned zeroizing allocation without key +/// resolution or broadcast. `out_document_json` must point to writable storage +/// for one `char *`; it is nulled before any other fallible work and, on success, +/// receives a string the caller MUST release with `platform_wallet_string_free` +/// (the ordinary free — this output is canonical document JSON, ciphertext and +/// metadata, no plaintext). #[allow(clippy::too_many_arguments)] -unsafe fn create_encrypted_document_impl( +pub unsafe fn create_encrypted_document_with_deferred_payload<'a>( wallet_handle: Handle, mnemonic_resolver_handle: *mut MnemonicResolverHandle, owner_identity_id: *const u8, contract_id: *const u8, document_type_name: *const c_char, - index: Option, + encryption_key_index: Option, version: u8, - payload: *const u8, payload_len: usize, + materialize_payload: impl FnOnce() -> Result>, PlatformWalletFFIResult> + 'a, signer_handle: *mut SignerHandle, out_document_id: *mut u8, out_document_json: *mut *mut c_char, ) -> PlatformWalletFFIResult { - check_ptr!(signer_handle); - check_ptr!(document_type_name); - check_ptr!(out_document_id); + // Same out-pointer contract as the C exports: the address is validated and + // its null sentinel published before any other fallible input, so a caller + // following the documented contract never frees a pointer this call did not + // own. check_ptr!(out_document_json); - *out_document_json = ptr::null_mut(); + // Contained for the same reason as at the C exports, even though the + // immediate caller is Rust. The deferred callback is owned by this closure, + // so it is dropped without being called on any earlier rejection and any + // materialized zeroizing payload is released during an unwind. + contain_panics(move || { + create_encrypted_document_inner( + wallet_handle, + mnemonic_resolver_handle, + owner_identity_id, + contract_id, + document_type_name, + version, + PayloadSource::Deferred { + len: payload_len, + index: encryption_key_index, + materialize: Box::new(materialize_payload), + }, + signer_handle, + out_document_id, + out_document_json, + ) + }) +} + +/// Allocate the next `encryptionKeyIndex` for `owner_identity_id` on +/// `contract_id`'s `document_type_name`, without creating a document. +/// +/// This exists so a host whose plaintext lives in a runtime-managed buffer — a +/// JVM `byte[]`, for example — can settle the index BEFORE copying that +/// plaintext anywhere native. Allocating waits on Platform, and the SDK sets no +/// request timeout, so a host that copied first would keep a plaintext copy +/// alive for an unbounded wait. Hosts that can pass a pointer to memory they +/// already own should use +/// [`platform_wallet_create_encrypted_document_with_signer_auto_index`] instead, +/// which sequences the same allocation internally and makes one call of it. +/// +/// It takes the SAME create arguments as the export it precedes, minus the +/// payload bytes themselves, and applies the SAME argument policy to them. That +/// is deliberate: an index is a one-way reservation, so allocating one for a +/// request that a later stage would reject anyway burns it for nothing. Passing +/// `version`, `payload_len` and `signer_handle` here is what lets those +/// rejections happen before the allocation rather than after it — the caller +/// cannot ask for an index without supplying everything needed to know the +/// create could succeed. +/// +/// `payload_len` is the plaintext length of the document about to be created; +/// nothing about the payload other than its length is needed. `signer_handle` is +/// not used for signing here — only its presence is checked, since a create +/// without a signer cannot proceed. +/// +/// The returned index is a reservation, not a promise: it has been handed out +/// and will not be handed out again in this process, so abandoning it leaves an +/// unused index rather than a duplicate. The caller passes it to +/// [`platform_wallet_create_encrypted_document_with_signer`]. +/// +/// # Safety +/// `owner_identity_id` and `contract_id` must each point to 32 readable bytes, +/// `document_type_name` must be a valid NUL-terminated C string, and `out_index` +/// must point to writable `u32` storage. `*out_index` is left untouched on any +/// error. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_allocate_encryption_key_index( + wallet_handle: Handle, + owner_identity_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + version: u8, + payload_len: usize, + signer_handle: *mut SignerHandle, + out_index: *mut u32, +) -> PlatformWalletFFIResult { + contain_panics(|| { + allocate_encryption_key_index_inner( + wallet_handle, + owner_identity_id, + contract_id, + document_type_name, + version, + payload_len, + signer_handle, + out_index, + ) + }) +} + +/// Rust-ABI body of [`platform_wallet_allocate_encryption_key_index`], split out +/// so a panic in it is caught before the `extern "C"` frame. +/// +/// # Safety +/// Same contract as the `extern "C"` wrapper. +#[allow(clippy::too_many_arguments)] +unsafe fn allocate_encryption_key_index_inner( + wallet_handle: Handle, + owner_identity_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + version: u8, + payload_len: usize, + signer_handle: *mut SignerHandle, + out_index: *mut u32, +) -> PlatformWalletFFIResult { + check_ptr!(signer_handle); + check_ptr!(document_type_name); + check_ptr!(out_index); + + // The same argument policy the create export applies, through the same + // helper, run BEFORE the allocator or the network is touched. An index is a + // one-way reservation, so a request that a later stage must reject has to be + // rejected here rather than after it has spent one. `None` because this call + // is what produces the index. + let preflight = + tx_metadata_create_preflight_result(payload_len, version, None, !signer_handle.is_null()); + if preflight.code != PlatformWalletFFIResultCode::Success { + return preflight; + } + let owner_id = unwrap_result_or_return!(read_identifier(owner_identity_id)); let contract_id_value = unwrap_result_or_return!(read_identifier(contract_id)); let document_type_str = unwrap_result_or_return!(CStr::from_ptr(document_type_name).to_str()).to_string(); - // Copy the payload into an owned buffer. Null is allowed only for a - // zero-length payload. It is wrapped in `Zeroizing` so the native plaintext - // copy is scrubbed on drop, and it is dropped explicitly the instant the - // encrypted properties are prepared (below) — the plaintext must NOT linger - // in scope across the broadcast `.await` (dashpay/platform#4091). - let payload_vec: Zeroizing> = Zeroizing::new(if payload_len == 0 { - Vec::new() - } else { - check_ptr!(payload); - slice::from_raw_parts(payload, payload_len).to_vec() + // Clone the identity handle out of the shared handle storage before the + // network round trip, so the process-wide guard is not held across it. + let cloned = + PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| wallet.identity().clone()); + let identity_wallet = unwrap_option_or_return!(cloned); + + let allocated = try_block_on_worker(async move { + identity_wallet + .allocate_encryption_key_index( + &owner_id, + &contract_id_value, + &document_type_str, + payload_len, + ) + .await }); + let index = match allocated { + Ok(result) => unwrap_result_or_return!(result), + Err(failure) => return worker_failure_result(failure), + }; + + *out_index = index; + PlatformWalletFFIResult::ok() +} + +/// Rust-ABI body shared by every encrypted-document create entry point: the +/// explicit-index C export +/// ([`platform_wallet_create_encrypted_document_with_signer`]), the +/// SDK-allocated C export +/// ([`platform_wallet_create_encrypted_document_with_signer_auto_index`]), and +/// the deferred-payload Rust helper +/// ([`create_encrypted_document_with_deferred_payload`]). +/// +/// Split out so a panic raised in here is caught before the `extern "C"` frame +/// (see [`contain_panics`]). Every caller has already validated +/// `out_document_json` and published its null sentinel. +/// +/// The three differ only in where the plaintext comes from and how the index was +/// settled, which [`PayloadSource`] carries. When it reports no settled index +/// the per-document `encryptionKeyIndex` is allocated from Platform state before +/// the caller's plaintext is copied and before any key material is resolved, so +/// the allocation never crosses the broadcast await with a master in scope and +/// an oversized payload fails without reserving an index. Whichever route was +/// taken, the owned plaintext is released before the broadcast begins. +/// +/// # Safety +/// Same contract as the `extern "C"` wrappers: every non-null pointer argument +/// must be valid for the duration of the call, a borrowed payload's pointer may +/// be null only when its length is `0`, and `out_document_json` must already +/// point to writable storage. +#[allow(clippy::too_many_arguments)] +unsafe fn create_encrypted_document_inner( + wallet_handle: Handle, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + owner_identity_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + version: u8, + payload: PayloadSource<'_>, + signer_handle: *mut SignerHandle, + out_document_id: *mut u8, + out_document_json: *mut *mut c_char, +) -> PlatformWalletFFIResult { + take_forced_inner_panic(); + + check_ptr!(document_type_name); + check_ptr!(out_document_id); + + // Everything decidable from the arguments alone — signer presence, payload + // size, wire version, and a settled index's derivability — is checked + // before the resolver, the network, the allocator, or any copy of the + // caller's plaintext. A borrowed payload reports its DECLARED length, so an + // over-large request is refused without its pointer ever being read; an + // unsettled index is derivable by construction. + let payload_len = payload.len(); + let preflight = tx_metadata_create_preflight_result( + payload_len, + version, + payload.settled_index(), + !signer_handle.is_null(), + ); + if preflight.code != PlatformWalletFFIResultCode::Success { + return preflight; + } + + // A borrowed payload's ADDRESS is validated here, before anything is + // allocated: a non-null pointer is required for a non-empty payload, and + // null is valid only for a zero-length one. A deferred payload has no + // address to check until its host callback returns owned bytes. + if let PayloadSource::Borrowed { ptr, len, .. } = &payload { + if *len != 0 && ptr.is_null() { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorNullPointer, + "payload ptr is null", + ); + } + } + + let owner_id = unwrap_result_or_return!(read_identifier(owner_identity_id)); + let contract_id_value = unwrap_result_or_return!(read_identifier(contract_id)); + let document_type_str = + unwrap_result_or_return!(CStr::from_ptr(document_type_name).to_str()).to_string(); let signer_addr = signer_handle as usize; let owner_id_for_async = owner_id; let contract_id_for_async = contract_id_value; - // `move` so the closure OWNS `payload_vec` and can drop it (scrubbing the - // plaintext) before the broadcast `.await`; the other captures are Copy or - // already moved into the nested `async move` block. - let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, move |wallet| { - let identity_wallet = wallet.identity().clone(); + // Resolve the handle before any network wait or plaintext materialization. + // The stored value is an `Arc`, so the process-wide storage guard is gone + // before either stage begins and an invalid handle never causes a host copy. + let Some(wallet_arc) = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, std::sync::Arc::clone) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "requested wallet handle not found", + ); + }; + let identity_wallet = wallet_arc.identity().clone(); + let identity_wallet_for_broadcast = identity_wallet.clone(); + + // Settle the per-document encryptionKeyIndex and obtain the owned plaintext. + // + // A borrowed pointer and a deferred host materializer converge here. The + // host either supplied the index, or the SDK allocates it from Platform. + // Allocating is a network round trip that needs only the payload's LENGTH, + // so it is sequenced strictly ahead of the copy: no owned plaintext exists + // while that round trip is in flight. Being ahead of the copy also puts it + // ahead of every key resolution, so it cannot strand a resolved master. + let (declared_len, index, materialize): (usize, Option, DeferredPayloadMaterializer<'_>) = + match payload { + PayloadSource::Borrowed { + ptr, len, index, .. + } => ( + len, + index, + Box::new(move || { + // The pointer was validated above and is dereferenced only once + // the index is settled. The owned copy is scrubbed on drop. + Ok(Zeroizing::new(if len == 0 { + Vec::new() + } else { + slice::from_raw_parts(ptr, len).to_vec() + })) + }), + ), + PayloadSource::Deferred { + len, + index, + materialize, + } => (len, index, materialize), + }; - // Resolve the per-document encryptionKeyIndex FIRST, before any key - // material is in scope: the host either supplies it explicitly - // (`Some`, migration / tests) or omits it (`None`), in which case Rust - // allocates the next index from authoritative Platform state, serialized - // under the wallet's allocator mutex (dashpay/platform#4186 follow-up). - // The allocation touches no secrets, so it can run on the worker before - // the master is resolved. `allocate_encryption_key_index` runs the - // deterministic payload-size gate (network-free) BEFORE reserving, so an - // oversized payload fails without consuming an index — no allocator gap - // (dashpay/platform#4186 review). - let resolved_index: u32 = match index { - Some(i) => i, + let identity_wallet_for_alloc = identity_wallet.clone(); + let sequenced = settle_index_and_materialize_payload( + declared_len, + || match index { + Some(supplied) => Ok(supplied), None => { - let iw = identity_wallet.clone(); - let doc_type = document_type_str.clone(); - let payload_len = payload_vec.len(); - block_on_worker(async move { - iw.allocate_encryption_key_index( - &owner_id_for_async, - &contract_id_for_async, - &doc_type, - payload_len, - ) - .await - }) - .map_err(PlatformWalletFFIResult::from)? + let document_type_for_alloc = document_type_str.clone(); + match try_block_on_worker(async move { + identity_wallet_for_alloc + .allocate_encryption_key_index( + &owner_id_for_async, + &contract_id_for_async, + &document_type_for_alloc, + declared_len, + ) + .await + }) { + Ok(allocated) => allocated.map_err(PlatformWalletFFIResult::from), + Err(failure) => Err(worker_failure_result(failure)), + } } - }; + }, + materialize, + ); + let (resolved_index, payload_vec) = match sequenced { + Ok(sequenced) => sequenced, + Err(failure) => return failure, + }; - // Key-source selection by wallet capability (may synchronously call - // back into the host mnemonic resolver for external-signable - // wallets — see `tx_metadata_key_master_for_wallet`). The resolved - // master is wrapped in a Drop-wiping guard. - let master_opt = - unsafe { tx_metadata_key_master_for_wallet(wallet, mnemonic_resolver_handle) }? - .map(WipingMaster); - - // Derive the AES key + seal the wire blob SYNCHRONOUSLY, then wipe the - // master BEFORE any network `.await`: the master xprv never crosses the - // broadcast await (dashpay/platform#4091). Only the sealed properties - // (ciphertext, no key material) cross into the async block below. - let key_source = match master_opt.as_ref() { - Some(master) => TxMetadataKeySource::Master(&master.0), - None => TxMetadataKeySource::ResidentWallet, - }; - let properties_json = identity_wallet - .prepare_encrypted_txmetadata_properties( - &owner_id_for_async, - resolved_index, - version, - &payload_vec, - key_source, - ) - .map_err(PlatformWalletFFIResult::from)?; - // The plaintext is now sealed inside `properties_json` (ciphertext - // only). Scrub the native plaintext copy AND the master immediately — - // neither may cross the broadcast `.await` below. `payload_vec` is - // `Zeroizing`, so the drop also wipes its bytes (dashpay/platform#4091). - drop(payload_vec); - drop(master_opt); + // Key-source selection by wallet capability (may synchronously call + // back into the host mnemonic resolver for external-signable + // wallets — see `tx_metadata_key_master_for_wallet`). The resolved + // master is wrapped in a Drop-wiping guard. + let master_opt = match tx_metadata_key_master_for_wallet(&wallet_arc, mnemonic_resolver_handle) + { + Ok(master) => master.map(WipingMaster), + Err(failure) => return failure, + }; - let result: Result<(Identifier, String), PlatformWalletError> = - block_on_worker(async move { + // Derive the AES key + seal the wire blob SYNCHRONOUSLY; release the + // plaintext and the master; only then broadcast. Neither the plaintext — + // whether this call copied it or the host handed it over — nor the master + // xprv crosses the broadcast await; only the sealed properties (ciphertext, + // no key material) do. + let broadcast_outcome = seal_and_release_before_broadcasting( + payload_vec, + master_opt, + |plaintext, master_opt| { + let key_source = match master_opt.as_ref() { + Some(master) => TxMetadataKeySource::Master(&master.0), + None => TxMetadataKeySource::ResidentWallet, + }; + identity_wallet + .prepare_encrypted_txmetadata_properties( + &owner_id_for_async, + resolved_index, + version, + plaintext, + key_source, + ) + .map_err(PlatformWalletFFIResult::from) + }, + |properties_json| { + // Fallible worker entry: a runtime that cannot be built, or a + // worker that does not complete, becomes a value this export maps + // instead of a panic that would reach the C frame. + try_block_on_worker(async move { let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); // Generic create path (no key material in scope): fetches the // contract, sanitizes the hex `encryptedMetadata` into `Bytes`, // auto-selects the AUTHENTICATION signing key, and broadcasts on // the 8 MB worker stack. - let confirmed: Document = identity_wallet + let confirmed: Document = identity_wallet_for_broadcast .create_document_with_signer( &owner_id_for_async, &contract_id_for_async, @@ -538,10 +1107,14 @@ unsafe fn create_encrypted_document_impl( .await?; let json_string = confirmed_document_to_json(&confirmed)?; Ok::<_, PlatformWalletError>((confirmed.id(), json_string)) - }); - result.map_err(PlatformWalletFFIResult::from) - }); - let result = unwrap_option_or_return!(option); + }) + }, + ); + let result: Result<(Identifier, String), PlatformWalletError> = match broadcast_outcome { + Ok(Ok(result)) => result, + Ok(Err(failure)) => return worker_failure_result(failure), + Err(failure) => return failure, + }; let (document_id, document_json) = unwrap_result_or_return!(result); let json_cstring = unwrap_result_or_return!(CString::new(document_json)); @@ -557,17 +1130,38 @@ unsafe fn create_encrypted_document_impl( /// `owner_identity_id` on `contract_id`'s `document_type_name` updated at or /// after `since_ms` (epoch-millis). /// -/// Goes through `IdentityWallet::fetch_encrypted_documents` — the wire- -/// compatible read counterpart of the legacy `getTxMetaData(since, key)`. Each -/// document's `encryptedMetadata` blob is decrypted with the identity's derived -/// key; documents that can't be derived/decrypted are skipped (never abort the -/// fetch). +/// The wire-compatible read counterpart of the legacy +/// `getTxMetaData(since, key)`, run in three deliberate stages. The staging is +/// a security guarantee, not an implementation detail: /// -/// The AES key source is selected by the wallet's capability: a key-resident -/// wallet derives in-process; an external-signable / watch-only wallet (the -/// Android/iOS apps) derives through `mnemonic_resolver_handle` — required -/// non-null for that shape, ignored otherwise (see -/// `tx_metadata_key_master_for_wallet`). +/// 1. `IdentityWallet::fetch_raw_encrypted_documents` on a worker thread — +/// contract resolution and the paginated scan, with NO key material in +/// scope. A scan that fails or returns nothing ends here. +/// 2. Only if that scan produced candidates, the AES key source is acquired on +/// the ORIGINAL calling thread, so a host resolver callback runs on the +/// thread that entered this export rather than a runtime worker. +/// 3. `IdentityWallet::decrypt_fetched_documents` — synchronous derive and +/// decrypt, after which the resolved master is erased immediately. +/// +/// Nothing secret is therefore alive across the contract fetch or the paginated +/// walk, both of which are unbounded waits (the SDK sets no request timeout), +/// and a fetch with nothing to decrypt never consults the host at all — which +/// matters where that consultation prompts the user. +/// +/// The key source is selected by the wallet's capability: a key-resident wallet +/// derives in-process; an external-signable / watch-only wallet (the Android +/// and iOS apps) derives through `mnemonic_resolver_handle` — required non-null +/// for that shape, ignored otherwise (see `tx_metadata_key_master_for_wallet`). +/// +/// Documents that cannot be derived or decrypted, and documents carrying an +/// unsupported wire version, are skipped and never abort the fetch. +/// +/// A returned `payload` is NOT authenticated. The envelope is AES-256-CBC with +/// PKCS7 and no integrity tag, so a wrong key or modified ciphertext usually +/// fails the unpad and is skipped — but PKCS7 accepts a wrong plaintext often +/// enough that an element can carry opaque garbage. The caller must strictly +/// parse each `payload` (CBOR for `version` 0, protobuf for 1) and discard +/// anything that does not parse, rather than trusting its presence here. /// /// On success `*out_documents_json` receives an owned NUL-terminated JSON array /// containing decrypted, plaintext-equivalent data (release with @@ -577,7 +1171,33 @@ unsafe fn create_encrypted_document_impl( /// `{ "id": base58, "ownerId": base58, "keyIndex": u32, "encryptionKeyIndex": /// u32, "version": u8, "updatedAt": u64|null, "payload": base64 }`, where /// `payload` is the decrypted, opaque plaintext the caller parses (a protobuf -/// `TxMetadataBatch` for `version == 1`). +/// `TxMetadataBatch` for `version == 1`). Documents whose blob is malformed, +/// wrong-keyed, or carries an unsupported wire version are skipped rather than +/// failing the whole fetch. +/// +/// # Safety +/// Every pointer below must stay valid for the whole synchronous duration of +/// this call; the call borrows them and retains none of them afterwards. +/// +/// - `owner_identity_id` and `contract_id` must each point to 32 readable bytes. +/// - `document_type_name` must be a valid NUL-terminated C string of UTF-8. +/// - `mnemonic_resolver_handle` may be null for a wallet with resident private +/// keys, and must be live and non-null for an external-signable wallet. +/// There is no signer on this path: a fetch broadcasts nothing. +/// - `out_documents_json` must point to writable storage for one `char *`. It is +/// set to null before any other fallible work, so on EVERY error path the +/// caller is left holding null and must free nothing. On success it receives +/// ownership of a NUL-terminated C string. +/// +/// That output carries DECRYPTED plaintext and MUST be released with +/// `platform_wallet_sensitive_string_free`, which wipes the allocation through +/// its terminating NUL. Passing it to the ordinary `platform_wallet_string_free` +/// would free the plaintext without scrubbing it. Pass the original, unmodified +/// pointer — the release function computes the length from it — and treat the +/// allocation as read-only until then. +/// +/// The returned `PlatformWalletFFIResult` owns its message and must be released +/// with `platform_wallet_ffi_result_free`. #[no_mangle] pub unsafe extern "C" fn platform_wallet_fetch_encrypted_documents( wallet_handle: Handle, @@ -588,11 +1208,50 @@ pub unsafe extern "C" fn platform_wallet_fetch_encrypted_documents( since_ms: u64, out_documents_json: *mut *mut c_char, ) -> PlatformWalletFFIResult { - check_ptr!(document_type_name); + // The sensitive out-parameter's ADDRESS is validated and its null sentinel + // published before ANY other fallible input, so every later rejection — + // including a bad document type or identifier — leaves the caller holding + // null. This output carries decrypted plaintext and is released with + // `platform_wallet_sensitive_string_free`, so a caller following the + // documented contract must never be handed a stale pointer to free. This + // runs in the `extern "C"` frame itself, so the sentinel is published even + // if the inner function later fails in any way. check_ptr!(out_documents_json); - *out_documents_json = ptr::null_mut(); + contain_panics(|| { + fetch_encrypted_documents_inner( + wallet_handle, + mnemonic_resolver_handle, + owner_identity_id, + contract_id, + document_type_name, + since_ms, + out_documents_json, + ) + }) +} + +/// Rust-ABI inner for [`platform_wallet_fetch_encrypted_documents`], so a panic +/// in the decrypt path cannot reach the non-unwinding C frame. +/// +/// The caller has already validated `out_documents_json`'s address and published +/// its null sentinel, so every return from here leaves the caller holding null +/// unless the sensitive JSON was successfully written. +/// +/// # Safety +/// Same contract as the export. +unsafe fn fetch_encrypted_documents_inner( + wallet_handle: Handle, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + owner_identity_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + since_ms: u64, + out_documents_json: *mut *mut c_char, +) -> PlatformWalletFFIResult { + check_ptr!(document_type_name); + let owner_id = unwrap_result_or_return!(read_identifier(owner_identity_id)); let contract_id_value = unwrap_result_or_return!(read_identifier(contract_id)); let document_type_str = @@ -601,48 +1260,74 @@ pub unsafe extern "C" fn platform_wallet_fetch_encrypted_documents( let owner_id_for_async = owner_id; let contract_id_for_async = contract_id_value; - let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { - let identity_wallet = wallet.identity().clone(); + // Take an owned handle out of the shared storage and let the read guard go, + // for the same reason as the create path: that guard is shared by every + // wallet handle in the process, and the resolver call plus the paginated + // fetch below are unbounded waits. + let Some(wallet_arc) = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, std::sync::Arc::clone) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "requested wallet handle not found", + ); + }; + let identity_wallet = wallet_arc.identity().clone(); + + // Phase 1 — NETWORK ONLY, on a worker. No key material exists yet: no host + // resolver has been consulted and no master is in scope, so a scan that + // fails or finds nothing costs the caller no prompt and leaves no secret + // alive across the contract fetch or the paginated walk (both unbounded — + // the SDK sets no request timeout). + let raw_for_async = identity_wallet.clone(); + let document_type_for_async = document_type_str.clone(); + let raw_result: Result)>, PlatformWalletError> = + match try_block_on_worker(async move { + raw_for_async + .fetch_raw_encrypted_documents( + &owner_id_for_async, + &contract_id_for_async, + &document_type_for_async, + since_ms, + ) + .await + }) { + Ok(result) => result, + Err(failure) => return worker_failure_result(failure), + }; + // Carried through unchanged, including entries the SDK could not + // materialize: the decrypt stage records each skip, so an all-unmaterialized + // page stays distinguishable from a page that was genuinely empty. + let raw_docs = unwrap_result_or_return!(raw_result); + + // Nothing to decrypt: return the empty array without ever touching a key. + if raw_docs.is_empty() { + let sensitive_json = unwrap_result_or_return!(serialize_decrypted_documents(&[])); + *out_documents_json = sensitive_json.into_raw(); + return PlatformWalletFFIResult::ok(); + } - // Key-source selection by wallet capability (may synchronously call - // back into the host mnemonic resolver for external-signable - // wallets — see `tx_metadata_key_master_for_wallet`). The resolved - // master is wrapped in a Drop-wiping guard. - let master_opt = - unsafe { tx_metadata_key_master_for_wallet(wallet, mnemonic_resolver_handle) }? - .map(WipingMaster); + // Phase 2 — key acquisition, on the ORIGINAL calling thread. The host + // mnemonic resolver is a caller-supplied callback; invoking it from the + // thread that entered this export keeps it on the thread the host's own + // contract was written for, rather than a Tokio worker. + let master_opt = match tx_metadata_key_master_for_wallet(&wallet_arc, mnemonic_resolver_handle) + { + Ok(master) => master.map(WipingMaster), + Err(failure) => return failure, + }; - let result: Result, PlatformWalletError> = - block_on_worker(async move { - // TRADEOFF (dashpay/platform#4091): unlike create, a document's - // (keyIndex, encryptionKeyIndex) are only known AFTER its page is - // fetched, so the master cannot be fully pre-derived before the - // network work. It therefore stays resident across the pagination - // awaits — but inside the `WipingMaster` Drop guard, so a panic or - // early return still scrubs its scalar (a manual post-await erase - // would be skipped on those paths). Per-document key derivation is - // itself synchronous, between page fetches (see - // `fetch_encrypted_documents`). - let key_source = match master_opt.as_ref() { - Some(master) => TxMetadataKeySource::Master(&master.0), - None => TxMetadataKeySource::ResidentWallet, - }; - let fetched = identity_wallet - .fetch_encrypted_documents( - &owner_id_for_async, - &contract_id_for_async, - &document_type_str, - since_ms, - key_source, - ) - .await; - drop(master_opt); // scrub as soon as the fetch completes - fetched - }); - result.map_err(PlatformWalletFFIResult::from) - }); - let result = unwrap_option_or_return!(option); - let docs = unwrap_result_or_return!(result); + // Phase 3 — SYNCHRONOUS derive + decrypt, then wipe. No await separates the + // acquisition above from the drop below, so the master is never live across + // a network round trip. The guard scrubs on ordinary return, on an error + // return and on an unwinding panic; an abort runs no destructor and is not + // covered, and the write cannot reach a register copy the optimizer made. + let key_source = match master_opt.as_ref() { + Some(master) => TxMetadataKeySource::Master(&master.0), + None => TxMetadataKeySource::ResidentWallet, + }; + let decrypted = identity_wallet.decrypt_fetched_documents(&owner_id, &raw_docs, key_source); + drop(master_opt); + let docs = unwrap_result_or_return!(decrypted); let sensitive_json = unwrap_result_or_return!(serialize_decrypted_documents(&docs)); *out_documents_json = sensitive_json.into_raw(); @@ -1065,7 +1750,7 @@ mod tests { ); } - // ── tx_metadata_key_master_for_wallet dispatch (dashpay/platform#4091) ── + // ── tx_metadata_key_master_for_wallet dispatch ── // // `tx_metadata_key_master_for_wallet` needs a live `PlatformWallet` (wallet // manager + SDK), which a unit test can't cheaply build, so its load-bearing @@ -1111,4 +1796,1189 @@ mod tests { "external-signable / watch-only wallet + null resolver must error, not derive" ); } + + // ── Boundary contracts of the encrypted exports ───────────────────────── + // + // Every case below uses a wallet handle guaranteed absent from the storage + // map, so the export's lookup misses (`NotFound`) and no resolver callback, + // key derivation, allocator or broadcast ever runs. That miss is what makes + // ordering observable: whichever check reports first is the check that ran + // first. No invalid pointer is dereferenced — arguments that must be + // non-null point at real test-owned storage the export only null-checks. + + /// A wallet handle guaranteed absent from the storage map. + const UNKNOWN_WALLET_HANDLE: Handle = u64::MAX; + + /// A non-null pointer to real, test-owned storage, used where the export + /// only checks for null and never dereferences. + fn opaque_non_null(storage: &mut u8) -> *mut T { + storage as *mut u8 as *mut T + } + + fn platform_wallet_ffi_max_plaintext_len() -> usize { + platform_wallet::wallet::identity::crypto::tx_metadata::MAX_TX_METADATA_PLAINTEXT_LEN + } + + /// The shared argument gate pins both sides of the index ceiling, the + /// version set, and the signer precondition. + #[test] + fn the_shared_argument_gate_pins_both_sides_of_the_index_ceiling() { + use platform_wallet::wallet::identity::crypto::tx_metadata::MAX_TX_METADATA_ENCRYPTION_KEY_INDEX; + + assert_eq!( + tx_metadata_create_preflight_result( + 8, + 1, + Some(MAX_TX_METADATA_ENCRYPTION_KEY_INDEX), + true + ) + .code, + PlatformWalletFFIResultCode::Success, + "the maximum derivable index is a valid argument and must pass" + ); + assert_eq!( + tx_metadata_create_preflight_result( + 8, + 1, + Some(MAX_TX_METADATA_ENCRYPTION_KEY_INDEX + 1), + true + ) + .code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "one past the maximum has no derivable key and must be refused" + ); + assert_eq!( + tx_metadata_create_preflight_result(8, 1, None, true).code, + PlatformWalletFFIResultCode::Success, + "an index about to be allocated is derivable by construction" + ); + assert_eq!( + tx_metadata_create_preflight_result(8, 2, Some(1), true).code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "a version the legacy stack cannot decode must be refused" + ); + assert_eq!( + tx_metadata_create_preflight_result( + platform_wallet_ffi_max_plaintext_len() + 1, + 1, + Some(1), + true + ) + .code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "a payload that cannot be sealed must be refused" + ); + assert_eq!( + tx_metadata_create_preflight_result(8, 1, Some(1), false).code, + PlatformWalletFFIResultCode::ErrorNullPointer, + "a create with no signer cannot broadcast, so the gate must refuse it \ + alongside the wallet-protocol arguments" + ); + assert_eq!( + tx_metadata_create_preflight_result( + platform_wallet_ffi_max_plaintext_len(), + 1, + Some(1), + true + ) + .code, + PlatformWalletFFIResultCode::Success, + "the largest sealable payload is a valid argument" + ); + } + + /// Drive the allocation-only export with one argument varied. + fn allocate_index_with( + version: u8, + payload_len: usize, + signer: Option<&mut u8>, + ) -> (PlatformWalletFFIResult, u32) { + let doc_type = CString::new("txMetadata").expect("no interior NUL"); + let owner = [1u8; 32]; + let contract = [2u8; 32]; + let mut out_index: u32 = u32::MAX; + + let signer_ptr = match signer { + Some(storage) => opaque_non_null(storage), + None => ptr::null_mut(), + }; + let result = unsafe { + platform_wallet_allocate_encryption_key_index( + UNKNOWN_WALLET_HANDLE, + owner.as_ptr(), + contract.as_ptr(), + doc_type.as_ptr(), + version, + payload_len, + signer_ptr, + &mut out_index, + ) + }; + (result, out_index) + } + + /// An undecodable wire version must not reserve an index. + #[test] + fn allocation_rejects_an_unsupported_version_before_allocating() { + let mut signer_storage = 0u8; + let (result, out_index) = allocate_index_with(2, 8, Some(&mut signer_storage)); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "a version the core cannot seal must be rejected before an index is \ + reserved; reaching the allocator would report a handle error instead" + ); + assert_eq!( + out_index, + u32::MAX, + "the output must be left untouched when no index was allocated" + ); + } + + /// A missing signer must not reserve an index. + #[test] + fn allocation_rejects_a_missing_signer_before_allocating() { + let (result, out_index) = allocate_index_with(1, 8, None); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorNullPointer, + "a create that has no signer cannot succeed, so it must not reserve an \ + index; reaching the allocator would report a handle error instead" + ); + assert_eq!(out_index, u32::MAX, "the output must be left untouched"); + } + + /// An over-large payload must not reserve an index. + #[test] + fn allocation_rejects_an_oversized_payload_before_allocating() { + let mut signer_storage = 0u8; + let (result, out_index) = allocate_index_with( + 1, + platform_wallet_ffi_max_plaintext_len() + 1, + Some(&mut signer_storage), + ); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "a payload that cannot be sealed must not reserve an index" + ); + assert_eq!(out_index, u32::MAX, "the output must be left untouched"); + } + + /// A request whose arguments are all valid gets past the argument gate and + /// on to the wallet lookup — proving the rejections above are the gate's + /// doing and not an unconditional refusal. + #[test] + fn allocation_with_valid_arguments_reaches_the_wallet_lookup() { + let mut signer_storage = 0u8; + let (result, _) = allocate_index_with(1, 8, Some(&mut signer_storage)); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::NotFound, + "with every argument valid the call must get as far as resolving the \ + wallet handle, which is unknown here" + ); + } + + /// The index is resolved before the plaintext is copied, not after. + /// + /// Recorded through the same sequencing helper production uses, so the order + /// asserted here is the order the export runs: swapping the two statements + /// changes this recording. + #[test] + fn the_index_is_resolved_before_the_plaintext_is_copied() { + let order = std::cell::RefCell::new(Vec::new()); + + let sequenced = allocate_before_materializing( + || { + order.borrow_mut().push("resolve-index"); + Ok(7) + }, + || { + order.borrow_mut().push("copy-plaintext"); + }, + ); + + assert!(sequenced.is_ok(), "both steps succeed in this case"); + assert_eq!( + order.into_inner(), + vec!["resolve-index", "copy-plaintext"], + "the plaintext must not be copied into a native buffer until the index \ + is settled; copying first leaves it resident across an unbounded \ + Platform round trip" + ); + } + + /// A failed index resolution copies nothing at all. + #[test] + fn a_failed_index_resolution_never_copies_the_plaintext() { + let copied = std::cell::Cell::new(false); + + let sequenced = allocate_before_materializing( + || { + Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUnknown, + "allocation failed", + )) + }, + || copied.set(true), + ); + + assert!(sequenced.is_err(), "the resolution failure must propagate"); + assert!( + !copied.get(), + "a request that cannot proceed must not copy the caller's plaintext" + ); + } + + /// The deferred payload seam owns the complete ordering contract used by + /// runtime-managed hosts: allocation succeeds before materialization, and + /// the materializer is consumed exactly once. + #[test] + fn deferred_payload_is_materialized_once_after_index_resolution() { + let order = std::cell::RefCell::new(Vec::new()); + + let (index, payload) = settle_index_and_materialize_payload( + 3, + || { + order.borrow_mut().push("resolve-index"); + Ok(7) + }, + || { + order.borrow_mut().push("materialize"); + Ok(Zeroizing::new(vec![1, 2, 3])) + }, + ) + .expect("both stages succeed"); + + assert_eq!(index, 7); + assert_eq!(payload.as_slice(), [1, 2, 3]); + assert_eq!( + order.into_inner(), + vec!["resolve-index", "materialize"], + "the plaintext copy must happen once and only after index resolution" + ); + } + + /// A failed allocation must leave the deferred materializer untouched. + #[test] + fn deferred_payload_is_not_materialized_when_index_resolution_fails() { + let materialize_calls = std::cell::Cell::new(0); + + let outcome = settle_index_and_materialize_payload( + 3, + || { + Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUnknown, + "allocation failed", + )) + }, + || { + materialize_calls.set(materialize_calls.get() + 1); + Ok(Zeroizing::new(vec![1, 2, 3])) + }, + ); + + assert!(outcome.is_err()); + assert_eq!(materialize_calls.get(), 0); + } + + /// The declared length is part of the deferred-materialization contract. + /// A mismatched buffer is rejected before key resolution or broadcast. + #[test] + fn deferred_payload_rejects_a_materialized_length_mismatch() { + let materialize_calls = std::cell::Cell::new(0); + + let outcome = settle_index_and_materialize_payload( + 3, + || Ok(7), + || { + materialize_calls.set(materialize_calls.get() + 1); + Ok(Zeroizing::new(vec![1, 2])) + }, + ); + + assert_eq!( + outcome + .expect_err("the materialized length must match") + .code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + assert_eq!(materialize_calls.get(), 1); + } + + // ── The owned plaintext dies before the broadcast begins ──────────────── + // + // A host that cannot pin its own buffer across the call (the JVM bridge) + // hands its ONLY native plaintext copy over by value. What makes that + // transfer worth anything is what happens to the copy next: it must be + // sealed, released, and only THEN broadcast. The broadcast is an unbounded + // network wait — the SDK sets no request timeout — so a copy still alive + // when it starts is a copy alive for however long the network takes. + // + // Recorded through the same seam production runs, so the order asserted + // here is the order the exports run. + + /// The owned plaintext copy, standing in for `Zeroizing>` and + /// recording the moment its storage is released. + struct ReleaseRecorder<'a> { + events: &'a std::cell::RefCell>, + label: &'static str, + } + + impl Drop for ReleaseRecorder<'_> { + fn drop(&mut self) { + self.events.borrow_mut().push(self.label); + } + } + + /// The plaintext and the resolved key material are both gone before the + /// broadcast starts. + #[test] + fn the_owned_plaintext_is_released_before_the_broadcast_begins() { + let events = std::cell::RefCell::new(Vec::new()); + + let outcome = seal_and_release_before_broadcasting( + ReleaseRecorder { + events: &events, + label: "release-plaintext", + }, + ReleaseRecorder { + events: &events, + label: "release-secret", + }, + |_plaintext, _secret| { + events.borrow_mut().push("seal"); + Ok::<_, PlatformWalletFFIResult>("ciphertext") + }, + |sealed| { + events.borrow_mut().push("broadcast"); + sealed + }, + ); + + assert_eq!( + outcome.expect("both stages succeed in this case"), + "ciphertext" + ); + assert_eq!( + events.into_inner(), + vec!["seal", "release-plaintext", "release-secret", "broadcast"], + "the plaintext and the resolved key material must both be released \ + BEFORE the broadcast begins; releasing them after it returns keeps \ + them resident for the whole of an unbounded network wait" + ); + } + + /// A seal that fails releases both secrets and broadcasts nothing. + #[test] + fn a_failed_seal_releases_the_plaintext_and_never_broadcasts() { + let events = std::cell::RefCell::new(Vec::new()); + + let outcome = seal_and_release_before_broadcasting( + ReleaseRecorder { + events: &events, + label: "release-plaintext", + }, + ReleaseRecorder { + events: &events, + label: "release-secret", + }, + |_plaintext, _secret| { + Err::<&str, _>(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "derivation failed", + )) + }, + |sealed| { + events.borrow_mut().push("broadcast"); + sealed + }, + ); + + assert!(outcome.is_err(), "the seal failure must propagate"); + assert_eq!( + events.into_inner(), + vec!["release-plaintext", "release-secret"], + "a create that cannot seal must still release what it holds, and must \ + not reach the network at all" + ); + } + + /// A null payload with a non-zero length is rejected from the arguments + /// alone — before an index is consumed and before the network is touched. + #[test] + fn create_encrypted_auto_index_rejects_a_null_payload_before_allocating() { + let mut out_json: *mut c_char = ptr::null_mut(); + let mut out_id = [0u8; 32]; + let doc_type = CString::new("txMetadata").expect("no interior NUL"); + let owner = [1u8; 32]; + let contract = [2u8; 32]; + let mut signer_storage = 0u8; + + let result = unsafe { + platform_wallet_create_encrypted_document_with_signer_auto_index( + UNKNOWN_WALLET_HANDLE, + ptr::null_mut(), + owner.as_ptr(), + contract.as_ptr(), + doc_type.as_ptr(), + 1, + ptr::null(), + 8, + opaque_non_null(&mut signer_storage), + out_id.as_mut_ptr(), + &mut out_json, + ) + }; + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorNullPointer, + "a null payload with a non-zero length must be rejected from the \ + arguments, not after an index has been allocated" + ); + assert!(out_json.is_null()); + } + + /// The create export publishes its documented null sentinel before any + /// other fallible validation, so a caller following the contract never frees + /// a pointer this call did not own. + #[test] + fn create_encrypted_publishes_null_json_out_before_other_validation() { + let mut sentinel_storage: c_char = 0x7f; + let mut out_json: *mut c_char = &mut sentinel_storage; + let mut out_id = [0u8; 32]; + let owner = [1u8; 32]; + let contract = [2u8; 32]; + let mut signer_storage = 0u8; + + // A NULL document type trips a check that runs after the sentinel is + // published, so the sentinel must already have been cleared. + let result = unsafe { + platform_wallet_create_encrypted_document_with_signer( + UNKNOWN_WALLET_HANDLE, + ptr::null_mut(), + owner.as_ptr(), + contract.as_ptr(), + ptr::null(), + 1, + 1, + ptr::null(), + 0, + opaque_non_null(&mut signer_storage), + out_id.as_mut_ptr(), + &mut out_json, + ) + }; + + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert!( + out_json.is_null(), + "the out pointer must be nulled before any other fallible input is \ + validated, not only on the success path" + ); + } + + /// Same contract on the auto-index export. + #[test] + fn create_encrypted_auto_index_publishes_null_json_out_before_other_validation() { + let mut sentinel_storage: c_char = 0x7f; + let mut out_json: *mut c_char = &mut sentinel_storage; + let mut out_id = [0u8; 32]; + let owner = [1u8; 32]; + let contract = [2u8; 32]; + let mut signer_storage = 0u8; + + let result = unsafe { + platform_wallet_create_encrypted_document_with_signer_auto_index( + UNKNOWN_WALLET_HANDLE, + ptr::null_mut(), + owner.as_ptr(), + contract.as_ptr(), + ptr::null(), + 1, + ptr::null(), + 0, + opaque_non_null(&mut signer_storage), + out_id.as_mut_ptr(), + &mut out_json, + ) + }; + + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert!(out_json.is_null()); + } + + /// The fetch export's output carries decrypted plaintext and is released + /// with the sensitive free, so its sentinel must be published before every + /// other fallible input too. + #[test] + fn fetch_encrypted_publishes_null_json_out_before_other_validation() { + let mut sentinel_storage: c_char = 0x7f; + let mut out_json: *mut c_char = &mut sentinel_storage; + let owner = [1u8; 32]; + let contract = [2u8; 32]; + + let result = unsafe { + platform_wallet_fetch_encrypted_documents( + UNKNOWN_WALLET_HANDLE, + ptr::null_mut(), + owner.as_ptr(), + contract.as_ptr(), + ptr::null(), + 0, + &mut out_json, + ) + }; + + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert!( + out_json.is_null(), + "a stale non-null pointer here would be freed with the sensitive free \ + by a caller following the documented contract" + ); + } + + /// An oversized length is rejected without the payload pointer ever being + /// read, so a caller that passes a length larger than its buffer is refused + /// rather than over-read. + #[test] + fn create_encrypted_rejects_oversized_length_before_touching_the_payload_pointer() { + let mut out_json: *mut c_char = ptr::null_mut(); + let mut out_id = [0u8; 32]; + let doc_type = CString::new("txMetadata").expect("no interior NUL"); + let owner = [1u8; 32]; + let contract = [2u8; 32]; + let mut signer_storage = 0u8; + // One real byte, with a declared length far beyond it. The size gate + // rejects from the length alone, so this is never dereferenced. + let one_byte = [0u8; 1]; + + let result = unsafe { + platform_wallet_create_encrypted_document_with_signer_auto_index( + UNKNOWN_WALLET_HANDLE, + ptr::null_mut(), + owner.as_ptr(), + contract.as_ptr(), + doc_type.as_ptr(), + 1, + one_byte.as_ptr(), + platform_wallet_ffi_max_plaintext_len() + 1, + opaque_non_null(&mut signer_storage), + out_id.as_mut_ptr(), + &mut out_json, + ) + }; + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "the declared length alone must decide this, before any read" + ); + assert!(out_json.is_null()); + } + + // ── Runtime and worker failures are values, not panics ────────────────── + + /// A runtime that cannot be built surfaces as a mapped result rather than a + /// panic crossing the C frame. + #[test] + fn a_runtime_init_failure_maps_to_a_result_instead_of_panicking() { + crate::runtime::force_runtime_init_failure_once(); + let outcome = try_block_on_worker(async { 1u8 }); + + let failure = outcome.expect_err("the forced failure must be reported"); + assert_eq!(failure, crate::runtime::WorkerFailure::RuntimeInit); + assert_eq!( + worker_failure_result(failure).code, + PlatformWalletFFIResultCode::ErrorUnknown, + "neither stage is the caller's fault, so both map to the unknown code" + ); + + // The forcing is one-shot: the shared runtime is untouched and the next + // call still works. + assert_eq!( + try_block_on_worker(async { 2u8 }).expect("the next call must succeed"), + 2 + ); + } + + /// A worker that does not complete surfaces the same way. + #[test] + fn a_worker_join_failure_maps_to_a_result_instead_of_panicking() { + crate::runtime::force_worker_join_failure_once(); + let outcome = try_block_on_worker(async { 1u8 }); + + let failure = outcome.expect_err("the forced failure must be reported"); + assert_eq!(failure, crate::runtime::WorkerFailure::WorkerJoin); + assert_eq!( + worker_failure_result(failure).code, + PlatformWalletFFIResultCode::ErrorUnknown + ); + assert_eq!( + try_block_on_worker(async { 3u8 }).expect("the next call must succeed"), + 3 + ); + } + + /// A panic inside an inner function is contained before the `extern "C"` + /// frame, where unwinding into a non-unwinding frame would abort the host. + /// + /// Only meaningful where unwinding exists: under `panic = "abort"` the + /// process is gone at the point of the panic and nothing can catch it. + #[cfg(panic = "unwind")] + #[test] + fn an_inner_panic_is_contained_before_the_extern_c_boundary() { + let result = contain_panics(|| { + take_forced_inner_panic(); + PlatformWalletFFIResult::ok() + }); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::Success, + "with no panic forced, the inner result passes through unchanged" + ); + + force_inner_panic_once(); + let contained = contain_panics(|| { + take_forced_inner_panic(); + PlatformWalletFFIResult::ok() + }); + assert_eq!( + contained.code, + PlatformWalletFFIResultCode::ErrorUnknown, + "a panic must become an ordinary error value rather than unwinding \ + into the C frame" + ); + } + + // ── The host resolver is consulted only when there is something to decrypt ── + // + // A wallet registered through the manager is stored external-signable, so its + // txMetadata key must come from the host mnemonic resolver — on a device that + // callback can prompt the user. The fetch export must therefore run its + // network scan FIRST and consult the resolver only if that scan produced + // candidates. Counting the callback is what makes the ordering observable: + // if acquisition ran before the scan, the count would be 1 in every case + // below, including the ones that never had anything to decrypt. + + /// Host-side resolver context: the phrase to hand back, plus a count of how + /// many times the host was consulted. + struct ResolverContext { + /// Derived at runtime from all-zero entropy so no recovery phrase is + /// committed to the repository. + phrase: String, + calls: std::sync::atomic::AtomicUsize, + } + + unsafe extern "C" fn counting_resolve( + ctx: *const std::ffi::c_void, + _wallet_id_bytes: *const u8, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + ) -> i32 { + let context = &*(ctx as *const ResolverContext); + context + .calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + + let phrase = context.phrase.as_bytes(); + if phrase.len() + 1 > out_capacity { + return rs_sdk_ffi::mnemonic_resolver_result::BUFFER_TOO_SMALL; + } + ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out_buf, phrase.len()); + *out_buf.add(phrase.len()) = 0; + *out_len = phrase.len(); + rs_sdk_ffi::mnemonic_resolver_result::SUCCESS + } + + unsafe extern "C" fn noop_destroy(_ctx: *mut std::ffi::c_void) {} + + /// The identity the fixture owns, and the id the export is called with. + const FIXTURE_OWNER: [u8; 32] = [3u8; 32]; + + struct ResolverFixture { + wallet_handle: Handle, + resolver: *mut MnemonicResolverHandle, + context: *mut ResolverContext, + manager_handle: Handle, + sdk: Box, + } + + impl ResolverFixture { + fn resolver_calls(&self) -> usize { + unsafe { + (*self.context) + .calls + .load(std::sync::atomic::Ordering::SeqCst) + } + } + } + + impl Drop for ResolverFixture { + fn drop(&mut self) { + unsafe { + let _ = crate::wallet::platform_wallet_destroy(self.wallet_handle); + let _ = crate::manager::platform_wallet_manager_destroy(self.manager_handle); + rs_sdk_ffi::dash_sdk_mnemonic_resolver_destroy(self.resolver); + drop(Box::from_raw(self.context)); + } + } + } + + /// An identity carrying the ECDSA key the txMetadata derivation selects. + fn fixture_identity() -> dpp::identity::Identity { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + + let key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 2, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: dpp::platform_value::BinaryData::new(vec![0x02; 33]), + disabled_at: None, + }); + let mut public_keys = BTreeMap::new(); + public_keys.insert(2, key); + + dpp::identity::Identity::V0(IdentityV0 { + id: Identifier::from(FIXTURE_OWNER), + public_keys, + balance: 0, + revision: 0, + }) + } + + /// Build a manager on a mock SDK, register a wallet through the real FFI + /// path (which stores it external-signable), give it a resident identity + /// slot, and wire a counting host resolver. + fn resolver_fixture() -> ResolverFixture { + use key_wallet::mnemonic::{Language, Mnemonic}; + use std::ffi::c_void; + + unsafe extern "C" fn begin_changeset(_ctx: *mut c_void, _wallet_id: *const u8) -> i32 { + 0 + } + unsafe extern "C" fn end_changeset( + _ctx: *mut c_void, + _wallet_id: *const u8, + _success: bool, + ) -> i32 { + 0 + } + + let mnemonic = + Mnemonic::from_entropy(&[0u8; 16], Language::English).expect("16 bytes of entropy"); + let phrase = mnemonic.phrase().to_string(); + + // Pin the protocol version so a registered query expectation encodes the + // same way the production scan encodes its request. + let sdk = Box::new( + dash_sdk::SdkBuilder::new_mock() + .with_version(dpp::version::PlatformVersion::latest()) + .build() + .expect("mock sdk builds"), + ); + let persistence = crate::PersistenceCallbacks { + on_changeset_begin_fn: Some(begin_changeset), + on_changeset_end_fn: Some(end_changeset), + ..Default::default() + }; + let events = crate::EventHandlerCallbacks { + context: ptr::null_mut(), + on_wallet_event_fn: None, + on_error_fn: None, + on_platform_address_sync_completed_fn: None, + on_shielded_sync_completed_fn: None, + on_shielded_sync_progress_fn: None, + on_shielded_tree_progress_fn: None, + }; + + let mut manager_handle: Handle = 0; + let result = unsafe { + crate::manager::platform_wallet_manager_create( + &*sdk as *const dash_sdk::Sdk as *const c_void, + &persistence, + &events, + &mut manager_handle, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + + let mnemonic_c = CString::new(phrase.clone()).expect("no interior NUL"); + let mut wallet_handle: Handle = 0; + let mut wallet_id = [0u8; 32]; + let result = unsafe { + crate::manager::platform_wallet_manager_create_wallet_from_mnemonic( + manager_handle, + mnemonic_c.as_ptr(), + crate::FFINetwork::Testnet, + 0, + &mut wallet_handle, + &mut wallet_id, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + + PLATFORM_WALLET_STORAGE + .with_item(wallet_handle, |wallet| { + let persister = wallet.persister().clone(); + let id = wallet.wallet_id(); + let mut wm = wallet.wallet_manager().blocking_write(); + let info = wm.get_wallet_info_mut(&id).expect("registered wallet info"); + info.identity_manager + .add_identity(fixture_identity(), 0, id, &persister) + .expect("add the fixture identity"); + }) + .expect("wallet handle is live"); + + let context = Box::into_raw(Box::new(ResolverContext { + phrase, + calls: std::sync::atomic::AtomicUsize::new(0), + })); + let resolver = unsafe { + rs_sdk_ffi::dash_sdk_mnemonic_resolver_create( + context as *mut std::ffi::c_void, + counting_resolve, + noop_destroy, + ) + }; + + ResolverFixture { + wallet_handle, + resolver, + context, + manager_handle, + sdk, + } + } + + /// Drive the real fetch export, returning the result and the JSON the export + /// produced (`None` when it left the sensitive out-pointer null). The + /// allocation is released through the sensitive free before returning. + fn fetch_encrypted_with( + fixture: &ResolverFixture, + ) -> (PlatformWalletFFIResult, Option) { + let mut out_json: *mut c_char = ptr::null_mut(); + let doc_type = CString::new("txMetadata").expect("no interior NUL"); + let contract = [4u8; 32]; + + let result = unsafe { + platform_wallet_fetch_encrypted_documents( + fixture.wallet_handle, + fixture.resolver, + FIXTURE_OWNER.as_ptr(), + contract.as_ptr(), + doc_type.as_ptr(), + 0, + &mut out_json, + ) + }; + let json = if out_json.is_null() { + None + } else { + let rendered = unsafe { CStr::from_ptr(out_json) } + .to_str() + .expect("the serializer guarantees ASCII") + .to_string(); + unsafe { crate::types::platform_wallet_sensitive_string_free(out_json) }; + Some(rendered) + }; + (result, json) + } + + /// A scan that FAILS must never have consulted the host resolver. + /// + /// No contract fetch is registered on the mock, so the very first network + /// step fails. If key acquisition ran before the scan the count would be 1 + /// here, and a device user would have been prompted for a fetch that could + /// never return anything. + #[test] + fn a_failing_fetch_never_consults_the_host_resolver() { + let fixture = resolver_fixture(); + + let (result, json) = fetch_encrypted_with(&fixture); + + assert_ne!( + result.code, + PlatformWalletFFIResultCode::Success, + "the scan cannot succeed with no registered contract" + ); + assert!( + json.is_none(), + "the sensitive out pointer stays null on error" + ); + assert_eq!( + fixture.resolver_calls(), + 0, + "a failed scan must not have prompted the host for key material" + ); + } + + /// A scan that returns NOTHING must never have consulted the host resolver. + /// + /// The contract resolves and the page comes back empty, so the export gets + /// all the way through its network work and then has nothing to decrypt. + #[test] + fn an_empty_fetch_never_consults_the_host_resolver() { + let mut fixture = resolver_fixture(); + + let contract = std::sync::Arc::new( + dpp::tests::fixtures::get_data_contract_fixture(None, 0, dpp::version::LATEST_VERSION) + .data_contract_owned(), + ); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("registration runtime"); + runtime.block_on(async { + fixture + .sdk + .mock() + .expect_fetch(Identifier::from([4u8; 32]), Some((*contract).clone())) + .await + .expect("register the contract fetch"); + // The exact query the production loop issues, answered with a short + // (empty) page so the scan completes rather than failing. + let empty: dash_sdk::query_types::Documents = Default::default(); + fixture + .sdk + .mock() + .expect_fetch_many( + empty_page_query(std::sync::Arc::clone(&contract)), + Some(empty), + ) + .await + .expect("register the empty page"); + }); + + let (result, json) = fetch_encrypted_with(&fixture); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::Success, + "a scan that completes with no documents is a success, not an error" + ); + assert_eq!( + json.as_deref(), + Some("[]"), + "the export must still publish an owned, empty JSON array" + ); + assert_eq!( + fixture.resolver_calls(), + 0, + "a scan that produced no candidate documents must not have prompted \ + the host for key material" + ); + } + + /// A non-empty scan consults the host resolver exactly once, and only after + /// the scan itself has run. + /// + /// The page is sealed under the SAME seed the counting resolver hands back, + /// so the export's own derivation opens it — which means the decrypt stage + /// genuinely ran rather than being skipped. Together with the two cases + /// above (which prove a failing or empty scan consults the host zero times) + /// this pins the ordering: acquisition happens on the candidates-exist path + /// and on no other. + #[test] + fn a_non_empty_fetch_consults_the_host_resolver_exactly_once_after_the_scan() { + use platform_wallet::wallet::identity::crypto::tx_metadata::{ + derive_tx_metadata_key_from_master, seal_tx_metadata, + }; + + const ENCRYPTION_KEY_INDEX: u32 = 1; + const PLAINTEXT: &[u8] = b"memo=ffi-round-trip"; + + let mut fixture = resolver_fixture(); + let network = fixture.sdk.network; + + // Seal with the resolver's own seed, in a block so the sealing secrets + // do not outlive it. The master zeroizes on drop; the explicit erase + // additionally narrows the scalar's lifetime within this block. + let blob = { + use key_wallet::bip32::ExtendedPrivKey; + use key_wallet::mnemonic::{Language, Mnemonic}; + + let seed = zeroize::Zeroizing::new( + Mnemonic::from_entropy(&[0u8; 16], Language::English) + .expect("16 bytes of entropy") + .to_seed(""), + ); + let mut master = ExtendedPrivKey::new_master(network, seed.as_ref()) + .expect("master from the resolver's own seed"); + // Slot 0 and key id 2 are what `fixture_identity` registers, so this + // is the derivation the export will re-run. + let aes_key = + derive_tx_metadata_key_from_master(&master, network, 0, 2, ENCRYPTION_KEY_INDEX) + .expect("derive"); + let iv = [0x6Du8; 16]; + let sealed = seal_tx_metadata(&aes_key, 1, &iv, PLAINTEXT).expect("seal"); + master.private_key.non_secure_erase(); + sealed + }; + + let contract = std::sync::Arc::new( + dpp::tests::fixtures::get_data_contract_fixture(None, 0, dpp::version::LATEST_VERSION) + .data_contract_owned(), + ); + let doc_id = Identifier::from([0x77u8; 32]); + let mut properties: BTreeMap = Default::default(); + properties.insert("keyIndex".to_string(), dpp::platform_value::Value::U32(2)); + properties.insert( + "encryptionKeyIndex".to_string(), + dpp::platform_value::Value::U32(ENCRYPTION_KEY_INDEX), + ); + properties.insert( + "encryptedMetadata".to_string(), + dpp::platform_value::Value::Bytes(blob), + ); + let document = Document::V0(dpp::document::DocumentV0 { + id: doc_id, + owner_id: Identifier::from(FIXTURE_OWNER), + properties, + revision: Some(1), + created_at: None, + updated_at: Some(1_700_000_000_000), + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + }); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("registration runtime"); + runtime.block_on(async { + fixture + .sdk + .mock() + .expect_fetch(Identifier::from([4u8; 32]), Some((*contract).clone())) + .await + .expect("register the contract fetch"); + let mut page: dash_sdk::query_types::Documents = Default::default(); + page.insert(doc_id, Some(document)); + fixture + .sdk + .mock() + .expect_fetch_many( + empty_page_query(std::sync::Arc::clone(&contract)), + Some(page), + ) + .await + .expect("register the single-document page"); + }); + + assert_eq!( + fixture.resolver_calls(), + 0, + "nothing has consulted the host before the export is entered" + ); + + let (result, json) = fetch_encrypted_with(&fixture); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::Success, + "the page was sealed under the resolver's own seed, so it must decrypt" + ); + let json = json.expect("a successful fetch publishes an owned JSON array"); + // The registered query was consumed and its document decrypted: the + // payload only appears if the decrypt stage ran on what the scan + // returned. + let expected_payload = base64_of(PLAINTEXT); + assert!( + json.contains(&expected_payload), + "the decrypted payload must reach the caller; got {json}" + ); + assert_eq!( + fixture.resolver_calls(), + 1, + "the host must be consulted exactly once, and only because the scan \ + produced a candidate — a second call would mean the key was acquired \ + per document rather than once for the batch" + ); + } + + /// Standard base64 of `bytes`, matching the serializer's payload encoding. + fn base64_of(bytes: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) + } + + /// The exact `DocumentQuery` the production scan issues for its first page. + fn empty_page_query( + contract: std::sync::Arc, + ) -> dash_sdk::platform::DocumentQuery { + use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; + use dpp::platform_value::platform_value; + + dash_sdk::platform::DocumentQuery { + select: dash_sdk::drive::query::SelectProjection::documents(), + data_contract: contract, + document_type_name: "txMetadata".to_string(), + where_clauses: vec![ + WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: platform_value!(Identifier::from(FIXTURE_OWNER)), + }, + WhereClause { + field: "$updatedAt".to_string(), + operator: WhereOperator::GreaterThanOrEquals, + value: platform_value!(0u64), + }, + ], + group_by: vec![], + having: vec![], + order_by_clauses: vec![OrderClause { + field: "$updatedAt".to_string(), + ascending: true, + }], + limit: 100, + start: None, + } + } + + /// The fetch path's output is built by the sensitive serializer and is + /// released by the sensitive free — not by the ordinary string free. + /// + /// This is what keeps decrypted plaintext in an allocation that is wiped on + /// release. Routing it back through an ordinary `CString` would leave the + /// plaintext in a non-zeroizing allocation, so the ownership is asserted + /// here rather than left to the export's call site alone. + #[test] + fn the_fetch_output_is_owned_and_released_by_the_sensitive_contract() { + let serialized = + serialize_decrypted_documents(&[]).expect("an empty document set serializes"); + let raw = serialized.into_raw(); + assert!(!raw.is_null(), "the serializer hands back an owned pointer"); + + let rendered = unsafe { CStr::from_ptr(raw) } + .to_str() + .expect("the serializer guarantees ASCII"); + assert_eq!( + rendered, "[]", + "the wire shape is the same JSON array the ordinary path produced" + ); + + // Released through the sensitive free, which wipes the allocation + // including its terminator. The ordinary free must never be used here. + unsafe { crate::types::platform_wallet_sensitive_string_free(raw) }; + } } diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index cb9fbc7104..221a372344 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -345,6 +345,25 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::TxMetadataPayloadTooLarge { .. } => { PlatformWalletFFIResultCode::ErrorInvalidParameter } + // A txMetadata wire version byte the legacy stack cannot decode. + // Like the size cap it is a caller-input error, and it maps to the + // already-mirrored ErrorInvalidParameter so no new numeric code + // churns the Swift/Kotlin mirror enums. Mapped as its own dedicated + // variant — the generic invalid-data error stays on ErrorUnknown, so + // this stays distinguishable and hosts need no version list of their + // own. + PlatformWalletError::UnsupportedTxMetadataVersion { .. } => { + PlatformWalletFFIResultCode::ErrorInvalidParameter + } + // A caller-supplied encryptionKeyIndex above the hardened-derivation + // ceiling. Another out-of-range caller argument, so it joins the two + // above on the already-mirrored ErrorInvalidParameter; the typed + // Display carries the supplied index and the accepted maximum. The + // allocator's own exhaustion variant is deliberately NOT mapped here: + // that one is not a caller-input error. + PlatformWalletError::TxMetadataEncryptionKeyIndexNotDerivable { .. } => { + PlatformWalletFFIResultCode::ErrorInvalidParameter + } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; PlatformWalletFFIResult::err(code, error.to_string()) diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 0d8318690b..c965ca7967 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -133,6 +133,10 @@ pub use persistence::*; pub use platform_address_sync::*; pub use platform_address_types::*; pub use platform_addresses::*; +// The txMetadata plaintext ceiling, surfaced here so callers that link this +// crate as an rlib (the JNI layer) can gate on the same value the C exports +// enforce instead of restating it. +pub use platform_wallet::wallet::identity::crypto::tx_metadata::MAX_TX_METADATA_PLAINTEXT_LEN; pub use platform_wallet_info::*; pub use provider_key_at_index::*; #[cfg(feature = "shielded")] diff --git a/packages/rs-platform-wallet-ffi/src/runtime.rs b/packages/rs-platform-wallet-ffi/src/runtime.rs index ee96010db0..f9147d50fa 100644 --- a/packages/rs-platform-wallet-ffi/src/runtime.rs +++ b/packages/rs-platform-wallet-ffi/src/runtime.rs @@ -23,41 +23,179 @@ /// affecting memory footprint (we spin up a small number of workers). const WORKER_STACK_BYTES: usize = 8 * 1024 * 1024; -/// Get the shared tokio runtime. +/// Which piece of the shared async machinery failed, independently of the +/// request being served. /// -/// All async FFI functions use this runtime. Prefer -/// [`block_on_worker`] over `runtime().block_on(...)` so the heavy -/// work runs on a worker thread with the larger stack configured -/// here, rather than the (small) calling thread. -pub(crate) fn runtime() -> &'static tokio::runtime::Runtime { - static RT: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { +/// Deliberately a unit-like enum: it names the STAGE and nothing else. The +/// underlying `io::Error`, `JoinError` and panic payload are dropped at the +/// point of mapping, so nothing unbounded or caller-derived travels through +/// this VALUE into an FFI result message or a log. +/// +/// That is a property of the value, not of the process. A panicking worker +/// still runs the default panic hook at the point of the panic — before this +/// mapping happens — and that hook may emit the payload on its own channel. +/// Futures submitted through this module must therefore never panic with +/// sensitive or caller-derived data; the classification here is not a redaction +/// mechanism for panics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkerFailure { + /// The shared runtime could not be built. + RuntimeInit, + /// The worker task did not run to completion (it panicked or was cancelled). + WorkerJoin, +} + +impl std::fmt::Display for WorkerFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + WorkerFailure::RuntimeInit => "async runtime could not be created", + WorkerFailure::WorkerJoin => "async worker did not complete", + }) + } +} + +impl std::error::Error for WorkerFailure {} + +// One-shot failure injection, scoped to the calling thread so parallel tests +// cannot observe or race each other's forcing. Each hook is consumed by the +// first check that sees it and leaves the flag clear, so a forced failure +// affects exactly one call and no state survives the test. +#[cfg(test)] +thread_local! { + static FORCED_RUNTIME_INIT_FAILURE: std::cell::Cell = const { std::cell::Cell::new(false) }; + static FORCED_WORKER_JOIN_FAILURE: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Make the next [`try_runtime`] call on THIS thread report [`WorkerFailure::RuntimeInit`]. +#[cfg(test)] +pub(crate) fn force_runtime_init_failure_once() { + FORCED_RUNTIME_INIT_FAILURE.with(|flag| flag.set(true)); +} + +/// Make the next [`try_block_on_worker`] call on THIS thread report +/// [`WorkerFailure::WorkerJoin`]. +#[cfg(test)] +pub(crate) fn force_worker_join_failure_once() { + FORCED_WORKER_JOIN_FAILURE.with(|flag| flag.set(true)); +} + +#[cfg(test)] +fn take_forced_runtime_init_failure() -> bool { + FORCED_RUNTIME_INIT_FAILURE.with(|flag| flag.replace(false)) +} + +#[cfg(not(test))] +fn take_forced_runtime_init_failure() -> bool { + false +} + +#[cfg(test)] +fn take_forced_worker_join_failure() -> bool { + FORCED_WORKER_JOIN_FAILURE.with(|flag| flag.replace(false)) +} + +#[cfg(not(test))] +fn take_forced_worker_join_failure() -> bool { + false +} + +/// Get the shared tokio runtime, reporting construction failure as a value. +/// +/// Preferred by callers that cross a non-unwinding `extern "C"` boundary: a +/// panic there would unwind into a frame that cannot unwind and be turned into +/// a forced abort, so the failure has to be a value they can map. +pub(crate) fn try_runtime() -> Result<&'static tokio::runtime::Runtime, WorkerFailure> { + // Checked before the shared runtime is touched, so a forced failure never + // builds, caches, replaces or poisons it — the next call still gets the + // real runtime. Kept outside the cell mechanism so it exercises the + // caller's mapping rather than the cell's retry behavior. + if take_forced_runtime_init_failure() { + return Err(WorkerFailure::RuntimeInit); + } + + static RT: once_cell::sync::OnceCell = + once_cell::sync::OnceCell::new(); + + get_or_try_init_runtime(&RT, || { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(WORKER_STACK_BYTES) .build() - .expect("Failed to create tokio runtime for platform-wallet-ffi"); + .map_err(|_| WorkerFailure::RuntimeInit)?; #[cfg(feature = "tokio-metrics")] metrics::spawn_sampler(&rt); - rt - }); - &RT + Ok(rt) + }) +} + +/// Return the cell's runtime, initializing it once if it is empty. +/// +/// A failing initializer is NOT recorded: construction can fail for conditions +/// that pass, such as the OS momentarily refusing to spawn threads, and +/// remembering that first failure would make one transient refusal permanent +/// for the life of the process. The cell therefore stays empty until an +/// initializer succeeds, after which the runtime is shared by every caller. +/// The returned reference borrows from `cell`, so this works for the shared +/// `static` cell and for a local one a test owns — the retry behavior is the +/// same either way and nothing here assumes a `'static` lifetime. +fn get_or_try_init_runtime( + cell: &once_cell::sync::OnceCell, + init: impl FnOnce() -> Result, +) -> Result<&tokio::runtime::Runtime, WorkerFailure> { + cell.get_or_try_init(init) +} + +/// Get the shared tokio runtime. +/// +/// All async FFI functions use this runtime. Prefer +/// [`block_on_worker`] over `runtime().block_on(...)` so the heavy +/// work runs on a worker thread with the larger stack configured +/// here, rather than the (small) calling thread. +pub(crate) fn runtime() -> &'static tokio::runtime::Runtime { + try_runtime().expect("Failed to create tokio runtime for platform-wallet-ffi") +} + +/// Drive `future` to completion on a worker thread, reporting runtime and +/// worker failure as values rather than panicking. +/// +/// The calling thread still blocks (that's what FFI wants); it just parks on a +/// oneshot instead of driving the future itself. +pub(crate) fn try_block_on_worker(future: F) -> Result +where + F: std::future::Future + Send + 'static, + F::Output: Send + 'static, +{ + let rt = try_runtime()?; + + // Consumed on the CALLING thread, before the spawn: the future itself runs + // on a worker, where a thread-local set by the caller is not visible. + if take_forced_worker_join_failure() { + return Err(WorkerFailure::WorkerJoin); + } + + rt.block_on(async move { + // The `JoinError` (and any panic payload it carries) is dropped here — + // only the stage travels onward. + rt.spawn(future) + .await + .map_err(|_| WorkerFailure::WorkerJoin) + }) } /// Drive `future` to completion, moving the actual polling onto a /// worker thread so the caller's stack size doesn't bound the /// computation. /// -/// The calling thread still blocks (that's what FFI wants); it just -/// parks on a oneshot instead of driving the future itself. +/// Panics if the runtime cannot be built or the worker fails to complete. Call +/// sites that cannot afford a panic use [`try_block_on_worker`] instead. pub(crate) fn block_on_worker(future: F) -> F::Output where F: std::future::Future + Send + 'static, F::Output: Send + 'static, { - let rt = runtime(); - rt.block_on(async move { rt.spawn(future).await.expect("tokio worker panicked") }) + try_block_on_worker(future).expect("platform-wallet-ffi async worker failed") } /// Run `f` to completion on a freshly spawned scoped OS thread with the @@ -76,9 +214,13 @@ where /// compiles: it reuses pooled runtime workers instead of paying a /// thread spawn per call. /// -/// A panic inside `f` is propagated as a panic here, matching -/// [`block_on_worker`]'s "tokio worker panicked" convention — a panic -/// in the pass is a bug, not a recoverable condition. +/// A panic inside `f` is propagated as a panic here. This helper and +/// [`block_on_worker`] share that stance: a panic in the passed work, or +/// a worker that fails to complete, is a programmer or runtime fault +/// rather than a recoverable condition, and the infallible helper +/// panics on it. Call sites that must not panic — anything crossing a +/// non-unwinding `extern "C"` frame — use [`try_block_on_worker`] and +/// map [`WorkerFailure`] to a result instead. pub(crate) fn run_on_big_stack_thread(f: impl FnOnce() -> T + Send) -> std::io::Result { std::thread::scope(|scope| { let handle = std::thread::Builder::new() diff --git a/packages/rs-platform-wallet-ffi/src/tx_metadata_json.rs b/packages/rs-platform-wallet-ffi/src/tx_metadata_json.rs index 2356c417b0..5fab39518a 100644 --- a/packages/rs-platform-wallet-ffi/src/tx_metadata_json.rs +++ b/packages/rs-platform-wallet-ffi/src/tx_metadata_json.rs @@ -366,6 +366,11 @@ mod tests { #[test] fn should_write_into_mutable_owned_bytes_before_cstring_transfer() { + // Deliberately `&Box<[u8]>` rather than `&[u8]`: the whole point is to + // pin `inner`'s type as an OWNED, mutable heap allocation the serializer + // writes into before ownership transfers to the C string. Taking a slice + // here would accept a borrow of anything and assert nothing. + #[allow(clippy::borrowed_box)] fn assert_mutable_byte_owner(_: &Box<[u8]>) {} let mut serialized = SensitiveCString::new(6).expect("allocate"); diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 779439f6ed..e337d2a7a6 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -47,6 +47,61 @@ pub enum PlatformWalletError { )] TxMetadataPayloadTooLarge { len: usize, max: usize }, + /// The txMetadata `encryptionKeyIndex` series for one identity, contract and + /// document type has no next derivable value left. + /// + /// The index is a hardened derivation-path element, so the series ends at + /// [`crate::wallet::identity::crypto::tx_metadata::MAX_TX_METADATA_ENCRYPTION_KEY_INDEX`]. + /// Continuing past it could only hand out an index with no derivable key, or + /// repeat one already in use — so the allocation fails instead. Reaching this + /// requires over two billion documents for one identity on one document type. + #[error( + "txMetadata encryptionKeyIndex space is exhausted for this identity, \ + contract and document type; no further index can be allocated that is \ + both derivable and unused" + )] + TxMetadataEncryptionKeyIndexExhausted, + + /// A caller-supplied `encryptionKeyIndex` with no derivable key. + /// + /// The index is the hardened last element of the txMetadata derivation path, + /// which carries only 31 bits, so anything above `max` addresses no key at + /// all. Typed (rather than a generic invalid-data error) so hosts can tell a + /// bad argument from a wallet or network failure, and so the rejection can + /// happen from the arguments alone — before the plaintext is copied and + /// before the host key resolver runs. + #[error( + "txMetadata encryptionKeyIndex {index} has no derivable key; the index is \ + a hardened derivation element and must be at most {max}" + )] + TxMetadataEncryptionKeyIndexNotDerivable { index: u32, max: u32 }, + + /// A caller-supplied txMetadata wire version byte outside the set the + /// legacy `decryptTxMetadata` stack can decode. Sealing it would write a + /// document no reader could open, so it is rejected before the envelope is + /// built. Typed (rather than a generic invalid-data error) so hosts can + /// distinguish it at the FFI boundary and need no version list of their own. + #[error( + "txMetadata wire version {version} is not decodable by the legacy stack; \ + only 0 (CBOR) and 1 (protobuf) are understood" + )] + UnsupportedTxMetadataVersion { version: u8 }, + + /// A paginated encrypted-document scan stopped advancing: a full page + /// produced a cursor that had already been used, so continuing would + /// refetch the same documents without end. + /// + /// Reported rather than silently truncated — a caller cannot tell a partial + /// history from a complete one, and for transaction metadata that + /// difference matters. `pages` is the number of pages read before the + /// repeat was seen; the cursor itself is a document identifier and is + /// deliberately not carried here. + #[error( + "encrypted-document pagination stopped advancing after {pages} page(s): \ + the source repeated a page cursor" + )] + EncryptedDocumentPaginationStalled { pages: usize }, + #[error("Failed to persist state: {0}")] /// A persister `store(...)` round failed. Returned (not swallowed) by /// user-initiated writes whose loss leaves a silent, non-self-healing diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs index 1078212069..cf2fb850c0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs @@ -117,9 +117,8 @@ const ENCRYPTED_METADATA_FIELD_MAX: usize = 4096; /// /// Note the envelope for the maximum plaintext is 4081 bytes, not 4096: the /// gap 4082..=4096 is unreachable because the next plaintext byte (4064) forces -/// a fresh padding block that jumps straight to 4097. (The 4063/4064 boundary -/// itself matches the reviewer's figure; the "4063 → 4096" envelope size in the -/// review does not — see the module tests, which pin the real 4081-byte blob.) +/// a fresh padding block that jumps straight to 4097. The module tests pin the +/// real 4081-byte blob, so the distinction stays checked rather than asserted. pub const MAX_TX_METADATA_PLAINTEXT_LEN: usize = { // Largest whole ciphertext (a multiple of the AES block) that still fits // the field alongside the version+IV header. @@ -149,6 +148,76 @@ pub fn ensure_tx_metadata_payload_fits(payload_len: usize) -> Result<(), Platfor Ok(()) } +/// The largest `encryptionKeyIndex` a txMetadata key can be derived at. +/// +/// The index is the last element of the derivation path and is HARDENED +/// ([`tx_metadata_derivation_path`]), and a hardened BIP32 child number carries +/// only 31 bits — the top bit is the hardening flag. An index above this has no +/// derivable key at all, so it can never seal or open a document. Pinned against +/// the derivation itself by unit test rather than restated from the spec. +pub const MAX_TX_METADATA_ENCRYPTION_KEY_INDEX: u32 = 0x7fff_ffff; + +/// Reject an `encryptionKeyIndex` that has no derivable key. +/// +/// Decidable from the argument alone. Without this the failure surfaces deep in +/// key derivation — after the plaintext has been copied and after the host key +/// resolver has run, which on some hosts prompts the user — and arrives as an +/// opaque invalid-data error rather than as the caller-input error it is. +pub fn ensure_tx_metadata_encryption_key_index_derivable( + index: u32, +) -> Result<(), PlatformWalletError> { + if index > MAX_TX_METADATA_ENCRYPTION_KEY_INDEX { + return Err( + PlatformWalletError::TxMetadataEncryptionKeyIndexNotDerivable { + index, + max: MAX_TX_METADATA_ENCRYPTION_KEY_INDEX, + }, + ); + } + Ok(()) +} + +/// Reject a `txMetadata` wire version byte the legacy stack cannot decode. +/// +/// Decidable from the argument alone, so entry points run it before touching a +/// payload, a wallet, a host key resolver or the network: consulting a device +/// keychain — which on some hosts prompts the user — for a request that can +/// never be sealed is work nobody asked for. [`seal_tx_metadata`] keeps the +/// same check as its choke-point last line of defense, so a caller that skips +/// the early gate still cannot produce an undecodable document. +pub fn ensure_tx_metadata_version_supported(version: u8) -> Result<(), PlatformWalletError> { + if version != VERSION_CBOR && version != VERSION_PROTOBUF { + return Err(PlatformWalletError::UnsupportedTxMetadataVersion { version }); + } + Ok(()) +} + +/// Everything about an encrypted-document create that is decidable from the +/// arguments alone, in one place. +/// +/// Every entry point — the core preparation choke point, both C exports, and +/// the index allocator — runs exactly this before doing anything expensive or +/// irreversible: copying the caller's plaintext, consulting the host key +/// resolver, reaching the network, or reserving an index. Grouping the checks +/// is what keeps a request that must fail from doing any of that, and keeps the +/// policy itself in Rust rather than duplicated per host. +/// +/// `encryption_key_index` is `None` when the SDK is about to allocate one; there +/// is nothing to validate in that case, because an allocated index is derivable +/// by construction. +pub fn ensure_tx_metadata_create_inputs_valid( + payload_len: usize, + version: u8, + encryption_key_index: Option, +) -> Result<(), PlatformWalletError> { + ensure_tx_metadata_payload_fits(payload_len)?; + ensure_tx_metadata_version_supported(version)?; + if let Some(index) = encryption_key_index { + ensure_tx_metadata_encryption_key_index_derivable(index)?; + } + Ok(()) +} + /// Build the full tx-metadata key derivation path /// `identity_auth_path(identity_index, key_index) / 32769' / encryption_key_index'` /// — the single path both key sources ([`derive_tx_metadata_key`] and @@ -220,10 +289,27 @@ pub fn derive_tx_metadata_key( let path = tx_metadata_derivation_path(network, identity_index, key_index, encryption_key_index)?; - let ext = wallet.derive_extended_private_key(&path).map_err(|e| { + let mut ext = wallet.derive_extended_private_key(&path).map_err(|e| { PlatformWalletError::InvalidIdentityData(format!("Failed to derive txMetadata key: {e}")) })?; - Ok(Zeroizing::new(ext.private_key.secret_bytes())) + Ok(take_and_erase_secret(&mut ext.private_key)) +} + +/// Copy a derived scalar into zeroizing storage and erase the source. +/// +/// The pinned `ExtendedPrivKey` zeroizes its private key and chain code on drop, +/// while a bare `secp256k1::SecretKey` does not erase itself. Explicitly erasing +/// the source immediately after copying narrows the scalar's lifetime instead +/// of relying on the enclosing extended key's later lexical drop. +/// +/// "Non-secure" names the guarantee honestly: the write is best-effort against +/// a compiler that may keep a register copy or a value the optimizer already +/// duplicated. It removes the long-lived stack residue, which is the exposure +/// worth removing here; it does not promise every byte is unrecoverable. +fn take_and_erase_secret(secret: &mut dashcore::secp256k1::SecretKey) -> Zeroizing<[u8; 32]> { + let copy = Zeroizing::new(secret.secret_bytes()); + secret.non_secure_erase(); + copy } /// Derive the AES-256 key for one `txMetadata` document from a caller-supplied @@ -237,8 +323,9 @@ pub fn derive_tx_metadata_key( /// watch-only, the FFI layer resolves the wallet's mnemonic on demand via the /// host `MnemonicResolverHandle`, builds the master xprv, calls this, and /// wipes the master (`master.private_key.non_secure_erase()`) before -/// returning — atomic derive + use + zeroize. The returned scalar is -/// [`Zeroizing`], so the key itself is scrubbed on drop as well. +/// returning — atomic derive + use + erase. The returned scalar is +/// [`Zeroizing`], so the copy handed to the caller is scrubbed on drop, and the +/// intermediate derived scalar is erased here before this function returns. pub fn derive_tx_metadata_key_from_master( master: &ExtendedPrivKey, network: Network, @@ -252,16 +339,14 @@ pub fn derive_tx_metadata_key_from_master( tx_metadata_derivation_path(network, identity_index, key_index, encryption_key_index)?; let secp = Secp256k1::new(); - // `ExtendedPrivKey` has no `Drop`/`Zeroize`; its inner - // `secp256k1::SecretKey` memzeroes on drop, and the scalar copy we - // return is wrapped in `Zeroizing` (same hygiene note as - // `derive_ecdsa_identity_auth_keypair_from_master`). - let derived = master.derive_priv(&secp, &path).map_err(|e| { + // The derived `ExtendedPrivKey` zeroizes on drop. Erase its inner scalar + // immediately after copying so it does not remain live until scope exit. + let mut derived = master.derive_priv(&secp, &path).map_err(|e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to derive txMetadata key from master: {e}" )) })?; - Ok(Zeroizing::new(derived.private_key.secret_bytes())) + Ok(take_and_erase_secret(&mut derived.private_key)) } /// Seal an already-serialized `txMetadata` payload into the stored @@ -275,10 +360,10 @@ pub fn derive_tx_metadata_key_from_master( /// `version` MUST be [`VERSION_CBOR`] (0) or [`VERSION_PROTOBUF`] (1) — the only /// two values the legacy dashj `decryptTxMetadata` switches on. Sealing any /// other byte would produce a document that installs fine but the legacy stack -/// cannot decode, silently breaking the bidirectional wire-compat guarantee, so -/// it is rejected HERE, at the one choke point every layer (JNI, FFI, resident -/// wallet) funnels through — not only in the Kotlin `require` -/// (dashpay/platform#4091). +/// cannot decode, silently breaking the bidirectional wire-compat guarantee. It +/// is rejected HERE, at the one choke point every layer funnels through, so no +/// caller can produce such a document by reaching this function directly; entry +/// points reject it earlier via [`ensure_tx_metadata_version_supported`]. /// /// `payload` must be at most [`MAX_TX_METADATA_PLAINTEXT_LEN`] bytes: a larger /// plaintext seals into a blob that overflows the `encryptedMetadata` field and @@ -292,16 +377,11 @@ pub fn seal_tx_metadata( iv: &[u8; 16], payload: &[u8], ) -> Result, PlatformWalletError> { - if version != VERSION_CBOR && version != VERSION_PROTOBUF { - return Err(PlatformWalletError::InvalidIdentityData(format!( - "txMetadata version byte {version} is not wire-decodable; only \ - {VERSION_CBOR} (CBOR) and {VERSION_PROTOBUF} (protobuf) are understood \ - by the legacy decryptTxMetadata" - ))); - } - // Choke-point size guard: reject a plaintext that would overflow the - // encryptedMetadata field once framed (typed error, not an opaque DPP - // failure at broadcast). + // Choke-point guards. Entry points reject both conditions earlier, from the + // arguments alone; repeating them here means a caller that reaches this + // function by another route still cannot seal an undecodable or oversized + // document. + ensure_tx_metadata_version_supported(version)?; ensure_tx_metadata_payload_fits(payload.len())?; let ciphertext = platform_encryption::encrypt_aes_256_cbc(key, iv, payload); let mut blob = Vec::with_capacity(BLOB_HEADER_LEN + ciphertext.len()); @@ -343,9 +423,34 @@ impl std::fmt::Debug for OpenedTxMetadata { /// AES-256-CBC-decrypt the remainder, returning the version + opaque payload. /// /// Errors (never panics) on a malformed blob — too short, a ciphertext length -/// that is not a positive multiple of the AES block size, or a decrypt/unpad -/// failure (e.g. the wrong key, which PKCS7 rejects). A malformed or -/// wrong-keyed document must be skipped by the caller, not abort a sync. +/// that is not a positive multiple of the AES block size, an unsupported +/// leading version byte, or a decrypt/unpad failure. A document that errors +/// must be skipped by the caller, not abort a sync. +/// +/// ## Success is not authentication +/// This envelope is AES-256-CBC with PKCS7 and NO integrity tag, so `Ok` means +/// only that the bytes unpadded cleanly — not that they are genuine. A wrong +/// key, or ciphertext someone modified, usually fails the unpad, but PKCS7 +/// accepts a wrong plaintext often enough that it must not be treated as a +/// check: the caller can be handed opaque garbage under a valid-looking +/// envelope. Nothing here detects that, and nothing here can — the format +/// carries no MAC. +/// +/// Callers must therefore fully validate the payload they get back (parse the +/// CBOR or protobuf strictly, reject unexpected shapes) and treat a parse +/// failure as a skipped document rather than as corruption to repair. Do not +/// act on a payload merely because `open_tx_metadata` returned `Ok`. +/// +/// The version is validated here, symmetrically with [`seal_tx_metadata`]: +/// [`VERSION_CBOR`] (0) and [`VERSION_PROTOBUF`] (1) are the only envelope +/// versions the legacy format defines, so an unsupported byte labels a payload +/// no reader can correctly interpret. Such a document is refused here and +/// SKIPPED by the fetch orchestration rather than surfaced. +/// +/// The returned `version` is meaningful and callers MUST dispatch on it: `0` +/// carries a CBOR payload and `1` a protobuf `TxMetadataBatch`. The PAYLOAD +/// stays opaque to this crate — it does not parse either — but the ENVELOPE +/// version is a closed set, not a pass-through. pub fn open_tx_metadata( key: &[u8; 32], blob: &[u8], @@ -366,7 +471,13 @@ pub fn open_tx_metadata( ))); } + // Judged BEFORE the ciphertext is touched: an unsupported version means no + // reader in this stack can interpret what is inside, so decrypting it would + // only produce plaintext nobody may act on. Sealing refuses the same set, so + // a document carrying one of these bytes cannot have been written here. let version = blob[0]; + ensure_tx_metadata_version_supported(version)?; + let iv: [u8; 16] = blob[1..BLOB_HEADER_LEN] .try_into() .expect("slice [1..17) is exactly 16 bytes"); @@ -409,6 +520,203 @@ mod tests { assert_ne!(*a, *diff_key, "keyIndex must change the derived key"); } + /// `OpenedTxMetadata`'s `Debug` never renders the decrypted plaintext. + /// + /// `Debug` is hand-written precisely so a stray `{:?}`, `dbg!()` or tracing + /// statement cannot leak financial plaintext into a log. A derive would + /// print the payload verbatim, so the redaction needs its own assertion — + /// and it has to remain useful, which is why the length is still expected to + /// appear. + #[test] + fn opened_tx_metadata_debug_redacts_the_plaintext() { + const MARKER: &str = "s3cr3t-memo-marker"; + let payload = format!("memo={MARKER}").into_bytes(); + let opened = OpenedTxMetadata { + version: VERSION_PROTOBUF, + payload: Zeroizing::new(payload.clone()), + }; + + let rendered = format!("{opened:?}"); + + assert!( + !rendered.contains(MARKER), + "Debug leaked the decrypted plaintext: {rendered}" + ); + assert!( + !rendered.contains(&format!("{:?}", payload.as_slice())), + "Debug leaked the raw payload bytes: {rendered}" + ); + assert!( + rendered.contains(&payload.len().to_string()), + "the redaction must keep the length, which is what makes it useful: {rendered}" + ); + assert!( + rendered.contains("version"), + "non-secret metadata must survive redaction: {rendered}" + ); + } + + /// A blob whose version byte was changed to an unsupported value is refused + /// rather than opened. + /// + /// The ciphertext here is intact and decrypts perfectly — only the leading + /// version byte has been changed — so nothing except an explicit check can + /// stop it. Returning it would hand the caller a payload labelled with a + /// version the legacy format never defined. Callers dispatch on the byte — + /// `0` is CBOR, `1` is protobuf — so an unrecognised value has no branch to + /// take, and the most likely outcome is that it is guessed at. Refusing it + /// is what keeps that guess from happening. + /// + /// Sealing already refuses these bytes, so a document carrying one cannot + /// have been written by this stack; accepting it on read would be an + /// asymmetry with nothing behind it. + #[test] + fn open_rejects_a_blob_whose_version_was_changed_to_an_unsupported_byte() { + let key = [0x33u8; 32]; + let iv = [0x44u8; 16]; + let payload = b"real metadata".to_vec(); + + let sealed = seal_tx_metadata(&key, VERSION_PROTOBUF, &iv, &payload).expect("seal"); + // Sanity: untouched, it opens and round-trips. + let opened = open_tx_metadata(&key, &sealed).expect("the untouched blob opens"); + assert_eq!(opened.version, VERSION_PROTOBUF); + assert_eq!(opened.payload.as_slice(), payload.as_slice()); + + for unsupported in [2u8, 3, 200, 255] { + let mut mutated = sealed.clone(); + mutated[0] = unsupported; + + match open_tx_metadata(&key, &mutated) { + Err(PlatformWalletError::UnsupportedTxMetadataVersion { version }) => { + assert_eq!(version, unsupported, "the rejection names the byte it saw"); + } + Ok(opened) => panic!( + "version {unsupported} must not open; returning it hands the caller a \ + payload labelled with a version no reader understands (got version {} \ + and {} payload bytes)", + opened.version, + opened.payload.len() + ), + Err(other) => panic!( + "version {unsupported} must be refused as UnsupportedTxMetadataVersion, \ + got {other:?}" + ), + } + } + } + + /// The intermediate derived scalar is erased once its bytes are copied. + /// + /// `secp256k1::SecretKey` does not erase itself on drop, so without an + /// explicit erase the scalar the derivation produced stays in its stack slot + /// after the call returns while only the returned copy is scrubbed. The + /// erase is what removes that residue, and nothing about the returned key + /// would change if it were dropped — so it needs its own assertion. + #[test] + fn the_derived_scalar_is_erased_after_its_bytes_are_copied() { + let wallet = test_wallet(); + let path = tx_metadata_derivation_path(Network::Testnet, 0, 3, 1).expect("path"); + let mut ext = wallet + .derive_extended_private_key(&path) + .expect("derive extended private key"); + + let original = ext.private_key.secret_bytes(); + assert_ne!( + original, [0u8; 32], + "the fixture must derive a real scalar for this to prove anything" + ); + + let copied = take_and_erase_secret(&mut ext.private_key); + + assert_eq!( + *copied, original, + "the caller's copy must be the scalar that was derived" + ); + assert_ne!( + ext.private_key.secret_bytes(), + original, + "the source scalar must not still hold the derived key after the copy; \ + secp256k1::SecretKey does not erase on drop, so leaving it intact \ + leaves key material in the stack slot the derivation wrote it to" + ); + } + + /// The declared index ceiling is exactly where derivation stops working. + /// + /// [`MAX_TX_METADATA_ENCRYPTION_KEY_INDEX`] is a claim about BIP32 hardened + /// child numbers, and the allocator, both C exports and both hosts all trust + /// it. Restating the spec value would be worth nothing, so this asserts it + /// against the derivation itself: the maximum derives, and one past it does + /// not. If the path ever stops hardening this element, the constant is wrong + /// and this test says so. + #[test] + fn the_index_ceiling_is_the_last_derivable_hardened_child() { + let wallet = test_wallet(); + + tx_metadata_derivation_path(Network::Testnet, 0, 3, MAX_TX_METADATA_ENCRYPTION_KEY_INDEX) + .expect("the declared maximum must be a derivable hardened child"); + derive_tx_metadata_key( + &wallet, + Network::Testnet, + 0, + 3, + MAX_TX_METADATA_ENCRYPTION_KEY_INDEX, + ) + .expect("and a key must actually derive at it"); + + let past_the_end = MAX_TX_METADATA_ENCRYPTION_KEY_INDEX + 1; + assert!( + tx_metadata_derivation_path(Network::Testnet, 0, 3, past_the_end).is_err(), + "one past the declared maximum must not be derivable; if it is, the \ + ceiling is set too low and callers are being denied usable indices" + ); + + // The gate agrees with the derivation on both sides of the boundary. + assert!( + ensure_tx_metadata_encryption_key_index_derivable(MAX_TX_METADATA_ENCRYPTION_KEY_INDEX) + .is_ok(), + "the gate must accept every index that derives" + ); + match ensure_tx_metadata_encryption_key_index_derivable(past_the_end) { + Err(PlatformWalletError::TxMetadataEncryptionKeyIndexNotDerivable { index, max }) => { + assert_eq!(index, past_the_end); + assert_eq!(max, MAX_TX_METADATA_ENCRYPTION_KEY_INDEX); + } + other => panic!("expected a typed not-derivable rejection, got {other:?}"), + } + } + + /// The aggregate create gate rejects each bad argument on its own, and + /// treats an about-to-be-allocated index as nothing to check. + #[test] + fn the_create_gate_covers_size_version_and_index() { + ensure_tx_metadata_create_inputs_valid(0, VERSION_PROTOBUF, Some(1)) + .expect("a valid request passes"); + ensure_tx_metadata_create_inputs_valid(0, VERSION_PROTOBUF, None) + .expect("an index about to be allocated is derivable by construction"); + + assert!(matches!( + ensure_tx_metadata_create_inputs_valid( + MAX_TX_METADATA_PLAINTEXT_LEN + 1, + VERSION_PROTOBUF, + Some(1) + ), + Err(PlatformWalletError::TxMetadataPayloadTooLarge { .. }) + )); + assert!(matches!( + ensure_tx_metadata_create_inputs_valid(0, 2, Some(1)), + Err(PlatformWalletError::UnsupportedTxMetadataVersion { version: 2 }) + )); + assert!(matches!( + ensure_tx_metadata_create_inputs_valid( + 0, + VERSION_PROTOBUF, + Some(MAX_TX_METADATA_ENCRYPTION_KEY_INDEX + 1) + ), + Err(PlatformWalletError::TxMetadataEncryptionKeyIndexNotDerivable { .. }) + )); + } + /// Full seal → open round-trip across both version bytes. #[test] fn seal_open_round_trips() { @@ -428,7 +736,7 @@ mod tests { } } - /// Rust-side wire-version guard (dashpay/platform#4091): + /// Rust-side wire-version guard: /// `seal_tx_metadata` accepts only the two /// versions the legacy `decryptTxMetadata` understands (0 = CBOR, 1 = /// protobuf) and rejects everything else, so the guard holds even when a @@ -444,20 +752,31 @@ mod tests { assert!(seal_tx_metadata(&key, VERSION_PROTOBUF, &iv, &payload).is_ok()); // Every other byte (2..=255) is rejected — none can be produced by - // sealing, so a non-decodable document can never reach the wire. + // sealing, so a non-decodable document can never reach the wire. The + // rejection carries the dedicated typed variant, which is what lets the + // FFI boundary surface it as a caller-input error instead of flattening + // it into the generic unknown-failure code. for version in 2u8..=255 { - assert!( - seal_tx_metadata(&key, version, &iv, &payload).is_err(), - "version {version} must be rejected as non-wire-decodable" - ); + match seal_tx_metadata(&key, version, &iv, &payload) { + Err(PlatformWalletError::UnsupportedTxMetadataVersion { version: reported }) => { + assert_eq!( + reported, version, + "the rejection must report the version it rejected" + ); + } + other => panic!( + "version {version} must be rejected as UnsupportedTxMetadataVersion, \ + got {other:?}" + ), + } } } - /// Payload-size boundary (dashpay/platform#4091): the largest plaintext the + /// Payload-size boundary: the largest plaintext the /// `encryptedMetadata` field (`maxItems` 4096) can hold once framed is /// [`MAX_TX_METADATA_PLAINTEXT_LEN`] = 4063, and 4064 is the first rejected - /// length. Pins the REAL PKCS7 envelope math against the code, not the - /// reviewer's "4063 → 4096" arithmetic: because PKCS7 adds a whole padding + /// length. Pins the REAL PKCS7 envelope math against the code rather than a + /// "4063 → 4096" approximation: because PKCS7 adds a whole padding /// block when the plaintext is block-aligned, a 4063-byte plaintext frames to /// a 4081-byte blob (1 version + 16 IV + 4064 ciphertext), and a 4064-byte /// plaintext jumps to 4097 (4080 ciphertext) — overflowing the field. @@ -610,11 +929,10 @@ mod tests { /// The external-signable wallet shape (the Android/iOS apps: NO resident /// private keys — every key derives host-side through the mnemonic - /// resolver): the in-wallet derive must fail (this exact failure zeroed - /// the on-device decrypt-proof), and the resolver-master path — fed by a - /// stub "resolver" supplying the test mnemonic — must decrypt a blob the - /// resident stack sealed. Round-trips seal(resident) → open(master) and - /// seal(master) → open(resident), proving an external-signable device + /// resolver): the in-wallet derive must fail, and the resolver-master path + /// — fed by a stub resolver supplying the test mnemonic — must decrypt a + /// blob the resident stack sealed. Round-trips seal(resident) → open(master) + /// and seal(master) → open(resident), proving an external-signable device /// wallet reads and writes documents interchangeably with a key-resident /// wallet on the same mnemonic. #[test] @@ -752,7 +1070,7 @@ mod tests { /// (`keyId = 2`, `encryptionKeyIndex = 1`) independently of anything this /// crate constructs, and it produced exactly `4a2eaec1…`. So this vector's /// path is proven by the legacy library, not merely mirrored back from - /// Rust's own `tx_metadata_derivation_path` (dashpay/platform#4091). + /// Rust's own `tx_metadata_derivation_path`. /// Note the factory has NO identity-index argument — the /// legacy tx-metadata path is fixed at the primary identity, which is why /// wire-compat is defined here and only here. @@ -861,39 +1179,33 @@ mod tests { ); } - /// **Independent legacy-INSTALL wire-compat vector (dashpay/platform#4186, - /// reviewer shumkov's "one independent check — decrypting a blob produced by - /// a real legacy dash-wallet install" ask).** + /// **Independent legacy-INSTALL wire-compat vector: one check that decrypts + /// a blob produced by a real legacy dash-wallet install.** /// /// Unlike [`legacy_dashj_wire_compat_vector`] and /// [`nonzero_identity_index_derivation_slot_is_internally_consistent`] — - /// which this repo generated by driving dashj-core's crypto primitives from a - /// JVM scratch program (`tests/legacy_wire_compat/LegacyKeyN.java`) — this - /// vector was NOT produced by this repo at all. It is a blob a real + /// which are generated by driving dashj-core's crypto primitives from a JVM + /// scratch program (`tests/legacy_wire_compat/LegacyKeyN.java`) — this + /// vector was not produced by this repo at all. It is a blob a real /// **dash-wallet 11.9 Android install** (the shipping dashj crypto path) - /// created on TESTNET, encrypted, and published to Dash Platform. It was then - /// fetched back off testnet and decrypted here with the NEW Rust crypto, - /// closing the loop the JVM-generated vectors cannot: those prove Rust ⟷ - /// dashj-core agree on primitives this repo invokes; THIS proves the new Rust - /// `open` path decrypts a document that a stock legacy app, running end to - /// end, actually wrote to the network. + /// created on TESTNET, encrypted, and published to Dash Platform. It closes + /// the loop the JVM-generated vectors cannot: those prove Rust ⟷ dashj-core + /// agree on primitives this repo invokes; THIS proves the Rust `open` path + /// decrypts a document that a stock legacy app, running end to end, actually + /// wrote to the network. /// - /// ## Provenance (how the blob was captured — reproducible) + /// ## Provenance /// - /// The wallet is a DESIGNATED THROWAWAY, testnet-only, provided by the owner - /// explicitly for this fixture; its recovery phrase is public by intent. On a + /// The wallet is a testnet-only throwaway used solely for this fixture. On a /// stock dash-wallet 11.9 testnet install it registered the DPNS username - /// `yabba2`, did a send + a receive, and saved transaction metadata; the app - /// encrypted that metadata and published one `txMetadata` document to - /// Platform. The manual, testnet-gated helper - /// `capture_legacy_yabba2_txmetadata_blobs` in `tests/txmetadata_fetch.rs` - /// resolves `yabba2` via DPNS to identity - /// `ESR1nfF3bj4TR2ZkLmDuSeu6r7VzpTurYi47BV6XwsoP`, runs the exact production - /// query ([`super::super::network::query_owned_encrypted_documents`]), and - /// prints the blob hex + `keyIndex`/`encryptionKeyIndex` + decrypted - /// plaintext hard-coded below. Captured document: `keyIndex = 2` - /// (the identity's registered ENCRYPTION/MEDIUM key), `encryptionKeyIndex = - /// 1`, `$updatedAt = 1784666696610`, blob version byte `1` (protobuf). + /// `yabba2`, did a send and a receive, and saved transaction metadata; the + /// app encrypted that metadata and published one `txMetadata` document to + /// Platform under identity + /// `ESR1nfF3bj4TR2ZkLmDuSeu6r7VzpTurYi47BV6XwsoP`. That document was fetched + /// from testnet once and its values hard-coded below, so this test needs no + /// network and no recovery phrase: `keyIndex = 2` (the identity's registered + /// ENCRYPTION/MEDIUM key), `encryptionKeyIndex = 1`, + /// `$updatedAt = 1784666696610`, blob version byte `1` (protobuf). /// /// ## What the decrypted plaintext is (real metadata, not a scratch string) /// @@ -906,19 +1218,23 @@ mod tests { /// does not depend on the protobuf schema (the payload is opaque to this /// crate), so it stays green regardless of future proto field changes. /// - /// The key is derived from the throwaway recovery phrase with THIS branch's + /// The key is derived from the throwaway recovery phrase with this crate's /// own [`derive_tx_metadata_key`] at `identity_index = 0` (the only slot a /// legacy `createTxMetadata` flow writes — see [`derive_tx_metadata_key`]), - /// using the document's own `keyIndex`/`encryptionKeyIndex`. This is entirely - /// network-free: the blob is the real captured bytes, and decryption - /// succeeding under PKCS7 is itself the proof the derivation matches the - /// legacy install byte-for-byte. + /// using the document's own `keyIndex`/`encryptionKeyIndex`. This is + /// entirely network-free: the blob is the real captured bytes, and the + /// byte-for-byte plaintext equality asserted below is what proves the + /// derivation matches the legacy install. Decryption merely returning `Ok` + /// would prove nothing — this envelope has no integrity tag. #[test] fn legacy_install_yabba2_wire_compat_vector() { use key_wallet::mnemonic::{Language, Mnemonic}; - // The DESIGNATED THROWAWAY testnet wallet the legacy dash-wallet 11.9 - // install ran under (public by intent for this fixture). + // The testnet-only throwaway wallet the legacy dash-wallet 11.9 install + // ran under. It exists solely to make this vector reproducible: the + // derivation is half of what the test proves, so the phrase has to be + // here rather than a pre-derived key. It guards no value and must never + // be reused for anything. const PHRASE: &str = "across jungle only rocket promote mule behave siren crush pole awful deposit"; @@ -988,7 +1304,7 @@ mod tests { } /// **Internal derivation-slot consistency at a nonzero `identity_index` — - /// NOT a legacy wire-compat claim** (dashpay/platform#4091). This + /// NOT a legacy wire-compat claim**. This /// exercises that the `identity_index` parameter lands in /// the correct path slot and is deterministic across both key sources, so a /// refactor that dropped, swapped, or misplaced it would fail loudly. It does diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index 75fd09b17c..9fb54a6205 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -3,8 +3,7 @@ //! //! Implements the wallet-contract encrypted-document surface the Android //! wallet needs to retire the legacy `org.dashj.platform` stack -//! (dashpay/platform#4086 create, #4087 decrypt-on-fetch; -//! dashpay/dash-wallet#1507). The encryption ENVELOPE — key derivation, the +//! for create and decrypt-on-fetch. The encryption ENVELOPE — key derivation, the //! `version ‖ IV ‖ AES-256-CBC(payload)` blob, and the `keyIndex` / //! `encryptionKeyIndex` / `encryptedMetadata` document fields — is //! wire-compatible with the legacy `BlockchainIdentity.publishTxMetaData` / @@ -30,101 +29,154 @@ use zeroize::Zeroizing; use crate::error::PlatformWalletError; use crate::wallet::identity::crypto::tx_metadata::{ - derive_tx_metadata_key, derive_tx_metadata_key_from_master, ensure_tx_metadata_payload_fits, - open_tx_metadata, seal_tx_metadata, + derive_tx_metadata_key, derive_tx_metadata_key_from_master, + ensure_tx_metadata_create_inputs_valid, ensure_tx_metadata_payload_fits, open_tx_metadata, + seal_tx_metadata, MAX_TX_METADATA_ENCRYPTION_KEY_INDEX, }; use super::*; -/// In-process high-water map for txMetadata `encryptionKeyIndex` allocation, -/// keyed by owner identity id → the NEXT index to hand out for that identity. -/// Wrapped in an `Arc>` so it is shared across every -/// clone of [`IdentityWallet`] and serializes concurrent allocations (see -/// [`reserve_next_index`] / [`IdentityWallet::allocate_encryption_key_index`]). +/// The series one `encryptionKeyIndex` high-water belongs to. +/// +/// A high-water is only meaningful for the exact set of documents its seed +/// counted, and that count is scoped to one owner identity, one contract and one +/// document type — all three of which the create API accepts from the caller. +/// Keying the map by the whole triple keeps each series' `1 + count` contract +/// true instead of letting one series continue another's numbering. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct EncryptionKeyIndexScope { + owner_identity_id: Identifier, + contract_id: Identifier, + document_type_name: String, +} + +impl EncryptionKeyIndexScope { + pub(crate) fn new( + owner_identity_id: &Identifier, + contract_id: &Identifier, + document_type_name: &str, + ) -> Self { + Self { + owner_identity_id: *owner_identity_id, + contract_id: *contract_id, + document_type_name: document_type_name.to_string(), + } + } +} + +/// In-process high-water map for txMetadata `encryptionKeyIndex` allocation: +/// each [`EncryptionKeyIndexScope`] maps to the NEXT index to hand out for that +/// series. Wrapped in an `Arc>` so it is shared across +/// every clone of [`IdentityWallet`] and serializes concurrent allocations. pub(crate) type EncryptionKeyIndexAllocator = - Arc>>; + Arc>>; -/// The legacy `encryptionKeyIndex` for the NEXT txMetadata document given the -/// count of documents that already exist for the identity — dash-wallet's -/// `1 + countAllRequests()`. +/// The `encryptionKeyIndex` for the NEXT txMetadata document given the count of +/// documents that already exist in the series. +/// +/// This is the legacy wallet's `1 + countAllRequests()` +/// (`SELECT COUNT(*) FROM transaction_metadata_platform`): empty state +/// (`count == 0`) → `1`; `n` existing documents → `n + 1`. It is `count + 1`, +/// NOT `max(index) + 1`, so a wallet migrating from the legacy stack keeps +/// producing the same series it produced before. /// -/// `countAllRequests()` was `SELECT COUNT(*) FROM transaction_metadata_platform` -/// (the count of the identity's published txMetadata documents in the app's -/// local cache — see `PlatformSyncService.publishTxMetaData`, -/// `TransactionMetadataDocumentDao.countAllRequests`). Empty state -/// (`count == 0`) → `1`; `n` existing documents → `n + 1`. This is `count + 1`, -/// NOT `max(index) + 1` — it matches the legacy formula byte-for-byte -/// (dashpay/platform#4186). Saturates at `u32::MAX` (an unreachable -/// 4-billion-document wallet) rather than wrapping back to `0`. -pub(crate) fn next_encryption_key_index_from_count(count: u32) -> u32 { - count.saturating_add(1) +/// A count with no representable successor is an error rather than a clamp: the +/// clamped value would be an index the series has already used. +pub(crate) fn next_encryption_key_index_from_count(count: u32) -> Result { + count + .checked_add(1) + .ok_or(PlatformWalletError::TxMetadataEncryptionKeyIndexExhausted) } -/// Atomically reserve the next `encryptionKeyIndex` for `owner` from the shared -/// `allocator`, serializing concurrent callers under its mutex so two creates -/// through the SAME wallet process can never pick the same index. +/// Reserve the next `encryptionKeyIndex` for `scope` from the shared +/// `allocator`, so two creates through the same wallet process never pick the +/// same index. /// -/// The first allocation for an owner in this process seeds the high-water from -/// `seed` — the Platform-derived `1 + count`, evaluated lazily UNDER the lock so -/// a racing caller blocks on the seed rather than re-computing it — and every -/// subsequent allocation hands out a monotonically increasing index with no -/// further network work. The stored value is always `handed_out + 1`. +/// The first allocation for a scope seeds the high-water from `seed` — the +/// Platform-derived `1 + count` — and every subsequent allocation hands out a +/// monotonically increasing index with no further network work. The stored value +/// is always `handed_out + 1`. /// -/// Cross-DEVICE uniqueness is NOT guaranteed (another device that has not yet -/// reflected its writes on Platform can seed to the same base); see +/// The seed runs OUTSIDE the allocator lock. It is an unbounded Platform round +/// trip (the SDK sets no request timeout) and this allocator is shared by every +/// identity in the process, so holding the lock across it would let one +/// unresponsive node block encrypted-document creates for every other identity +/// too. Racing callers are reconciled after the fact instead: whichever seed +/// lands first owns the series, and the others adopt it rather than overwrite +/// it, so a scope's high-water only ever moves forward and no index is handed +/// out twice. +/// +/// Cross-DEVICE uniqueness is not guaranteed; see /// [`IdentityWallet::allocate_encryption_key_index`] for why that stays safe. /// -/// Two deliberate trade-offs of the single per-wallet mutex + optimistic -/// reservation: allocations for OTHER owners in the same wallet serialize -/// behind a first-time seed fetch (benign for the normal one-identity case), -/// and a create that fails after allocating leaves a harmless index GAP — -/// never a collision — since the high-water is not rolled back. +/// A create that fails after allocating leaves a harmless index GAP — never a +/// collision — because the high-water is not rolled back. pub(crate) async fn reserve_next_index( - allocator: &tokio::sync::Mutex>, - owner: &Identifier, + allocator: &tokio::sync::Mutex>, + scope: &EncryptionKeyIndexScope, seed: S, ) -> Result where S: std::future::Future>, { - // Hold the guard across the (first-time only) seed await: this is exactly - // what serializes racing allocators — a second caller that finds the map - // empty blocks here until the first has seeded and inserted its `next + 1`. + // A seeded scope needs no network work, so it is answered under a guard held + // only for the map access itself. + { + let mut guard = allocator.lock().await; + if let Some(next) = guard.get(scope).copied() { + return hand_out(&mut guard, scope, next); + } + } + + let seeded = seed.await?; + let mut guard = allocator.lock().await; - let next = match guard.get(owner).copied() { - Some(n) => n, - None => seed.await?, - }; - guard.insert(*owner, next.saturating_add(1)); + // Another caller may have seeded this scope while the round trip above was + // in flight. Its value already reflects hand-outs this caller cannot see, so + // adopting it — rather than overwriting with a count taken before those + // hand-outs — is what keeps the two callers from picking the same index. + let next = guard.get(scope).copied().unwrap_or(seeded); + hand_out(&mut guard, scope, next) +} + +/// Record that `next` has been handed out for `scope` and return it. +/// +/// The series ends at [`MAX_TX_METADATA_ENCRYPTION_KEY_INDEX`] — above it the +/// index addresses no derivable key, so handing one out would produce a document +/// nothing can open. The ceiling value itself is usable; it is the value AFTER +/// it that is refused, which is why the stored successor may sit one past the +/// maximum and is only rejected when a later caller tries to use it. +fn hand_out( + allocated: &mut std::collections::HashMap, + scope: &EncryptionKeyIndexScope, + next: u32, +) -> Result { + if next > MAX_TX_METADATA_ENCRYPTION_KEY_INDEX { + return Err(PlatformWalletError::TxMetadataEncryptionKeyIndexExhausted); + } + // Cannot overflow: `next` is at most the maximum, which is far below `u32::MAX`. + allocated.insert(scope.clone(), next + 1); Ok(next) } /// [`reserve_next_index`] with the deterministic payload-size gate run FIRST, so /// an over-large payload — one that MUST fail — never consumes an index. /// -/// The size check ([`ensure_tx_metadata_payload_fits`]) is a pure, deterministic -/// bound (`payload_len <= MAX_TX_METADATA_PLAINTEXT_LEN`) that needs no network -/// and no key material. Running it before the allocator is touched means an -/// oversized payload returns the typed -/// [`PlatformWalletError::TxMetadataPayloadTooLarge`] WITHOUT seeding the -/// high-water or advancing it — no index is reserved, so the allocator leaves no -/// gap for a request that was always going to be rejected. Only once the payload -/// is known to fit do we (lazily, under the lock) seed/hand out the next index -/// (dashpay/platform#4186 review: validate size before allocating the index). +/// The size check ([`ensure_tx_metadata_payload_fits`]) is a pure bound that +/// needs no network and no key material. Running it before the allocator is +/// touched means an oversized payload is rejected without seeding or advancing +/// the high-water, so an always-doomed request leaves no gap behind it. pub(crate) async fn reserve_next_index_checked( - allocator: &tokio::sync::Mutex>, - owner: &Identifier, + allocator: &tokio::sync::Mutex>, + scope: &EncryptionKeyIndexScope, payload_len: usize, seed: S, ) -> Result where S: std::future::Future>, { - // Deterministic, network-free size gate BEFORE any allocation: an oversized - // payload fails here, so `seed` is never polled and the high-water is never - // seeded/advanced — no consumed index, no gap. ensure_tx_metadata_payload_fits(payload_len)?; - reserve_next_index(allocator, owner, seed).await + reserve_next_index(allocator, scope, seed).await } /// Where one encrypted-document call derives the per-document txMetadata AES @@ -138,11 +190,10 @@ where /// - an external-signable / watch-only wallet (the Android/iOS apps: the seed /// lives host-side, keys derive on demand through the registered mnemonic /// resolver) holds NO in-process private keys — the in-wallet derive fails -/// with `External signable wallet has no private key` (the exact on-device -/// failure that zeroed the decrypt-proof). For that shape the FFI resolves -/// the wallet's mnemonic via the host `MnemonicResolverHandle`, builds the -/// master xprv, passes [`TxMetadataKeySource::Master`], and wipes the -/// master after the call — atomic derive + use + zeroize. +/// with `External signable wallet has no private key`. For that shape the FFI +/// resolves the wallet's mnemonic via the host `MnemonicResolverHandle`, +/// builds the master xprv, passes [`TxMetadataKeySource::Master`], and wipes +/// the master after the call — atomic derive + use + zeroize. /// /// Both sources derive the IDENTICAL path /// ([`crate::wallet::identity::crypto::tx_metadata::tx_metadata_derivation_path`]), @@ -213,11 +264,15 @@ const FIELD_ENCRYPTED_METADATA: &str = "encryptedMetadata"; /// NEITHER on-device sink, while host tests / desktop file logging still capture /// it through `tracing`. /// -/// These per-poll stage lines carry identity / contract / document ids, so now -/// that the `sdkFetched=0` root cause is fixed (external-signable txMetadata -/// derive, dashpay/platform#4091) they are deliberately DEBUG — they must NOT -/// persist identity-correlated data to logcat on every successful fetch. Genuine -/// failures use [`breadcrumb_error`] (WARN) so they stay visible on-device. +/// Genuine failures use [`breadcrumb_error`] (WARN) so they stay visible +/// on-device; routine stage lines stay at DEBUG. +/// +/// No breadcrumb on this path may carry an owner, contract or document +/// identifier, or an error's `Display`. Logcat is readable by any process +/// holding READ_LOGS and is captured in bug reports, so a full identifier there +/// correlates a device to an on-chain identity, and an echoed error body is +/// unbounded and can carry query shapes and contract internals. Stage names, +/// [`error_kind`] classifications, counts and booleans are what belong here. fn breadcrumb(line: &str) { tracing::debug!("{line}"); log::debug!("{line}"); @@ -226,13 +281,29 @@ fn breadcrumb(line: &str) { /// Emit a FAILURE breadcrumb through both logging facades at **WARN** level, so /// a genuine error or skip stays visible in Android logcat (`android_logger` /// Info+). Use ONLY for actual failure / skip paths — never per-poll -/// informational stages, which belong on [`breadcrumb`] (DEBUG) to keep -/// identity-correlated data out of the device log. +/// informational stages, which belong on [`breadcrumb`] (DEBUG). The same +/// redaction rules apply at every level. fn breadcrumb_error(line: &str) { tracing::warn!("{line}"); log::warn!("{line}"); } +/// A stable, bounded classification of a failure, for breadcrumbs that must not +/// transcribe an error's `Display`. The returned token names the failure class +/// only — it never contains caller data, an identifier, or a message body — and +/// is stable enough to tell the stages apart in a device log. +fn error_kind(error: &PlatformWalletError) -> &'static str { + match error { + PlatformWalletError::Sdk(_) => "sdk", + PlatformWalletError::WalletNotFound(_) => "wallet-not-found", + PlatformWalletError::IdentityNotFound(_) => "identity-not-found", + PlatformWalletError::UnsupportedTxMetadataVersion { .. } => "unsupported-version", + PlatformWalletError::TxMetadataPayloadTooLarge { .. } => "payload-too-large", + PlatformWalletError::InvalidIdentityData(_) => "invalid-identity-data", + _ => "other", + } +} + /// One decrypted encrypted-document, returned to the caller (serialized to /// JSON at the FFI boundary). The `payload` is the opaque, decrypted plaintext /// the app parses itself (a protobuf `TxMetadataBatch` for `version == 1`). @@ -284,6 +355,55 @@ impl std::fmt::Debug for DecryptedEncryptedDocument { mod decrypted_document_tests { use super::*; + /// `DecryptedEncryptedDocument`'s `Debug` never renders the decrypted + /// plaintext. + /// + /// Same reasoning as the sibling redaction on `OpenedTxMetadata`: `Debug` is + /// hand-written so a stray `{:?}` cannot put financial plaintext in a log, + /// and a derive would print it verbatim. The identifying, non-secret fields + /// must still be rendered or the redaction would make the type useless to + /// debug with. + #[test] + fn decrypted_document_debug_redacts_the_plaintext() { + const MARKER: &str = "s3cr3t-memo-marker"; + let payload = format!("memo={MARKER}").into_bytes(); + let document = DecryptedEncryptedDocument { + document_id: Identifier::new([9u8; 32]), + owner_id: Identifier::new([8u8; 32]), + key_index: 2, + encryption_key_index: 1, + version: 1, + updated_at_ms: Some(1_700_000_000_000), + payload: Zeroizing::new(payload.clone()), + }; + + let rendered = format!("{document:?}"); + + assert!( + !rendered.contains(MARKER), + "Debug leaked the decrypted plaintext: {rendered}" + ); + assert!( + !rendered.contains(&format!("{:?}", payload.as_slice())), + "Debug leaked the raw payload bytes: {rendered}" + ); + assert!( + rendered.contains(&payload.len().to_string()), + "the redaction must keep the length: {rendered}" + ); + for field in [ + "key_index", + "encryption_key_index", + "version", + "updated_at_ms", + ] { + assert!( + rendered.contains(field), + "non-secret field {field} must survive redaction: {rendered}" + ); + } + } + #[test] fn should_use_zeroizing_storage_for_decrypted_payload() { let document = DecryptedEncryptedDocument { @@ -380,50 +500,55 @@ impl IdentityWallet { 0, ) .await?; - Ok(u32::try_from(raw.len()).unwrap_or(u32::MAX)) + // A count that does not fit the index's own width cannot produce a + // usable index, so it fails here rather than being clamped into one the + // series has already used. + u32::try_from(raw.len()) + .map_err(|_| PlatformWalletError::TxMetadataEncryptionKeyIndexExhausted) } /// Allocate the next `encryptionKeyIndex` for an encrypted-document create - /// when the host supplies none — moving the index-selection policy off the - /// Kotlin host and into authoritative Rust/Platform state - /// (dashpay/platform#4186 follow-up: the host-thin rule forbids a key-index - /// policy loop in the host; hosts now provide only the opaque payload). + /// when the host supplies none, keeping the index-selection policy in the + /// SDK rather than asking every host to reimplement it. /// - /// Semantics MATCH the retired dash-wallet counter EXACTLY: the index is - /// `1 + countAllRequests()`, where the count is now + /// Semantics match the legacy wallet counter exactly: the index is + /// `1 + count`, where the count is /// [`Self::count_owned_txmetadata_documents`] read from Platform at create - /// time instead of the app's local `transaction_metadata_platform` table. - /// Empty state → `1`; `n` existing documents → `n + 1` (see - /// [`next_encryption_key_index_from_count`]). + /// time instead of the app's local table. Empty state → `1`; `n` existing + /// documents → `n + 1`. /// /// Allocation is serialized through the wallet's shared - /// [`EncryptionKeyIndexAllocator`] mutex (see [`reserve_next_index`]): two - /// concurrent creates through the SAME wallet process can NEVER pick the - /// same index — the first seeds the in-process high-water from Platform, the - /// second hands out the next value without a second query. + /// [`EncryptionKeyIndexAllocator`], keyed by owner identity, contract and + /// document type: two concurrent creates in the same series through the same + /// wallet process never pick the same index — the first seeds the in-process + /// high-water from Platform, the second hands out the next value without a + /// second query. /// - /// ## Cross-device caveat (best-effort per device, NOT data-loss) - /// Uniqueness is guaranteed only PER DEVICE. Two devices sharing an identity - /// can seed to the same base before either's write is visible to the other, - /// so both may write a document at the same `encryptionKeyIndex`. This is - /// SAFE, not lossy: every encrypted document stores its OWN `keyIndex` + - /// `encryptionKeyIndex`, and the reader + /// ## Uniqueness is per device, and the index is not a document sequence + /// Uniqueness is guaranteed only PER DEVICE, and only for creates that come + /// through this allocator. Two devices sharing an identity can seed from the + /// same base before either's write is visible to the other, and a caller + /// that supplies its own index (the migration/test path) bypasses the + /// high-water entirely, so the same `encryptionKeyIndex` can legitimately + /// appear on two documents. + /// + /// That is safe, not lossy: every encrypted document stores its OWN + /// `keyIndex` and `encryptionKeyIndex`, and the reader /// ([`Self::fetch_encrypted_documents`]) derives each document's key from - /// the document's own stored indices — so two documents sharing an index - /// each carry a fresh random IV, decrypt independently, and are BOTH - /// returned. A duplicate index is not even an extra decrypt attempt (the - /// reader never guesses indices); no document is overwritten or shadowed. + /// that document's own stored indices — so two documents sharing an index + /// each carry a fresh random IV, decrypt independently, and are both + /// returned. No document is overwritten or shadowed. + /// + /// What follows from that: the index is an encryption-key selector, NOT a + /// document sequence number. It must not be used to order documents, detect + /// gaps, count them, or address one — only the document's own stored fields + /// decide how it decrypts. /// /// ## Size validated BEFORE allocating (no index consumed on failure) /// `payload_len` is the plaintext length of the document about to be sealed. - /// It is checked against - /// [`MAX_TX_METADATA_PLAINTEXT_LEN`](crate::wallet::identity::crypto::tx_metadata::MAX_TX_METADATA_PLAINTEXT_LEN) - /// up front — a pure, - /// network-free bound — via [`reserve_next_index_checked`], so an oversized - /// payload (which the deterministic 4063-byte limit MUST reject) fails with - /// [`PlatformWalletError::TxMetadataPayloadTooLarge`] WITHOUT ever counting on - /// Platform or advancing the allocator's high-water. An always-doomed request - /// therefore leaves no index gap (dashpay/platform#4186 review). + /// It is checked up front — a pure, network-free bound — so an oversized + /// payload fails without ever counting on Platform or advancing the + /// high-water, leaving no index gap behind an always-doomed request. pub async fn allocate_encryption_key_index( &self, owner_identity_id: &Identifier, @@ -431,26 +556,23 @@ impl IdentityWallet { document_type_name: &str, payload_len: usize, ) -> Result { - reserve_next_index_checked( - &self.enc_key_index_allocator, - owner_identity_id, - payload_len, - async { - let count = self - .count_owned_txmetadata_documents( - contract_id, - owner_identity_id, - document_type_name, - ) - .await?; - let index = next_encryption_key_index_from_count(count); - breadcrumb(&format!( - "allocate_encryption_key_index: seeded owner={owner_identity_id} \ - existing_count={count} next_index={index}" - )); - Ok(index) - }, - ) + let scope = + EncryptionKeyIndexScope::new(owner_identity_id, contract_id, document_type_name); + reserve_next_index_checked(&self.enc_key_index_allocator, &scope, payload_len, async { + let count = self + .count_owned_txmetadata_documents( + contract_id, + owner_identity_id, + document_type_name, + ) + .await?; + let index = next_encryption_key_index_from_count(count)?; + breadcrumb(&format!( + "allocate_encryption_key_index: seeded existing_count={count} \ + next_index={index}" + )); + Ok(index) + }) .await } @@ -528,7 +650,7 @@ impl IdentityWallet { /// **Crosses no `.await`** (resolves via `blocking_read`, derives, seals) so /// the FFI caller can WIPE the resolved master xprv before the network /// broadcast: the master never lives across an await - /// (dashpay/platform#4091). Call from a sync context only. The subsequent + /// Call from a sync context only. The subsequent /// generic [`Self::create_document_with_signer`] then broadcasts the returned /// properties with no key material in scope. /// @@ -553,12 +675,14 @@ impl IdentityWallet { ) -> Result { use dashcore::secp256k1::rand::{thread_rng, RngCore}; - // Reject an over-large payload BEFORE any key derivation or network - // work: a plaintext that cannot fit the encryptedMetadata field once - // sealed would otherwise derive the key, seal, and only then die at - // broadcast with an opaque DPP schema error. Fail fast with a typed - // error instead (dashpay/platform#4091). - ensure_tx_metadata_payload_fits(payload.len())?; + // Every one of these is decidable from the arguments alone, so they are + // rejected before this call resolves an encryption context, selects a + // key, derives AES material or draws an IV. A payload that cannot fit + // the encryptedMetadata field, a version the legacy stack cannot decode, + // or an index with no derivable key would otherwise do all of that work + // and only then fail — the size case at broadcast with an opaque schema + // error, the other two at the derivation or sealing choke point. + ensure_tx_metadata_create_inputs_valid(payload.len(), version, Some(encryption_key_index))?; let (identity, identity_index, wallet) = self.resolve_encryption_context_blocking(owner_identity_id)?; @@ -578,16 +702,16 @@ impl IdentityWallet { .inspect_err(|e| { breadcrumb_error(&format!( "prepare_encrypted_txmetadata: key derivation failed \ - key_source={} owner={owner_identity_id} error={e}", - key_source.label() + key_source={} error_kind={}", + key_source.label(), + error_kind(e) )); })?; let mut iv = [0u8; 16]; thread_rng().fill_bytes(&mut iv); // Rejects a non-wire-decodable version byte (only 0/1) before it can be // sealed into a document the legacy stack can't decode, and enforces the - // payload-size limit as the choke-point last line of defense - // (dashpay/platform#4091). + // payload-size limit as the choke-point last line of defense. let blob = seal_tx_metadata(&aes_key, version, &iv, payload)?; // Byte-array fields are accepted as hex strings by the generic create @@ -600,51 +724,42 @@ impl IdentityWallet { .to_string()) } - /// Fetch every encrypted `txMetadata`-style document owned by - /// `owner_identity_id` on `contract_id`'s `document_type_name` updated at or - /// after `since_ms`, and DECRYPT each with the identity's derived key. + /// The NETWORK half of the encrypted-document fetch: resolve the contract + /// and run the paginated owner-scoped scan, returning the raw entries + /// exactly as Drive returned them. /// - /// Mirrors the legacy `getTxMetaData(sinceTime, key)`: the query is + /// The query mirrors the legacy `getTxMetaData(sinceTime, key)`: /// `$ownerId == owner AND $updatedAt >= since_ms` ordered by `$updatedAt` - /// ascending, paginated so a wallet with many documents isn't truncated. A - /// document whose key can't be derived or whose blob doesn't decrypt is - /// SKIPPED with a warning (a malformed document must not abort the sync), - /// matching the resident `contactInfo` sweep. - pub async fn fetch_encrypted_documents( + /// ascending, paginated so a wallet with many documents isn't truncated. + /// + /// Touches no key material at all, so a caller that must not acquire a key + /// before it knows there is something to decrypt can await this first and + /// only then resolve one. That matters for hosts whose key acquisition runs + /// a user-visible prompt, and because acquired material would otherwise have + /// to survive this scan — an unbounded wait, since the SDK sets no request + /// timeout. + /// + /// Pairs with [`Self::decrypt_fetched_documents`], which is synchronous. + pub async fn fetch_raw_encrypted_documents( &self, owner_identity_id: &Identifier, contract_id: &Identifier, document_type_name: &str, since_ms: u64, - key_source: TxMetadataKeySource<'_>, - ) -> Result, PlatformWalletError> { + ) -> Result)>, PlatformWalletError> { use dash_sdk::platform::{ContextProvider, Fetch}; - // On-device diagnostic breadcrumbs, dual-emitted at warn level (see - // [`breadcrumb`]): this call sits under an active `sdkFetched=0` - // investigation — every stage must be provably visible in `adb logcat`. - breadcrumb(&format!( - "fetch_encrypted_documents: entry owner={owner_identity_id} \ - contract={contract_id} type={document_type_name} since_ms={since_ms} \ - key_source={}", - key_source.label() - )); - // Fetch the contract and register it so `fetch_many`'s proof // verification can resolve it through the context provider (the mobile // provider never fetches contracts itself). let contract = DataContract::fetch(&self.sdk, *contract_id) .await .map_err(|e| { - breadcrumb_error(&format!( - "fetch_encrypted_documents: contract fetch failed contract={contract_id} error={e}" - )); + breadcrumb_error("fetch_encrypted_documents: contract fetch failed error_kind=sdk"); PlatformWalletError::Sdk(e) })? .ok_or_else(|| { - breadcrumb_error(&format!( - "fetch_encrypted_documents: contract not found on Platform contract={contract_id}" - )); + breadcrumb_error("fetch_encrypted_documents: contract not found on Platform"); PlatformWalletError::InvalidIdentityData(format!( "Data contract {contract_id} not found on Platform; cannot fetch documents" )) @@ -657,20 +772,10 @@ impl IdentityWallet { provider.register_data_contract(Arc::clone(&contract)); } - let (_identity, identity_index, wallet) = self - .resolve_encryption_context(owner_identity_id) - .await - .inspect_err(|e| { - breadcrumb_error(&format!( - "fetch_encrypted_documents: encryption-context resolution failed \ - owner={owner_identity_id} error={e}" - )); - })?; - // The wire query, split out so its exact shape is integration-testable // against testnet without a resident wallet/identity (see // `tests/txmetadata_fetch.rs`). - let raw_docs = query_owned_encrypted_documents( + query_owned_encrypted_documents( &self.sdk, Arc::clone(&contract), owner_identity_id, @@ -680,21 +785,133 @@ impl IdentityWallet { .await .inspect_err(|e| { breadcrumb_error(&format!( - "fetch_encrypted_documents: document query failed owner={owner_identity_id} error={e}" + "fetch_encrypted_documents: document query failed error_kind={}", + error_kind(e) )); - })?; + }) + } + + /// The DECRYPT half: turn raw entries from + /// [`Self::fetch_raw_encrypted_documents`] into decrypted documents. + /// + /// **Crosses no `.await`** — it resolves its context with a blocking read + /// and derives synchronously — so a caller may acquire key material, call + /// this, and wipe that material immediately, without it ever being live + /// across a network round trip. Call from a sync context only; a blocking + /// read panics inside an async task. + /// + /// Returns an empty vec for empty input without resolving anything, so a + /// caller that skipped acquisition on an empty scan stays correct if it + /// calls this anyway. + pub fn decrypt_fetched_documents( + &self, + owner_identity_id: &Identifier, + raw_docs: &[(Identifier, Option)], + key_source: TxMetadataKeySource<'_>, + ) -> Result, PlatformWalletError> { + if raw_docs.is_empty() { + return Ok(Vec::new()); + } + let (_identity, identity_index, wallet) = + self.resolve_encryption_context_blocking(owner_identity_id)?; + Ok(self.decrypt_raw_documents(raw_docs, identity_index, &wallet, key_source)) + } + + /// Fetch and decrypt in one call, for wallets that hold their keys in + /// process. + /// + /// RESIDENT-KEY ONLY, deliberately: it takes no key source, because + /// accepting a caller-supplied master would mean holding that master across + /// the raw network scan this method awaits internally — an unbounded wait, + /// since the SDK sets no request timeout. A wallet with resident keys has + /// nothing to hold: the key derives from the wallet itself, synchronously, + /// at decrypt time. + /// + /// An external-signable caller — anything whose key comes from a host + /// resolver or an externally supplied xprv — must use the two stages + /// instead: [`Self::fetch_raw_encrypted_documents`] first, then acquire the + /// key, then [`Self::decrypt_fetched_documents`], then wipe. That ordering + /// is what keeps the secret off the network path, and it cannot be expressed + /// through this convenience. + pub async fn fetch_encrypted_documents( + &self, + owner_identity_id: &Identifier, + contract_id: &Identifier, + document_type_name: &str, + since_ms: u64, + ) -> Result, PlatformWalletError> { + // Resident-only by construction: a caller-supplied master would have to + // be held across the raw scan below, which is exactly what the split + // exists to prevent. A wallet with resident keys has nothing to hold — + // the key derives from the wallet itself, at decrypt time. + let key_source = TxMetadataKeySource::ResidentWallet; + + // Stage breadcrumbs for this fetch. An empty result on this path is + // indistinguishable from a failure without them: the query can return + // nothing, a document can fail to materialize, or a decrypt can be + // skipped, and each stage below records which one happened. + breadcrumb(&format!( + "fetch_encrypted_documents: entry key_source={}", + key_source.label() + )); + + let raw_docs = self + .fetch_raw_encrypted_documents( + owner_identity_id, + contract_id, + document_type_name, + since_ms, + ) + .await?; + + // Nothing came back, so there is nothing to decrypt and no reason to + // touch a key at all. + if raw_docs.is_empty() { + breadcrumb("fetch_encrypted_documents: query returned no documents; no key acquired"); + return Ok(Vec::new()); + } + + // Candidates exist: acquire the key context now, with every network + // await already behind us. Everything from here to the end of the loop + // is synchronous, so the resolved material never crosses an await. + let (_identity, identity_index, wallet) = self + .resolve_encryption_context(owner_identity_id) + .await + .inspect_err(|e| { + breadcrumb_error(&format!( + "fetch_encrypted_documents: encryption-context resolution failed \ + error_kind={}", + error_kind(e) + )); + })?; + Ok(self.decrypt_raw_documents(&raw_docs, identity_index, &wallet, key_source)) + } + + /// Decrypt raw entries with an already-resolved context. + /// + /// Pure and synchronous: no network, no context resolution, no awaits. A + /// document that cannot be materialized, is missing its fields, carries an + /// unsupported wire version, or fails to decrypt is SKIPPED with a + /// breadcrumb — one bad document must never abort a sync. + fn decrypt_raw_documents( + &self, + raw_docs: &[(Identifier, Option)], + identity_index: u32, + wallet: &key_wallet::wallet::Wallet, + key_source: TxMetadataKeySource<'_>, + ) -> Vec { let mut out = Vec::new(); - for (doc_id, maybe_doc) in raw_docs.iter() { + for (position, (doc_id, maybe_doc)) in raw_docs.iter().enumerate() { let Some(doc) = maybe_doc else { // A raw entry the SDK could not materialize (e.g. a proved - // fetch returning an id without a document). Previously a - // SILENT skip — under proofs this is exactly the shape that - // turns "2 documents exist" into an empty result with no - // error, so it must leave a trail. + // fetch returning an id without a document). Skipped, but never + // silently: under proofs this is exactly the shape that turns + // "documents exist" into an empty result with no error, so it + // must leave a trail. breadcrumb_error(&format!( - "fetch_encrypted_documents: raw entry NOT materialized doc={doc_id} \ - owner={owner_identity_id}; skipping" + "fetch_encrypted_documents: raw entry NOT materialized \ + position={position}; skipping" )); continue; }; @@ -708,8 +925,8 @@ impl IdentityWallet { .and_then(|v: &Value| v.to_integer::().ok()), ) else { breadcrumb_error(&format!( - "fetch_encrypted_documents: document missing key indices doc={doc_id} \ - owner={owner_identity_id}; skipping" + "fetch_encrypted_documents: document missing key indices \ + position={position}; skipping" )); continue; }; @@ -718,14 +935,14 @@ impl IdentityWallet { .and_then(|v: &Value| v.to_binary_bytes().ok()) else { breadcrumb_error(&format!( - "fetch_encrypted_documents: document missing encryptedMetadata doc={doc_id} \ - owner={owner_identity_id}; skipping" + "fetch_encrypted_documents: document missing encryptedMetadata \ + position={position}; skipping" )); continue; }; let aes_key = match key_source.derive( - &wallet, + wallet, self.sdk.network, identity_index, key_index, @@ -734,9 +951,10 @@ impl IdentityWallet { Ok(k) => k, Err(e) => { breadcrumb_error(&format!( - "fetch_encrypted_documents: txMetadata key derivation failed doc={doc_id} \ - owner={owner_identity_id} key_source={} error={e}; skipping", - key_source.label() + "fetch_encrypted_documents: txMetadata key derivation failed \ + position={position} key_source={} error_kind={}; skipping", + key_source.label(), + error_kind(&e) )); continue; } @@ -745,8 +963,9 @@ impl IdentityWallet { Ok(o) => o, Err(e) => { breadcrumb_error(&format!( - "fetch_encrypted_documents: txMetadata decrypt failed doc={doc_id} \ - owner={owner_identity_id} error={e}; skipping" + "fetch_encrypted_documents: txMetadata decrypt failed \ + position={position} error_kind={}; skipping", + error_kind(&e) )); continue; } @@ -763,12 +982,81 @@ impl IdentityWallet { }); } breadcrumb(&format!( - "fetch_encrypted_documents: returning decrypted documents owner={owner_identity_id} \ - raw={} decrypted={}", + "fetch_encrypted_documents: returning decrypted documents raw={} decrypted={}", raw_docs.len(), out.len() )); - Ok(out) + out + } +} + +/// What a paginated scan does once it has read a page. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NextPage { + /// The page just read was the last one; the scan is complete. + Done, + /// Request the next page continuing after this cursor. + ContinueAfter(Identifier), +} + +/// Decides, from page shape alone, whether a paginated scan is still advancing. +/// +/// Every full page hands back the cursor the next request continues from. A +/// cursor that has already been used means the source is repeating itself, and +/// paging on would refetch the same documents without end while the result grew +/// without bound. Yielding what was collected so far would be worse than +/// failing: a caller cannot tell a truncated history from a complete one, and +/// for transaction metadata that difference matters — so a repeat becomes a +/// typed error instead. +/// +/// Every cursor is remembered, not just the previous one, so a scan that cycles +/// through several pages before returning to an earlier cursor is caught on the +/// same terms as one that immediately repeats itself. +/// +/// Deliberately pure, synchronous and finite: the decision depends only on how +/// many entries a page held and which key ended it, never on the network or on +/// elapsed time. That is what lets the stall contract be exercised directly, +/// rather than by starting a scan against an always-ready source and relying on +/// a timeout to stop it. +#[derive(Debug, Default)] +struct PaginationProgress { + /// Cursors the scan has already continued from. + issued_cursors: std::collections::HashSet, + /// Pages read so far, reported with a stall so the failure says how far the + /// scan got. + pages_read: usize, +} + +impl PaginationProgress { + /// Record one page and decide what the scan does next. + /// + /// `page_len` is how many entries the source returned and `page_limit` the + /// number requested, so a short page ends the scan. `last_id` is the page's + /// final key in the order the source returned it, which is the cursor the + /// next request would continue from. + fn record_page( + &mut self, + page_len: usize, + page_limit: usize, + last_id: Option, + ) -> Result { + self.pages_read += 1; + + // A page the source could not fill is the last page. + if page_len < page_limit { + return Ok(NextPage::Done); + } + + match last_id { + // `insert` reports whether the cursor is new; a cursor already used + // means this page did not move the scan forward. + Some(id) if self.issued_cursors.insert(id) => Ok(NextPage::ContinueAfter(id)), + Some(_) => Err(PlatformWalletError::EncryptedDocumentPaginationStalled { + pages: self.pages_read, + }), + // A full page with no final key yields no cursor to continue from. + None => Ok(NextPage::Done), + } } } @@ -798,17 +1086,16 @@ pub async fn query_owned_encrypted_documents( use dash_sdk::dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; use dash_sdk::platform::FetchMany; - use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::platform_value::platform_value; const PAGE: u32 = 100; - breadcrumb(&format!( - "query_owned_encrypted_documents: entry owner={owner_identity_id} contract={} \ - type={document_type_name} since_ms={since_ms}", - contract.id() - )); + // `since_ms` is caller-supplied and a timestamp correlates a device to + // when it last synced, so the value is not rendered — only that the scan + // started. + breadcrumb("query_owned_encrypted_documents: entry"); let mut raw_docs: Vec<(Identifier, Option)> = Vec::new(); let mut start: Option = None; + let mut progress = PaginationProgress::default(); loop { let query = dash_sdk::platform::DocumentQuery { select: dash_sdk::drive::query::SelectProjection::documents(), @@ -837,33 +1124,33 @@ pub async fn query_owned_encrypted_documents( }; let page = Document::fetch_many(sdk, query).await.map_err(|e| { - breadcrumb_error(&format!( - "query_owned_encrypted_documents: fetch_many failed owner={owner_identity_id} \ - type={document_type_name} error={e}" - )); + breadcrumb_error("query_owned_encrypted_documents: fetch_many failed error_kind=sdk"); PlatformWalletError::Sdk(e) })?; let page_len = page.len(); let last_id = page.keys().last().copied(); raw_docs.extend(page); - if page_len < PAGE as usize { - break; - } - match last_id { - Some(id) => start = Some(Start::StartAfter(id.to_buffer().to_vec())), - None => break, + // Decided before the next request is built, so a stalled scan costs no + // further round-trips. + match progress + .record_page(page_len, PAGE as usize, last_id) + .inspect_err(|_| { + breadcrumb_error("query_owned_encrypted_documents: page cursor repeated; stopping") + })? { + NextPage::Done => break, + NextPage::ContinueAfter(id) => { + start = Some(Start::StartAfter(id.to_buffer().to_vec())); + } } } - // On-device diagnostic breadcrumb: the probe reported `sdkFetched=0` with - // ZERO decrypt-skip warnings, which can only mean the query itself returned - // nothing OR nothing materialized. Log the raw count (BEFORE decrypt) so an - // `adb logcat` run pins the empty result to the query vs the - // materialization vs the decrypt stage without guessing. + // Both counts are recorded BEFORE any decrypt, so an empty end result can be + // attributed to the query returning nothing, to documents the SDK could not + // materialize, or to the decrypt stage that runs after this — three causes + // that are otherwise indistinguishable from one another. breadcrumb(&format!( "query_owned_encrypted_documents: fetched raw encrypted documents \ - owner={owner_identity_id} type={document_type_name} since_ms={since_ms} \ raw_count={} materialized={}", raw_docs.len(), raw_docs.iter().filter(|(_, d)| d.is_some()).count() @@ -873,146 +1160,338 @@ pub async fn query_owned_encrypted_documents( #[cfg(test)] mod allocator_tests { - //! Unit tests for the `encryptionKeyIndex` allocator - //! (dashpay/platform#4186 follow-up). These exercise the index math and the - //! atomic in-process reservation WITHOUT a live SDK: the Platform-derived - //! seed is injected as a plain future, so `1 + count` semantics, per-owner - //! isolation, and the concurrent no-collision guarantee are all pinned here. + //! The `encryptionKeyIndex` allocator and the pagination scan it seeds from. + //! + //! Both are exercised without a live SDK: the Platform-derived seed is + //! injected as a plain future and the pagination decision is driven + //! directly, so every case here is finite by construction rather than + //! bounded by a wall-clock timeout. use super::*; + /// The identity these cases allocate for. + const TEST_OWNER: Identifier = Identifier::new([7u8; 32]); + /// Two contracts an identity could hold encrypted documents on. The + /// allocator seeds from a count taken for ONE contract and document type, so + /// these exist to prove a high-water never crosses into another scope. + const TEST_CONTRACT_A: Identifier = Identifier::new([11u8; 32]); + const TEST_CONTRACT_B: Identifier = Identifier::new([12u8; 32]); + fn empty_allocator() -> EncryptionKeyIndexAllocator { Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())) } - /// The index math is EXACTLY dash-wallet's `1 + countAllRequests()`: - /// empty state → 1, `n` existing → `n + 1` (count+1, not max+1), saturating - /// at the ceiling rather than wrapping to 0. + /// The allocator key a hand-out belongs to, built the way production builds + /// it. Tests go through this helper so what the allocator considers "the + /// same series" is stated in exactly one place. + fn test_scope( + owner: Identifier, + contract: Identifier, + document_type_name: &str, + ) -> EncryptionKeyIndexScope { + EncryptionKeyIndexScope::new(&owner, &contract, document_type_name) + } + + /// The seeding formula is the legacy wallet's, exactly. + /// + /// A migrating install keeps numbering where its old local counter left off + /// only because this is `count + 1` and not `max(index) + 1` — the two agree + /// on a dense series and diverge the moment one has a gap, and a divergence + /// here silently changes which key every later document is sealed under. #[test] - fn next_index_matches_legacy_one_plus_count() { - assert_eq!(next_encryption_key_index_from_count(0), 1); - assert_eq!(next_encryption_key_index_from_count(1), 2); - assert_eq!(next_encryption_key_index_from_count(5), 6); - assert_eq!(next_encryption_key_index_from_count(u32::MAX), u32::MAX); + fn the_seed_formula_is_one_plus_the_existing_count() { + assert_eq!( + next_encryption_key_index_from_count(0).expect("empty state"), + 1 + ); + assert_eq!(next_encryption_key_index_from_count(1).expect("one"), 2); + assert_eq!(next_encryption_key_index_from_count(5).expect("five"), 6); + assert!( + matches!( + next_encryption_key_index_from_count(u32::MAX), + Err(PlatformWalletError::TxMetadataEncryptionKeyIndexExhausted) + ), + "a count with no representable successor must fail rather than clamp \ + onto an index the series already used" + ); } - /// Empty state seeds to `1 + count(0) == 1`, then hands out 2, 3 … WITHOUT - /// re-seeding (the seed future must not be polled again once the high-water - /// is established). + /// Empty state seeds to `1 + count(0) == 1`, then hands out 2, 3 … without + /// re-seeding: once the high-water exists, no further network work may + /// happen, so the seed future must never be polled again. #[tokio::test] async fn empty_state_seeds_to_one_then_increments() { - let alloc = empty_allocator(); - let owner = Identifier::from([7u8; 32]); + let allocator = empty_allocator(); + let scope = test_scope(TEST_OWNER, TEST_CONTRACT_A, "txMetadata"); - let first = reserve_next_index(&alloc, &owner, async { - Ok(next_encryption_key_index_from_count(0)) - }) - .await - .expect("seed ok"); + let first = reserve_next_index(&allocator, &scope, async { Ok(1) }) + .await + .expect("the first allocation seeds"); assert_eq!(first, 1, "empty state must allocate index 1"); - // A seed that panics if awaited proves the second/third allocations - // never re-seed — they read the cached high-water instead. let must_not_seed = - || async { unreachable!("must not re-seed once the high-water is established") }; + || async { unreachable!("a seeded scope must not query Platform again") }; assert_eq!( - reserve_next_index(&alloc, &owner, must_not_seed()) + reserve_next_index(&allocator, &scope, must_not_seed()) .await - .unwrap(), + .expect("second allocation"), 2 ); assert_eq!( - reserve_next_index(&alloc, &owner, must_not_seed()) + reserve_next_index(&allocator, &scope, must_not_seed()) .await - .unwrap(), + .expect("third allocation"), 3 ); } - /// Distinct owners keep independent high-waters — one identity's allocations - /// never perturb another's. + /// The last derivable index is usable, and the series ends immediately after. + /// + /// The index is a hardened derivation element, so a hand-out above + /// [`MAX_TX_METADATA_ENCRYPTION_KEY_INDEX`] would seal a document with a key + /// nothing can re-derive — worse than refusing, because the failure would + /// surface only when someone later tried to read it. The boundary has to be + /// exact in both directions: one too low silently denies a usable index, one + /// too high hands out an unusable one. + #[tokio::test] + async fn the_last_derivable_index_is_handed_out_once_then_the_series_is_exhausted() { + let allocator = empty_allocator(); + let scope = test_scope(TEST_OWNER, TEST_CONTRACT_A, "txMetadata"); + + let last = reserve_next_index(&allocator, &scope, async { + Ok(MAX_TX_METADATA_ENCRYPTION_KEY_INDEX) + }) + .await + .expect("the maximum derivable index is usable and must be handed out"); + assert_eq!(last, MAX_TX_METADATA_ENCRYPTION_KEY_INDEX); + + let outcome = reserve_next_index(&allocator, &scope, async { + unreachable!("the scope is already seeded") + }) + .await; + assert!( + matches!( + outcome, + Err(PlatformWalletError::TxMetadataEncryptionKeyIndexExhausted) + ), + "the index after the last derivable one must be refused rather than \ + handed out; got {outcome:?}" + ); + + // Exhaustion is terminal, not a one-off: a later caller must not find a + // usable high-water sitting past the end of the series. + let outcome_again = reserve_next_index(&allocator, &scope, async { + unreachable!("the scope is already seeded") + }) + .await; + assert!( + matches!( + outcome_again, + Err(PlatformWalletError::TxMetadataEncryptionKeyIndexExhausted) + ), + "an exhausted scope must keep failing; got {outcome_again:?}" + ); + } + + /// A seed already past the derivable range never hands out anything. + /// + /// The seed comes from a Platform document count, so a corrupted or + /// adversarial count is the one way a scope can start beyond the end of the + /// series rather than walking to it. + #[tokio::test] + async fn a_seed_past_the_derivable_range_is_refused_outright() { + let allocator = empty_allocator(); + let scope = test_scope(TEST_OWNER, TEST_CONTRACT_A, "txMetadata"); + + for seeded in [MAX_TX_METADATA_ENCRYPTION_KEY_INDEX + 1, u32::MAX] { + let outcome = reserve_next_index(&allocator, &scope, async move { Ok(seeded) }).await; + assert!( + matches!( + outcome, + Err(PlatformWalletError::TxMetadataEncryptionKeyIndexExhausted) + ), + "a seed of {seeded} is past the derivable range and must be refused; \ + got {outcome:?}" + ); + } + } + + /// A high-water is only valid for the scope it was counted from. + /// + /// The seed counts the documents of ONE (owner, contract, document type) + /// triple, and both the FFI exports and the host APIs accept an arbitrary + /// contract and document type. Reusing one triple's high-water for another + /// would hand out an index derived from a count that never described it — + /// breaking the `1 + count` contract for the second series. #[tokio::test] - async fn distinct_owners_seed_independently() { - let alloc = empty_allocator(); - let a = Identifier::from([1u8; 32]); - let b = Identifier::from([2u8; 32]); + async fn each_owner_contract_and_document_type_seeds_independently() { + let allocator = empty_allocator(); - // a: 3 existing docs → 4; b: 0 existing → 1; then a again → 5. + let a = test_scope(TEST_OWNER, TEST_CONTRACT_A, "txMetadata"); assert_eq!( - reserve_next_index(&alloc, &a, async { - Ok(next_encryption_key_index_from_count(3)) - }) - .await - .unwrap(), + reserve_next_index(&allocator, &a, async { Ok(4) }) + .await + .expect("contract A seeds from its own count of 3"), 4 ); + + // Same owner, different contract: a fresh series, seeded from its own + // (empty) count rather than continuing contract A's. + let b = test_scope(TEST_OWNER, TEST_CONTRACT_B, "txMetadata"); assert_eq!( - reserve_next_index(&alloc, &b, async { - Ok(next_encryption_key_index_from_count(0)) - }) - .await - .unwrap(), - 1 + reserve_next_index(&allocator, &b, async { Ok(1) }) + .await + .expect("contract B seeds independently"), + 1, + "a second contract must seed from its own count, not continue the first's" ); + + // Same owner and contract, different document type: likewise its own + // series. + let other_type = test_scope(TEST_OWNER, TEST_CONTRACT_A, "otherEncryptedType"); assert_eq!( - reserve_next_index(&alloc, &a, async { unreachable!("a already seeded") }) + reserve_next_index(&allocator, &other_type, async { Ok(1) }) .await - .unwrap(), + .expect("the other document type seeds independently"), + 1, + "a second document type must seed from its own count, not continue \ + the first's" + ); + + // The original series is untouched by either of them. + assert_eq!( + reserve_next_index(&allocator, &a, async { + unreachable!("contract A is already seeded") + }) + .await + .expect("contract A continues"), 5 ); } - /// The core concurrency guarantee: two allocations racing on the SAME owner - /// through the SAME allocator get DISTINCT indices. The mutex serializes - /// them even though both start from an empty map and both would otherwise - /// seed to 1. `yield_now` inside the seed widens the interleaving window so - /// a broken (non-serialized) allocator would reliably hand out 1 twice. + /// The core concurrency guarantee: two allocations racing on the SAME scope + /// get DISTINCT indices, even when both of their seed futures actually run. + /// + /// Both seeds observing the same pre-write Platform count is the expected + /// case — the count query is not serialized with the create — so the + /// allocator, not the seed, is what makes the two hand-outs differ. #[tokio::test] - async fn concurrent_allocations_never_collide() { - let alloc = empty_allocator(); - let owner = Identifier::from([3u8; 32]); + async fn concurrent_first_allocations_never_collide_even_when_both_seeds_run() { + let allocator = empty_allocator(); + let scope = test_scope(TEST_OWNER, TEST_CONTRACT_A, "txMetadata"); + // Both seeds yield first, so each is guaranteed to be in flight while + // the other runs, and both compute the same value from the same count. let seed = || async { tokio::task::yield_now().await; - Ok(next_encryption_key_index_from_count(0)) + Ok(1) }; - let (r1, r2) = tokio::join!( - reserve_next_index(&alloc, &owner, seed()), - reserve_next_index(&alloc, &owner, seed()), + let (first, second) = tokio::join!( + reserve_next_index(&allocator, &scope, seed()), + reserve_next_index(&allocator, &scope, seed()), ); - let (i1, i2) = (r1.expect("task 1"), r2.expect("task 2")); - - assert_ne!(i1, i2, "concurrent allocations must not collide"); - let mut got = [i1, i2]; - got.sort_unstable(); + let mut handed_out = [ + first.expect("first allocation"), + second.expect("second allocation"), + ]; + handed_out.sort_unstable(); assert_eq!( - got, + handed_out, [1, 2], - "the two racing indices must be exactly 1 and 2" + "two racing allocations for one scope must hand out two different indices" + ); + } + + /// A seed that never answers must not freeze the whole wallet. + /// + /// The seed is a Platform round trip and the SDK sets no request timeout, so + /// a node that accepts the connection and never replies stalls it forever. + /// The allocator state is shared by every identity in the process: if the + /// shared lock were held across that round trip, one unresponsive node would + /// block every other encrypted-document create in the wallet instead of just + /// the one waiting on it. + #[tokio::test(start_paused = true)] + async fn a_stalled_seed_does_not_block_other_scopes_or_cached_allocations() { + /// Long enough that only a genuinely blocked allocation reaches it; + /// virtual time makes it elapse instantly when nothing can progress. + const STALL_BUDGET: std::time::Duration = std::time::Duration::from_secs(30); + + let allocator = empty_allocator(); + let stalled_scope = test_scope(TEST_OWNER, TEST_CONTRACT_A, "txMetadata"); + let cached_scope = test_scope(TEST_OWNER, TEST_CONTRACT_B, "txMetadata"); + let fresh_scope = test_scope(TEST_OWNER, TEST_CONTRACT_B, "otherEncryptedType"); + + // Seed one scope up front so its later hand-out needs no network at all. + reserve_next_index(&allocator, &cached_scope, async { Ok(1) }) + .await + .expect("cached scope seeds"); + + // `_never_answers` is held to the end of the test, so the seed below + // stays pending rather than resolving with a channel error. + let (_never_answers, never_answered) = tokio::sync::oneshot::channel::(); + let stalled = reserve_next_index(&allocator, &stalled_scope, async { + Ok(never_answered + .await + .expect("the stalled seed never answers")) + }); + tokio::pin!(stalled); + + // Drive the stalled allocation up to its seed await, which is where a + // lock-holding implementation would be holding the shared lock. + tokio::select! { + _ = &mut stalled => panic!("the stalled seed must not complete"), + _ = tokio::task::yield_now() => {} + } + + let cached = tokio::time::timeout( + STALL_BUDGET, + reserve_next_index(&allocator, &cached_scope, async { + unreachable!("the cached scope is already seeded") + }), + ) + .await; + assert_eq!( + cached + .expect("a hand-out from an already-seeded scope must not wait on another scope's network call") + .expect("cached allocation"), + 2 + ); + + let fresh = tokio::time::timeout( + STALL_BUDGET, + reserve_next_index(&allocator, &fresh_scope, async { Ok(1) }), + ) + .await; + assert_eq!( + fresh + .expect( + "another scope's first allocation must not wait on an unrelated stalled seed" + ) + .expect("fresh allocation"), + 1 ); } - /// An oversized payload on the auto-index path fails with the typed - /// `TxMetadataPayloadTooLarge` BEFORE the allocator is touched: the seed is - /// never polled, so the high-water is neither seeded nor advanced — no index - /// is consumed and no gap is left (dashpay/platform#4186 review). A - /// subsequent well-sized reservation for the same owner still starts at the - /// legacy `1 + count(0) == 1`, proving nothing was reserved by the doomed - /// request. + /// An oversized payload is rejected before the allocator is touched. + /// + /// The size bound is deterministic and needs no network, so a request that + /// must fail should not seed the high-water or consume an index — otherwise + /// every rejected batch would burn an index and leave a gap in a series the + /// legacy stack expects to be dense. #[tokio::test] - async fn oversized_payload_does_not_advance_highwater() { + async fn an_oversized_payload_does_not_seed_or_advance_the_high_water() { use crate::wallet::identity::crypto::tx_metadata::MAX_TX_METADATA_PLAINTEXT_LEN; - let alloc = empty_allocator(); - let owner = Identifier::from([8u8; 32]); + let allocator = empty_allocator(); + let scope = test_scope(TEST_OWNER, TEST_CONTRACT_A, "txMetadata"); - // 4064 bytes (MAX + 1) is the first rejected length. The seed panics if - // polled — proving the size gate short-circuits before any allocation. - let result = - reserve_next_index_checked(&alloc, &owner, MAX_TX_METADATA_PLAINTEXT_LEN + 1, async { - unreachable!("seed must not run when the payload is oversized") - }) - .await; - match result { + let outcome = reserve_next_index_checked( + &allocator, + &scope, + MAX_TX_METADATA_PLAINTEXT_LEN + 1, + async { unreachable!("an oversized payload must not reach the seed") }, + ) + .await; + match outcome { Err(PlatformWalletError::TxMetadataPayloadTooLarge { len, max }) => { assert_eq!(len, MAX_TX_METADATA_PLAINTEXT_LEN + 1); assert_eq!(max, MAX_TX_METADATA_PLAINTEXT_LEN); @@ -1020,22 +1499,1311 @@ mod allocator_tests { other => panic!("expected TxMetadataPayloadTooLarge, got {other:?}"), } - // High-water NOT advanced: the owner was never inserted into the map. + // The rejected request left nothing behind in the map. assert!( - alloc.lock().await.get(&owner).is_none(), - "an oversized payload must not seed/advance the allocator high-water" + allocator.lock().await.get(&scope).is_none(), + "an oversized payload must not seed or advance the high-water" ); - // The next well-sized reservation still seeds fresh at 1 — no gap was - // left by the rejected oversized request. - let index = reserve_next_index_checked(&alloc, &owner, 0, async { - Ok(next_encryption_key_index_from_count(0)) - }) - .await - .expect("well-sized reservation seeds ok"); + // The next well-sized request still seeds fresh at 1: the rejected one + // left no reservation and no gap. + let index = reserve_next_index_checked(&allocator, &scope, 0, async { Ok(1) }) + .await + .expect("a well-sized payload allocates"); assert_eq!( index, 1, - "the first index after a rejected oversized payload must still be 1 (no gap)" + "the first index after a rejected oversized payload must still be 1" + ); + } + + // ── Pagination stall detection ────────────────────────────────────────── + // + // These drive [`PaginationProgress`] — the same decision the production scan + // makes after every page — directly. Feeding it page shapes is finite by + // construction: each call returns, so a scan that would never stop shows up + // as the wrong return value rather than as a test that has to be cut short. + // Exercising it through a source that always answers would instead need a + // timeout, which reports "still running when time ran out" and not "the + // repeat was detected". + + /// Page limit these cases page at. Small on purpose: the stall contract + /// depends on a page being FULL, not on how many entries that takes, so two + /// keeps each scenario readable as a sequence of cursors. + const STALL_PAGE_LIMIT: usize = 2; + + /// Distinct page cursors, named so a scan reads as the sequence it is. + const CURSOR_A: Identifier = Identifier::new([0xA1; 32]); + const CURSOR_B: Identifier = Identifier::new([0xB2; 32]); + + /// A source that answers every request with the same full page is reported, + /// not paged forever. + /// + /// The first page yields cursor A and the scan continues after it. The + /// source hands back a full page ending at A again, so the scan is not + /// advancing: continuing would refetch the same documents indefinitely and + /// grow the result without bound. Yielding what was collected would be worse + /// than failing, because a caller cannot distinguish a truncated history + /// from a complete one — so it is a typed error, reported on the second + /// page, which is the first one that could prove the repeat. + #[test] + fn a_page_cursor_that_immediately_repeats_is_reported_as_a_stall() { + let mut progress = PaginationProgress::default(); + + assert_eq!( + progress + .record_page(STALL_PAGE_LIMIT, STALL_PAGE_LIMIT, Some(CURSOR_A)) + .expect("the first page cannot repeat anything and must continue"), + NextPage::ContinueAfter(CURSOR_A), + "a full page must continue after the cursor it ended on" + ); + + match progress.record_page(STALL_PAGE_LIMIT, STALL_PAGE_LIMIT, Some(CURSOR_A)) { + Err(PlatformWalletError::EncryptedDocumentPaginationStalled { pages }) => assert_eq!( + pages, 2, + "the stall is reported on the page that proved the repeat, and both \ + pages were read to get there" + ), + other => panic!( + "a repeated page cursor must be reported as its own error rather than \ + continued or reported as something else; got {other:?}" + ), + } + } + + /// A cursor cycle that passes through another page is reported on the same + /// terms as one that repeats immediately. + /// + /// The scan runs A, then B, then A again. Only comparing against the + /// PREVIOUS cursor would see B follow A and A follow B and call both an + /// advance, so the scan would loop over the same two pages forever. Every + /// cursor the scan has continued from is remembered, so returning to A is a + /// stall no matter how many pages the cycle spans. + #[test] + fn a_page_cursor_that_repeats_after_an_intervening_page_is_reported_as_a_stall() { + let mut progress = PaginationProgress::default(); + + assert_eq!( + progress + .record_page(STALL_PAGE_LIMIT, STALL_PAGE_LIMIT, Some(CURSOR_A)) + .expect("the first page must continue"), + NextPage::ContinueAfter(CURSOR_A) + ); + assert_eq!( + progress + .record_page(STALL_PAGE_LIMIT, STALL_PAGE_LIMIT, Some(CURSOR_B)) + .expect("a new cursor is an advance and must continue"), + NextPage::ContinueAfter(CURSOR_B), + "a cursor the scan has not used before must not be mistaken for a stall" + ); + + match progress.record_page(STALL_PAGE_LIMIT, STALL_PAGE_LIMIT, Some(CURSOR_A)) { + Err(PlatformWalletError::EncryptedDocumentPaginationStalled { pages }) => assert_eq!( + pages, 3, + "the cycle took three pages to close, and the count must say so" + ), + other => panic!( + "returning to an earlier cursor must be reported as a stall even with a \ + page in between; got {other:?}" + ), + } + } + + /// A scan that keeps advancing runs to its natural end. + /// + /// Guards the detector against the opposite failure: rejecting healthy + /// scans. Distinct cursors continue, and the short page that follows ends + /// the scan rather than asking for a cursor it has no reason to distrust. + #[test] + fn an_advancing_scan_runs_to_a_short_page_without_a_stall() { + let mut progress = PaginationProgress::default(); + + for cursor in [CURSOR_A, CURSOR_B] { + assert_eq!( + progress + .record_page(STALL_PAGE_LIMIT, STALL_PAGE_LIMIT, Some(cursor)) + .expect("distinct cursors are an advancing scan, never a stall"), + NextPage::ContinueAfter(cursor) + ); + } + + assert_eq!( + progress + .record_page(STALL_PAGE_LIMIT - 1, STALL_PAGE_LIMIT, Some(CURSOR_A)) + .expect("a short page ends the scan and cannot stall it"), + NextPage::Done, + "a page the source could not fill is the last page, so its key is never \ + used as a cursor and repeating one is not a stall" + ); + } + + /// A full page carrying no final key ends the scan. + /// + /// There is no cursor to continue from, so the only alternative to stopping + /// would be reissuing the previous request unchanged. + #[test] + fn a_full_page_without_a_final_key_ends_the_scan() { + let mut progress = PaginationProgress::default(); + + assert_eq!( + progress + .record_page(STALL_PAGE_LIMIT, STALL_PAGE_LIMIT, None) + .expect("a missing cursor ends the scan rather than failing it"), + NextPage::Done + ); + } +} + +#[cfg(test)] +mod query_tests { + //! The query path against a mocked Platform: what its breadcrumbs may say, + //! how it walks pages, and what the allocator's seed counts. All offline — + //! every expectation is registered on a mock SDK, so nothing here reaches a + //! network. + use super::*; + use std::sync::Mutex; + + use crate::changeset::{PersistenceError, PlatformWalletPersistence}; + use crate::wallet::WalletId; + use crate::ClientStartState; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + + // ── Breadcrumb redaction ──────────────────────────────────────────────── + // + // The encrypted-document breadcrumbs are dual-emitted to Android logcat. + // Logcat is readable by any process holding READ_LOGS and survives in bug + // reports, so a breadcrumb must never persist data that correlates a device + // to an on-chain identity, nor echo a raw error body (which can carry query + // shapes, contract internals, or decrypted context). Stable codes, booleans + // and bounded non-sensitive context are fine; full identifiers are not. + + /// Captures every `tracing` event's level and rendered `message` so a test + /// can assert on what the breadcrumbs actually emit. + #[derive(Clone, Default)] + struct CapturedBreadcrumbs(Arc>>); + + impl CapturedBreadcrumbs { + fn lines(&self) -> Vec<(tracing::Level, String)> { + self.0.lock().expect("capture buffer not poisoned").clone() + } + } + + /// Pulls the `message` field out of an event, which is where both + /// [`breadcrumb`] and [`breadcrumb_error`] put their whole formatted line. + struct MessageVisitor(String); + + impl tracing::field::Visit for MessageVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.0 = format!("{value:?}"); + } + } + } + + impl tracing_subscriber::Layer for CapturedBreadcrumbs { + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + let mut visitor = MessageVisitor(String::new()); + event.record(&mut visitor); + self.0 + .lock() + .expect("capture buffer not poisoned") + .push((*event.metadata().level(), visitor.0)); + } + } + + /// The owner identifier the breadcrumbs and queries are given. + const TEST_OWNER: Identifier = Identifier::new([7u8; 32]); + + /// Outcome of one captured, deterministically-failing query run. + struct CapturedQuery { + lines: Vec<(tracing::Level, String)>, + contract_id: Identifier, + error: PlatformWalletError, + } + + /// Drive the real query path against a mock SDK carrying NO registered + /// expectation. The contract is supplied directly, so the query runs and + /// `fetch_many` fails deterministically — exercising the entry breadcrumb + /// and the failure breadcrumb in a single call, with no network. + async fn capture_failing_query_for_type(document_type_name: &str) -> CapturedQuery { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + + let sdk = dash_sdk::Sdk::new_mock(); + let contract = Arc::new( + dpp::tests::fixtures::get_data_contract_fixture(None, 0, dpp::version::LATEST_VERSION) + .data_contract_owned(), + ); + let contract_id = contract.id(); + + let captured = CapturedBreadcrumbs::default(); + let collected = captured.clone(); + let error = { + let _guard = tracing_subscriber::registry().with(captured).set_default(); + query_owned_encrypted_documents(&sdk, contract, &TEST_OWNER, document_type_name, 0) + .await + .expect_err("a mock SDK with no expectation must fail the page fetch") + }; + + let lines = collected.lines(); + assert!( + !lines.is_empty(), + "the query path must emit breadcrumbs for these assertions to mean anything" + ); + CapturedQuery { + lines, + contract_id, + error, + } + } + + /// The ordinary document type this module is written for. + async fn capture_failing_query() -> CapturedQuery { + capture_failing_query_for_type("txMetadata").await + } + + struct NoopPersister; + impl PlatformWalletPersistence for NoopPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: crate::changeset::PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + struct NoopEventHandler; + impl crate::events::EventHandler for NoopEventHandler {} + impl crate::PlatformEventHandler for NoopEventHandler {} + + /// The document type is caller-supplied and travels straight from the host + /// into this module. Nothing bounds its length, character set, or content, + /// so a breadcrumb that interpolates it raw lets a caller write arbitrary + /// text — including secret-looking material and embedded newlines that + /// forge additional log lines — into a device log that any process holding + /// READ_LOGS can read. + #[tokio::test] + async fn query_breadcrumbs_do_not_echo_the_caller_supplied_document_type() { + // A hostile document type: an embedded newline to forge a log line, and + // a marker standing in for whatever the caller chose to put here. + const MARKER: &str = "s3cr3t-marker-do-not-log"; + let hostile = format!("txMetadata\nFORGED WARN line {MARKER}"); + + let captured = capture_failing_query_for_type(&hostile).await; + + for (level, line) in &captured.lines { + assert!( + !line.contains(MARKER), + "{level} breadcrumb echoes caller-supplied document-type content: {line}" + ); + assert!( + !line.contains('\n'), + "{level} breadcrumb contains an embedded newline, letting a caller \ + forge additional log lines: {line}" + ); + } + } + + /// No breadcrumb, at any level, may carry a full owner or contract + /// identifier: logcat is readable by any process holding READ_LOGS and + /// survives in bug reports, so a full identifier there correlates a device + /// to an on-chain identity. + #[tokio::test] + async fn query_breadcrumbs_redact_owner_and_contract_identifiers() { + let captured = capture_failing_query().await; + + // Rendered exactly the way the breadcrumbs interpolate them (`Display`). + let owner_rendered = format!("{TEST_OWNER}"); + let contract_rendered = format!("{}", captured.contract_id); + + for (level, line) in &captured.lines { + assert!( + !line.contains(&owner_rendered), + "{level} breadcrumb carries the full owner identity id: {line}" + ); + assert!( + !line.contains(&contract_rendered), + "{level} breadcrumb carries the full contract id: {line}" + ); + } + } + + /// A failure breadcrumb must classify, not transcribe. The SDK error body + /// is unbounded and carries query and contract internals, so the exact + /// `Display` of the error the call returned must not appear in the WARN + /// line. The label itself is not the problem — the verbatim body is — so + /// this compares against the real error string rather than banning a token. + #[tokio::test] + async fn query_failure_breadcrumb_redacts_the_raw_sdk_error_body() { + let captured = capture_failing_query().await; + + // The exact body the breadcrumb would transcribe: the inner SDK error's + // own `Display`, taken from the very error this call returned. + let raw_body = match &captured.error { + PlatformWalletError::Sdk(sdk_error) => format!("{sdk_error}"), + other => panic!("expected the page fetch to fail as Sdk(_), got {other:?}"), + }; + assert!( + !raw_body.is_empty(), + "the SDK error must render to something for this assertion to bite" + ); + + let warnings: Vec<_> = captured + .lines + .iter() + .filter(|(level, _)| *level == tracing::Level::WARN) + .collect(); + assert!( + !warnings.is_empty(), + "the failed page fetch must emit a WARN breadcrumb" + ); + + for (level, line) in warnings { + assert!( + !line.contains(&raw_body), + "{level} breadcrumb transcribes the raw SDK error body verbatim \ + instead of a stable classification.\n raw body: {raw_body}\n line: {line}" + ); + } + } + + // ── Pagination ────────────────────────────────────────────────────────── + + /// Page size the query paginates at, mirrored from the production loop. + const PAGE_SIZE: usize = 100; + + /// Rebuild the exact `DocumentQuery` the production loop issues for a given + /// cursor, so mock expectations key on the same request the code sends. + fn expected_page_query( + contract: Arc, + owner: &Identifier, + start: Option< + dash_sdk::dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start, + >, + ) -> dash_sdk::platform::DocumentQuery { + use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; + use dpp::platform_value::platform_value; + + dash_sdk::platform::DocumentQuery { + select: dash_sdk::drive::query::SelectProjection::documents(), + data_contract: contract, + document_type_name: "txMetadata".to_string(), + where_clauses: vec![ + WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: platform_value!(owner), + }, + WhereClause { + field: "$updatedAt".to_string(), + operator: WhereOperator::GreaterThanOrEquals, + value: platform_value!(0u64), + }, + ], + group_by: vec![], + having: vec![], + order_by_clauses: vec![OrderClause { + field: "$updatedAt".to_string(), + ascending: true, + }], + limit: PAGE_SIZE as u32, + start, + } + } + + /// A document carrying the given id and `$updatedAt`. + fn document_at(id: Identifier, updated_at_ms: u64) -> Document { + Document::V0(dpp::document::DocumentV0 { + id, + owner_id: TEST_OWNER, + properties: Default::default(), + revision: Some(1), + created_at: None, + updated_at: Some(updated_at_ms), + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + }) + } + + /// Full pagination walk over a boundary-sized first page, offline. + /// + /// The scenario is the one that separates an order-preserving cursor from a + /// sorted one: every document on page one shares the SAME `$updatedAt`, so + /// the `$updatedAt asc` ordering cannot disambiguate them, and the ids are + /// assigned in DESCENDING order so the final returned document is also the + /// numerically smallest. That last entry is additionally unmaterialized + /// (`None`), the shape a proved fetch returns for a document it could not + /// produce. The cursor must still be that final entry's key: a sorted map + /// would hand back the largest id instead and silently skip every document + /// between them on the next page. + /// + /// Termination is proved by construction — only two page requests are + /// registered, so a third would find no expectation and fail the call. + #[tokio::test] + async fn paginates_by_final_insertion_order_key_across_a_full_page() { + use dash_sdk::dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; + + // Pin the protocol version so the wire encoding of page two matches the + // expectation registered for it; an unpinned mock ratchets to the + // latest version after the first response and re-encodes the request. + let mut sdk = dash_sdk::SdkBuilder::new_mock() + .with_version(dpp::version::PlatformVersion::latest()) + .build() + .expect("mock sdk builds"); + + let contract = Arc::new( + dpp::tests::fixtures::get_data_contract_fixture(None, 0, dpp::version::LATEST_VERSION) + .data_contract_owned(), + ); + // Page one: exactly PAGE_SIZE entries, identical timestamps, descending + // ids, final entry unmaterialized. + const SHARED_TIMESTAMP: u64 = 1_700_000_000_000; + let page_one_ids: Vec = (0..PAGE_SIZE) + .map(|i| Identifier::from([(200 - i) as u8; 32])) + .collect(); + let mut page_one: drive_proof_verifier::types::Documents = Default::default(); + for (position, id) in page_one_ids.iter().enumerate() { + let is_final = position == PAGE_SIZE - 1; + page_one.insert( + *id, + if is_final { + None + } else { + Some(document_at(*id, SHARED_TIMESTAMP)) + }, + ); + } + let final_page_one_key = *page_one_ids.last().expect("page one is not empty"); + + // Page two: short, so the loop terminates after consuming it. + let page_two_ids: Vec = (0..3) + .map(|i| Identifier::from([(50 - i) as u8; 32])) + .collect(); + let mut page_two: drive_proof_verifier::types::Documents = Default::default(); + for id in &page_two_ids { + page_two.insert(*id, Some(document_at(*id, SHARED_TIMESTAMP + 1))); + } + + sdk.mock() + .expect_fetch_many( + expected_page_query(Arc::clone(&contract), &TEST_OWNER, None), + Some(page_one), + ) + .await + .expect("register page one"); + sdk.mock() + .expect_fetch_many( + expected_page_query( + Arc::clone(&contract), + &TEST_OWNER, + Some(Start::StartAfter(final_page_one_key.to_buffer().to_vec())), + ), + Some(page_two), + ) + .await + .expect("register page two"); + + let fetched = query_owned_encrypted_documents( + &sdk, + Arc::clone(&contract), + &TEST_OWNER, + "txMetadata", + 0, + ) + .await + .expect( + "both pages are registered; a failure here means the cursor did not select the \ + final insertion-order key, so page two was requested with the wrong StartAfter", + ); + + // Every document, exactly once, in Drive's returned order. + let expected_order: Vec = page_one_ids + .iter() + .chain(page_two_ids.iter()) + .copied() + .collect(); + let actual_order: Vec = fetched.iter().map(|(id, _)| *id).collect(); + assert_eq!( + actual_order, expected_order, + "results must preserve Drive's returned order across the page boundary" + ); + assert_eq!( + fetched.len(), + PAGE_SIZE + page_two_ids.len(), + "every document is returned exactly once" + ); + + // The unmaterialized entry is preserved rather than dropped, so callers + // never silently under-report. + assert!( + fetched[PAGE_SIZE - 1].1.is_none(), + "the final page-one entry was unmaterialized and must be preserved as None" + ); + assert_eq!( + fetched.iter().filter(|(_, doc)| doc.is_none()).count(), + 1, + "exactly one entry was unmaterialized" + ); + } + + // ── Key acquisition happens after the query, never before ─────────────── + // + // Acquiring the txMetadata key context can consult the host key resolver — + // which on some platforms prompts the user — and whatever it yields would + // then have to survive the paginated scan, an unbounded wait. A scan that + // fails, or that finds nothing, must therefore cost no key acquisition at + // all. + // + // The wallet these cases build has NO managed identity, so any attempt to + // resolve the encryption context fails with an identity error. That is what + // makes the ordering observable: an identity error proves acquisition was + // reached, and its absence proves it was not. + + /// Build a wallet on a mock SDK with no managed identity. + async fn wallet_without_managed_identity( + sdk: dash_sdk::Sdk, + ) -> std::sync::Arc { + use key_wallet::mnemonic::{Language, Mnemonic}; + + let manager = Arc::new(crate::PlatformWalletManager::new( + Arc::new(sdk), + Arc::new(NoopPersister), + Arc::new(NoopEventHandler) as Arc, + )); + let seed = Mnemonic::from_entropy(&[0u8; 16], Language::English) + .expect("16 bytes of entropy") + .to_seed(""); + manager + .create_wallet_from_seed_bytes( + key_wallet::Network::Testnet, + &seed, + WalletAccountCreationOptions::None, + Some(0), + ) + .await + .expect("wallet creation on a mock sdk") + } + + /// A query that fails surfaces the query's own error and acquires no key. + /// + /// If the context were resolved first, this wallet's missing identity would + /// fail before the query ever ran and the caller would see an identity error + /// instead — so the error's own kind is the proof of ordering. + #[tokio::test] + async fn a_failing_query_reports_the_query_error_and_acquires_no_key() { + let mut sdk = dash_sdk::Sdk::new_mock(); + let contract = Arc::new( + dpp::tests::fixtures::get_data_contract_fixture(None, 0, dpp::version::LATEST_VERSION) + .data_contract_owned(), + ); + let contract_id = { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + contract.id() + }; + // No `expect_fetch_many` is registered, so the page fetch fails. + sdk.mock() + .expect_fetch(contract_id, Some((*contract).clone())) + .await + .expect("register the contract fetch"); + + let wallet = wallet_without_managed_identity(sdk).await; + let error = wallet + .identity() + .fetch_encrypted_documents(&TEST_OWNER, &contract_id, "txMetadata", 0) + .await + .expect_err("the unregistered page fetch must fail"); + + assert!( + matches!(error, PlatformWalletError::Sdk(_)), + "a failing scan must surface the scan's own error, not an identity \ + error — an identity error would mean the key context was resolved \ + before the query ran; got {error:?}" + ); + } + + /// A query that returns nothing yields an empty result and acquires no key. + /// + /// This wallet cannot resolve an encryption context at all, so the call + /// succeeding is itself the proof that no acquisition was attempted. + #[tokio::test] + async fn an_empty_query_returns_no_documents_and_acquires_no_key() { + let mut sdk = dash_sdk::SdkBuilder::new_mock() + .with_version(dpp::version::PlatformVersion::latest()) + .build() + .expect("mock sdk builds"); + let contract = Arc::new( + dpp::tests::fixtures::get_data_contract_fixture(None, 0, dpp::version::LATEST_VERSION) + .data_contract_owned(), + ); + let contract_id = { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + contract.id() + }; + sdk.mock() + .expect_fetch(contract_id, Some((*contract).clone())) + .await + .expect("register the contract fetch"); + // A short (empty) page ends the scan immediately. + let empty: drive_proof_verifier::types::Documents = Default::default(); + sdk.mock() + .expect_fetch_many( + expected_page_query(Arc::clone(&contract), &TEST_OWNER, None), + Some(empty), + ) + .await + .expect("register the empty page"); + + let wallet = wallet_without_managed_identity(sdk).await; + let fetched = wallet + .identity() + .fetch_encrypted_documents(&TEST_OWNER, &contract_id, "txMetadata", 0) + .await + .expect( + "an empty scan must succeed without acquiring a key; this wallet has no \ + managed identity, so any acquisition attempt would have failed here", + ); + + assert!( + fetched.is_empty(), + "no documents were returned by the query" + ); + } + + /// A query that DOES return candidates goes on to acquire the key context. + /// + /// The mirror of the two cases above: with something to decrypt, acquisition + /// must be reached — and on this identity-less wallet that surfaces as an + /// identity error. Without this, the two negative cases could also be + /// satisfied by never acquiring a key at all. + #[tokio::test] + async fn a_non_empty_query_goes_on_to_acquire_the_key_context() { + let mut sdk = dash_sdk::SdkBuilder::new_mock() + .with_version(dpp::version::PlatformVersion::latest()) + .build() + .expect("mock sdk builds"); + let contract = Arc::new( + dpp::tests::fixtures::get_data_contract_fixture(None, 0, dpp::version::LATEST_VERSION) + .data_contract_owned(), + ); + let contract_id = { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + contract.id() + }; + sdk.mock() + .expect_fetch(contract_id, Some((*contract).clone())) + .await + .expect("register the contract fetch"); + + let id = Identifier::from([0x5Au8; 32]); + let mut page: drive_proof_verifier::types::Documents = Default::default(); + page.insert(id, Some(document_at(id, 1_700_000_000_000))); + sdk.mock() + .expect_fetch_many( + expected_page_query(Arc::clone(&contract), &TEST_OWNER, None), + Some(page), + ) + .await + .expect("register the single-document page"); + + let wallet = wallet_without_managed_identity(sdk).await; + let error = wallet + .identity() + .fetch_encrypted_documents(&TEST_OWNER, &contract_id, "txMetadata", 0) + .await + .expect_err("this wallet cannot resolve an encryption context"); + + assert!( + !matches!(error, PlatformWalletError::Sdk(_)), + "with a candidate document present the key context must be acquired, \ + which on this wallet fails with an identity error rather than a scan \ + error; got {error:?}" + ); + } + + /// A document carrying the given id, `$updatedAt` and txMetadata fields. + fn encrypted_document_at( + id: Identifier, + updated_at_ms: u64, + key_index: u32, + encryption_key_index: u32, + blob: Vec, + ) -> Document { + let mut properties: std::collections::BTreeMap = Default::default(); + properties.insert(FIELD_KEY_INDEX.to_string(), Value::U32(key_index)); + properties.insert( + FIELD_ENCRYPTION_KEY_INDEX.to_string(), + Value::U32(encryption_key_index), + ); + properties.insert(FIELD_ENCRYPTED_METADATA.to_string(), Value::Bytes(blob)); + + Document::V0(dpp::document::DocumentV0 { + id, + owner_id: TEST_OWNER, + properties, + revision: Some(1), + created_at: None, + updated_at: Some(updated_at_ms), + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + }) + } + + /// A wallet whose manager holds a managed identity at a resident HD slot, + /// carrying the ECDSA ENCRYPTION key the txMetadata reader selects. + /// + /// Returns the wallet plus the `(identity_index, key_index)` the reader will + /// derive at, so a fixture can seal a blob with the reader's own derivation + /// instead of guessing it. + async fn wallet_with_managed_identity( + sdk: dash_sdk::Sdk, + owner: Identifier, + ) -> (std::sync::Arc, u32, u32) { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, IdentityV0}; + use key_wallet::mnemonic::{Language, Mnemonic}; + + const IDENTITY_INDEX: u32 = 0; + const KEY_INDEX: u32 = 2; + + // The wallet must live on the SDK's own network: the txMetadata + // derivation path is network-dependent and the reader takes its network + // from the SDK, so a wallet built on another one derives different keys + // and even a different wallet id. + let network = sdk.network; + let manager = Arc::new(crate::PlatformWalletManager::new( + Arc::new(sdk), + Arc::new(NoopPersister), + Arc::new(NoopEventHandler) as Arc, + )); + let seed = Mnemonic::from_entropy(&[0u8; 16], Language::English) + .expect("16 bytes of entropy") + .to_seed(""); + let wallet = manager + .create_wallet_from_seed_bytes( + network, + &seed, + WalletAccountCreationOptions::None, + Some(0), + ) + .await + .expect("wallet creation on a mock sdk"); + + // The reader selects an ECDSA ENCRYPTION/MEDIUM key, so the fixture + // identity must carry one at the id the blob will be sealed under. + let encryption_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: KEY_INDEX, + purpose: Purpose::ENCRYPTION, + security_level: SecurityLevel::MEDIUM, + key_type: KeyType::ECDSA_SECP256K1, + contract_bounds: None, + read_only: false, + data: dpp::platform_value::BinaryData::new(vec![0x02; 33]), + disabled_at: None, + }); + let identity = dpp::identity::Identity::V0(IdentityV0 { + id: owner, + public_keys: [(KEY_INDEX, encryption_key)].into_iter().collect(), + balance: 0, + revision: 1, + }); + + let identity_wallet = wallet.identity(); + let wallet_id = identity_wallet.wallet_id; + let persister = identity_wallet.persister.clone(); + { + let mut wm = identity_wallet.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&wallet_id) + .expect("the wallet just created is registered"); + info.identity_manager + .add_identity(identity, IDENTITY_INDEX, wallet_id, &persister) + .expect("register the managed identity"); + } + + (wallet, IDENTITY_INDEX, KEY_INDEX) + } + + /// The BIP-39 seed every wallet fixture in this module is built from. + fn fixture_seed() -> [u8; 64] { + use key_wallet::mnemonic::{Language, Mnemonic}; + Mnemonic::from_entropy(&[0u8; 16], Language::English) + .expect("16 bytes of entropy") + .to_seed("") + } + + /// Restore resident private keys on an already-registered wallet. + /// + /// Registration downgrades a wallet to external-signable, which is the + /// mobile shape. A desktop or test wallet that keeps its keys in process + /// takes the other branch of the key-source dispatch, and that branch has to + /// be exercised against a wallet that genuinely holds them — swapping in a + /// seed-bearing wallet built from the SAME seed keeps the wallet id, and so + /// the registration, intact. + async fn make_wallet_resident(wallet: &crate::PlatformWallet) { + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + + let identity_wallet = wallet.identity(); + let network = identity_wallet.sdk.network; + let resident = + Wallet::from_seed_bytes(fixture_seed(), network, WalletAccountCreationOptions::None) + .expect("seed-bearing wallet"); + + let mut wm = identity_wallet.wallet_manager.write().await; + let (stored, _info) = wm + .get_wallet_mut_and_info_mut(&identity_wallet.wallet_id) + .expect("the wallet is registered"); + assert_eq!( + stored.wallet_id, resident.wallet_id, + "the resident wallet must be the same wallet, or the registration \ + and the managed identity would no longer refer to it" + ); + *stored = resident; + } + + /// The whole decrypt-on-fetch orchestration, end to end against a mocked + /// Platform: one document this wallet can open, one whose blob is malformed, + /// and one whose wire version is unsupported. + /// + /// The per-piece tests cover the query shape and the crypto separately, but + /// only driving the orchestrator shows what a caller actually receives: that + /// a bad document is SKIPPED rather than aborting the sync, that an + /// unsupported version is skipped on the same terms, and that the surviving + /// document arrives with its plaintext and its non-secret metadata intact. + /// A skip that silently dropped everything would satisfy neither. + // A plain `#[test]` driving its own runtime: only the network stages are + // awaited, and the decrypt stage runs outside the runtime entirely — the + // same split the FFI makes, and required because + // `decrypt_fetched_documents` takes a blocking read. + #[test] + fn fetch_decrypts_the_valid_document_and_skips_the_malformed_and_unsupported_ones() { + use crate::wallet::identity::crypto::tx_metadata::{ + derive_tx_metadata_key_from_master, seal_tx_metadata, VERSION_PROTOBUF, + }; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use key_wallet::bip32::ExtendedPrivKey; + use key_wallet::mnemonic::{Language, Mnemonic}; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + + let mut sdk = dash_sdk::SdkBuilder::new_mock() + .with_version(dpp::version::PlatformVersion::latest()) + .build() + .expect("mock sdk builds"); + let contract = Arc::new( + dpp::tests::fixtures::get_data_contract_fixture(None, 0, dpp::version::LATEST_VERSION) + .data_contract_owned(), + ); + let contract_id = contract.id(); + + let (wallet, identity_index, key_index) = + runtime.block_on(wallet_with_managed_identity(sdk.clone(), TEST_OWNER)); + + // Seal a real blob with the SAME derivation the reader will use, so the + // valid document is one this wallet genuinely owns. + const ENCRYPTION_KEY_INDEX: u32 = 1; + const PLAINTEXT: &[u8] = b"memo=coffee;taxCategory=expense"; + // Seal on the SDK's own network: the reader derives with `sdk.network`, + // and the derivation path is network-dependent, so a mismatch here would + // produce a key that cannot open its own blob. + let network = wallet.identity().sdk.network; + + // Sealing secrets live in this block and nowhere else. The seed is + // `Zeroizing`, so it is scrubbed when the block ends; the master + // zeroizes on drop and its scalar is also erased explicitly at the use + // boundary; the AES key is `Zeroizing` and is dropped with the block. + // Nothing derived from them is in scope after it, so none of them is + // live across the raw scan below. + let (good_blob, unsupported_blob) = { + let seed = Zeroizing::new( + Mnemonic::from_entropy(&[0u8; 16], Language::English) + .expect("16 bytes of entropy") + .to_seed(""), + ); + let mut master = ExtendedPrivKey::new_master(network, seed.as_ref()) + .expect("master xprv from the wallet's own seed"); + let aes_key = derive_tx_metadata_key_from_master( + &master, + network, + identity_index, + key_index, + ENCRYPTION_KEY_INDEX, + ) + .expect("derive the reader's own key"); + let iv = [0x5Cu8; 16]; + let good = seal_tx_metadata(&aes_key, VERSION_PROTOBUF, &iv, PLAINTEXT).expect("seal"); + // Same ciphertext, version byte changed to one nothing can interpret. + let mut unsupported = good.clone(); + unsupported[0] = 2; + // Best-effort erase: removes the stack residue, but cannot reach a + // register copy the optimizer may have made. + master.private_key.non_secure_erase(); + (good, unsupported) + }; + // Too short to be an envelope at all. + let malformed_blob = vec![VERSION_PROTOBUF, 0x00, 0x01]; + + let good_id = Identifier::from([0x11u8; 32]); + let malformed_id = Identifier::from([0x22u8; 32]); + let unsupported_id = Identifier::from([0x33u8; 32]); + + let mut page: drive_proof_verifier::types::Documents = Default::default(); + page.insert( + good_id, + Some(encrypted_document_at( + good_id, + 1_700_000_000_000, + key_index, + ENCRYPTION_KEY_INDEX, + good_blob, + )), + ); + page.insert( + malformed_id, + Some(encrypted_document_at( + malformed_id, + 1_700_000_000_001, + key_index, + ENCRYPTION_KEY_INDEX, + malformed_blob, + )), + ); + page.insert( + unsupported_id, + Some(encrypted_document_at( + unsupported_id, + 1_700_000_000_002, + key_index, + ENCRYPTION_KEY_INDEX, + unsupported_blob, + )), + ); + + runtime.block_on(async { + sdk.mock() + .expect_fetch(contract_id, Some((*contract).clone())) + .await + .expect("register the contract fetch"); + sdk.mock() + .expect_fetch_many( + expected_page_query(Arc::clone(&contract), &TEST_OWNER, None), + Some(page), + ) + .await + .expect("register the page"); + }); + + // Stage 1 — network only. No key material is in scope: the sealing block + // above ended, so nothing it produced is alive across this scan. + let raw = runtime + .block_on(async { + wallet + .identity() + .fetch_raw_encrypted_documents(&TEST_OWNER, &contract_id, "txMetadata", 0) + .await + }) + .expect("the raw scan must succeed without any key"); + assert_eq!( + raw.len(), + 3, + "all three raw entries come back from the scan" + ); + + // Stage 2 — acquire a FRESH master only now that there is something to + // decrypt, decrypt synchronously, and erase it before leaving the block. + // This runs outside the runtime, matching the FFI, whose decrypt stage + // executes on its own calling thread; `decrypt_fetched_documents` takes + // a blocking read and must not run inside an async task. + let fetched = { + let seed = Zeroizing::new( + Mnemonic::from_entropy(&[0u8; 16], Language::English) + .expect("16 bytes of entropy") + .to_seed(""), + ); + let mut master = ExtendedPrivKey::new_master(network, seed.as_ref()) + .expect("master xprv acquired after the scan"); + let decrypted = wallet.identity().decrypt_fetched_documents( + &TEST_OWNER, + &raw, + TxMetadataKeySource::Master(&master), + ); + master.private_key.non_secure_erase(); + decrypted + } + .expect("a bad document must never abort the decrypt stage"); + + assert_eq!( + fetched.len(), + 1, + "exactly the one openable document must be returned; the malformed and \ + unsupported ones are skipped, not surfaced and not fatal" + ); + let only = &fetched[0]; + assert_eq!( + only.document_id, good_id, + "the surviving document is the valid one" + ); + assert_eq!( + only.payload.as_slice(), + PLAINTEXT, + "the decrypted plaintext must reach the caller intact" + ); + assert_eq!(only.version, VERSION_PROTOBUF); + assert_eq!(only.key_index, key_index); + assert_eq!(only.encryption_key_index, ENCRYPTION_KEY_INDEX); + assert_eq!(only.updated_at_ms, Some(1_700_000_000_000)); + } + + /// The same orchestration, on a wallet that holds its private keys in + /// process. + /// + /// The sibling case above runs the external-signable shape, where the key + /// comes from a resolved master. This one takes the OTHER branch of the + /// key-source dispatch: `ResidentWallet` derives from the wallet itself, so + /// a defect confined to that branch — a wrong wallet, a wrong network, a + /// derivation that silently disagrees with the master path — would not show + /// up in the master case at all. + #[tokio::test] + async fn a_resident_key_wallet_decrypts_its_own_document_through_the_fetch_path() { + use crate::wallet::identity::crypto::tx_metadata::{ + derive_tx_metadata_key, seal_tx_metadata, VERSION_PROTOBUF, + }; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + + let mut sdk = dash_sdk::SdkBuilder::new_mock() + .with_version(dpp::version::PlatformVersion::latest()) + .build() + .expect("mock sdk builds"); + let contract = Arc::new( + dpp::tests::fixtures::get_data_contract_fixture(None, 0, dpp::version::LATEST_VERSION) + .data_contract_owned(), + ); + let contract_id = contract.id(); + + let (wallet, identity_index, key_index) = + wallet_with_managed_identity(sdk.clone(), TEST_OWNER).await; + make_wallet_resident(&wallet).await; + + // Seal with the SAME resident wallet and network the reader resolves, + // so the blob is one this wallet genuinely owns. + const ENCRYPTION_KEY_INDEX: u32 = 4; + const PLAINTEXT: &[u8] = b"memo=resident;taxCategory=income"; + let network = wallet.identity().sdk.network; + let resident = { + let wm = wallet.identity().wallet_manager.read().await; + wm.get_wallet(&wallet.identity().wallet_id) + .expect("the wallet is registered") + .clone() + }; + let aes_key = derive_tx_metadata_key( + &resident, + network, + identity_index, + key_index, + ENCRYPTION_KEY_INDEX, + ) + .expect("a resident wallet derives its own txMetadata key in process"); + let iv = [0x7Bu8; 16]; + let blob = seal_tx_metadata(&aes_key, VERSION_PROTOBUF, &iv, PLAINTEXT).expect("seal"); + + let id = Identifier::from([0x44u8; 32]); + let mut page: drive_proof_verifier::types::Documents = Default::default(); + page.insert( + id, + Some(encrypted_document_at( + id, + 1_700_000_000_003, + key_index, + ENCRYPTION_KEY_INDEX, + blob, + )), + ); + + sdk.mock() + .expect_fetch(contract_id, Some((*contract).clone())) + .await + .expect("register the contract fetch"); + sdk.mock() + .expect_fetch_many( + expected_page_query(Arc::clone(&contract), &TEST_OWNER, None), + Some(page), + ) + .await + .expect("register the page"); + + let fetched = wallet + .identity() + .fetch_encrypted_documents(&TEST_OWNER, &contract_id, "txMetadata", 0) + .await + .expect("a resident-key wallet must decrypt its own document"); + + assert_eq!( + fetched.len(), + 1, + "the resident branch must return the document it can open; a silent \ + skip here would look identical to a bad document" + ); + let only = &fetched[0]; + assert_eq!(only.document_id, id); + assert_eq!( + only.payload.as_slice(), + PLAINTEXT, + "the decrypted plaintext must reach the caller intact" + ); + assert_eq!(only.version, VERSION_PROTOBUF); + assert_eq!(only.key_index, key_index); + assert_eq!(only.encryption_key_index, ENCRYPTION_KEY_INDEX); + assert_eq!(only.updated_at_ms, Some(1_700_000_000_003)); + } + + /// The authoritative seed path, end to end against a mocked Platform. + /// + /// This is the path that turns Drive's answer into the first index, and + /// every part of it can silently go wrong: a missed page under-counts, a + /// dropped un-materialized entry under-counts, and an off-by-one in the + /// formula collides with an existing document. All three failures produce a + /// plausible-looking index, so only counting real pages end to end pins it. + #[tokio::test] + async fn a_first_allocation_counts_every_raw_entry_across_pages() { + use dash_sdk::dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + + // Pin the protocol version so page two's registered wire encoding + // matches what the loop sends after the first response. + let mut sdk = dash_sdk::SdkBuilder::new_mock() + .with_version(dpp::version::PlatformVersion::latest()) + .build() + .expect("mock sdk builds"); + + let contract = Arc::new( + dpp::tests::fixtures::get_data_contract_fixture(None, 0, dpp::version::LATEST_VERSION) + .data_contract_owned(), + ); + let contract_id = contract.id(); + + // A full first page whose final entry is un-materialized — the shape a + // proved fetch returns for a document it could not produce, which still + // denotes an existing document and must still be counted. + const SHARED_TIMESTAMP: u64 = 1_700_000_000_000; + let page_one_ids: Vec = (0..PAGE_SIZE) + .map(|i| Identifier::from([(200 - i) as u8; 32])) + .collect(); + let mut page_one: drive_proof_verifier::types::Documents = Default::default(); + for (position, id) in page_one_ids.iter().enumerate() { + let is_final = position == PAGE_SIZE - 1; + page_one.insert( + *id, + if is_final { + None + } else { + Some(document_at(*id, SHARED_TIMESTAMP)) + }, + ); + } + let final_page_one_key = *page_one_ids.last().expect("page one is not empty"); + + let page_two_ids: Vec = (0..3) + .map(|i| Identifier::from([(50 - i) as u8; 32])) + .collect(); + let mut page_two: drive_proof_verifier::types::Documents = Default::default(); + for id in &page_two_ids { + page_two.insert(*id, Some(document_at(*id, SHARED_TIMESTAMP + 1))); + } + let expected_raw_count = PAGE_SIZE + page_two_ids.len(); + + sdk.mock() + .expect_fetch(contract_id, Some((*contract).clone())) + .await + .expect("register the contract fetch"); + sdk.mock() + .expect_fetch_many( + expected_page_query(Arc::clone(&contract), &TEST_OWNER, None), + Some(page_one), + ) + .await + .expect("register page one"); + sdk.mock() + .expect_fetch_many( + expected_page_query( + Arc::clone(&contract), + &TEST_OWNER, + Some(Start::StartAfter(final_page_one_key.to_buffer().to_vec())), + ), + Some(page_two), + ) + .await + .expect("register page two"); + + let manager = Arc::new(crate::PlatformWalletManager::new( + Arc::new(sdk), + Arc::new(NoopPersister), + Arc::new(NoopEventHandler) as Arc, + )); + let wallet = { + use key_wallet::mnemonic::{Language, Mnemonic}; + let seed = Mnemonic::from_entropy(&[0u8; 16], Language::English) + .expect("16 bytes of entropy") + .to_seed(""); + manager + .create_wallet_from_seed_bytes( + key_wallet::Network::Testnet, + &seed, + WalletAccountCreationOptions::None, + Some(0), + ) + .await + .expect("wallet creation on a mock sdk") + }; + + let first = wallet + .identity() + .allocate_encryption_key_index(&TEST_OWNER, &contract_id, "txMetadata", 0) + .await + .expect("the first allocation counts the owner's documents"); + assert_eq!( + first, + expected_raw_count as u32 + 1, + "the first index must be 1 + every raw entry Drive returned, across \ + both pages and including the un-materialized one" + ); + + // The second allocation continues in process. A re-seed would count the + // same documents again and hand out the same index twice. + let second = wallet + .identity() + .allocate_encryption_key_index(&TEST_OWNER, &contract_id, "txMetadata", 0) + .await + .expect("the second allocation continues from the high-water"); + assert_eq!( + second, + expected_raw_count as u32 + 2, + "a seeded scope must continue in process rather than re-count" ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index 3e6d7b8601..6179f25909 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -156,30 +156,43 @@ pub fn derive_ecdsa_identity_auth_keypair_from_master( key_index, )?; let secp = Secp256k1::new(); - // `ExtendedPrivKey` doesn't implement `Zeroize`, so we can't - // wrap it in `Zeroizing` directly — but its inner - // `secp256k1::SecretKey` does implement `Drop` with a memzero, - // so the secret scalar is scrubbed when `derived` falls out of - // scope. The surrounding `chain_code` / `depth` / - // `parent_fingerprint` / `child_number` are non-secret BIP-32 - // metadata; leaking them on the stack is a non-event. The - // returned `private_key` is wrapped in `Zeroizing` below so - // the 32-byte scalar copy crossing the function boundary is - // also scrubbed on the caller's drop. - let derived = master.derive_priv(&secp, &path).map_err(|e| { + // The pinned `ExtendedPrivKey` zeroizes itself on drop, while a bare + // `secp256k1::SecretKey` does not. Erase the derived scalar immediately + // after copying it into the `Zeroizing` value that crosses the function + // boundary, rather than retaining it until the enclosing value's scope + // ends. + let mut derived = master.derive_priv(&secp, &path).map_err(|e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to derive private key at (identity={identity_index}, key={key_index}): {e}" )) })?; let extended_pub = ExtendedPubKey::from_priv(&secp, &derived); + let private_key = take_and_erase_identity_secret(&mut derived.private_key); + Ok(DerivedIdentityAuthKey { derivation_path: path, - private_key: Zeroizing::new(derived.private_key.secret_bytes()), + private_key, public_key: extended_pub.public_key.serialize(), }) } +/// Copy a derived scalar into zeroizing storage and erase the source. +/// +/// `secp256k1::SecretKey` does not erase itself on drop, so the intermediate +/// scalar would otherwise outlive this call in the stack slot the derivation +/// wrote it to, while only the returned copy is scrubbed. +/// +/// Best-effort: the write removes that long-lived residue, but cannot reach a +/// register copy or one the optimizer already made. +fn take_and_erase_identity_secret( + secret: &mut dashcore::secp256k1::SecretKey, +) -> Zeroizing<[u8; 32]> { + let copy = Zeroizing::new(secret.secret_bytes()); + secret.non_secure_erase(); + copy +} + /// Derive the DIP-9 identity-authentication keypair at /// `(identity_index, key_index)` on `network`. /// @@ -323,13 +336,11 @@ pub struct IdentityWallet { /// signer-generic `PutDocument` trait) behind two by-value methods /// so the call sites stay simple. pub(crate) sdk_writer: Arc, - /// In-process, per-owner-identity high-water map for allocating the - /// txMetadata `encryptionKeyIndex` when the host omits it — the Rust-side - /// index-allocation policy (dashpay/platform#4186 follow-up). Shared across - /// every clone of this handle (an `Arc`), so two concurrent - /// encrypted-document creates through the SAME wallet process serialize - /// under its mutex and can never pick the same index. Best-effort unique - /// PER DEVICE only; see + /// In-process high-water map for allocating the txMetadata + /// `encryptionKeyIndex` when the host omits it. Shared across every clone of + /// this handle (an `Arc`), so two concurrent encrypted-document creates + /// through the same wallet process serialize through it and can never pick + /// the same index. Best-effort unique PER DEVICE only; see /// [`IdentityWallet::allocate_encryption_key_index`](crate::wallet::identity::IdentityWallet::allocate_encryption_key_index). pub(crate) enc_key_index_allocator: EncryptionKeyIndexAllocator, } @@ -483,6 +494,36 @@ mod tests { use key_wallet::wallet::Wallet; use key_wallet::Network; + /// The intermediate derived scalar is erased once its bytes are copied. + /// + /// `secp256k1::SecretKey` does not erase itself on drop, so without the + /// explicit erase the identity-auth scalar stays in the stack slot the + /// derivation wrote it to after the call returns, while only the returned + /// copy is scrubbed. Removing the erase changes nothing a caller can see, so + /// this is what makes it fail. + #[test] + fn the_derived_identity_scalar_is_erased_after_its_bytes_are_copied() { + use dashcore::secp256k1::SecretKey; + + let mut secret = SecretKey::from_slice(&[0x2Au8; 32]).expect("valid scalar"); + let original = secret.secret_bytes(); + assert_ne!(original, [0u8; 32], "the fixture must be a real scalar"); + + let copied = take_and_erase_identity_secret(&mut secret); + + assert_eq!( + *copied, original, + "the caller's copy must be the scalar that was derived" + ); + assert_ne!( + secret.secret_bytes(), + original, + "the source scalar must not still hold the key after the copy; \ + secp256k1::SecretKey does not erase on drop, so leaving it intact \ + leaves key material in the stack slot the derivation wrote it to" + ); + } + /// English BIP-39 test vector (all-zero entropy). Same fixture the /// FFI-side derive tests use, so the derivations here can be /// cross-checked against those if a regression ever appears on one diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 523f2b1ac0..7f4e098d7e 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -456,9 +456,9 @@ impl PlatformWallet { sdk_writer: Arc::new( crate::wallet::identity::network::sdk_writer::SdkWriter::new(Arc::clone(&sdk)), ), - // Fresh, empty allocator: encryptionKeyIndex high-water is seeded - // lazily per owner-identity from Platform state on the first - // host-omitted create (dashpay/platform#4186 follow-up). + // Fresh, empty allocator: the encryptionKeyIndex high-water is + // seeded lazily per scope from Platform state on the first + // host-omitted create. enc_key_index_allocator: Arc::new(tokio::sync::Mutex::new( std::collections::HashMap::new(), )), diff --git a/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java b/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java index 1eac7ecb8e..8b63bb29e9 100644 --- a/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java +++ b/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java @@ -11,9 +11,8 @@ * This tool makes that assertion INDEPENDENTLY REPRODUCIBLE: it drives the * REAL `org.bitcoinj.wallet.DerivationPathFactory` (the same class the legacy * dash-sdk-kotlin identity-key chain uses) and compares its output to the - * hand-built path, so a maintainer can confirm the wire-compat anchor without - * trusting either this repo's prose or an AI agent's word - * (dashpay/platform#4091). + * hand-built path, so a maintainer can confirm the wire-compat anchor by + * running checked-in code rather than by trusting this repo's prose. * * Empirically (dashj-core 22.0.3, Testnet): * noArg blockchainIdentityECDSADerivationPath() = m/9'/1'/5'/0'/0'/0' (6 components) diff --git a/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md b/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md index 1d23b21aa6..036e5a8f00 100644 --- a/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md +++ b/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md @@ -3,20 +3,18 @@ The hard-coded wire-compat vectors in `src/wallet/identity/crypto/tx_metadata.rs` come from two independent sources: -- **A real legacy dash-wallet INSTALL** (the strongest check — - dashpay/platform#4186, reviewer shumkov's "decrypt a blob produced by a real - legacy dash-wallet install" ask): `legacy_install_yabba2_wire_compat_vector`. - Its blob was NOT generated by this repo — a stock **dash-wallet 11.9** Android - install (shipping dashj crypto path) registered DPNS username `yabba2` on - testnet, did a send + receive, saved metadata, and published one encrypted - `txMetadata` document to Platform. The testnet-gated helper - `capture_legacy_yabba2_txmetadata_blobs` in `tests/txmetadata_fetch.rs` - fetched it back (identity `ESR1nfF3bj4TR2ZkLmDuSeu6r7VzpTurYi47BV6XwsoP`, - `keyIndex = 2`, `encryptionKeyIndex = 1`, version 1/protobuf) and the new Rust - crypto decrypted it to its real protobuf `TxMetadataBatch` plaintext (two - items, memos `"username"`/`"faucet"`, USD exchange rates). The wallet is a - designated throwaway; its recovery phrase is public by intent. This vector - needs no JVM tooling — it is checked in from the captured bytes. +- **A real legacy dash-wallet INSTALL** (the strongest check): + `legacy_install_yabba2_wire_compat_vector`. Its blob was NOT generated by this + repo — a stock **dash-wallet 11.9** Android install (shipping dashj crypto + path) registered DPNS username `yabba2` on testnet, did a send and a receive, + saved metadata, and published one encrypted `txMetadata` document to Platform + under identity `ESR1nfF3bj4TR2ZkLmDuSeu6r7VzpTurYi47BV6XwsoP` (`keyIndex = 2`, + `encryptionKeyIndex = 1`, version 1/protobuf). That document was fetched from + testnet once and the Rust crypto decrypted it to its real protobuf + `TxMetadataBatch` plaintext (two items, memos `"username"`/`"faucet"`, USD + exchange rates). The wallet is a testnet-only throwaway used solely for this + fixture. This vector needs no JVM tooling and no network — it is checked in + from the captured bytes. - **JVM-generated dashj-core vectors** — the two checked-in JVM tools below back the other two hard-coded vectors @@ -32,7 +30,7 @@ The hard-coded wire-compat vectors in REAL `org.bitcoinj.wallet.DerivationPathFactory` and confirms that `LegacyKeyN`'s hand-built account path equals the factory's output at identityIndex 0, so the wire-compat anchor is independently reproducible from - checked-in code — not just asserted in prose (dashpay/platform#4091). + checked-in code — not just asserted in prose. ## What each vector proves (and what it does NOT) diff --git a/packages/rs-platform-wallet/tests/txmetadata_fetch.rs b/packages/rs-platform-wallet/tests/txmetadata_fetch.rs index dd3885ebc4..725184bf8d 100644 --- a/packages/rs-platform-wallet/tests/txmetadata_fetch.rs +++ b/packages/rs-platform-wallet/tests/txmetadata_fetch.rs @@ -1,20 +1,24 @@ -//! Testnet integration test for the encrypted `txMetadata` FETCH path -//! (dashpay/platform#4087). Runs the EXACT production query +//! Testnet integration test for the encrypted `txMetadata` FETCH path. +//! +//! Runs the EXACT production query //! ([`platform_wallet::query_owned_encrypted_documents`], the network half of //! `IdentityWallet::fetch_encrypted_documents`) against a real testnet identity -//! that has two legacy-written encrypted `txMetadata` documents, and asserts the -//! query returns both with the expected `keyIndex` / `encryptionKeyIndex` / -//! `encryptedMetadata` fields. +//! that contains two known legacy-written encrypted `txMetadata` documents, and +//! asserts the query returns both with the expected `keyIndex` / +//! `encryptionKeyIndex` / `encryptedMetadata` fields. The public identity may +//! accumulate unrelated newer documents; those do not change the fixture. +//! +//! This pins the wire query so a regression in the where-clause, order-by or +//! encoding is caught here rather than only on-device. The test is `#[ignore]`d +//! because it hits live testnet: it is a MANUAL, testnet-gated gate, not part of +//! the default `cargo test` or CI run, and no scheduled job runs `--ignored`. +//! Treat it as a local / pre-release regression check. //! -//! This pins the wire query so a regression in the where-clause / order-by / -//! encoding is caught by this check rather than only on-device. NOTE: the test -//! is `#[ignore]`d because it hits live testnet, so it is a MANUAL, testnet- -//! gated check — run it explicitly with `--ignored` (see below). It is NOT part -//! of the default `cargo test` / CI run, and no scheduled job runs `--ignored` -//! today; treat it as a local / pre-release regression gate. The -//! DECRYPT half is not exercised here — it needs the owner's mnemonic — but the -//! per-document field extraction that feeds decrypt IS asserted, proving the -//! pipeline reaches the decrypt step for both documents. +//! The DECRYPT half is not exercised here — it would need the owner's mnemonic, +//! which must not live in this repository — but the per-document field +//! extraction that feeds decrypt IS asserted, proving the pipeline reaches the +//! decrypt step for both documents. The network-free decrypt coverage lives in +//! the unit tests beside the crypto itself. //! //! # Running //! ```bash @@ -36,12 +40,19 @@ use key_wallet::Network; use platform_wallet::query_owned_encrypted_documents; use rs_sdk_trusted_context_provider::TrustedHttpContextProvider; -/// Testnet identity that owns the two legacy-written encrypted `txMetadata` +/// Testnet identity that owns the known legacy-written encrypted `txMetadata` /// documents (base58). const OWNER_B58: &str = "532rVHxLD6Z3MNiu5LZyNqn55Ybz4bydZozXU4cqqp1L"; /// The wallet-utils system data contract (base58) — its `txMetadata` type. const CONTRACT_B58: &str = "7CSFGeF4WNzgDmx94zwvHkYaG3Dx4XEe5LFsFgJswLbm"; const DOC_TYPE: &str = "txMetadata"; +/// Documents captured from the legacy Android writer before this SDK path +/// existed. Their ids make the live check stable as the public identity's +/// history grows. +const LEGACY_DOCUMENT_IDS_B58: [&str; 2] = [ + "CEfcKQVb5vw6Fv5K7p3W85LDLmbdfQHAt4vFD1v37BSk", + "9FVM3CDx9JFQ3Xs1fMvgNsPGaDqXqmWSTWT43M81ohq2", +]; async fn testnet_sdk() -> Arc { let provider = @@ -69,8 +80,7 @@ async fn fetch_returns_both_legacy_txmetadata_documents() { .expect("wallet-utils contract present on testnet"); // Production parity (`IdentityWallet::fetch_encrypted_documents`): // register the fetched contract with the trusted context provider before - // the query, exactly as the on-device path does. With this line the repro - // is config-identical to the device call: `SdkBuilder::new_testnet()` + + // the query, exactly as the wallet path does: `SdkBuilder::new_testnet()` + // `TrustedHttpContextProvider::new(Testnet, None, 100)`, proofs on // (builder default), platform version auto (0), since_ms = 0. { @@ -81,24 +91,26 @@ async fn fetch_returns_both_legacy_txmetadata_documents() { } let contract = Arc::new(contract); - // The exact production query (since_ms = 0 => fetch everything, as the - // decrypt-proof probe does). + // The exact production query (`since_ms = 0` requests the full history). let docs = query_owned_encrypted_documents(&sdk, Arc::clone(&contract), &owner, DOC_TYPE, 0) .await .expect("query owned encrypted documents"); let materialized: Vec<_> = docs.iter().filter_map(|(_, d)| d.as_ref()).collect(); - assert_eq!( - materialized.len(), - 2, - "expected 2 legacy-written txMetadata documents for {OWNER_B58}, got {} (raw entries: {})", - materialized.len(), - docs.len() - ); - // Every document must expose the fields the decrypt step consumes: - // integer keyIndex/encryptionKeyIndex and a byte-array encryptedMetadata. - for doc in materialized { + // Each captured legacy document must still be present and expose the fields + // the decrypt step consumes: integer keyIndex/encryptionKeyIndex and a + // byte-array encryptedMetadata. + for expected_id in LEGACY_DOCUMENT_IDS_B58 { + let doc = materialized + .iter() + .find(|doc| doc.id().to_string(Encoding::Base58) == expected_id) + .unwrap_or_else(|| { + panic!( + "legacy txMetadata document {expected_id} was absent from {} raw entries", + docs.len() + ) + }); let key_index = doc .properties() .get("keyIndex") @@ -116,7 +128,7 @@ async fn fetch_returns_both_legacy_txmetadata_documents() { .map(|b| b.len()) .expect("encryptedMetadata is a byte array"); - // These identities' documents were written by the Android wallet with + // These documents were written by the Android wallet with // the ENCRYPTION/MEDIUM key (id 2); the blob is version(1)+IV(16)+CBC. assert_eq!(key_index, 2, "keyIndex should be the ENCRYPTION key id"); assert!(encryption_key_index >= 1, "encryptionKeyIndex is 1-based"); @@ -126,141 +138,3 @@ async fn fetch_returns_both_legacy_txmetadata_documents() { ); } } - -/// Independent legacy-install capture SCAFFOLDING (dashpay/platform#4186, -/// reviewer shumkov's "decrypt a blob produced by a real legacy dash-wallet -/// install" ask). This is a MANUAL, testnet-gated helper — it resolves the -/// throwaway wallet's DPNS name `yabba2` to its identity id, fetches every -/// `txMetadata` document that identity owns, derives the tx-metadata key from -/// the wallet's recovery phrase with THIS branch's own derivation -/// (`derive_tx_metadata_key`, identity_index 0), opens each blob, and prints the -/// blob hex + key indices + decrypted plaintext so the captured values can be -/// hard-coded into the network-free fixture -/// `legacy_install_yabba2_wire_compat_vector` in -/// `src/wallet/identity/crypto/tx_metadata.rs`. -/// -/// The wallet is a DESIGNATED THROWAWAY provided for this fixture; its recovery -/// phrase is intended to become public in the repo. -/// -/// # Running -/// ```bash -/// cargo test -p platform-wallet --test txmetadata_fetch \ -/// capture_legacy_yabba2 -- --ignored --nocapture -/// ``` -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "hits testnet"] -async fn capture_legacy_yabba2_txmetadata_blobs() { - use key_wallet::mnemonic::{Language, Mnemonic}; - use key_wallet::wallet::initialization::WalletAccountCreationOptions; - use key_wallet::wallet::Wallet; - use platform_wallet::wallet::identity::crypto::tx_metadata::{ - derive_tx_metadata_key, open_tx_metadata, - }; - - let _ = tracing_subscriber::fmt().with_env_filter("info").try_init(); - - // The DESIGNATED THROWAWAY testnet wallet the legacy dash-wallet 11.9 install - // ran under (public by design for this fixture). - const YABBA2_PHRASE: &str = - "across jungle only rocket promote mule behave siren crush pole awful deposit"; - const DPNS_NAME: &str = "yabba2"; - - let sdk = testnet_sdk().await; - - // 1. Resolve the DPNS name -> identity id (the SDK's own resolver). - let owner = sdk - .resolve_dpns_name(DPNS_NAME) - .await - .expect("resolve_dpns_name call") - .expect("DPNS name yabba2 resolves to an identity"); - println!( - "RESOLVED yabba2 -> identity {}", - owner.to_string(Encoding::Base58) - ); - - // 2. Fetch + register the wallet-utils contract (production parity). - let contract_id = Identifier::from_string(CONTRACT_B58, Encoding::Base58).expect("contract id"); - let contract = DataContract::fetch(&sdk, contract_id) - .await - .expect("fetch contract") - .expect("wallet-utils contract present on testnet"); - { - use dash_sdk::platform::ContextProvider; - if let Some(provider) = sdk.context_provider() { - provider.register_data_contract(Arc::new(contract.clone())); - } - } - let contract = Arc::new(contract); - - // 3. The exact production query (since_ms = 0 => fetch everything). - let docs = query_owned_encrypted_documents(&sdk, Arc::clone(&contract), &owner, DOC_TYPE, 0) - .await - .expect("query owned encrypted documents"); - let materialized: Vec<_> = docs.iter().filter_map(|(_, d)| d.as_ref()).collect(); - println!( - "FETCHED {} txMetadata document(s) (raw entries: {}) for identity {}", - materialized.len(), - docs.len(), - owner.to_string(Encoding::Base58) - ); - if materialized.is_empty() { - println!( - "ZERO documents. Queried contract={CONTRACT_B58} type={DOC_TYPE} owner={} since_ms=0", - owner.to_string(Encoding::Base58) - ); - return; - } - - // 4. Derive keys from the wallet's recovery phrase with the branch's own - // derivation (identity_index 0 — the only slot a legacy wallet writes). - let wallet = Wallet::from_mnemonic( - Mnemonic::from_phrase(YABBA2_PHRASE, Language::English).expect("valid recovery phrase"), - Network::Testnet, - WalletAccountCreationOptions::None, - ) - .expect("wallet from recovery phrase"); - - // 5. Decrypt each document with the new Rust open path and print the capture. - for (i, doc) in materialized.iter().enumerate() { - let props = doc.properties(); - let key_index = props - .get("keyIndex") - .and_then(|v: &Value| v.to_integer::().ok()) - .expect("keyIndex is a u32"); - let encryption_key_index = props - .get("encryptionKeyIndex") - .and_then(|v: &Value| v.to_integer::().ok()) - .expect("encryptionKeyIndex is a u32"); - let blob = props - .get("encryptedMetadata") - .and_then(|v: &Value| v.to_binary_bytes().ok()) - .expect("encryptedMetadata is a byte array"); - let created_at = doc.created_at(); - let updated_at = doc.updated_at(); - - let aes_key = derive_tx_metadata_key( - &wallet, - Network::Testnet, - 0, - key_index, - encryption_key_index, - ) - .expect("derive txMetadata key at identity_index 0"); - let opened = open_tx_metadata(&aes_key, &blob).expect("open legacy blob"); - - println!("---- DOCUMENT {i} ----"); - println!("keyIndex = {key_index}"); - println!("encryptionKeyIndex = {encryption_key_index}"); - println!("createdAt = {created_at:?}"); - println!("updatedAt = {updated_at:?}"); - println!("blob_len = {}", blob.len()); - println!("BLOB_HEX = {}", hex::encode(&blob)); - println!("version = {}", opened.version); - println!("plaintext_len = {}", opened.payload.len()); - println!("PLAINTEXT_HEX = {}", hex::encode(&opened.payload)); - println!( - "PLAINTEXT_UTF8_LOSSY= {}", - String::from_utf8_lossy(&opened.payload) - ); - } -} diff --git a/packages/rs-unified-sdk-jni/src/support.rs b/packages/rs-unified-sdk-jni/src/support.rs index ce07d0c7a4..aa5f58eaba 100644 --- a/packages/rs-unified-sdk-jni/src/support.rs +++ b/packages/rs-unified-sdk-jni/src/support.rs @@ -75,14 +75,17 @@ pub fn take_pwffi_error(env: &mut JNIEnv, mut result: PlatformWalletFFIResult) - .to_string_lossy() .into_owned() }; - // Diagnostic breadcrumb (warn-level so it provably reaches logcat): the - // raw platform-wallet code, the offset code Kotlin will see, and the full - // message — visible even when the Kotlin caller contains the exception. + // Diagnostic breadcrumb (warn-level so it provably reaches logcat): the raw + // platform-wallet code and the offset code Kotlin will see. The message + // itself is NOT logged — it is an unbounded native string that can carry + // caller-supplied text, query shapes or contract internals, and a device log + // is readable by any process holding the log permission and is captured in + // bug reports. The caller still receives it on the exception, so nothing is + // lost; the two codes are enough to line a report up against either side of + // the mapping. log::warn!( - "take_pwffi_error: platform-wallet code {} (thrown as DashSDKException code {}): {}", - result.code as i32, - result.code as i32 + PWFFI_CODE_OFFSET, - message + "{}", + platform_wallet_error_breadcrumb(result.code as i32, &message) ); throw_sdk_exception(env, result.code as i32 + PWFFI_CODE_OFFSET, &message); // SAFETY: `result` is a fresh PlatformWalletFFIResult; free its message. @@ -90,6 +93,37 @@ pub fn take_pwffi_error(env: &mut JNIEnv, mut result: PlatformWalletFFIResult) - true } +/// The breadcrumb recorded when a platform-wallet result is converted into a +/// Kotlin exception. +/// +/// Records the raw platform-wallet code and the offset code the caller will see, +/// and deliberately renders neither the message nor anything derived from it. +/// The message is an unbounded native string that can carry caller-supplied +/// text, query shapes or contract internals; a device log is readable by any +/// process holding the log permission and is captured in bug reports. The +/// caller still receives the message on the exception itself, so keeping both +/// codes is enough to line a report up against either side of the mapping +/// without carrying anything unbounded. +pub(crate) fn platform_wallet_error_breadcrumb( + platform_wallet_code: i32, + _message: &str, +) -> String { + format!( + "take_pwffi_error: platform_wallet_code={} thrown_code={}", + platform_wallet_code, + platform_wallet_code + PWFFI_CODE_OFFSET + ) +} + +/// The breadcrumb recorded when an exception is thrown to Kotlin. +/// +/// Same reasoning as [`platform_wallet_error_breadcrumb`]: the message reaches +/// the caller on the exception, so the log records which error was raised +/// rather than what it said. +pub(crate) fn thrown_exception_breadcrumb(code: i32, _message: &str) -> String { + format!("throw_sdk_exception: code={code}") +} + /// The process-wide JVM, cached in [`crate::JNI_OnLoad`]. Callback /// trampolines use this to attach Tokio worker threads. pub static JVM: OnceLock = OnceLock::new(); @@ -103,8 +137,9 @@ pub const SDK_EXCEPTION_CLASS: &str = "org/dashfoundation/dashsdk/ffi/DashSDKExc pub fn throw_sdk_exception(env: &mut JNIEnv, code: i32, message: &str) { // Diagnostic breadcrumb (warn-level so it provably reaches logcat): every // native→Kotlin error conversion is visible even when the Kotlin caller - // contains the exception into a status line. - log::warn!("throw_sdk_exception: code={code} message={message}"); + // contains the exception into a status line. Only the code is recorded — + // see [`platform_wallet_error_breadcrumb`] for why the message is not. + log::warn!("{}", thrown_exception_breadcrumb(code, message)); // If an exception is already pending we must not call further JNI // functions that would themselves throw. if env.exception_check().unwrap_or(false) { @@ -147,9 +182,64 @@ pub fn guard(env: &mut JNIEnv, default: T, f: impl FnOnce(&mut JNIEnv) -> T) #[cfg(test)] mod tests { - use super::{generic_asset_lock_recovery_allowed, net_from_ord}; + use super::{ + generic_asset_lock_recovery_allowed, net_from_ord, platform_wallet_error_breadcrumb, + thrown_exception_breadcrumb, PWFFI_CODE_OFFSET, + }; use dash_network::ffi::FFINetwork; + /// A message shaped like the worst thing a native error can carry: a marker + /// standing in for caller-supplied or contract-internal text, and an + /// embedded newline that would forge an additional log line. + const HOSTILE_MESSAGE: &str = + "failed for ownerId 5Dc…\nFORGED WARN line s3cr3t-marker-do-not-log"; + const MARKER: &str = "s3cr3t-marker-do-not-log"; + + /// The breadcrumb that accompanies every native→Kotlin error conversion + /// records the two codes and nothing from the message. + /// + /// The message is unbounded and can carry caller-supplied text, query shapes + /// or contract internals; the caller still receives it on the exception, so + /// nothing is lost by keeping it out of a device log. + #[test] + fn the_platform_wallet_error_breadcrumb_records_codes_and_never_the_message() { + let line = platform_wallet_error_breadcrumb(6, HOSTILE_MESSAGE); + + assert!( + !line.contains(MARKER), + "the message body must never reach the log: {line}" + ); + assert!( + !line.contains('\n'), + "an embedded newline would let an error body forge further log lines: {line}" + ); + assert!( + line.contains("platform_wallet_code=6"), + "the raw platform-wallet code must be recorded: {line}" + ); + assert!( + line.contains(&format!("thrown_code={}", 6 + PWFFI_CODE_OFFSET)), + "the offset code the caller will see must be recorded so a report can \ + be lined up against either side of the mapping: {line}" + ); + } + + /// Same contract on the throw path, which every JNI export reaches. + #[test] + fn the_thrown_exception_breadcrumb_records_the_code_and_never_the_message() { + let line = thrown_exception_breadcrumb(1042, HOSTILE_MESSAGE); + + assert!( + !line.contains(MARKER), + "the message body must never reach the log: {line}" + ); + assert!(!line.contains('\n'), "no forged log lines: {line}"); + assert_eq!( + line, "throw_sdk_exception: code=1042", + "the breadcrumb is the stage label plus the numeric code, nothing else" + ); + } + #[test] fn generic_asset_lock_recovery_rejects_invitation_authority() { assert!(generic_asset_lock_recovery_allowed(false)); diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 3f123c142c..2084d3eb0b 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -80,6 +80,42 @@ impl Drop for SensitivePlatformWalletString { } } +/// Nullable owner for ORDINARY strings returned by +/// `create_encrypted_document_with_deferred_payload`. +/// +/// The create output is the confirmed document's canonical JSON — ciphertext +/// and metadata, no plaintext — so it is released with the ordinary free, not +/// the sensitive one. The two contracts are deliberately distinct types so a +/// call site cannot pair an allocation with the wrong release function. +/// +/// Install this immediately after the FFI call transfers ownership, so every +/// later result check, null check, JNI allocation failure and unwind releases +/// the allocation exactly once. +struct OrdinaryPlatformWalletString(*mut c_char); + +impl OrdinaryPlatformWalletString { + fn as_c_str(&self) -> Option<&CStr> { + if self.0.is_null() { + None + } else { + // SAFETY: a non-null pointer came from the platform-wallet FFI + // CString result and remains owned by this guard. + Some(unsafe { CStr::from_ptr(self.0) }) + } + } +} + +impl Drop for OrdinaryPlatformWalletString { + fn drop(&mut self) { + // SAFETY: this guard is the sole owner of the nullable pointer, and the + // create contract names the ordinary free as its release function. The + // ordinary free is null-safe. + unsafe { + platform_wallet_ffi::platform_wallet_string_free(self.0); + } + } +} + /// Copy an ASCII C string directly into a JVM string without constructing /// jni-rs's intermediate owned `JNIString`. /// @@ -813,8 +849,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do /// Create + broadcast an ENCRYPTED wallet-contract document (the wire- /// compatible `txMetadata` shape) — the JNI bridge over -/// `platform_wallet_create_encrypted_document_with_signer` and its -/// Rust-allocated-index sibling. +/// the Rust-ABI composite +/// `create_encrypted_document_with_deferred_payload`. /// /// The SDK derives the identity encryption key, seals `payload` into the /// legacy `version ‖ IV ‖ AES-256-CBC` blob, and writes @@ -822,13 +858,19 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do /// version byte (`1` = protobuf); `payload` is the already-serialized opaque /// plaintext (a protobuf `TxMetadataBatch`) — the SDK does not parse it. /// -/// `encryption_key_index` carries the per-document index OR the `-1` sentinel -/// (dashpay/platform#4186 follow-up): a non-negative value is used verbatim -/// (routed to the explicit-index export, retained for migration / tests), while -/// `-1` means "let the SDK allocate the index from authoritative Platform state" -/// and routes to `platform_wallet_create_encrypted_document_with_signer_auto_index`. -/// Any value `< -1` is rejected. Returns the confirmed document's canonical JSON -/// (its 32-byte id is the base58 `$id` field); null after throwing on error. +/// `encryption_key_index` carries the per-document index OR the `-1` sentinel: +/// a non-negative value is used verbatim (retained for migration / tests), while +/// `-1` means "let the SDK allocate the index from authoritative Platform +/// state". Any value `< -1` is rejected. +/// +/// Both shapes enter one Rust-owned operation. It settles the index BEFORE it +/// invokes JNI's deferred callback to copy the caller's `byte[]` into native +/// memory. A JVM array cannot be pinned across the automatic-index query, so the +/// callback returns an owned zeroizing copy only after that query completes. +/// Rust scrubs the copy as soon as the properties are sealed, before broadcast. +/// This helper has Rust ABI only and adds no C symbol. Returns the confirmed +/// document's canonical JSON (its 32-byte id is the base58 `$id` field); null +/// after throwing on error. #[no_mangle] #[allow(clippy::too_many_arguments)] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_documentCreateEncrypted( @@ -854,42 +896,22 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do let Some(doc_type) = read_cstring(env, &document_type, "documentType") else { return ptr::null_mut(); }; - // encryptionKeyIndex == -1 is the "let Rust allocate" sentinel - // (dashpay/platform#4186 follow-up): the host omits the index and the - // SDK derives the next one from Platform state. A non-negative value is - // an explicit caller-supplied index; anything below -1 is invalid. - if encryption_key_index < -1 { - throw_sdk_exception( - env, - 1, - "encryptionKeyIndex must be >= 0, or -1 to let the SDK allocate it", - ); - return ptr::null_mut(); - } - let auto_index = encryption_key_index == -1; - // Only 0 (CBOR) and 1 (protobuf) are wire-decodable by the legacy dashj - // decryptTxMetadata; anything else seals a document the legacy stack - // can't read. Fail fast here with the correct bound instead of the stale - // 0..=255 range. The Rust core `seal_tx_metadata` enforces the same - // invariant as the last line of defense. - if !(0..=1).contains(&version) { - throw_sdk_exception( - env, - 1, - "version must be 0 (CBOR) or 1 (protobuf) — the only wire-decodable txMetadata versions", - ); - return ptr::null_mut(); - } - // The JNI-owned plaintext copy. Wrapped in `Zeroizing` so it is scrubbed - // on drop, mirroring the inner FFI copy (`payload_vec` in - // `rs-platform-wallet-ffi/src/document.rs`). The inner copy is dropped - // before its broadcast `.await`; from here the whole broadcast happens - // synchronously *inside* the single FFI call below, so the earliest this - // buffer can be released is the instant that call returns — dropped - // explicitly there rather than left to linger (unscrubbed) to end of - // scope. This is the only plaintext copy in this function. - let payload_bytes = match env.convert_byte_array(&payload) { - Ok(b) => zeroize::Zeroizing::new(b), + // Narrow the Java-signed arguments to the widths the C ABI takes. + // Anything representable is handed to Rust, which owns the protocol + // policy; only values with no representation stop here. + let validated = match encrypted_create_preflight(encryption_key_index, version) { + Ok(validated) => validated, + Err(error) => { + throw_sdk_exception(env, 1, &error.to_string()); + return ptr::null_mut(); + } + }; + + // Read the DECLARED length from the array header — no copy — so the + // shared size policy can reject an over-large batch before any plaintext + // moves and before any network work. + let payload_len = match env.get_array_length(&payload) { + Ok(len) => len as usize, Err(_) => { let _ = env.exception_clear(); throw_sdk_exception(env, 1, "payload byte[] was null/invalid"); @@ -897,65 +919,62 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do } }; + let encryption_key_index = match validated.encryption_key_index { + EncryptionKeyIndexRequest::Explicit(index) => Some(index), + EncryptionKeyIndexRequest::Allocate => None, + }; + let mut out_id = [0u8; 32]; let mut out_json: *mut c_char = ptr::null_mut(); - let result = if auto_index { - // Host omitted the index: route to the ABI-additive sibling that - // takes no encryptionKeyIndex and lets Rust allocate it. - unsafe { - platform_wallet_ffi::platform_wallet_create_encrypted_document_with_signer_auto_index( - wallet_handle as Handle, - mnemonic_resolver_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, - owner.as_ptr(), - contract.as_ptr(), - doc_type.as_ptr(), - version as u8, - payload_bytes.as_ptr(), - payload_bytes.len(), - signer_handle as *mut SignerHandle, - out_id.as_mut_ptr(), - &mut out_json as *mut *mut c_char, - ) - } - } else { - unsafe { - platform_wallet_ffi::platform_wallet_create_encrypted_document_with_signer( - wallet_handle as Handle, - mnemonic_resolver_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, - owner.as_ptr(), - contract.as_ptr(), - doc_type.as_ptr(), - encryption_key_index as u32, - version as u8, - payload_bytes.as_ptr(), - payload_bytes.len(), - signer_handle as *mut SignerHandle, - out_id.as_mut_ptr(), - &mut out_json as *mut *mut c_char, - ) - } + // One Rust-owned composite performs preflight, index allocation and + // create. JNI supplies a deferred materializer, so Rust invokes the JVM + // copy exactly once and only after an automatic index query has + // completed. The returned native copy moves straight into zeroizing + // preparation and is scrubbed before broadcast. The caller's original + // JVM ByteArray remains runtime-managed and cannot be scrubbed here. + let result = unsafe { + platform_wallet_ffi::create_encrypted_document_with_deferred_payload( + wallet_handle as Handle, + mnemonic_resolver_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, + owner.as_ptr(), + contract.as_ptr(), + doc_type.as_ptr(), + encryption_key_index, + validated.version, + payload_len, + || match env.convert_byte_array(&payload) { + Ok(bytes) => Ok(zeroize::Zeroizing::new(bytes)), + Err(_) => { + let _ = env.exception_clear(); + Err(platform_wallet_ffi::PlatformWalletFFIResult::err( + platform_wallet_ffi::PlatformWalletFFIResultCode::ErrorInvalidParameter, + "payload byte[] was null/invalid", + )) + } + }, + signer_handle as *mut SignerHandle, + out_id.as_mut_ptr(), + &mut out_json as *mut *mut c_char, + ) }; - // The FFI call has returned: the plaintext has been sealed into - // ciphertext and the broadcast has already completed. Scrub this copy - // now (as soon as possible), before result/JSON handling. - drop(payload_bytes); + // Ownership of the canonical JSON has transferred; install the guard + // before any result, null or JNI-allocation handling so every later + // path — success, early return, or unwind — releases it exactly once + // through the ordinary free. + let out_json = OrdinaryPlatformWalletString(out_json); if take_pwffi_error(env, result) { return ptr::null_mut(); } - if out_json.is_null() { + let Some(json) = out_json.as_c_str() else { throw_sdk_exception( env, 99, "encrypted document create returned success but no canonical JSON", ); return ptr::null_mut(); - } - let json = unsafe { CStr::from_ptr(out_json) } - .to_string_lossy() - .into_owned(); - unsafe { platform_wallet_ffi::platform_wallet_string_free(out_json) }; + }; - env.new_string(json) + env.new_string(json.to_string_lossy()) .map(|s| s.into_raw()) .unwrap_or(ptr::null_mut()) }) @@ -970,7 +989,14 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do /// `{ "id", "ownerId" (base58), "keyIndex", "encryptionKeyIndex", "version", /// "updatedAt" (u64|null), "payload" (base64 of the decrypted opaque plaintext)}`. /// The caller parses each `payload` itself (a protobuf `TxMetadataBatch` for -/// `version == 1`). Documents that can't be decrypted are skipped Rust-side. +/// `version == 1`). Documents that can't be decrypted, and documents carrying +/// an unsupported wire version, are skipped Rust-side. +/// +/// A returned `payload` is NOT authenticated: the envelope is AES-256-CBC with +/// PKCS7 and no integrity tag, so a wrong key or modified ciphertext usually +/// fails the unpad and is skipped, but can occasionally unpad cleanly and +/// surface opaque garbage. Parse each payload strictly and discard what does +/// not parse. /// SDK-owned native plaintext allocations are zeroized before release; the /// returned JVM string remains runtime-managed and cannot be reliably wiped. /// Null after throwing on error. @@ -987,18 +1013,21 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do ) -> jstring { guard(&mut env, ptr::null_mut(), |env| { // Informational stage breadcrumbs are DEBUG; only genuine failure paths - // are WARN. The `sdkFetched=0` root cause is fixed (external-signable - // txMetadata derive), so these no longer need to be loud. Android visibility: `JNI_OnLoad` installs `android_logger` at - // `LevelFilter::Info`, so DEBUG lines stay OUT of on-device logcat while - // WARN error lines remain visible. NEVER log a raw handle value: only + // are WARN. `JNI_OnLoad` installs Android logging at `LevelFilter::Info`, + // so routine sync stages stay out of on-device logcat while failure + // lines remain visible. NEVER log a raw handle value: only // whether each handle is nonzero — `mnemonic_resolver_handle` is a live // `*mut MnemonicResolverHandle`, so `{:#x}` would leak a heap pointer. + // `sinceMs` is deliberately NOT rendered: it is caller-controlled and a + // timestamp correlates a device to when it last synced, which a device + // log readable by any process holding the log permission — and captured + // in bug reports — must not carry. Handle presence is a boolean and + // reveals nothing about the caller. log::debug!( "documentFetchEncrypted: entry wallet_handle_nonzero={} \ - mnemonic_resolver_handle_nonzero={} since_ms={}", + mnemonic_resolver_handle_nonzero={}", wallet_handle != 0, - mnemonic_resolver_handle != 0, - since_ms + mnemonic_resolver_handle != 0 ); let Some(owner) = read_id32(env, &owner_id, "ownerId") else { log::warn!("documentFetchEncrypted: ownerId byte[] invalid; throwing"); @@ -1013,16 +1042,13 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do return ptr::null_mut(); }; if since_ms < 0 { - log::warn!("documentFetchEncrypted: sinceMs {since_ms} negative; throwing"); + log::warn!("documentFetchEncrypted: sinceMs negative; throwing"); throw_sdk_exception(env, 1, "sinceMs must be non-negative"); return ptr::null_mut(); } log::debug!( - "documentFetchEncrypted: args owner={} contract={} document_type={:?} — \ - calling platform_wallet_fetch_encrypted_documents", - hex32(&owner), - hex32(&contract), - doc_type + "{}", + fetch_encrypted_call_breadcrumb(&owner, &contract, doc_type.to_bytes()) ); let mut out_json: *mut c_char = ptr::null_mut(); @@ -1071,11 +1097,6 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do }) } -/// Lowercase-hex render of a 32-byte id for diagnostic log lines. -fn hex32(bytes: &[u8; 32]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} - // ── Contested-resource vote ─────────────────────────────────────────── /// Cast a masternode contested-resource vote and wait for the response — @@ -1197,3 +1218,416 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_ca // `success(null)`), so nothing else to release here. }) } + +/// What the Java caller asked for regarding the per-document +/// `encryptionKeyIndex`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EncryptionKeyIndexRequest { + /// An explicit index the caller chose, kept for migration and tests. + Explicit(u32), + /// No index supplied: the SDK allocates one from Platform state. Carried by + /// the [`AUTO_ENCRYPTION_KEY_INDEX`] sentinel, because the Java signature's + /// `int` has no other way to say "absent". + Allocate, +} + +/// The Java value meaning "no index supplied; allocate one". +/// +/// A sentinel rather than a boxed `Integer` so the native signature stays a +/// primitive `int` and the call needs no JVM object. +pub(crate) const AUTO_ENCRYPTION_KEY_INDEX: jint = -1; + +/// Java-signed encrypted-create arguments narrowed to the widths the C ABI +/// takes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ValidatedEncryptedCreate { + pub(crate) encryption_key_index: EncryptionKeyIndexRequest, + pub(crate) version: u8, +} + +/// Why a Java-supplied encrypted-create argument could not be narrowed. +/// +/// Each cause is its own variant so a caller — and a future change to one of +/// the conventions — can address exactly one of them without disturbing the +/// other. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EncryptedCreatePreflightError { + /// An index below the allocate sentinel, which denotes neither an explicit + /// index nor a request to allocate one. + EncryptionKeyIndexOutOfRange { value: jint }, + /// A version outside the byte the wire format carries. + VersionOutOfByteRange { value: jint }, +} + +impl std::fmt::Display for EncryptedCreatePreflightError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EncryptedCreatePreflightError::EncryptionKeyIndexOutOfRange { value } => { + write!( + f, + "encryptionKeyIndex must be non-negative, or \ + {AUTO_ENCRYPTION_KEY_INDEX} to let the SDK allocate it, got {value}" + ) + } + EncryptedCreatePreflightError::VersionOutOfByteRange { value } => { + write!(f, "version must fit a single byte (0..=255), got {value}") + } + } + } +} + +/// Narrow the Java-signed encrypted-create arguments. +/// +/// This layer bridges representations; it does not decide protocol. Every value +/// that fits its target width is passed through for the Rust core to accept or +/// reject, so there is no second place where the set of meaningful versions is +/// written down and no way for the two to disagree. The one convention it does +/// own is the absent-index sentinel, which exists only because the Java +/// signature cannot express absence. +pub(crate) fn encrypted_create_preflight( + encryption_key_index: jint, + version: jint, +) -> Result { + let encryption_key_index = if encryption_key_index == AUTO_ENCRYPTION_KEY_INDEX { + EncryptionKeyIndexRequest::Allocate + } else { + EncryptionKeyIndexRequest::Explicit(u32::try_from(encryption_key_index).map_err(|_| { + EncryptedCreatePreflightError::EncryptionKeyIndexOutOfRange { + value: encryption_key_index, + } + })?) + }; + let version = u8::try_from(version) + .map_err(|_| EncryptedCreatePreflightError::VersionOutOfByteRange { value: version })?; + + Ok(ValidatedEncryptedCreate { + encryption_key_index, + version, + }) +} + +/// The stage line recorded when the encrypted fetch reaches the native call. +/// +/// Takes the call's arguments so the seam sits where the call does, and +/// deliberately renders none of them: a device log is readable by any process +/// holding the log permission and is captured in bug reports, so an identifier +/// there correlates a device to an on-chain identity, and caller-supplied text +/// can embed a newline to forge further log lines. +pub(crate) fn fetch_encrypted_call_breadcrumb( + _owner: &[u8; 32], + _contract: &[u8; 32], + _document_type: &[u8], +) -> String { + "documentFetchEncrypted: calling platform_wallet_fetch_encrypted_documents".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use platform_wallet_ffi::PlatformWalletFFIResultCode; + + // ── Java representation narrowing ─────────────────────────────────────── + // + // This layer bridges representations only. Anything that fits its target + // width is passed through for the Rust core to accept or reject, so the set + // of meaningful versions is written down in exactly one place. + + /// The sentinel is the one convention this layer owns, because the Java + /// `int` signature cannot express an absent index. + #[test] + fn the_allocate_sentinel_is_the_only_negative_index_accepted() { + assert_eq!( + encrypted_create_preflight(AUTO_ENCRYPTION_KEY_INDEX, 1) + .expect("the sentinel is representable") + .encryption_key_index, + EncryptionKeyIndexRequest::Allocate, + "-1 means the SDK allocates the index" + ); + assert_eq!( + encrypted_create_preflight(0, 1) + .expect("zero is a valid explicit index") + .encryption_key_index, + EncryptionKeyIndexRequest::Explicit(0) + ); + assert_eq!( + encrypted_create_preflight(7, 1) + .expect("a positive index is explicit") + .encryption_key_index, + EncryptionKeyIndexRequest::Explicit(7) + ); + + for below_sentinel in [-2, -1000, jint::MIN] { + assert!( + matches!( + encrypted_create_preflight(below_sentinel, 1), + Err(EncryptedCreatePreflightError::EncryptionKeyIndexOutOfRange { value }) + if value == below_sentinel + ), + "a value below the sentinel denotes neither an explicit index nor a \ + request to allocate one; got {below_sentinel}" + ); + } + } + + /// Every value that fits a byte passes this layer — including versions the + /// core will refuse. Narrowing is not policy. + #[test] + fn every_byte_width_version_passes_narrowing_and_policy_stays_in_rust() { + for version in 0..=255i32 { + let validated = encrypted_create_preflight(0, version) + .expect("every value that fits a byte must pass the narrowing layer"); + assert_eq!(validated.version, version as u8); + } + + for out_of_range in [-1, 256, jint::MAX] { + assert!( + matches!( + encrypted_create_preflight(0, out_of_range), + Err(EncryptedCreatePreflightError::VersionOutOfByteRange { value }) + if value == out_of_range + ), + "a version with no byte representation stops here; got {out_of_range}" + ); + } + + // Version 2 fits a byte, so it passes narrowing — and is then refused by + // the shared Rust policy, which is the only place that decides it. + assert_eq!( + encrypted_create_preflight(0, 2) + .expect("2 is representable") + .version, + 2 + ); + assert_eq!( + platform_wallet_ffi::tx_metadata_create_preflight_result(8, 2, Some(0), true).code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "the wire-version decision belongs to Rust, not to this layer" + ); + } + + // ── The bridge keeps no plaintext copy across the broadcast ───────────── + + /// The Rust composite JNI calls owns protocol preflight and does not invoke + /// the deferred JVM-array materializer for a request it already rejects. + #[test] + fn the_deferred_composite_rejects_before_jni_materialization() { + let mut out_id = [0u8; 32]; + let mut out_json = ptr::null_mut(); + let materialize_calls = std::cell::Cell::new(0); + + let result = unsafe { + platform_wallet_ffi::create_encrypted_document_with_deferred_payload( + u64::MAX, + ptr::null_mut(), + [1u8; 32].as_ptr(), + [2u8; 32].as_ptr(), + c"txMetadata".as_ptr(), + None, + 2, + 3, + || { + materialize_calls.set(materialize_calls.get() + 1); + Ok(zeroize::Zeroizing::new(vec![1, 2, 3])) + }, + ptr::dangling_mut::(), + out_id.as_mut_ptr(), + &mut out_json, + ) + }; + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "the composite owns protocol preflight" + ); + assert_eq!(materialize_calls.get(), 0); + assert!( + out_json.is_null(), + "the composite publishes the null sentinel" + ); + } + + /// The production JNI export must route create through one Rust-owned + /// deferred composite. A runtime test cannot construct a representative + /// Android `JNIEnv` here, so this assertion pins the bridge structure that + /// keeps the JVM array conversion inside the deferred callback. + #[test] + fn production_jni_create_routes_through_one_deferred_composite() { + let source = include_str!("transactions.rs"); + let export = source + .split_once( + "pub extern \"system\" fn \ + Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_documentCreateEncrypted", + ) + .expect("production encrypted-create JNI export must exist") + .1 + .split_once("/// Fetch + DECRYPT every encrypted wallet-contract document") + .expect("encrypted-create export must end before the fetch export") + .0; + + assert!( + export.contains("create_encrypted_document_with_deferred_payload("), + "production JNI create must call the deferred Rust composite" + ); + let normalized = export.split_whitespace().collect::>().join(" "); + + assert_eq!( + export + .matches("create_encrypted_document_with_deferred_payload(") + .count(), + 1, + "production JNI create must make exactly one composite call" + ); + assert_eq!( + export.matches("env.convert_byte_array(&payload)").count(), + 1, + "production JNI create must materialize the array exactly once" + ); + assert!( + normalized.contains("payload_len, || match env.convert_byte_array(&payload)"), + "the JVM array conversion must be the composite's deferred materializer argument" + ); + assert!( + !export.contains("platform_wallet_allocate_encryption_key_index"), + "JNI must not stitch a standalone allocator into create" + ); + assert!( + !export.contains("platform_wallet_create_encrypted_document_with_signer("), + "JNI must not stitch a second create call after allocation" + ); + } + + // ── Native string ownership ───────────────────────────────────────────── + + /// The create guard releases through the ORDINARY free, and the fetch guard + /// through the SENSITIVE one. The two are distinct types so a call site + /// cannot pair an allocation with the wrong release function. + /// + /// Both are exercised on their null form here, which every release path must + /// tolerate: it is what an early return before a successful FFI call leaves + /// behind, and what an unwind through the same scope drops. + #[test] + fn both_native_string_guards_release_a_null_pointer_safely() { + drop(OrdinaryPlatformWalletString(ptr::null_mut())); + drop(SensitivePlatformWalletString(ptr::null_mut())); + } + + /// A null guard reports no string rather than dereferencing. + #[test] + fn a_null_guard_reports_no_string() { + assert!(OrdinaryPlatformWalletString(ptr::null_mut()) + .as_c_str() + .is_none()); + assert!(SensitivePlatformWalletString(ptr::null_mut()) + .as_c_str() + .is_none()); + } + + /// The create guard owns a real ordinary allocation and releases it through + /// the ordinary free — on the normal path and on an unwind through the same + /// scope. + #[test] + fn the_ordinary_guard_releases_a_real_allocation_on_both_paths() { + let owned = CString::new("{\"$id\":\"abc\"}").expect("no interior NUL"); + let guard = OrdinaryPlatformWalletString(owned.into_raw()); + assert_eq!( + guard + .as_c_str() + .expect("a non-null guard reports its string") + .to_str() + .expect("ASCII"), + "{\"$id\":\"abc\"}" + ); + drop(guard); + + // An unwind through a scope holding the guard must still release it. + let unwound = std::panic::catch_unwind(|| { + let owned = CString::new("{}").expect("no interior NUL"); + let _guard = OrdinaryPlatformWalletString(owned.into_raw()); + panic!("unwind with the guard live"); + }); + assert!(unwound.is_err(), "the panic must have unwound"); + } + + /// The fetch guard owns a real allocation and releases it through the + /// SENSITIVE free — on the normal path and on an unwind through the same + /// scope. + /// + /// Symmetric with the ordinary guard's test, and deliberately exercising the + /// real `Drop` rather than only the null form: the null case cannot tell the + /// two release functions apart, because both are null-safe. A `CString` + /// allocation is layout-compatible with what the sensitive free expects, and + /// `platform-wallet-ffi` separately proves that free wipes through the + /// terminating NUL. + #[test] + fn the_sensitive_guard_releases_a_real_allocation_on_both_paths() { + let owned = CString::new("[{\"payload\":\"AAECAw==\"}]").expect("no interior NUL"); + let guard = SensitivePlatformWalletString(owned.into_raw()); + assert_eq!( + guard + .as_c_str() + .expect("a non-null guard reports its string") + .to_str() + .expect("the serializer guarantees ASCII"), + "[{\"payload\":\"AAECAw==\"}]" + ); + // Normal-path release through the sensitive contract. + drop(guard); + + // An unwind through a scope holding the guard must still release it — + // the path a JNI-allocation failure or a panic between the FFI call and + // the return would take. + let unwound = std::panic::catch_unwind(|| { + let owned = CString::new("[]").expect("no interior NUL"); + let _guard = SensitivePlatformWalletString(owned.into_raw()); + panic!("unwind with the sensitive guard live"); + }); + assert!(unwound.is_err(), "the panic must have unwound"); + } + + /// The fetch output's ASCII / no-interior-NUL precondition is what lets the + /// bridge hand the Rust buffer straight to `NewStringUTF` with no + /// intermediate copy. A non-ASCII or NUL-bearing buffer would break that, + /// so the precondition is asserted rather than assumed. + #[test] + fn the_fetch_output_is_ascii_with_no_interior_nul() { + let serialized = CString::new("[]").expect("the serializer emits no interior NUL"); + let bytes = serialized.as_bytes(); + assert!( + bytes.is_ascii(), + "the sensitive serializer guarantees ASCII, which is already valid \ + modified UTF-8 for NewStringUTF" + ); + assert!( + !bytes.contains(&0), + "an interior NUL would truncate the string NewStringUTF builds" + ); + } + + // ── Sanitized breadcrumbs ─────────────────────────────────────────────── + + /// The fetch call breadcrumb renders none of its arguments. + #[test] + fn the_fetch_call_breadcrumb_renders_no_caller_data() { + const MARKER: &str = "s3cr3t-marker-do-not-log"; + let owner = [0xABu8; 32]; + let contract = [0xCDu8; 32]; + let hostile = format!("txMetadata\nFORGED line {MARKER}"); + + let line = fetch_encrypted_call_breadcrumb(&owner, &contract, hostile.as_bytes()); + + assert!(!line.contains(MARKER), "caller text must not reach the log"); + assert!( + !line.contains('\n'), + "an embedded newline would let a caller forge further log lines" + ); + assert!( + !line.contains("abab") && !line.contains("cdcd"), + "identifiers must not be rendered in any form" + ); + assert_eq!( + line, "documentFetchEncrypted: calling platform_wallet_fetch_encrypted_documents", + "the breadcrumb is a fixed stage label" + ); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift index 1315f49f93..5013d6912c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Wallet/WalletStorage.swift @@ -102,6 +102,14 @@ public class WalletStorage { /// /// Returning raw bytes lets security-sensitive call sites avoid /// materializing a Swift `String` unless they truly need one. + /// + /// The returned `Data` is still runtime-managed and plaintext-equivalent. + /// Keychain hands its result back as a `Data`, so this cannot be avoided, + /// and the SDK cannot overwrite it: its storage may be shared, and a + /// runtime copy or move leaves copies nothing here can reach. Callers + /// should mask or consume it immediately — see `MnemonicResolver` — and + /// treat scrubbing their own derived buffers as exposure reduction rather + /// than erasure. public func retrieveMnemonicUTF8Bytes(for walletId: Data) throws -> Data { let account = perWalletMnemonicAccount(for: walletId) let query: [String: Any] = [ diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift index 2e292c0736..2661c2d4df 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/MnemonicResolverAndPersister.swift @@ -14,6 +14,16 @@ private func scrubBytes(_ bytes: inout [UInt8]) { /// Best-effort in-memory obfuscation for mnemonic UTF-8 bytes while /// they sit on the Swift heap between the Keychain read and the final /// copy into Rust's `Zeroizing` buffer. +/// +/// "Best-effort" is meant literally, and the limit is upstream of this type. +/// The bytes arrive as a `Data` that `WalletStorage` obtained from Keychain — +/// a runtime-managed value whose storage may be shared and which the SDK has +/// no way to overwrite. This masks its own copy and scrubs every explicit +/// `[UInt8]` buffer it makes, including the plaintext it derives from that +/// `Data`; it cannot reach the `Data` itself or anything the runtime copied +/// out of it. What this bounds is the window in which an unobfuscated copy +/// exists in storage the SDK controls, not the existence of plaintext on the +/// heap. private final class MaskedMnemonicUTF8 { private var maskedBytes: [UInt8] private var maskBytes: [UInt8] @@ -68,10 +78,17 @@ private final class MaskedMnemonicUTF8 { /// `dash_sdk_sign_with_mnemonic_resolver_and_path`) calls back /// into Swift via this resolver to fetch the BIP-39 mnemonic for /// the wallet whose identity keys it's deriving. The mnemonic is -/// copied directly into a Rust-owned `Zeroizing` stack buffer; it -/// never round-trips back to Swift after this single read. On the -/// Swift side the bytes are masked while idle, then deobfuscated only -/// long enough to copy into the FFI output buffer. +/// written into a Rust-owned `Zeroizing` buffer and never round-trips +/// back to Swift after this single read. On the Swift side the bytes +/// are masked while idle, then deobfuscated only long enough to copy +/// into the FFI output buffer. +/// +/// That copy is not made from Keychain memory directly. `WalletStorage` +/// necessarily returns a Swift `Data`, and the masked form is derived from +/// it, so a runtime-managed intermediate exists no matter how narrow this +/// path is written. The explicit `[UInt8]` buffers here are scrubbed; that +/// `Data` and any copy the runtime made of it are not — Swift offers no way +/// to overwrite them. Treat the residual exposure as reduced, not removed. /// /// # Lifetime contract /// diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 5ff3665be9..4a539d8617 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3432,30 +3432,35 @@ extension ManagedPlatformWallet { /// create-with-signer path. The written document is decryptable by the /// legacy `org.dashj.platform` stack and vice versa. The resolved master /// xprv is wiped BETWEEN the (synchronous) derivation and the (async) - /// broadcast, so no key material crosses the network `.await` - /// (dashpay/platform#4091). + /// broadcast, so no key material crosses the network `.await`. /// - /// The `encryptionKeyIndex` is no longer a host parameter: Rust allocates - /// it (dashpay/platform#4195), matching the Android auto-index path where - /// Kotlin's `createEncryptedDocument` omits it (`encryptionKeyIndex = - /// null`). Host-side index assignment risked cross-device collisions, so - /// both platforms now defer to the Rust-side allocator. + /// The `encryptionKeyIndex` is not a host parameter: Rust allocates it, + /// matching the Android path where Kotlin's `createEncryptedDocument` + /// omits it. Host-side index assignment risks cross-device collisions, so + /// both platforms defer to the Rust-side allocator. /// /// Batching stays app-side: the caller serializes its items into - /// `payload` (a protobuf `TxMetadataBatch` for `version == 1`). The - /// plaintext `payload` is copied directly into a Rust-owned `Zeroizing` - /// buffer - /// (scrubbed on drop, before the broadcast await) — this wrapper keeps - /// no extra Swift-side copy, the same handling as the seed bytes that - /// flow through `MnemonicResolver`. Callers that hold sensitive - /// plaintext should scrub their own buffer after the call returns. + /// `payload` (a protobuf `TxMetadataBatch` for `version == 1`). This wrapper + /// makes no intentional payload copy before the FFI call; it presents a + /// temporary byte view that Rust copies into a `Zeroizing` buffer. Swift's + /// runtime may still materialize or copy `Data` storage, as documented + /// below. /// - /// `version` MUST be `0` (CBOR) or `1` (protobuf): `seal_tx_metadata` - /// writes the byte verbatim and the legacy dashj `decryptTxMetadata` - /// switches on exactly those two values, so an out-of-range byte would - /// silently seal a document the legacy stack can't decode. The guard - /// runs before any FFI call (mirrors the Kotlin - /// `DocumentTransactions.createEncryptedDocument` `require`). + /// `version` is the wire byte, passed through as-is. Which values are + /// meaningful is decided by the wallet core, which rejects an unsupported + /// one before anything is sealed and surfaces it as an invalid-parameter + /// error. + /// + /// ### What is not scrubbed + /// The SDK zeroizes the native copies Rust makes of `payload`. It cannot + /// scrub `payload` itself: `Data` is caller-owned, its backing storage may + /// be shared, and copy-on-write or a runtime move can leave further copies + /// the SDK never sees. Treat `payload` and everything derived from it as + /// plaintext-equivalent for as long as it is reachable: keep it + /// short-lived, never log it, and overwrite your own buffer once this call + /// returns where that is feasible. Overwriting the `Data` you hold does not + /// reach any copy its storage was shared into, so this reduces exposure + /// rather than eliminating it. /// /// # Key source: chosen by wallet capability (Rust-side) /// @@ -3464,7 +3469,11 @@ extension ManagedPlatformWallet { /// external-signable / Keychain-backed wallet (the app's shape) /// derives on demand through the resolver. The resolver is pinned /// across the synchronous FFI call with `withExtendedLifetime`, same as - /// `previewIdentityRegistrationKeys`. + /// `previewIdentityRegistrationKeys`. Rust calls it back on the thread that + /// entered the export, after the index allocation has returned and before + /// the broadcast starts — never on a runtime worker. That thread belongs to + /// the detached task this method runs in, so a resolver that waits on + /// Keychain blocks neither the UI thread nor an actor's executor. /// /// Lifetime contract: the `signer` instance MUST stay alive for the /// duration of the synchronous FFI call (Rust holds a `passUnretained` @@ -3479,15 +3488,6 @@ extension ManagedPlatformWallet { signer: KeychainSigner, storage: WalletStorage = WalletStorage() ) async throws -> (Identifier, String) { - // Reject wire-meaningless version bytes before touching the FFI so - // a bad byte never seals a document the legacy stack can't decode - // (dashpay/platform#4091). Mirrors the Kotlin `require`. - guard version == 0 || version == 1 else { - throw PlatformWalletError.invalidParameter( - "version must be 0 (CBOR) or 1 (protobuf), got \(version)" - ) - } - let handle = self.handle let signerHandle = signer.handle // Rust pulls the BIP-39 mnemonic on demand for external-signable @@ -3509,25 +3509,38 @@ extension ManagedPlatformWallet { // Pin BOTH the signer and the resolver for the whole FFI call // (see `createDocument` / `previewIdentityRegistrationKeys` for - // why a bare `_ = signer` is unreliable under -O). Rust - // dereferences both ctx pointers synchronously inside - // `block_on_worker`. + // why a bare `_ = signer` is unreliable under -O). They are + // dereferenced at different moments and on different threads, so + // one pin spanning the entire synchronous call is what keeps both + // valid. Rust consults the resolver on the thread that entered the + // export — this one — after the index-allocation worker has + // returned, and dereferences the signer later, on the worker that + // runs the broadcast. Because this runs in a detached task, "the + // export-entry thread" is a cooperative-pool thread rather than the + // UI thread or any actor's executor, so a resolver callback that + // waits on Keychain blocks only here. let result = withExtendedLifetime(resolver) { withExtendedLifetime(signer) { ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in contractBytes.withUnsafeBufferPointer { contractBp -> PlatformWalletFFIResult in documentType.withCString { typePtr -> PlatformWalletFFIResult in - // Borrow the plaintext bytes in place — no - // extra Swift copy. `baseAddress` is nil for - // an empty payload, which the FFI accepts - // only when `payload_len == 0`. + // Borrow a temporary byte view; this wrapper + // makes no intentional explicit copy. + // `baseAddress` is nil for an empty payload, + // which the FFI accepts only when + // `payload_len == 0`. payload.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in let payloadPtr = raw.bindMemory(to: UInt8.self).baseAddress return documentIdBytes.withUnsafeMutableBufferPointer { outBp in // Auto-index export: Rust allocates the // per-document `encryptionKeyIndex` from // Platform state, so no index argument is - // passed (dashpay/platform#4195). + // passed. This host can hand over a + // pointer to memory it already owns, so + // the single-call export is correct here. + // Hosts whose plaintext lives in a + // runtime-managed buffer use a Rust-ABI + // composite with deferred materialization. platform_wallet_create_encrypted_document_with_signer_auto_index( handle, resolverHandle, @@ -3549,15 +3562,20 @@ extension ManagedPlatformWallet { } } - try result.check() - // Take ownership of the JSON and release the Rust allocation. + // Registered BEFORE the throwing check: the export publishes a null + // sentinel on failure, but a non-null output must be released on + // every path out of this scope, including one that throws. A defer + // placed after the check would leak whatever the call had already + // written. The create output is canonical JSON — ciphertext and + // metadata, no plaintext — so it is released with the ordinary free. defer { if let p = documentJsonPtr { platform_wallet_string_free(p) } } + try result.check() // On a successful broadcast the Rust side always writes the // canonical JSON; a null pointer here is an FFI/ABI contract // violation. Fail loudly rather than persist an empty body. guard let jsonPtr = documentJsonPtr else { throw PlatformWalletError.walletOperation( - "create_encrypted_document_with_signer returned no canonical document JSON" + "create_encrypted_document_with_signer_auto_index returned no canonical document JSON" ) } let canonicalJSON = String(cString: jsonPtr) @@ -3573,29 +3591,48 @@ extension ManagedPlatformWallet { /// `getTxMetaData(since, key)` — bridges /// `platform_wallet_fetch_encrypted_documents`. Each document's /// `encryptedMetadata` blob is decrypted with the identity's derived - /// key; documents that can't be derived/decrypted are skipped Rust-side + /// key. Decryption is NOT authentication: the envelope is AES-256-CBC with + /// PKCS7 and no integrity tag, so a wrong key or modified ciphertext + /// usually fails the unpad and is skipped, but can occasionally unpad + /// cleanly and surface opaque garbage. Parse every `payload` strictly — + /// CBOR for `version` 0, protobuf for 1 — and discard what does not parse. + /// + /// Documents that can't be derived/decrypted are skipped Rust-side /// (a bad document never aborts the fetch). /// /// Each element of the returned array is /// `{ "id": base58, "ownerId": base58, "keyIndex": UInt32, /// "encryptionKeyIndex": UInt32, "version": UInt8, /// "updatedAt": UInt64|null, "payload": base64 }`, where `payload` is - /// the decrypted opaque plaintext the caller parses itself (a protobuf - /// `TxMetadataBatch` for `version == 1`). + /// the decrypted opaque plaintext the caller parses itself. Callers MUST + /// dispatch on `version`: `0` is a CBOR payload, `1` a protobuf + /// `TxMetadataBatch`. Those are the only versions the legacy format + /// defines; a document carrying anything else is skipped by the SDK and + /// never appears in this array. /// - /// SDK-owned Rust/C decrypted payload and JSON buffers are zeroized before - /// deallocation. The returned host `String` is plaintext-equivalent; its - /// runtime-managed storage, copies, and parsed-object copies cannot be - /// reliably overwritten by the SDK. Parse it promptly, do not log it, and - /// do not retain or persist it longer than required. + /// ### What is not scrubbed + /// The SDK zeroizes the native decrypted-payload and JSON buffers it owns. + /// It cannot scrub the returned `String`: that is a runtime-managed object, + /// as are every copy of it and every object parsed out of it, and the + /// runtime may have moved or copied its storage. Treat it and everything + /// derived from it as plaintext-equivalent for as long as it is reachable: + /// parse promptly, never log it, and do not retain or persist it longer + /// than required. Unlike a `Data` buffer there is no overwrite to attempt + /// here at all, so short retention is the only control the caller has. /// /// # Key source: chosen by wallet capability (Rust-side) /// /// A `MnemonicResolver` is always passed, but Rust consults it only /// when the in-process wallet lacks resident keys (the app's - /// external-signable shape). The resolver is pinned across the + /// external-signable shape) AND the paginated scan actually found + /// candidates — an empty or failed fetch never calls back, which matters + /// where that callback prompts the user. The resolver is pinned across the /// synchronous FFI call with `withExtendedLifetime`, same as - /// `previewIdentityRegistrationKeys`. + /// `previewIdentityRegistrationKeys`. Rust calls it back on the thread that + /// entered the export, after the scan worker has returned — never on a + /// runtime worker. That thread belongs to the detached task this method + /// runs in, so a resolver that waits on Keychain blocks neither the UI + /// thread nor an actor's executor. public func fetchEncryptedDocuments( ownerIdentityId: Identifier, contractId: Identifier, @@ -3617,8 +3654,13 @@ extension ManagedPlatformWallet { // `platform_wallet_sensitive_string_free` below. var documentsJsonPtr: UnsafeMutablePointer? = nil - // Pin the resolver for the whole FFI call — Rust dereferences - // its ctx pointer synchronously inside `block_on_worker`. + // Pin the resolver for the whole FFI call. Rust consults it on the + // thread that entered the export — this one — after the paginated + // scan worker has returned, and only when that scan actually found + // candidates, so an empty or failed fetch never calls back at all. + // Because this runs in a detached task, that thread is a + // cooperative-pool thread rather than the UI thread or any actor's + // executor. let result = withExtendedLifetime(resolver) { ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in contractBytes.withUnsafeBufferPointer { contractBp -> PlatformWalletFFIResult in diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/EncryptedDocumentVersionValidationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/EncryptedDocumentVersionValidationTests.swift index e0f100918f..231661589c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/EncryptedDocumentVersionValidationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/EncryptedDocumentVersionValidationTests.swift @@ -1,24 +1,30 @@ import XCTest @testable import SwiftDashSDK -/// Version-byte validation for -/// `ManagedPlatformWallet.createEncryptedDocument` (dashpay/platform#4091). -/// Only `0` (CBOR) and `1` (protobuf) are wire-meaningful — `seal_tx_metadata` -/// writes the byte verbatim and the legacy dashj `decryptTxMetadata` switches -/// on exactly those two values, so an out-of-range byte would silently seal a -/// document the legacy stack can't decode. +/// Where the txMetadata wire-version decision lives, proven through +/// `ManagedPlatformWallet.createEncryptedDocument`. /// -/// The `guard` runs before any FFI call (no `platform_wallet_*` symbol is -/// dereferenced and the wallet `handle` is never used), so the REJECTION paths -/// are exercised with a dummy handle and no live wallet — the Swift mirror of -/// the Kotlin `DocumentTransactionsVersionValidationTest` -/// (`walletHandle = 0L`). The accepted values `0` / `1` would proceed into -/// native and can't be unit-tested here. +/// The wrapper does not decide which version bytes are meaningful. Only the +/// wallet core knows which ones the legacy stack can decode, and it rejects an +/// unsupported one from the arguments alone — before the wallet handle is +/// resolved, before the key resolver runs, and before anything is sealed. A +/// guard in Swift would be a second place where that set is written down, free +/// to drift from the core and to reject a value a later core accepts. +/// +/// These cases therefore assert the RUST behavior as it arrives through the +/// FFI: an unsupported byte reaches the export and comes back as a propagated +/// invalid-parameter result. That the rejection happens before the handle is +/// used is what lets a dummy, never-registered handle exercise it with no live +/// wallet. final class EncryptedDocumentVersionValidationTests: XCTestCase { - /// A dummy, never-dispatched wallet handle (matches Kotlin's `0L`). The - /// version guard throws before the handle is read, so no FFI dispatch - /// occurs on the rejection paths under test. + /// A dummy handle that is never registered in the FFI handle storage. + /// + /// The shared argument gate runs before the handle is resolved, so an + /// unsupported version is refused without it ever being read. If the + /// ordering regressed, these cases would surface a not-found failure + /// instead of the invalid-parameter one asserted below — which is exactly + /// what makes the ordering observable here. private func makeWallet() -> ManagedPlatformWallet { ManagedPlatformWallet(handle: 0, walletId: Data(count: 32)) } @@ -26,43 +32,75 @@ final class EncryptedDocumentVersionValidationTests: XCTestCase { private let id32 = Data(count: 32) private let payload = Data([0, 1, 2, 3]) - /// A signer is a required argument, but the version guard throws before it - /// is ever dereferenced — an in-memory-backed instance is enough to - /// satisfy the type. Built per-test. + /// A signer is a required argument. Only its presence is checked before the + /// version is rejected, so an in-memory-backed instance is enough. private func makeSigner() throws -> KeychainSigner { let container = try DashModelContainer.createInMemory() return KeychainSigner(modelContainer: container, network: .testnet) } - /// Bytes `2...255` (every value the legacy `0..=255` range once accepted - /// beyond the two wire-meaningful ones) are rejected with a message that - /// names them. - func testRejectsVersionBytesTheLegacyStackCannotDecode() async throws { + private func create(version: UInt8) async throws -> (Identifier, String) { let wallet = makeWallet() let signer = try makeSigner() + return try await wallet.createEncryptedDocument( + ownerIdentityId: id32, + contractId: id32, + documentType: "txMetadata", + version: version, + payload: payload, + signer: signer + ) + } + + /// An unsupported wire version is refused by the Rust core and the typed + /// failure propagates through the wrapper unchanged. + /// + /// The wrapper passes the byte through untouched, so what is asserted here + /// is the core's decision arriving intact — not a Swift-side check. + func testAnUnsupportedVersionIsRefusedByTheRustCore() async throws { for version: UInt8 in [2, 3, 127, 255] { do { - _ = try await wallet.createEncryptedDocument( - ownerIdentityId: id32, - contractId: id32, - documentType: "txMetadata", - version: version, - payload: payload, - signer: signer - ) - XCTFail("version=\(version) must be rejected") + _ = try await create(version: version) + XCTFail("version=\(version) must be refused by the wallet core") } catch let error as PlatformWalletError { guard case let .invalidParameter(message) = error else { - XCTFail("expected .invalidParameter for version=\(version), got \(error)") + XCTFail( + "version=\(version) must surface as .invalidParameter — a " + + "not-found failure would mean the wallet handle was " + + "resolved before the version was judged, got \(error)" + ) continue } - XCTAssertTrue( - message.contains("0 (CBOR) or 1 (protobuf)"), - "message should name the wire-meaningful versions, got: \(message)" + XCTAssertFalse( + message.isEmpty, + "the core's typed explanation must reach the caller" ) } catch { XCTFail("expected PlatformWalletError for version=\(version), got \(error)") } } } + + /// A supported version gets past the argument gate and on to the wallet + /// lookup, which fails because this handle was never registered. + /// + /// This is what shows the rejections above are the version gate's doing and + /// not an unconditional refusal of every call made with a dummy handle. + func testASupportedVersionGetsPastTheArgumentGate() async throws { + for version: UInt8 in [0, 1] { + do { + _ = try await create(version: version) + XCTFail("version=\(version) cannot succeed against an unregistered handle") + } catch let error as PlatformWalletError { + if case .invalidParameter = error { + XCTFail( + "version=\(version) is supported and must not be refused as an " + + "invalid argument; got \(error)" + ) + } + } catch { + XCTFail("expected PlatformWalletError for version=\(version), got \(error)") + } + } + } } From f845f9d877340764fce399154446858fad845704 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 3 Aug 2026 01:40:46 +0700 Subject: [PATCH 30/30] fix(kotlin-sdk): type wallet invalid-parameter errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose platform-wallet code 2 as a specific InvalidParameter subtype while preserving Generic matching, nativeCode, and the Rust-owned message for existing callers. Test would have caught this in CI: ✖ the regression did not compile because PlatformWallet.InvalidParameter was absent; ✔ the targeted and full Kotlin SDK unit suites pass with typed and Generic-compatible mapping. --- .../dashsdk/errors/DashSdkError.kt | 20 ++++++++++---- .../dashsdk/errors/DashSdkErrorTest.kt | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index de41a05412..9deffc3a0f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -63,10 +63,9 @@ sealed class DashSdkError( * rs-sdk-ffi `DashSDKErrorCode` range decoded above. * * The Android analog of Swift's `PlatformWalletError` enum - * (`PlatformWalletResult.swift`). Only the retry-semantics-bearing codes - * get dedicated types; everything else falls through to the - * [PlatformWallet] catch-all which still carries the native code + Rust - * message. + * (`PlatformWalletResult.swift`). Selected codes get dedicated types; + * everything else falls through to the [PlatformWallet] catch-all which + * still carries the native code + Rust message. */ sealed class PlatformWallet( message: String, @@ -77,6 +76,16 @@ sealed class DashSdkError( class InvalidHandle(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorInvalidParameter` (native code 2). The wallet core rejected a + * caller-supplied argument and owns the explanatory [message]. + * + * This remains a [Generic] so existing callers matching the fallback + * and inspecting [Generic.nativeCode] continue to work unchanged. + */ + class InvalidParameter(message: String, cause: Throwable? = null) : + Generic(nativeCode = 2, message = message, cause = cause) + /** * `ErrorWalletOperation` (native code 6). A generic wallet-operation * failure — the platform-wallet catch-all mapping, distinct from the @@ -181,7 +190,7 @@ sealed class DashSdkError( * Carries the platform-wallet [nativeCode] (already de-offset) and * the Rust-supplied message. */ - class Generic( + open class Generic( val nativeCode: Int, message: String, cause: Throwable? = null, @@ -232,6 +241,7 @@ sealed class DashSdkError( ): DashSdkError = when (code) { // PlatformWalletFFIResultCode variants (platform-wallet-ffi/src/error.rs) 1 -> PlatformWallet.InvalidHandle(message, cause) // ErrorInvalidHandle + 2 -> PlatformWallet.InvalidParameter(message, cause) // ErrorInvalidParameter 6 -> PlatformWallet.WalletOperation(message, cause) // ErrorWalletOperation 7, // ErrorIdentityNotFound 8, // ErrorContactNotFound diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index f8e397cade..8164d52a08 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -46,6 +46,33 @@ class DashSdkErrorTest { assertFalse(DashSdkError.InvalidParameter("x").isRetryable) } + @Test + fun platformWalletInvalidParameterIsTypedAndPreservesTheCoreMessage() { + val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + val coreMessage = "rust-owned txMetadata validation detail" + + val mapped = runCatching { + mapNativeErrors { throw DashSDKException(offset + 2, coreMessage) } + }.exceptionOrNull() + + assertTrue(mapped is DashSdkError.PlatformWallet.InvalidParameter) + assertEquals(coreMessage, mapped?.message) + } + + /** + * Adding a narrower type must not break callers that already branch on the + * generic platform-wallet error and inspect its native code. + */ + @Test + fun platformWalletInvalidParameterRemainsCompatibleWithTheGenericFallback() { + val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + val mapped = DashSdkError.fromNative(DashSDKException(offset + 2, "invalid input")) + + assertTrue(mapped is DashSdkError.PlatformWallet.InvalidParameter) + assertTrue(mapped is DashSdkError.PlatformWallet.Generic) + assertEquals(2, (mapped as DashSdkError.PlatformWallet.Generic).nativeCode) + } + @Test fun platformWalletCodesMapToPlatformWalletSubtree() { val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET