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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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