From e4c31ad3f764b5f00bed0a643c5ca780984ec4d2 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 4 Aug 2026 14:43:00 +0700 Subject: [PATCH] Revert "feat(platform-wallet): add encrypted txMetadata document support (#4277)" This reverts commit 6704a41a8508b62ce584c31529d2ed6c399c083b. --- Cargo.lock | 2 - .../dashsdk/documents/DocumentTransactions.kt | 130 +- .../dashsdk/ffi/TransactionsNative.kt | 65 - ...cumentTransactionsVersionValidationTest.kt | 127 -- packages/rs-platform-wallet-ffi/Cargo.toml | 4 - .../rs-platform-wallet-ffi/src/document.rs | 556 +-------- packages/rs-platform-wallet-ffi/src/error.rs | 9 - packages/rs-platform-wallet/Cargo.toml | 10 +- packages/rs-platform-wallet/src/error.rs | 15 - packages/rs-platform-wallet/src/lib.rs | 5 +- .../src/wallet/identity/crypto/mod.rs | 6 - .../src/wallet/identity/crypto/tx_metadata.rs | 1101 ----------------- .../identity/network/encrypted_document.rs | 732 ----------- .../src/wallet/identity/network/mod.rs | 4 - .../LegacyDerivationPathCheck.java | 80 -- .../tests/legacy_wire_compat/LegacyKeyN.java | 80 -- .../tests/legacy_wire_compat/README.md | 107 -- .../tests/txmetadata_fetch.rs | 266 ---- .../rs-unified-sdk-jni/src/transactions.rs | 279 ----- 19 files changed, 9 insertions(+), 3569 deletions(-) delete mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt delete mode 100644 packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs delete mode 100644 packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs delete mode 100644 packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java delete mode 100644 packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyKeyN.java delete mode 100644 packages/rs-platform-wallet/tests/legacy_wire_compat/README.md delete mode 100644 packages/rs-platform-wallet/tests/txmetadata_fetch.rs diff --git a/Cargo.lock b/Cargo.lock index fcbbb115465..6fabfe1fe26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5209,7 +5209,6 @@ dependencies = [ "image", "key-wallet", "key-wallet-manager", - "log", "platform-encryption", "rand 0.8.6", "rayon", @@ -5234,7 +5233,6 @@ version = "4.1.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 835468e11c0..518bff78278 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,6 +1,9 @@ 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 @@ -248,131 +251,4 @@ 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`). The identity encryption key id (the - * `keyIndex` field) is chosen SDK-side to match the legacy stack, so the key - * never crosses the FFI boundary. - * - * ### `encryptionKeyIndex` selection (dashpay/platform#4186 follow-up) - * Leave [encryptionKeyIndex] `null` (the default) to let the SDK generate - * the per-document index in Rust — the host-thin path. Rust draws a valid - * non-zero 31-bit BIP-32 child index from the operating-system CSPRNG. The - * index is a derivation input stored on each document, not a protocol - * sequence number; a repeated index is non-lossy because each document also - * has a fresh IV and readers derive from that document's stored fields. - * - * Passing an explicit non-negative [encryptionKeyIndex] is retained ONLY for - * migration / tests and is discouraged: hosts should not own derivation - * index policy. - * - * @param encryptionKeyIndex `null` to let the SDK generate the index - * (preferred); or an explicit non-negative per-document index - * (migration / tests only). - * @param version payload version byte (`1` = protobuf, as the wallet writes). - * @param payload already-serialized opaque plaintext; the SDK does not - * parse it. - * [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, - version: Int, - payload: ByteArray, - signerHandle: Long, - encryptionKeyIndex: Int? = null, - ): String = gate.op { - require(ownerId.size == 32) { "ownerId must be 32 bytes" } - require(contractId.size == 32) { "contractId must be 32 bytes" } - require(encryptionKeyIndex == null || encryptionKeyIndex >= 0) { - "encryptionKeyIndex, when supplied, must be non-negative, got $encryptionKeyIndex" - } - // Only 0 (CBOR) and 1 (protobuf) are wire-meaningful: `seal_tx_metadata` - // writes this byte verbatim into the envelope and the legacy dashj stack - // (decryptTxMetadata) switches on exactly those two values. Accepting 2..255 - // would silently seal a document the legacy stack can't decode, breaking the - // bidirectional wire-compat guarantee (dashpay/platform#4091). - require(version == 0 || version == 1) { - "version must be 0 (CBOR) or 1 (protobuf), got $version" - } - mapNativeErrors { - TransactionsNative.documentCreateEncrypted( - walletHandle, - mnemonicResolverHandle, - ownerId, - contractId, - documentType, - // -1 is the JNI sentinel for "let Rust generate the index". - encryptionKeyIndex ?: -1, - 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. - * - * [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, - sinceMs: Long, - ): 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" } - mapNativeErrors { - TransactionsNative.documentFetchEncrypted( - walletHandle, - mnemonicResolverHandle, - 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 597098a2b29..d41c25b7507 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,71 +164,6 @@ 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 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 per-document index, OR `-1` to let the SDK - * generate it in Rust with the operating-system CSPRNG. A non-negative - * value routes to the explicit-index FFI export (migration / tests); - * `-1` routes to - * `platform_wallet_create_encrypted_document_with_signer_auto_index`, - * which omits the index. Values `< -1` are rejected. - * @param version payload version byte (`1` = protobuf, as the wallet writes). - * @param payload the already-serialized opaque plaintext (a protobuf - * `TxMetadataBatch`); the SDK does not parse it. - * @return the confirmed document's canonical JSON (its 32-byte id is the - * base58 `$id` field). - */ - external fun documentCreateEncrypted( - walletHandle: Long, - mnemonicResolverHandle: 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)`. - * - * @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 - * fail to decrypt are skipped Rust-side. - */ - external fun documentFetchEncrypted( - walletHandle: Long, - mnemonicResolverHandle: 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/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt deleted file mode 100644 index b070955e53f..00000000000 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -package org.dashfoundation.dashsdk.documents - -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertFalse -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * Input validation for [DocumentTransactions.createEncryptedDocument]. - * - * Two independent guards are exercised here, both of which run BEFORE any - * native call (`TransactionsNative`), so the REJECTION paths are testable on the - * JVM without the JNI library loaded: - * - * 1. Version byte (dashpay/platform#4091): only 0 (CBOR) and 1 (protobuf) are - * wire-meaningful — `seal_tx_metadata` writes the byte verbatim and the - * legacy dashj `decryptTxMetadata` switches on exactly those two values, so - * an out-of-range byte would silently seal a document the legacy stack can't - * decode. - * 2. `encryptionKeyIndex` (dashpay/platform#4186 follow-up): `null` is the - * preferred path (Rust generates the per-document index); an explicit - * value, when supplied, must be non-negative. - * - * Paths that PASS validation proceed into native and can't be fully unit-tested - * here (no JNI library); [noIndexPathPassesValidation] asserts only that the - * `null` index is accepted by the guard, not rejected as an argument error. - */ -class DocumentTransactionsVersionValidationTest { - - private val id32 = ByteArray(32) - private val payload = ByteArray(4) { it.toByte() } - - private fun createWithVersion(version: Int) = runBlocking { - DocumentTransactions().createEncryptedDocument( - walletHandle = 0L, - mnemonicResolverHandle = 0L, - ownerId = id32, - contractId = id32, - documentType = "txMetadata", - version = version, - payload = payload, - signerHandle = 0L, - encryptionKeyIndex = 0, - ) - } - - /** Bytes 2..255 (previously accepted by the `0..255` range) are now rejected. */ - @Test - fun rejectsVersionBytesTheLegacyStackCannotDecode() { - for (version in intArrayOf(2, 3, 127, 255)) { - val e = assertThrows( - "version=$version must be rejected", - IllegalArgumentException::class.java, - ) { createWithVersion(version) } - assertTrue( - "message should name the wire-meaningful versions, got: ${e.message}", - e.message!!.contains("0 (CBOR) or 1 (protobuf)"), - ) - } - } - - /** A negative version byte is likewise rejected. */ - @Test - fun rejectsNegativeVersion() { - assertThrows(IllegalArgumentException::class.java) { createWithVersion(-1) } - } - - /** - * An explicit NEGATIVE index (the migration/test-only path) is rejected by - * the `require`. `null` (the generate-in-Rust path) is the only way to omit - * an index; a negative explicit value is a caller error. - */ - @Test - fun rejectsExplicitNegativeIndex() { - val e = assertThrows(IllegalArgumentException::class.java) { - runBlocking { - DocumentTransactions().createEncryptedDocument( - walletHandle = 0L, - mnemonicResolverHandle = 0L, - ownerId = id32, - contractId = id32, - documentType = "txMetadata", - version = 1, - payload = payload, - signerHandle = 0L, - encryptionKeyIndex = -5, - ) - } - } - assertTrue( - "message should name encryptionKeyIndex, got: ${e.message}", - e.message!!.contains("encryptionKeyIndex"), - ) - } - - /** - * The no-index path (`encryptionKeyIndex` omitted → `null`, the default and - * preferred generate-in-Rust route) must PASS the argument guards. With all - * other inputs valid, the only failure that can surface is the native call - * itself (no JNI library in a JVM unit test), NOT an - * [IllegalArgumentException] from our `require`s — proving `null` is a valid - * argument rather than a rejected one. - */ - @Test - fun noIndexPathPassesValidation() { - val t = runCatching { - runBlocking { - DocumentTransactions().createEncryptedDocument( - walletHandle = 0L, - mnemonicResolverHandle = 0L, - ownerId = id32, - contractId = id32, - documentType = "txMetadata", - version = 1, - payload = payload, - signerHandle = 0L, - // encryptionKeyIndex omitted → null → generate in Rust. - ) - } - }.exceptionOrNull() - assertFalse( - "the null-index path must not be rejected as an argument error, got: $t", - t is IllegalArgumentException, - ) - } -} diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index 1f00dc4c555..9b8f9d279b1 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -56,10 +56,6 @@ 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 455d4f0099d..32509bc3356 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -4,144 +4,20 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; use std::ptr; use std::slice; -use std::sync::Arc; use dpp::document::{Document, DocumentV0Getters}; use dpp::prelude::Identifier; use dpp::serialization::ValueConvertible; -use key_wallet::bip32::ExtendedPrivKey; -use platform_wallet::wallet::identity::crypto::tx_metadata::{ - ensure_tx_metadata_payload_fits, - MAX_TX_METADATA_PLAINTEXT_LEN as CORE_MAX_TX_METADATA_PLAINTEXT_LEN, -}; -use platform_wallet::{PlatformWalletError, TxMetadataKeySource}; -use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle, VTableSigner}; -use zeroize::Zeroizing; +use platform_wallet::PlatformWalletError; +use rs_sdk_ffi::{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}; -/// Shared with JNI so the Java-array length can be rejected before either -/// native layer copies an oversized plaintext payload. -pub const MAX_TX_METADATA_PLAINTEXT_LEN: usize = CORE_MAX_TX_METADATA_PLAINTEXT_LEN; - -/// 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`: -/// -/// - 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 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 -/// 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", - )); - } - } - }; - 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 - } -} - /// Create + broadcast a new document on `contract_id`'s /// `document_type_name`, owned by `owner_identity_id`, signed via the /// external `signer_handle`. @@ -279,351 +155,6 @@ fn confirmed_document_to_json(document: &Document) -> Result PlatformWalletFFIResult { - // ABI-stable explicit-index entry point: the host supplies the per-document - // encryptionKeyIndex (migration / tests). Delegates to the shared impl with - // `Some(index)`. - create_encrypted_document_impl( - wallet_handle, - mnemonic_resolver_handle, - owner_identity_id, - contract_id, - document_type_name, - Some(encryption_key_index), - version, - payload, - payload_len, - signer_handle, - out_document_id, - out_document_json, - ) -} - -/// Create + broadcast an encrypted `txMetadata` document, letting Rust generate -/// the per-document `encryptionKeyIndex`. ABI-additive sibling of -/// [`platform_wallet_create_encrypted_document_with_signer`] — IDENTICAL -/// parameters minus `encryption_key_index`. -/// -/// The host omits the index; the SDK draws a non-zero 31-bit BIP-32 child index -/// from the operating-system CSPRNG. `encryptionKeyIndex` is stored on every -/// document and read back during decryption, so it is not a protocol sequence -/// number. A repeated index is non-lossy: each document also carries a fresh IV -/// and both derive/decrypt from their own stored fields. Every other behavior -/// (identity-key selection, AES derivation, sealing, master wiping, broadcast) -/// matches the explicit-index export. -/// -/// # Safety -/// Same contract as [`platform_wallet_create_encrypted_document_with_signer`]. -#[no_mangle] -#[allow(clippy::too_many_arguments)] -pub unsafe extern "C" fn platform_wallet_create_encrypted_document_with_signer_auto_index( - wallet_handle: Handle, - mnemonic_resolver_handle: *mut MnemonicResolverHandle, - owner_identity_id: *const u8, - contract_id: *const u8, - document_type_name: *const c_char, - version: u8, - payload: *const u8, - payload_len: usize, - signer_handle: *mut SignerHandle, - out_document_id: *mut u8, - out_document_json: *mut *mut c_char, -) -> PlatformWalletFFIResult { - // Rust-generated-index entry point: the host omits encryptionKeyIndex, so - // the shared impl generates it while preparing the encrypted properties. - create_encrypted_document_impl( - wallet_handle, - mnemonic_resolver_handle, - owner_identity_id, - contract_id, - document_type_name, - None, - version, - payload, - payload_len, - signer_handle, - out_document_id, - out_document_json, - ) -} - -/// Shared implementation behind the explicit-index -/// ([`platform_wallet_create_encrypted_document_with_signer`], `Some`) and -/// Rust-generated -/// ([`platform_wallet_create_encrypted_document_with_signer_auto_index`], -/// `None`) encrypted-document create exports. -/// -/// When `index` is `None`, `IdentityWallet` generates the per-document -/// `encryptionKeyIndex` locally while preparing the encrypted properties. No -/// Platform count, allocator state, or allocation network round trip is needed. -/// -/// # Safety -/// All pointers must be valid for the duration of the call; `payload` may be -/// null only when `payload_len == 0`. -#[allow(clippy::too_many_arguments)] -unsafe fn create_encrypted_document_impl( - wallet_handle: Handle, - mnemonic_resolver_handle: *mut MnemonicResolverHandle, - owner_identity_id: *const u8, - contract_id: *const u8, - document_type_name: *const c_char, - index: Option, - version: u8, - payload: *const u8, - payload_len: usize, - signer_handle: *mut SignerHandle, - out_document_id: *mut u8, - out_document_json: *mut *mut c_char, -) -> PlatformWalletFFIResult { - check_ptr!(signer_handle); - check_ptr!(document_type_name); - 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(); - - // Reject before either native layer copies the caller's plaintext. This is - // deterministic and needs no wallet, resolver, randomness, or network. - unwrap_result_or_return!(ensure_tx_metadata_payload_fits(payload_len)); - - // 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; - - // Clone the Arc under the global handle-store read lock and release that - // lock immediately. Resolver callbacks and network waits must not block - // lifecycle writes for every wallet handle in the process. - let wallet = - unwrap_option_or_return!(PLATFORM_WALLET_STORAGE.with_item(wallet_handle, Arc::clone)); - let identity_wallet = wallet.identity().clone(); - - // Key-source selection may synchronously call back into the host resolver. - // The master is wrapped in a Drop-wiping guard and resolved only after the - // deterministic payload check above. - let master_opt = unwrap_result_or_return!(unsafe { - tx_metadata_key_master_for_wallet(&wallet, mnemonic_resolver_handle) - }) - .map(WipingMaster); - - // Derive + seal synchronously, then wipe plaintext and master before the - // broadcast await. The auto path generates its index inside platform-wallet - // and the explicit path remains available for migration/tests. - let key_source = match master_opt.as_ref() { - Some(master) => TxMetadataKeySource::Master(&master.0), - None => TxMetadataKeySource::ResidentWallet, - }; - let properties_json = unwrap_result_or_return!(match index { - Some(index) => identity_wallet.prepare_encrypted_txmetadata_properties( - &owner_id_for_async, - index, - version, - &payload_vec, - key_source, - ), - None => identity_wallet.prepare_encrypted_txmetadata_properties_auto_index( - &owner_id_for_async, - version, - &payload_vec, - key_source, - ), - }); - drop(payload_vec); - drop(master_opt); - - 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_document_with_signer( - &owner_id_for_async, - &contract_id_for_async, - &document_type_str, - &properties_json, - signer, - ) - .await?; - let json_string = confirmed_document_to_json(&confirmed)?; - Ok::<_, PlatformWalletError>((confirmed.id(), json_string)) - }); - 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). -/// -/// 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 -/// `{ "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, - mnemonic_resolver_handle: *mut MnemonicResolverHandle, - owner_identity_id: *const u8, - contract_id: *const u8, - document_type_name: *const c_char, - since_ms: u64, - out_documents_json: *mut *mut c_char, -) -> PlatformWalletFFIResult { - 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; - - // Do not retain the process-wide handle-store guard across the resolver - // callback and paginated network fetch. - let wallet = - unwrap_option_or_return!(PLATFORM_WALLET_STORAGE.with_item(wallet_handle, Arc::clone)); - let identity_wallet = wallet.identity().clone(); - let master_opt = unwrap_result_or_return!(unsafe { - tx_metadata_key_master_for_wallet(&wallet, mnemonic_resolver_handle) - }) - .map(WipingMaster); - - let result: Result, PlatformWalletError> = - block_on_worker(async move { - // Unlike create, a document's derivation indices are only known - // after its page is fetched, so the master remains in its wiping - // guard across pagination and is scrubbed immediately afterward. - let key_source = match master_opt.as_ref() { - Some(master) => TxMetadataKeySource::Master(&master.0), - None => TxMetadataKeySource::ResidentWallet, - }; - let fetched = identity_wallet - .fetch_encrypted_documents( - &owner_id_for_async, - &contract_id_for_async, - &document_type_str, - since_ms, - key_source, - ) - .await; - drop(master_opt); - fetched - }); - 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`. @@ -1039,87 +570,4 @@ mod tests { json.get("$createdAt") ); } - - /// Oversized plaintext is rejected before the function looks up the wallet - /// handle or copies the payload. The deliberately invalid handle therefore - /// must not mask the deterministic input error. - #[test] - fn encrypted_document_rejects_oversized_payload_before_wallet_lookup() { - let owner_id = [1u8; 32]; - let contract_id = [2u8; 32]; - let document_type = CString::new("txMetadata").unwrap(); - let payload = vec![0u8; MAX_TX_METADATA_PLAINTEXT_LEN + 1]; - let mut document_id = [0u8; 32]; - let mut document_json = ptr::null_mut(); - - let result = unsafe { - platform_wallet_create_encrypted_document_with_signer_auto_index( - NULL_HANDLE, - ptr::null_mut(), - owner_id.as_ptr(), - contract_id.as_ptr(), - document_type.as_ptr(), - 1, - payload.as_ptr(), - payload.len(), - 1usize as *mut SignerHandle, - document_id.as_mut_ptr(), - &mut document_json, - ) - }; - - assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorInvalidParameter - ); - assert!(document_json.is_null()); - assert_eq!(document_id, [0u8; 32]); - } - - // ── 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-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 95683cb131d..6676678cb6e 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -423,15 +423,6 @@ impl From for PlatformWalletFFIResult { { PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable } - // 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/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 6c7e86a5b8e..5e72b3c2deb 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -24,7 +24,6 @@ dashcore = { workspace = true } thiserror = "1.0" async-trait = "0.1" arc-swap = "1" -rand = "0.8" # Collections bimap = "0.6" @@ -34,14 +33,8 @@ tokio = { version = "1", features = ["sync", "rt", "time", "macros"] } tokio-util = { version = "0.7.12" } dash-async = { path = "../rs-dash-async" } -# 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`. +# Logging tracing = "0.1" -log = "0.4" # Encoding hex = "0.4" @@ -96,6 +89,7 @@ drive-proof-verifier = { path = "../rs-drive-proof-verifier" } # (via `dashcore/test-utils`) for building funded, signable test wallets. # Dev-only, so the production build stays on the leaner default features. key-wallet = { workspace = true, features = ["test-utils"] } +rand = "0.8" # Drives the parallel decrypt benchmark in `shielded_decrypt_bench.rs`. rayon = "1.10" # Used by `shielded_tree_append_bench.rs` to open SQLite with tuned PRAGMAs diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 7495b74956a..b3abb044109 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -32,21 +32,6 @@ 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/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 0e6f90c8d7c..e91c5ccee0a 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -63,9 +63,8 @@ pub use wallet::core::{CoreWallet, SignedCoreTransaction}; // `identity::crypto::*` internally). pub use wallet::core_address_key::CoreAddressPrivateKey; pub use wallet::identity::network::{ - derive_identity_auth_keypair, query_owned_encrypted_documents, AutoAcceptProofSource, - ContactCryptoProvider, ContactInfoOpened, ContactInfoPublishOutcome, ContactInfoSealed, - DecryptedEncryptedDocument, SeedBindingVerification, TxMetadataKeySource, IDENTITY_GAP_LIMIT, + derive_identity_auth_keypair, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, + ContactInfoPublishOutcome, ContactInfoSealed, 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 bd72b8fe402..c0a0687b44b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs @@ -7,7 +7,6 @@ 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; @@ -23,9 +22,4 @@ 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, 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 deleted file mode 100644 index edf1210e48c..00000000000 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs +++ /dev/null @@ -1,1101 +0,0 @@ -//! 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()` 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 -//! `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::{DerivationPath, ExtendedPrivKey, 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; - -/// `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 -/// [`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 { - let root_path = identity_auth_derivation_path_for_type( - network, - KeyDerivationType::ECDSA, - identity_index, - key_index, - )?; - - Ok(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}" - )) - })?, - ])) -} - -/// 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'`. -/// -/// ## 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 -/// 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}")) - })?; - 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)`. -/// -/// `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`). -/// -/// `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). -/// -/// `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, - iv: &[u8; 16], - payload: &[u8], -) -> Result, PlatformWalletError> { - if version != VERSION_CBOR && version != VERSION_PROTOBUF { - return Err(PlatformWalletError::InvalidIdentityData(format!( - "txMetadata version byte {version} is not wire-decodable; only \ - {VERSION_CBOR} (CBOR) and {VERSION_PROTOBUF} (protobuf) are understood \ - by the legacy decryptTxMetadata" - ))); - } - // Choke-point size guard: reject a plaintext that would overflow the - // encryptedMetadata field once framed (typed error, not an opaque DPP - // failure at broadcast). - 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); - blob.extend_from_slice(iv); - blob.extend_from_slice(&ciphertext); - Ok(blob) -} - -/// The plaintext recovered from a stored `encryptedMetadata` blob. -/// -/// `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. - pub version: u8, - /// The decrypted, PKCS7-unpadded payload bytes — opaque to this crate. - 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. -/// -/// 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).expect("valid version"); - // 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); - } - } - - /// 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). - #[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" - ); - } - } - - /// 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()); - } - - /// [`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. - #[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).expect("valid version"); - - 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()); - } - - /// 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) - .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).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); - } - - /// 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 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. - 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).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"); - 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") - } - - /// **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). - /// 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 - /// 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 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 - // 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" - ); - } - - /// **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 - /// 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. 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`). - /// - /// ## Provenance of the `8cda…5196` value: SELF-REFERENTIAL - /// - /// 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' (hand-built, not from the factory) - /// AES_KEY=8cdadb6b8bcf8defd416f2f032255173df89478c971bb96ae9f3511aae355196 - /// BLOB=01496ce7…2cba627383 (random per run — the IV differs; key is fixed) - /// ``` - /// - /// `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 nonzero_identity_index_derivation_slot_is_internally_consistent() { - 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 slot), key_index 2, encryptionKeyIndex 1. - let key = derive_tx_metadata_key(&wallet, Network::Testnet, 1, 2, 1).expect("derive"); - - // 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 — proves the slot is exercised. - let index0_key: [u8; 32] = - hex_lit("4a2eaec1ad959105738996b49e0327f96a80b765249d2c9af8cf6aa689aa84d7"); - assert_ne!( - 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, 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 key at this slot too — resident and master must never drift. - 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, slot1_key, - "resolver-master derivation must match the resident derivation at identity_index=1" - ); - - // 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, &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 blob sealed at identity_index=1 to the original plaintext" - ); - } -} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs deleted file mode 100644 index 33436b29454..00000000000 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ /dev/null @@ -1,732 +0,0 @@ -//! 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::{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, derive_tx_metadata_key_from_master, ensure_tx_metadata_payload_fits, - open_tx_metadata, seal_tx_metadata, -}; -use rand::{rngs::OsRng, RngCore}; - -use super::*; - -/// Largest valid non-hardened BIP-32 child index. `encryptionKeyIndex` becomes a -/// hardened child in the txMetadata path, so its raw value must stay below -/// `2^31` (`ChildNumber::from_hardened_idx` enforces the same bound). -const MAX_ENCRYPTION_KEY_INDEX: u32 = 0x7fff_ffff; - -/// Convert one random word into a valid txMetadata `encryptionKeyIndex`. -/// -/// Zero is skipped to preserve the legacy convention that document indices -/// start at one. The high bit is cleared because BIP-32 reserves it for the -/// hardened-child encoding itself. -fn encryption_key_index_candidate(random: u32) -> Option { - let candidate = random & MAX_ENCRYPTION_KEY_INDEX; - (candidate != 0).then_some(candidate) -} - -/// Generate a Rust-owned per-document txMetadata derivation index. -/// -/// This index is a derivation input carried by the document, not a document id -/// or a protocol uniqueness token. Readers always derive from the document's -/// own `{keyIndex, encryptionKeyIndex}` fields, and every sealed document has a -/// fresh random IV, so an index collision is non-lossy. A CSPRNG-generated -/// 31-bit value therefore gives the host-thin API the property it needs without -/// pretending that a client-side Platform count is an atomic allocator. -fn generate_encryption_key_index() -> u32 { - loop { - if let Some(candidate) = encryption_key_index_candidate(OsRng.next_u32()) { - return candidate; - } - } -} - -/// 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"; -const FIELD_ENCRYPTION_KEY_INDEX: &str = "encryptionKeyIndex"; -const FIELD_ENCRYPTED_METADATA: &str = "encryptedMetadata"; - -/// 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`) 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}"); -} - -/// 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`). -/// -/// `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, - /// 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 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 - /// 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)) - } - - /// 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. - /// - /// **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`: an explicit per-document derivation index. - /// New host APIs should omit it and use - /// [`Self::prepare_encrypted_txmetadata_properties_auto_index`]; this - /// explicit path remains for migration and compatibility tests. - /// - `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`]). - pub fn prepare_encrypted_txmetadata_properties( - &self, - owner_identity_id: &Identifier, - encryption_key_index: u32, - version: u8, - payload: &[u8], - key_source: TxMetadataKeySource<'_>, - ) -> 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)?; - - // 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, - self.sdk.network, - identity_index, - key_index, - encryption_key_index, - ) - .inspect_err(|e| { - breadcrumb_error(&format!( - "prepare_encrypted_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); - // Rejects a non-wire-decodable version byte (only 0/1) before it can be - // sealed into a document the legacy stack can't decode, and enforces the - // payload-size limit as the choke-point last line of defense - // (dashpay/platform#4091). - 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. - Ok(serde_json::json!({ - FIELD_KEY_INDEX: key_index, - FIELD_ENCRYPTION_KEY_INDEX: encryption_key_index, - FIELD_ENCRYPTED_METADATA: hex::encode(&blob), - }) - .to_string()) - } - - /// Rust-owned-index sibling of - /// [`Self::prepare_encrypted_txmetadata_properties`]. - /// - /// Generates a valid non-zero 31-bit BIP-32 child index with the operating - /// system CSPRNG, then derives and seals the document with that index. No - /// Platform query, allocator mutex, or wallet-facade state is involved. - /// This is deliberate: `encryptionKeyIndex` is stored on every document and - /// consumed from that document during decryption, so it is not a protocol - /// sequence number and does not need globally authoritative allocation. - /// Every document also receives a fresh random IV; an unlikely repeated - /// index therefore remains non-lossy and both documents decrypt normally. - pub fn prepare_encrypted_txmetadata_properties_auto_index( - &self, - owner_identity_id: &Identifier, - version: u8, - payload: &[u8], - key_source: TxMetadataKeySource<'_>, - ) -> Result { - // Keep deterministic rejection ahead of randomness and key derivation. - ensure_tx_metadata_payload_fits(payload.len())?; - self.prepare_encrypted_txmetadata_properties( - owner_identity_id, - generate_encryption_key_index(), - version, - payload, - key_source, - ) - } - - /// 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, - key_source: TxMetadataKeySource<'_>, - ) -> Result, PlatformWalletError> { - use dash_sdk::platform::{ContextProvider, Fetch}; - - // Successful stage breadcrumbs stay at DEBUG; genuine failures below - // use `breadcrumb_error` and remain visible at WARN. - breadcrumb(&format!( - "fetch_encrypted_documents: entry owner={owner_identity_id} \ - contract={contract_id} type={document_type_name} since_ms={since_ms} \ - key_source={}", - key_source.label() - )); - - // Fetch the contract and register it so `fetch_many`'s proof - // verification can resolve it through the context provider (the mobile - // provider never fetches contracts itself). - let contract = DataContract::fetch(&self.sdk, *contract_id) - .await - .map_err(|e| { - breadcrumb_error(&format!( - "fetch_encrypted_documents: contract fetch failed contract={contract_id} error={e}" - )); - PlatformWalletError::Sdk(e) - })? - .ok_or_else(|| { - breadcrumb_error(&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" - )) - })?; - // 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::clone(&contract)); - } - - let (_identity, identity_index, wallet) = self - .resolve_encryption_context(owner_identity_id) - .await - .inspect_err(|e| { - breadcrumb_error(&format!( - "fetch_encrypted_documents: encryption-context resolution failed \ - owner={owner_identity_id} error={e}" - )); - })?; - - // The wire query, split out so its exact shape is integration-testable - // against testnet without a resident wallet/identity (see - // `tests/txmetadata_fetch.rs`). - let raw_docs = query_owned_encrypted_documents( - &self.sdk, - Arc::clone(&contract), - owner_identity_id, - document_type_name, - since_ms, - ) - .await - .inspect_err(|e| { - breadcrumb_error(&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 { - // 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_error(&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 - .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 { - breadcrumb_error(&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 { - breadcrumb_error(&format!( - "fetch_encrypted_documents: document missing encryptedMetadata doc={doc_id} \ - owner={owner_identity_id}; skipping" - )); - continue; - }; - - let aes_key = match key_source.derive( - &wallet, - self.sdk.network, - identity_index, - key_index, - encryption_key_index, - ) { - Ok(k) => k, - Err(e) => { - breadcrumb_error(&format!( - "fetch_encrypted_documents: txMetadata key derivation failed doc={doc_id} \ - owner={owner_identity_id} key_source={} error={e}; skipping", - key_source.label() - )); - continue; - } - }; - let opened = match open_tx_metadata(&aes_key, &blob) { - Ok(o) => o, - Err(e) => { - breadcrumb_error(&format!( - "fetch_encrypted_documents: txMetadata decrypt failed doc={doc_id} \ - owner={owner_identity_id} error={e}; 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, - }); - } - breadcrumb(&format!( - "fetch_encrypted_documents: returning decrypted documents owner={owner_identity_id} \ - raw={} decrypted={}", - raw_docs.len(), - out.len() - )); - 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::data_contract::accessors::v0::DataContractV0Getters; - use dpp::platform_value::platform_value; - - const PAGE: u32 = 100; - breadcrumb(&format!( - "query_owned_encrypted_documents: entry owner={owner_identity_id} contract={} \ - type={document_type_name} since_ms={since_ms}", - contract.id() - )); - 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(|e| { - breadcrumb_error(&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(); - 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, - } - } - - // Keep the raw/materialized count at DEBUG for host diagnostics without - // emitting identity-correlated success telemetry to Android logcat. - 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) -} - -#[cfg(test)] -mod index_generation_tests { - use super::*; - - #[test] - fn candidate_is_nonzero_and_within_the_bip32_child_domain() { - assert_eq!(encryption_key_index_candidate(0), None); - assert_eq!(encryption_key_index_candidate(1), Some(1)); - assert_eq!( - encryption_key_index_candidate(u32::MAX), - Some(MAX_ENCRYPTION_KEY_INDEX), - "the hardened marker bit must not leak into the raw child index" - ); - assert_eq!( - encryption_key_index_candidate(0x8000_0001), - Some(1), - "only the lower 31 bits are part of the raw child index" - ); - } - - #[test] - fn generated_indices_are_valid_hardened_children() { - use key_wallet::bip32::ChildNumber; - - for _ in 0..256 { - let index = generate_encryption_key_index(); - assert!((1..=MAX_ENCRYPTION_KEY_INDEX).contains(&index)); - ChildNumber::from_hardened_idx(index) - .expect("generated txMetadata index must be a valid hardened child"); - } - } -} 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 10f6e969277..bbcc27c09e4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -24,7 +24,6 @@ mod contract; mod discovery; mod document; mod dpns; -mod encrypted_document; mod identity_handle; mod loading; mod register_from_addresses; @@ -71,9 +70,6 @@ pub use contact_requests::{ pub use dashpay_view::DashPayView; pub use discovery::IdentityDiscoveryOptions; pub use dpns::{ContestContender, ContestVoteState, ContestWinner}; -pub use encrypted_document::{ - query_owned_encrypted_documents, DecryptedEncryptedDocument, TxMetadataKeySource, -}; pub use identity_handle::{ derive_ecdsa_identity_auth_keypair_from_master, derive_identity_auth_key_hash_from_master, derive_identity_auth_keypair, identity_auth_derivation_path_for_type, DerivedIdentityAuthKey, diff --git a/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java b/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java deleted file mode 100644 index 1eac7ecb8e8..00000000000 --- a/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java +++ /dev/null @@ -1,80 +0,0 @@ -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). - * - * 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/LegacyKeyN.java b/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyKeyN.java deleted file mode 100644 index b97bb75f41a..00000000000 --- a/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyKeyN.java +++ /dev/null @@ -1,80 +0,0 @@ -import java.util.*; -import org.bitcoinj.crypto.*; - -/** - * 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: - * (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(); } - 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); - - // 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)); - 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 deleted file mode 100644 index 1d23b21aa60..00000000000 --- a/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md +++ /dev/null @@ -1,107 +0,0 @@ -# Legacy txMetadata wire-compat vector generator - -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`, - `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). - -## 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. **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 - 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 - -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` -- `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: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 -(`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-platform-wallet/tests/txmetadata_fetch.rs b/packages/rs-platform-wallet/tests/txmetadata_fetch.rs deleted file mode 100644 index dd3885ebc4d..00000000000 --- a/packages/rs-platform-wallet/tests/txmetadata_fetch.rs +++ /dev/null @@ -1,266 +0,0 @@ -//! 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 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. -//! -//! # 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"); - // 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 - // 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)" - ); - } -} - -/// Independent legacy-install capture SCAFFOLDING (dashpay/platform#4186, -/// reviewer shumkov's "decrypt a blob produced by a real legacy dash-wallet -/// install" ask). This is a MANUAL, testnet-gated helper — it resolves the -/// throwaway wallet's DPNS name `yabba2` to its identity id, fetches every -/// `txMetadata` document that identity owns, derives the tx-metadata key from -/// the wallet's recovery phrase with THIS branch's own derivation -/// (`derive_tx_metadata_key`, identity_index 0), opens each blob, and prints the -/// blob hex + key indices + decrypted plaintext so the captured values can be -/// hard-coded into the network-free fixture -/// `legacy_install_yabba2_wire_compat_vector` in -/// `src/wallet/identity/crypto/tx_metadata.rs`. -/// -/// The wallet is a DESIGNATED THROWAWAY provided for this fixture; its recovery -/// phrase is intended to become public in the repo. -/// -/// # Running -/// ```bash -/// cargo test -p platform-wallet --test txmetadata_fetch \ -/// capture_legacy_yabba2 -- --ignored --nocapture -/// ``` -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "hits testnet"] -async fn capture_legacy_yabba2_txmetadata_blobs() { - use key_wallet::mnemonic::{Language, Mnemonic}; - use key_wallet::wallet::initialization::WalletAccountCreationOptions; - use key_wallet::wallet::Wallet; - use platform_wallet::wallet::identity::crypto::tx_metadata::{ - derive_tx_metadata_key, open_tx_metadata, - }; - - let _ = tracing_subscriber::fmt().with_env_filter("info").try_init(); - - // The DESIGNATED THROWAWAY testnet wallet the legacy dash-wallet 11.9 install - // ran under (public by design for this fixture). - const YABBA2_PHRASE: &str = - "across jungle only rocket promote mule behave siren crush pole awful deposit"; - const DPNS_NAME: &str = "yabba2"; - - let sdk = testnet_sdk().await; - - // 1. Resolve the DPNS name -> identity id (the SDK's own resolver). - let owner = sdk - .resolve_dpns_name(DPNS_NAME) - .await - .expect("resolve_dpns_name call") - .expect("DPNS name yabba2 resolves to an identity"); - println!( - "RESOLVED yabba2 -> identity {}", - owner.to_string(Encoding::Base58) - ); - - // 2. Fetch + register the wallet-utils contract (production parity). - let contract_id = Identifier::from_string(CONTRACT_B58, Encoding::Base58).expect("contract id"); - let contract = DataContract::fetch(&sdk, contract_id) - .await - .expect("fetch contract") - .expect("wallet-utils contract present on testnet"); - { - use dash_sdk::platform::ContextProvider; - if let Some(provider) = sdk.context_provider() { - provider.register_data_contract(Arc::new(contract.clone())); - } - } - let contract = Arc::new(contract); - - // 3. The exact production query (since_ms = 0 => fetch everything). - let docs = query_owned_encrypted_documents(&sdk, Arc::clone(&contract), &owner, DOC_TYPE, 0) - .await - .expect("query owned encrypted documents"); - let materialized: Vec<_> = docs.iter().filter_map(|(_, d)| d.as_ref()).collect(); - println!( - "FETCHED {} txMetadata document(s) (raw entries: {}) for identity {}", - materialized.len(), - docs.len(), - owner.to_string(Encoding::Base58) - ); - if materialized.is_empty() { - println!( - "ZERO documents. Queried contract={CONTRACT_B58} type={DOC_TYPE} owner={} since_ms=0", - owner.to_string(Encoding::Base58) - ); - return; - } - - // 4. Derive keys from the wallet's recovery phrase with the branch's own - // derivation (identity_index 0 — the only slot a legacy wallet writes). - let wallet = Wallet::from_mnemonic( - Mnemonic::from_phrase(YABBA2_PHRASE, Language::English).expect("valid recovery phrase"), - Network::Testnet, - WalletAccountCreationOptions::None, - ) - .expect("wallet from recovery phrase"); - - // 5. Decrypt each document with the new Rust open path and print the capture. - for (i, doc) in materialized.iter().enumerate() { - let props = doc.properties(); - let key_index = props - .get("keyIndex") - .and_then(|v: &Value| v.to_integer::().ok()) - .expect("keyIndex is a u32"); - let encryption_key_index = props - .get("encryptionKeyIndex") - .and_then(|v: &Value| v.to_integer::().ok()) - .expect("encryptionKeyIndex is a u32"); - let blob = props - .get("encryptedMetadata") - .and_then(|v: &Value| v.to_binary_bytes().ok()) - .expect("encryptedMetadata is a byte array"); - let created_at = doc.created_at(); - let updated_at = doc.updated_at(); - - let aes_key = derive_tx_metadata_key( - &wallet, - Network::Testnet, - 0, - key_index, - encryption_key_index, - ) - .expect("derive txMetadata key at identity_index 0"); - let opened = open_tx_metadata(&aes_key, &blob).expect("open legacy blob"); - - println!("---- DOCUMENT {i} ----"); - println!("keyIndex = {key_index}"); - println!("encryptionKeyIndex = {encryption_key_index}"); - println!("createdAt = {created_at:?}"); - println!("updatedAt = {updated_at:?}"); - println!("blob_len = {}", blob.len()); - println!("BLOB_HEX = {}", hex::encode(&blob)); - println!("version = {}", opened.version); - println!("plaintext_len = {}", opened.payload.len()); - println!("PLAINTEXT_HEX = {}", hex::encode(&opened.payload)); - println!( - "PLAINTEXT_UTF8_LOSSY= {}", - String::from_utf8_lossy(&opened.payload) - ); - } -} diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 5232abe00a8..04552c1e9ea 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -750,285 +750,6 @@ 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` and its -/// Rust-generated-index sibling. -/// -/// The SDK derives the identity encryption key, seals `payload` into the -/// legacy `version ‖ IV ‖ AES-256-CBC` blob, and writes -/// `{keyIndex, encryptionKeyIndex, encryptedMetadata}`. `version` is the payload -/// version byte (`1` = protobuf); `payload` is the already-serialized opaque -/// plaintext (a protobuf `TxMetadataBatch`) — the SDK does not parse it. -/// -/// `encryption_key_index` carries the per-document index OR the `-1` sentinel -/// (dashpay/platform#4186 follow-up): a non-negative value is used verbatim -/// (routed to the explicit-index export, retained for migration / tests), while -/// `-1` means "let the SDK generate the per-document index" -/// and routes to `platform_wallet_create_encrypted_document_with_signer_auto_index`. -/// Any value `< -1` is rejected. Returns the confirmed document's canonical JSON -/// (its 32-byte id is the base58 `$id` field); null after throwing on error. -#[no_mangle] -#[allow(clippy::too_many_arguments)] -pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_documentCreateEncrypted( - mut env: JNIEnv, - _class: JClass, - wallet_handle: jlong, - mnemonic_resolver_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(); - }; - // encryptionKeyIndex == -1 is the "let Rust generate" sentinel: the - // host omits the index and platform-wallet draws a valid BIP-32 child - // index from the operating-system CSPRNG. A non-negative value is - // an explicit caller-supplied index; anything below -1 is invalid. - if encryption_key_index < -1 { - throw_sdk_exception( - env, - 1, - "encryptionKeyIndex must be >= 0, or -1 to let the SDK generate it", - ); - return ptr::null_mut(); - } - let auto_index = encryption_key_index == -1; - // Only 0 (CBOR) and 1 (protobuf) are wire-decodable by the legacy dashj - // decryptTxMetadata; anything else seals a document the legacy stack - // can't read. Fail fast here with the correct bound instead of the stale - // 0..=255 range. The Rust core `seal_tx_metadata` enforces the same - // invariant as the last line of defense. - if !(0..=1).contains(&version) { - throw_sdk_exception( - env, - 1, - "version must be 0 (CBOR) or 1 (protobuf) — the only wire-decodable txMetadata versions", - ); - return ptr::null_mut(); - } - // Reject by Java-array length before `convert_byte_array` creates the - // JNI-owned plaintext copy. The FFI/core repeat the same shared bound. - let payload_len = match env.get_array_length(&payload) { - Ok(len) => len as usize, - Err(_) => { - let _ = env.exception_clear(); - throw_sdk_exception(env, 1, "payload byte[] was null/invalid"); - return ptr::null_mut(); - } - }; - if payload_len > platform_wallet_ffi::document::MAX_TX_METADATA_PLAINTEXT_LEN { - throw_sdk_exception( - env, - 1, - &format!( - "txMetadata payload is {payload_len} bytes; maximum is {} bytes", - platform_wallet_ffi::document::MAX_TX_METADATA_PLAINTEXT_LEN, - ), - ); - return ptr::null_mut(); - } - // The JNI-owned plaintext copy. Wrapped in `Zeroizing` so it is scrubbed - // on drop, mirroring the inner FFI copy (`payload_vec` in - // `rs-platform-wallet-ffi/src/document.rs`). The inner copy is dropped - // before its broadcast `.await`; from here the whole broadcast happens - // synchronously *inside* the single FFI call below, so the earliest this - // buffer can be released is the instant that call returns — dropped - // explicitly there rather than left to linger (unscrubbed) to end of - // scope. This is the only plaintext copy in this function. - let payload_bytes = match env.convert_byte_array(&payload) { - Ok(b) => zeroize::Zeroizing::new(b), - 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 = if auto_index { - // Host omitted the index: route to the ABI-additive sibling that - // takes no encryptionKeyIndex and lets Rust generate it. - unsafe { - platform_wallet_ffi::platform_wallet_create_encrypted_document_with_signer_auto_index( - wallet_handle as Handle, - mnemonic_resolver_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, - owner.as_ptr(), - contract.as_ptr(), - doc_type.as_ptr(), - version as u8, - payload_bytes.as_ptr(), - payload_bytes.len(), - signer_handle as *mut SignerHandle, - out_id.as_mut_ptr(), - &mut out_json as *mut *mut c_char, - ) - } - } else { - unsafe { - platform_wallet_ffi::platform_wallet_create_encrypted_document_with_signer( - wallet_handle as Handle, - mnemonic_resolver_handle as *mut rs_sdk_ffi::MnemonicResolverHandle, - owner.as_ptr(), - contract.as_ptr(), - doc_type.as_ptr(), - encryption_key_index as u32, - version as u8, - payload_bytes.as_ptr(), - payload_bytes.len(), - signer_handle as *mut SignerHandle, - out_id.as_mut_ptr(), - &mut out_json as *mut *mut c_char, - ) - } - }; - // The FFI call has returned: the plaintext has been sealed into - // ciphertext and the broadcast has already completed. Scrub this copy - // now (as soon as possible), before result/JSON handling. - drop(payload_bytes); - 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, - mnemonic_resolver_handle: jlong, - owner_id: JByteArray, - contract_id: JByteArray, - document_type: JString, - since_ms: jlong, -) -> jstring { - guard(&mut env, ptr::null_mut(), |env| { - // Informational stage breadcrumbs are DEBUG; only genuine failure paths - // are WARN. The `sdkFetched=0` root cause is fixed (external-signable - // txMetadata derive), so these no longer need to be loud. Android visibility: `JNI_OnLoad` installs `android_logger` at - // `LevelFilter::Info`, so DEBUG lines stay OUT of on-device logcat while - // WARN error lines remain visible. NEVER log a raw handle value: only - // 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 != 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::debug!( - "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 { - 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(), - 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() { - log::warn!("documentFetchEncrypted: success code but null JSON; throwing"); - 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) }; - log::debug!( - "documentFetchEncrypted: success, returning {} chars of JSON to Kotlin", - json.len() - ); - - env.new_string(json) - .map(|s| s.into_raw()) - .unwrap_or(ptr::null_mut()) - }) -} - -/// 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 —