diff --git a/Cargo.lock b/Cargo.lock index 066dfe24847..2cfe702ef48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5206,6 +5206,7 @@ dependencies = [ "image", "key-wallet", "key-wallet-manager", + "log", "platform-encryption", "rand 0.8.6", "rayon", @@ -5230,6 +5231,7 @@ 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 518bff78278..2999353f3b1 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt @@ -1,9 +1,6 @@ package org.dashfoundation.dashsdk.documents import org.dashfoundation.dashsdk.wallet.op - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.TransactionsNative @@ -251,4 +248,111 @@ 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: the SDK + * derives the identity encryption key, + * seals [payload] into the legacy `version ‖ IV ‖ AES-256-CBC` blob, and + * writes `{keyIndex, encryptionKeyIndex, encryptedMetadata}`. + * + * Batching stays app-side: the caller serializes its items into [payload] + * (a protobuf `TxMetadataBatch`) and supplies its own per-document + * [encryptionKeyIndex] (dash-wallet's `1 + countAllRequests()` counter). + * The identity encryption key id (the `keyIndex` field) is chosen SDK-side + * to match the legacy stack, so the key never crosses the FFI boundary. + * + * @param encryptionKeyIndex per-document index; non-negative. + * @param version payload version byte. Which values are meaningful is + * decided by the wallet core; an unsupported one is rejected there and + * surfaced as a platform-wallet invalid-parameter error. + * @param payload already-serialized opaque plaintext; the SDK does not + * parse it. + * [mnemonicResolverHandle] is the host mnemonic-resolver handle + * ([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, + encryptionKeyIndex: Int, + version: Int, + payload: ByteArray, + signerHandle: Long, + ): String = gate.op { + require(ownerId.size == 32) { "ownerId must be 32 bytes" } + require(contractId.size == 32) { "contractId must be 32 bytes" } + require(encryptionKeyIndex >= 0) { + "encryptionKeyIndex must be non-negative, got $encryptionKeyIndex" + } + mapNativeErrors { + TransactionsNative.documentCreateEncrypted( + walletHandle, + mnemonicResolverHandle, + ownerId, + contractId, + documentType, + encryptionKeyIndex, + version, + payload, + signerHandle, + ) + } + } + + /** + * Fetch + DECRYPT every encrypted wallet-contract document owned by + * [ownerId] on [contractId]'s [documentType] updated at or after [sinceMs] + * (epoch-millis). Implements the read half of the legacy + * `BlockchainIdentity.getTxMetaData(since, key)` retirement: 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/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index de41a05412a..daf16ac9b41 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -63,10 +63,11 @@ sealed class DashSdkError( * rs-sdk-ffi `DashSDKErrorCode` range decoded above. * * The Android analog of Swift's `PlatformWalletError` enum - * (`PlatformWalletResult.swift`). Only the retry-semantics-bearing codes - * get dedicated types; everything else falls through to the - * [PlatformWallet] catch-all which still carries the native code + Rust - * message. + * (`PlatformWalletResult.swift`). Selected codes get dedicated types — + * those a caller is expected to branch on, such as ones carrying retry + * semantics or naming a specific recoverable condition; everything else + * falls through to the [PlatformWallet] catch-all which still carries the + * native code + Rust message. */ sealed class PlatformWallet( message: String, @@ -77,6 +78,20 @@ sealed class DashSdkError( class InvalidHandle(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorInvalidParameter` (native code 2). The wallet core rejected a + * caller-supplied argument. + * + * The core owns which values are acceptable and explains the rejection + * in [message]; surface that text rather than restating the rule here, + * so the SDK cannot drift out of step with what the core enforces. + * + * Extends [Generic] so callers that already match the fallback on + * native code 2 keep working unchanged. + */ + class InvalidParameter(message: String, cause: Throwable? = null) : + Generic(nativeCode = 2, message = message, cause = cause) + /** * `ErrorWalletOperation` (native code 6). A generic wallet-operation * failure — the platform-wallet catch-all mapping, distinct from the @@ -177,11 +192,15 @@ sealed class DashSdkError( ) /** - * Any other `PlatformWalletFFIResultCode` without a dedicated type. - * Carries the platform-wallet [nativeCode] (already de-offset) and - * the Rust-supplied message. + * Any `PlatformWalletFFIResultCode` without a narrower type. Carries + * the platform-wallet [nativeCode] (already de-offset) and the + * Rust-supplied message, so callers can branch on the code directly. + * + * Open because narrower types extend it rather than replacing it: a + * code that gains its own type must keep satisfying the callers already + * matching this one, and must keep reporting the same [nativeCode]. */ - class Generic( + open class Generic( val nativeCode: Int, message: String, cause: Throwable? = null, @@ -232,6 +251,7 @@ sealed class DashSdkError( ): DashSdkError = when (code) { // PlatformWalletFFIResultCode variants (platform-wallet-ffi/src/error.rs) 1 -> PlatformWallet.InvalidHandle(message, cause) // ErrorInvalidHandle + 2 -> PlatformWallet.InvalidParameter(message, cause) // ErrorInvalidParameter 6 -> PlatformWallet.WalletOperation(message, cause) // ErrorWalletOperation 7, // ErrorIdentityNotFound 8, // ErrorContactNotFound diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index d41c25b7507..838b6303702 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -164,6 +164,67 @@ 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 app's per-document index (dash-wallet's + * monotonic `1 + countAllRequests()` counter); non-negative. + * @param version payload version byte (`1` = protobuf, as the wallet writes). + * @param payload the already-serialized opaque plaintext (a protobuf + * `TxMetadataBatch`); the SDK does not parse it. + * @return the confirmed document's canonical JSON (its 32-byte id is the + * base58 `$id` field). + */ + external fun documentCreateEncrypted( + walletHandle: Long, + 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/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index f8e397cade5..787f5929fa3 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -46,6 +46,79 @@ class DashSdkErrorTest { assertFalse(DashSdkError.InvalidParameter("x").isRetryable) } + /** + * A caller-input rejection decided by the Rust core must arrive as a typed + * platform-wallet invalid-parameter error carrying the core's own wording. + * + * The Rust core owns which values are acceptable. For the host to stop + * re-stating that policy it needs the rejection to be distinguishable from + * an unrelated failure and to keep its explanation intact, so the message + * can be surfaced without the host knowing what was wrong with the request. + * + * The message here is deliberately opaque: reproducing the core's actual + * wording would make this test a second copy of the very policy the host is + * supposed to be free of. The only contract this test owns is the numeric + * one — the offset code — plus the requirement that whatever text arrives + * is passed through untouched. + */ + @Test + fun platformWalletInvalidParameterIsTypedAndPreservesTheCoreMessage() { + val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + val coreMessage = "rust-owned txMetadata validation detail" + + val mapped = runCatching { + mapNativeErrors { throw DashSDKException(offset + 2, coreMessage) } + }.exceptionOrNull() + + assertTrue( + "a platform-wallet invalid-parameter code must map to its own type, " + + "not to the untyped fallback", + mapped is DashSdkError.PlatformWallet.InvalidParameter, + ) + assertEquals( + "the core's explanation must reach the host unchanged", + coreMessage, + (mapped as DashSdkError).message, + ) + } + + /** + * Giving a code its own type must not strand callers that already branch on + * the untyped fallback. + * + * Existing code catches [DashSdkError.PlatformWallet.Generic] and inspects + * its native code to recognise specific failures. Introducing a narrower + * type for a code those callers already handle would silently stop matching + * for them, so the narrower type has to remain a Generic and keep reporting + * the same native code. + */ + @Test + fun platformWalletInvalidParameterRemainsCompatibleWithTheGenericFallback() { + val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + val coreMessage = "rust-owned txMetadata validation detail" + + val mapped = DashSdkError.fromNative(DashSDKException(offset + 2, coreMessage)) + + assertTrue( + "the narrower type must still satisfy the fallback callers match on", + mapped is DashSdkError.PlatformWallet.Generic, + ) + assertTrue( + "and must still be the narrower type", + mapped is DashSdkError.PlatformWallet.InvalidParameter, + ) + assertEquals( + "callers reading the native code off the fallback must still see it", + 2, + (mapped as DashSdkError.PlatformWallet.Generic).nativeCode, + ) + assertEquals( + "the core's explanation must reach the host unchanged", + coreMessage, + mapped.message, + ) + } + @Test fun platformWalletCodesMapToPlatformWalletSubtree() { val offset = DashSdkError.PLATFORM_WALLET_CODE_OFFSET diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index 9b8f9d279b1..1f00dc4c555 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -56,6 +56,10 @@ anyhow = { version = "1.0.81" } # Swift decodes via Codable. See `tokens/group_queries.rs`. serde_json = "1.0" bs58 = "0.5" +# Base64-encode the decrypted (opaque) txMetadata payload in the fetch JSON, +# matching the codebase's binary-in-JSON convention. See `document.rs` +# `platform_wallet_fetch_encrypted_documents`. +base64 = "0.22.1" # Zeroize intermediate key material crossing the FFI boundary. zeroize = { version = "1", features = ["derive"] } diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index 32509bc3356..df6122edfcc 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -8,16 +8,214 @@ use std::slice; use dpp::document::{Document, DocumentV0Getters}; use dpp::prelude::Identifier; use dpp::serialization::ValueConvertible; -use platform_wallet::PlatformWalletError; -use rs_sdk_ffi::{SignerHandle, VTableSigner}; +use key_wallet::bip32::ExtendedPrivKey; +use platform_wallet::wallet::identity::crypto::tx_metadata::{ + ensure_tx_metadata_payload_fits, ensure_tx_metadata_version_supported, +}; +use platform_wallet::{PlatformWalletError, TxMetadataKeySource}; +use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle, VTableSigner}; +use zeroize::Zeroizing; use crate::check_ptr; use crate::error::*; use crate::handle::*; -use crate::runtime::block_on_worker; +use crate::identity_keys_from_mnemonic::resolve_master_from_resolver; +use crate::runtime::{block_on_worker, try_block_on_worker}; use crate::types::read_identifier; use crate::{unwrap_option_or_return, unwrap_result_or_return}; +/// RAII guard scrubbing a resolved master xprv's secret scalar on drop. +/// `ExtendedPrivKey` has no `Drop`/`Zeroize` of its own, so a resolved master +/// would otherwise linger on the stack past its use — and a manual +/// `non_secure_erase()` placed after an `.await` is skipped on panic / early +/// return. Wrapping the master here scrubs it on EVERY exit path +/// 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 txMetadata payload-size policy as an FFI result. +/// +/// A Rust-ABI helper, not a C symbol: the JNI layer links this crate as an +/// rlib and calls it directly, so both hosts and the C export apply one +/// implementation of the limit and cannot drift apart. It answers from the +/// declared LENGTH alone, which is what lets a caller reject an over-large +/// batch before materializing the plaintext. +/// +/// Success allocates nothing; a rejection carries the core's typed explanation +/// through the same mapping every other wallet error uses. +pub fn tx_metadata_payload_len_result(payload_len: usize) -> PlatformWalletFFIResult { + match ensure_tx_metadata_payload_fits(payload_len) { + Ok(()) => PlatformWalletFFIResult::ok(), + Err(error) => error.into(), + } +} + +/// Run an encrypted export's Rust-ABI inner function so a panic in it cannot +/// reach the `extern "C"` frame. +/// +/// Where unwinding exists, an escaping panic would unwind into a frame declared +/// with the non-unwinding C ABI; the compiler stops that with a forced abort, +/// killing the host. Catching here turns it into an ordinary result instead. +/// +/// Under `panic = "abort"` a panic aborts where it is raised, so no catch is +/// possible and the inner function is called directly. What that profile gains +/// is narrower and comes from elsewhere: the known runtime and worker-join +/// failures are handled as VALUES (see [`crate::runtime::WorkerFailure`]) and +/// so never become panics at all. Arbitrary panics remain fatal there. +/// +/// The caught payload is deliberately dropped: an FFI message must stay bounded +/// and free of anything caller-derived. +fn contain_panics(inner: impl FnOnce() -> PlatformWalletFFIResult) -> PlatformWalletFFIResult { + #[cfg(panic = "unwind")] + { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(inner)) { + Ok(result) => result, + Err(_) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUnknown, + "encrypted document operation failed unexpectedly", + ), + } + } + #[cfg(not(panic = "unwind"))] + { + inner() + } +} + +/// Map a shared-runtime failure to an FFI result. +/// +/// Neither stage is the caller's fault, so both surface as the unknown-failure +/// code carrying the failure's own fixed, stage-only text. +fn worker_failure_result(failure: crate::runtime::WorkerFailure) -> PlatformWalletFFIResult { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorUnknown, + failure.to_string(), + ) +} + +// One-shot, thread-scoped panic injection for the encrypted create inner +// function, used to prove the containment above. Consumed by the first check on +// the calling thread, leaving no state behind. +#[cfg(test)] +thread_local! { + static FORCED_INNER_PANIC: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Make the next encrypted create inner call on THIS thread panic. +#[cfg(test)] +pub(crate) fn force_inner_panic_once() { + FORCED_INNER_PANIC.with(|flag| flag.set(true)); +} + +#[cfg(test)] +fn take_forced_inner_panic() { + if FORCED_INNER_PANIC.with(|flag| flag.replace(false)) { + panic!("forced inner panic"); + } +} + +/// 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`. +#[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`. @@ -155,6 +353,380 @@ fn confirmed_document_to_json(document: &Document) -> Result PlatformWalletFFIResult { + // Validate the output-parameter ADDRESS and publish the documented null + // sentinel before any other fallible input or lookup, so every later + // rejection leaves the caller holding null rather than whatever the + // variable happened to contain. A caller that follows the documented + // contract would otherwise free a pointer this call never owned. This runs + // in the `extern "C"` frame itself, so the sentinel is published even if + // the inner function later fails in any way. + check_ptr!(out_document_json); + *out_document_json = ptr::null_mut(); + + contain_panics(|| { + create_encrypted_document_inner( + wallet_handle, + mnemonic_resolver_handle, + owner_identity_id, + contract_id, + document_type_name, + encryption_key_index, + version, + payload, + payload_len, + signer_handle, + out_document_id, + out_document_json, + ) + }) +} + +/// Rust-ABI body of [`platform_wallet_create_encrypted_document_with_signer`]. +/// +/// Split out so a panic raised in here is caught before the `extern "C"` frame +/// (see [`contain_panics`]). The caller has already validated +/// `out_document_json` and published its null sentinel. +/// +/// # Safety +/// Same contract as the `extern "C"` wrapper: every non-null pointer argument +/// must be valid for the duration of the call, and `out_document_json` must +/// already point to writable storage. +#[allow(clippy::too_many_arguments)] +unsafe fn create_encrypted_document_inner( + wallet_handle: Handle, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + owner_identity_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + encryption_key_index: u32, + 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 { + #[cfg(test)] + take_forced_inner_panic(); + + check_ptr!(signer_handle); + check_ptr!(document_type_name); + check_ptr!(out_document_id); + + // `payload_len` is caller-supplied and bounds the copy below, so apply the + // shared size policy to the LENGTH before the payload pointer is validated, + // dereferenced or copied, and before any wallet, resolver or network work. + // An over-large batch is rejectable from the arguments alone; deferring it + // would copy the plaintext and drive a host keychain round-trip for a + // request that can never succeed. Routed through the shared helper so this + // path and the JNI pre-copy gate cannot diverge. + let payload_len_check = tx_metadata_payload_len_result(payload_len); + if payload_len_check.code != PlatformWalletFFIResultCode::Success { + return payload_len_check; + } + + // The wire version is likewise decidable from the argument alone. Rejecting + // it here keeps an unsealable request from reaching the payload pointer, the + // wallet, or the host key resolver — the last of which drives a device + // keychain round-trip that can prompt the user. + unwrap_result_or_return!(ensure_tx_metadata_version_supported(version)); + + let owner_id = unwrap_result_or_return!(read_identifier(owner_identity_id)); + let contract_id_value = unwrap_result_or_return!(read_identifier(contract_id)); + let document_type_str = + unwrap_result_or_return!(CStr::from_ptr(document_type_name).to_str()).to_string(); + + // Copy the payload into an owned buffer. Null is allowed only for a + // zero-length payload. It is wrapped in `Zeroizing` so the native plaintext + // copy is scrubbed on drop, and it is dropped explicitly the instant the + // encrypted properties are prepared (below) — the plaintext must NOT linger + // in scope across the broadcast `.await`. + 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; + + // `move` so the closure OWNS `payload_vec` and can drop it (scrubbing the + // plaintext) before the broadcast `.await`; the other captures are Copy or + // already moved into the nested `async move` block. + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, move |wallet| { + let identity_wallet = wallet.identity().clone(); + + // Key-source selection by wallet capability (may synchronously call + // back into the host mnemonic resolver for external-signable + // wallets — see `tx_metadata_key_master_for_wallet`). The resolved + // master is wrapped in a Drop-wiping guard. + let master_opt = + unsafe { tx_metadata_key_master_for_wallet(wallet, mnemonic_resolver_handle) }? + .map(WipingMaster); + + // Derive the AES key + seal the wire blob SYNCHRONOUSLY, then wipe the + // master BEFORE any network `.await`: the master xprv never crosses the + // broadcast await. Only the sealed properties + // (ciphertext, no key material) cross into the async block below. + let key_source = match master_opt.as_ref() { + Some(master) => TxMetadataKeySource::Master(&master.0), + None => TxMetadataKeySource::ResidentWallet, + }; + let properties_json = identity_wallet + .prepare_encrypted_txmetadata_properties( + &owner_id_for_async, + encryption_key_index, + version, + &payload_vec, + key_source, + ) + .map_err(PlatformWalletFFIResult::from)?; + // The plaintext is now sealed inside `properties_json` (ciphertext + // only). Scrub the native plaintext copy AND the master immediately — + // neither may cross the broadcast `.await` below. `payload_vec` is + // `Zeroizing`, so the drop also wipes its bytes. + drop(payload_vec); + drop(master_opt); + + // Fallible worker entry: a runtime that cannot be built, or a worker + // that does not complete, becomes a value this export maps instead of a + // panic that would reach the C frame. + let result: Result<(Identifier, String), PlatformWalletError> = + try_block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + // Generic create path (no key material in scope): fetches the + // contract, sanitizes the hex `encryptedMetadata` into `Bytes`, + // auto-selects the AUTHENTICATION signing key, and broadcasts on + // the 8 MB worker stack. + let confirmed: Document = identity_wallet + .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)) + }) + .map_err(worker_failure_result)?; + result.map_err(PlatformWalletFFIResult::from) + }); + let result = unwrap_option_or_return!(option); + let (document_id, document_json) = unwrap_result_or_return!(result); + + let json_cstring = unwrap_result_or_return!(CString::new(document_json)); + + let bytes = document_id.to_buffer(); + let dst = slice::from_raw_parts_mut(out_document_id, 32); + dst.copy_from_slice(&bytes); + *out_document_json = json_cstring.into_raw(); + PlatformWalletFFIResult::ok() +} + +/// Fetch + DECRYPT every encrypted wallet-contract document owned by +/// `owner_identity_id` on `contract_id`'s `document_type_name` updated at or +/// after `since_ms` (epoch-millis). +/// +/// Goes through `IdentityWallet::fetch_encrypted_documents` — the wire- +/// compatible read counterpart of the legacy `getTxMetaData(since, key)`. Each +/// document's `encryptedMetadata` blob is decrypted with the identity's derived +/// key; documents that can't be derived/decrypted are skipped (never abort the +/// fetch). +/// +/// 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 { + // Validate the output-parameter ADDRESS and publish the documented null + // sentinel before any other fallible input or lookup, so every later + // rejection leaves the caller holding null rather than whatever the + // variable happened to contain. This runs in the `extern "C"` frame itself, + // so the sentinel is published even if the inner function later fails in + // any way. + check_ptr!(out_documents_json); + *out_documents_json = ptr::null_mut(); + + contain_panics(|| { + fetch_encrypted_documents_inner( + wallet_handle, + mnemonic_resolver_handle, + owner_identity_id, + contract_id, + document_type_name, + since_ms, + out_documents_json, + ) + }) +} + +/// Rust-ABI body of [`platform_wallet_fetch_encrypted_documents`]. +/// +/// Split out so a panic raised in here is caught before the `extern "C"` frame +/// (see [`contain_panics`]). The caller has already validated +/// `out_documents_json` and published its null sentinel. +/// +/// # Safety +/// Same contract as the `extern "C"` wrapper: every non-null pointer argument +/// must be valid for the duration of the call, and `out_documents_json` must +/// already point to writable storage. +unsafe fn fetch_encrypted_documents_inner( + wallet_handle: Handle, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + owner_identity_id: *const u8, + contract_id: *const u8, + document_type_name: *const c_char, + since_ms: u64, + out_documents_json: *mut *mut c_char, +) -> PlatformWalletFFIResult { + use base64::Engine; + + check_ptr!(document_type_name); + + let owner_id = unwrap_result_or_return!(read_identifier(owner_identity_id)); + let contract_id_value = unwrap_result_or_return!(read_identifier(contract_id)); + let document_type_str = + unwrap_result_or_return!(CStr::from_ptr(document_type_name).to_str()).to_string(); + + let owner_id_for_async = owner_id; + let contract_id_for_async = contract_id_value; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + + // Key-source selection by wallet capability (may synchronously call + // back into the host mnemonic resolver for external-signable + // wallets — see `tx_metadata_key_master_for_wallet`). The resolved + // master is wrapped in a Drop-wiping guard. + let master_opt = + unsafe { tx_metadata_key_master_for_wallet(wallet, mnemonic_resolver_handle) }? + .map(WipingMaster); + + // Fallible worker entry: a runtime that cannot be built, or a worker + // that does not complete, becomes a value this export maps instead of a + // panic that would reach the C frame. + let result: Result, PlatformWalletError> = + try_block_on_worker(async move { + // TRADEOFF: unlike create, a document's + // (keyIndex, encryptionKeyIndex) are only known AFTER its page is + // fetched, so the master cannot be fully pre-derived before the + // network work. It therefore stays resident across the pagination + // awaits — but inside the `WipingMaster` Drop guard, so a panic or + // early return still scrubs its scalar (a manual post-await erase + // would be skipped on those paths). Per-document key derivation is + // itself synchronous, between page fetches (see + // `fetch_encrypted_documents`). + let key_source = match master_opt.as_ref() { + Some(master) => TxMetadataKeySource::Master(&master.0), + None => TxMetadataKeySource::ResidentWallet, + }; + let fetched = identity_wallet + .fetch_encrypted_documents( + &owner_id_for_async, + &contract_id_for_async, + &document_type_str, + since_ms, + key_source, + ) + .await; + drop(master_opt); // scrub as soon as the fetch completes + fetched + }) + .map_err(worker_failure_result)?; + result.map_err(PlatformWalletFFIResult::from) + }); + let result = unwrap_option_or_return!(option); + let docs = unwrap_result_or_return!(result); + + let json_array: Vec = docs + .iter() + .map(|d| { + serde_json::json!({ + "id": bs58::encode(d.document_id.to_buffer()).into_string(), + "ownerId": bs58::encode(d.owner_id.to_buffer()).into_string(), + "keyIndex": d.key_index, + "encryptionKeyIndex": d.encryption_key_index, + "version": d.version, + "updatedAt": d.updated_at_ms, + "payload": base64::engine::general_purpose::STANDARD.encode(&d.payload), + }) + }) + .collect(); + let json_string = + unwrap_result_or_return!(serde_json::to_string(&serde_json::Value::Array(json_array))); + let json_cstring = unwrap_result_or_return!(CString::new(json_string)); + *out_documents_json = json_cstring.into_raw(); + PlatformWalletFFIResult::ok() +} + /// Replace + broadcast `document_id`'s properties on `contract_id`'s /// `document_type_name`, owned by `owner_identity_id`, signed via the /// external `signer_handle` with key `signing_key_id`. @@ -516,6 +1088,9 @@ mod tests { use super::*; use dpp::document::DocumentV0; use dpp::platform_value::Value; + // The single source of truth for the limit, so the boundary tests cannot + // drift from the Rust core that enforces it. + use platform_wallet::wallet::identity::crypto::tx_metadata::MAX_TX_METADATA_PLAINTEXT_LEN; use std::collections::BTreeMap; // The confirmed-document JSON handed to Swift must be the same canonical @@ -570,4 +1145,685 @@ mod tests { json.get("$createdAt") ); } + + // ── tx_metadata_key_master_for_wallet dispatch ────────────────────────── + // + // `tx_metadata_key_master_for_wallet` needs a live `PlatformWallet` (wallet + // manager + SDK), which a unit test can't cheaply build, so its load-bearing + // 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" + ); + } + + // ── Encrypted-export input contracts ──────────────────────────────────── + // + // These drive the real `extern "C"` entry points. They deliberately use a + // wallet handle that is absent from `PLATFORM_WALLET_STORAGE`: the handle + // lookup is a plain map `get`, so an unknown handle is a safe, ordinary + // miss (`NotFound`) and NO closure body — resolver callback, key + // derivation, allocator, or broadcast — ever runs. That miss is what makes + // the ordering observable: whichever check reports first is the check that + // ran first. + + /// A wallet handle guaranteed absent from the storage map, so the export's + /// `with_item` closure cannot run. + const UNKNOWN_WALLET_HANDLE: Handle = u64::MAX; + + /// A non-null pointer to real, test-owned storage, used where the export + /// only checks for null and never dereferences. + fn opaque_non_null(storage: &mut u8) -> *mut T { + storage as *mut u8 as *mut T + } + + // ── Resolver dispatch through the encrypted create export ─────────────── + // + // A wallet registered through `PlatformWalletManager` is downgraded to + // external-signable before it is stored, so it holds no in-process private + // keys and its txMetadata key must come from the host mnemonic resolver. + // That makes the real export drive a real resolver vtable, with the mock + // SDK guaranteeing no network is reachable. + + /// Host-side resolver context: the phrase to hand back, plus a count of how + /// many times the host was consulted. + struct ResolverContext { + /// Derived at runtime from all-zero entropy so no recovery phrase is + /// committed to the repository. + phrase: String, + calls: std::sync::atomic::AtomicUsize, + } + + /// Resolver callback that answers with the generated mnemonic and records + /// that the host was consulted — the observable that says whether the + /// export reached out to the device keychain. + unsafe extern "C" fn counting_resolve( + ctx: *const std::ffi::c_void, + _wallet_id_bytes: *const u8, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + ) -> i32 { + let context = &*(ctx as *const ResolverContext); + context + .calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + + let phrase = context.phrase.as_bytes(); + if phrase.len() + 1 > out_capacity { + return rs_sdk_ffi::mnemonic_resolver_result::BUFFER_TOO_SMALL; + } + ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out_buf, phrase.len()); + *out_buf.add(phrase.len()) = 0; + *out_len = phrase.len(); + rs_sdk_ffi::mnemonic_resolver_result::SUCCESS + } + + unsafe extern "C" fn noop_destroy(_ctx: *mut std::ffi::c_void) {} + + /// The identity the fixture owns, and the id the export is called with. + const FIXTURE_OWNER: [u8; 32] = [3u8; 32]; + + /// A live wallet handle backed by a mock SDK, plus the resolver handle and + /// its shared context. + struct ResolverFixture { + wallet_handle: Handle, + resolver: *mut MnemonicResolverHandle, + context: *mut ResolverContext, + manager_handle: Handle, + _sdk: Box, + } + + impl ResolverFixture { + fn resolver_calls(&self) -> usize { + unsafe { + (*self.context) + .calls + .load(std::sync::atomic::Ordering::SeqCst) + } + } + } + + impl Drop for ResolverFixture { + fn drop(&mut self) { + unsafe { + // Release the wallet handle BEFORE the manager so the process- + // wide `PLATFORM_WALLET_STORAGE` does not accumulate a wallet + // per test run. + let _ = crate::wallet::platform_wallet_destroy(self.wallet_handle); + let _ = crate::manager::platform_wallet_manager_destroy(self.manager_handle); + // `noop_destroy` frees nothing, so the context is owned here and + // freed exactly once. + rs_sdk_ffi::dash_sdk_mnemonic_resolver_destroy(self.resolver); + drop(Box::from_raw(self.context)); + } + } + } + + /// An identity carrying the ECDSA key the txMetadata key derivation selects + /// (`AUTHENTICATION` / `HIGH`, the documented fallback arm). + fn fixture_identity() -> dpp::identity::Identity { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + + let key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 2, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: dpp::platform_value::BinaryData::new(vec![0x02; 33]), + disabled_at: None, + }); + let mut public_keys = BTreeMap::new(); + public_keys.insert(2, key); + + dpp::identity::Identity::V0(IdentityV0 { + id: Identifier::from(FIXTURE_OWNER), + public_keys, + balance: 0, + revision: 0, + }) + } + + /// Build a manager on a mock SDK, register a wallet through the real FFI + /// path (which stores it as external-signable), give it a resident identity + /// slot so key preparation can proceed, and wire a counting host resolver. + fn resolver_fixture() -> ResolverFixture { + use key_wallet::mnemonic::{Language, Mnemonic}; + use std::ffi::c_void; + + unsafe extern "C" fn begin_changeset(_ctx: *mut c_void, _wallet_id: *const u8) -> i32 { + 0 + } + unsafe extern "C" fn end_changeset( + _ctx: *mut c_void, + _wallet_id: *const u8, + _success: bool, + ) -> i32 { + 0 + } + + // Generated, never committed. + let mnemonic = + Mnemonic::from_entropy(&[0u8; 16], Language::English).expect("16 bytes of entropy"); + let phrase = mnemonic.phrase().to_string(); + + let sdk = Box::new( + dash_sdk::SdkBuilder::new_mock() + .build() + .expect("mock sdk builds"), + ); + let persistence = crate::PersistenceCallbacks { + on_changeset_begin_fn: Some(begin_changeset), + on_changeset_end_fn: Some(end_changeset), + ..Default::default() + }; + let events = crate::EventHandlerCallbacks { + context: ptr::null_mut(), + on_wallet_event_fn: None, + on_error_fn: None, + on_platform_address_sync_completed_fn: None, + on_shielded_sync_completed_fn: None, + on_shielded_sync_progress_fn: None, + on_shielded_tree_progress_fn: None, + }; + + let mut manager_handle: Handle = 0; + let result = unsafe { + crate::manager::platform_wallet_manager_create( + &*sdk as *const dash_sdk::Sdk as *const c_void, + &persistence, + &events, + &mut manager_handle, + ) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::Success, + "manager creation must succeed" + ); + + let mnemonic_c = CString::new(phrase.clone()).expect("no interior NUL"); + let mut wallet_handle: Handle = 0; + let mut wallet_id = [0u8; 32]; + let result = unsafe { + crate::manager::platform_wallet_manager_create_wallet_from_mnemonic( + manager_handle, + mnemonic_c.as_ptr(), + crate::FFINetwork::Testnet, + 0, // no accounts: nothing here needs an address pool + &mut wallet_handle, + &mut wallet_id, + ) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::Success, + "wallet registration must succeed on a mock SDK" + ); + + // Give the wallet a resident identity slot so key preparation resolves + // an encryption context and reaches the wire-version check. + PLATFORM_WALLET_STORAGE + .with_item(wallet_handle, |wallet| { + let persister = wallet.persister().clone(); + let id = wallet.wallet_id(); + let mut wm = wallet.wallet_manager().blocking_write(); + let info = wm.get_wallet_info_mut(&id).expect("registered wallet info"); + info.identity_manager + .add_identity(fixture_identity(), 0, id, &persister) + .expect("add the fixture identity"); + }) + .expect("wallet handle is live"); + + let context = Box::into_raw(Box::new(ResolverContext { + phrase, + calls: std::sync::atomic::AtomicUsize::new(0), + })); + let resolver = unsafe { + rs_sdk_ffi::dash_sdk_mnemonic_resolver_create( + context as *mut std::ffi::c_void, + counting_resolve, + noop_destroy, + ) + }; + + ResolverFixture { + wallet_handle, + resolver, + context, + manager_handle, + _sdk: sdk, + } + } + + /// Invoke the encrypted create export against the fixture wallet, returning + /// the result and whether the JSON out-pointer was left null. + fn create_encrypted_with( + fixture: &ResolverFixture, + version: u8, + ) -> (PlatformWalletFFIResult, bool) { + let mut out_json: *mut c_char = ptr::null_mut(); + let mut out_id = [0u8; 32]; + let doc_type = CString::new("txMetadata").expect("no interior NUL"); + let contract = [4u8; 32]; + let payload = [0xabu8; 16]; + let mut signer_storage = 0u8; + + let result = unsafe { + platform_wallet_create_encrypted_document_with_signer( + fixture.wallet_handle, + fixture.resolver, + FIXTURE_OWNER.as_ptr(), + contract.as_ptr(), + doc_type.as_ptr(), + 0, + version, + payload.as_ptr(), + payload.len(), + opaque_non_null(&mut signer_storage), + out_id.as_mut_ptr(), + &mut out_json, + ) + }; + + let json_is_null = out_json.is_null(); + // Defensive: the documented contract is that this stays null on every + // error path, but reclaim it rather than leak if that ever changes. + if !json_is_null { + unsafe { drop(CString::from_raw(out_json)) }; + } + (result, json_is_null) + } + + /// Invoke the encrypted fetch export against the fixture wallet, returning + /// the result and whether the JSON out-pointer was left null. + fn fetch_encrypted_with(fixture: &ResolverFixture) -> (PlatformWalletFFIResult, bool) { + let mut out_json: *mut c_char = ptr::null_mut(); + let doc_type = CString::new("txMetadata").expect("no interior NUL"); + let contract = [4u8; 32]; + + let result = unsafe { + platform_wallet_fetch_encrypted_documents( + fixture.wallet_handle, + fixture.resolver, + FIXTURE_OWNER.as_ptr(), + contract.as_ptr(), + doc_type.as_ptr(), + 0, + &mut out_json, + ) + }; + + let json_is_null = out_json.is_null(); + if !json_is_null { + unsafe { drop(CString::from_raw(out_json)) }; + } + (result, json_is_null) + } + + /// The encrypted create export, driven end to end on an external-signable + /// wallet with a real host resolver vtable. + /// + /// A wire version the core cannot encode is decidable from the argument + /// alone, so it must be rejected before the export touches the payload + /// pointer, the wallet, the host resolver or the network. Reaching the + /// resolver first drives a device keychain round-trip — a user-visible + /// prompt on some hosts — for a request that can never succeed. + #[test] + fn create_encrypted_rejects_an_unsupported_version_before_resolver_or_key_work() { + let fixture = resolver_fixture(); + + let (result, json_is_null) = create_encrypted_with(&fixture, 2); + + assert_eq!( + fixture.resolver_calls(), + 0, + "an unsupported version is decidable from the arguments, so the host \ + resolver must never be consulted for it" + ); + assert!( + json_is_null, + "the JSON out-pointer must remain null on this error path" + ); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "an unsupported wire version must reach the host as a distinguishable, \ + Rust-owned caller-input error; flattening it leaves hosts unable to tell \ + it from any other failure, which is why they still carry their own \ + version literals" + ); + } + + // ── Runtime / worker failure containment at the C boundary ────────────── + // + // Both encrypted exports drive async work through the shared runtime. A + // runtime that fails to build, and a worker future that panics, are the two + // ways that machinery fails independently of the request. Left as panics, + // both end the host process: on an unwinding profile the unwind reaches the + // non-unwinding `extern "C"` frame and is stopped there by a forced abort; + // on an aborting profile the panic aborts where it is raised. Each must + // instead become an ordinary FFI result, with the documented nullable output + // still null. + // + // What that buys differs by profile. Runtime-construction failure, and a + // join error the runtime reports explicitly, become values on any profile. + // Recovering from a worker that PANICKED, and catching a panic raised in the + // inner function, both require unwinding; under `panic = "abort"` the panic + // aborts at its origin and neither mapping runs. The forced-failure tests + // below therefore cover the value paths, while the inner-panic test is + // gated to unwind builds. + // + // The forced failures are one-shot and scoped to the calling thread, so + // these tests do not perturb anything running in parallel. + + /// A worker that fails to join must reach the host as an ordinary error. + #[test] + fn create_encrypted_maps_forced_worker_join_failure_to_error_unknown_and_keeps_json_null() { + let fixture = resolver_fixture(); + crate::runtime::force_worker_join_failure_once(); + + // Version 1 is supported, so the request passes validation and key + // preparation and reaches the worker gate, where the failure is forced. + let (result, json_is_null) = create_encrypted_with(&fixture, 1); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorUnknown, + "a worker join failure is not a caller-input error; it must surface as \ + the unknown-failure code rather than ending the host process" + ); + assert!( + json_is_null, + "the JSON out-pointer must remain null when the worker fails" + ); + // The supported-version counterpart of the early version gate: a + // version the core accepts must still reach the host resolver, so the + // gate cannot be satisfied by rejecting everything. + assert_eq!( + fixture.resolver_calls(), + 1, + "a supported version must proceed to derive its key through the host \ + resolver exactly once" + ); + } + + /// The same containment on the fetch export. + #[test] + fn fetch_encrypted_maps_forced_worker_join_failure_to_error_unknown_and_keeps_json_null() { + let fixture = resolver_fixture(); + crate::runtime::force_worker_join_failure_once(); + + let (result, json_is_null) = fetch_encrypted_with(&fixture); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorUnknown, + "a worker join failure on the fetch path must surface as the \ + unknown-failure code rather than ending the host process" + ); + assert!( + json_is_null, + "the JSON out-pointer must remain null when the worker fails" + ); + } + + /// Runtime construction failure is the other independent machinery failure. + /// One export is enough: both reach it through the same shared helper. + #[test] + fn create_encrypted_maps_forced_runtime_init_failure_to_error_unknown_and_keeps_json_null() { + let fixture = resolver_fixture(); + crate::runtime::force_runtime_init_failure_once(); + + let (result, json_is_null) = create_encrypted_with(&fixture, 1); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorUnknown, + "a runtime that cannot be built must surface as the unknown-failure \ + code rather than aborting the host" + ); + assert!( + json_is_null, + "the JSON out-pointer must remain null when the runtime cannot be built" + ); + } + + /// Where unwinding exists, a panic raised inside the export's Rust-ABI inner + /// function must be caught before the `extern "C"` frame — otherwise the + /// unwind reaches a frame that cannot unwind and is turned into a forced + /// abort. On an aborting profile the panic aborts at its origin, so no catch + /// can help and the guarantee is asserted only where it can hold. + #[cfg(panic = "unwind")] + #[test] + fn create_encrypted_contains_an_inner_panic_before_the_extern_c_boundary() { + let fixture = resolver_fixture(); + force_inner_panic_once(); + + let (result, json_is_null) = create_encrypted_with(&fixture, 1); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorUnknown, + "a panic inside the inner function must be converted to a result before \ + the C frame, rather than unwinding into it and forcing an abort" + ); + assert!( + json_is_null, + "the JSON out-pointer must remain null when the inner function panics" + ); + } + + // Both encrypted exports document that `*out_..._json` is "left null" on + // ANY error, so a host reads that pointer after a failure and frees it when + // non-null. The null sentinel must therefore be published BEFORE any other + // fallible input validation — otherwise an early rejection returns with the + // caller's variable still holding its prior value, and a host following the + // documented contract frees a pointer this call never owned. + + /// An early input rejection must still leave `*out_document_json` null. + #[test] + fn create_encrypted_publishes_null_json_out_before_other_validation() { + // Real, test-owned storage so the sentinel is a valid non-null pointer + // rather than a fabricated address. + let mut sentinel_storage: c_char = 0x7f; + let mut out_json: *mut c_char = &mut sentinel_storage; + let mut out_id = [0u8; 32]; + let doc_type = CString::new("txMetadata").expect("no interior NUL"); + let owner = [1u8; 32]; + let contract = [2u8; 32]; + let payload = [0xabu8; 8]; + + // A NULL signer handle: a deliberately invalid input that trips an + // early validation path. The documented null-output contract must hold + // there too, not only on failures reached later in the call. + let result = unsafe { + platform_wallet_create_encrypted_document_with_signer( + UNKNOWN_WALLET_HANDLE, + ptr::null_mut(), + owner.as_ptr(), + contract.as_ptr(), + doc_type.as_ptr(), + 0, + 1, + payload.as_ptr(), + payload.len(), + ptr::null_mut(), + out_id.as_mut_ptr(), + &mut out_json, + ) + }; + + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert!( + out_json.is_null(), + "the export documents `*out_document_json` as null on any error, but an \ + early input rejection returned with the caller's pointer unchanged" + ); + } + + /// Same contract on the fetch export's documented nullable output. + #[test] + fn fetch_encrypted_publishes_null_json_out_before_other_validation() { + let mut sentinel_storage: c_char = 0x7f; + let mut out_json: *mut c_char = &mut sentinel_storage; + let owner = [1u8; 32]; + let contract = [2u8; 32]; + + // A NULL document type: a deliberately invalid input that trips an + // early validation path. The documented null-output contract must hold + // there too, not only on failures reached later in the call. + let result = unsafe { + platform_wallet_fetch_encrypted_documents( + UNKNOWN_WALLET_HANDLE, + ptr::null_mut(), + owner.as_ptr(), + contract.as_ptr(), + ptr::null(), + 0, + &mut out_json, + ) + }; + + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert!( + out_json.is_null(), + "the export documents `*out_documents_json` as null on any error, but an \ + early input rejection returned with the caller's pointer unchanged" + ); + } + + // The payload length limit is enforced by the Rust core; the boundary must + // apply it before it touches the payload pointer at all. `payload_len` is + // caller-supplied and is used both as the copy length and as the value the + // limit applies to, so checking the length first is what keeps an oversized + // or mis-declared length from reaching a dereference, a native copy, the + // wallet lookup, or the host resolver callback. + + /// A NULL payload pointer with an over-limit declared length: the length is + /// rejectable without reading a single byte. Returning the size error here + /// proves the limit is applied BEFORE pointer validation, dereference and + /// the native copy — the same gate that keeps an oversized real buffer from + /// being copied and from driving the resolver. + #[test] + fn create_encrypted_rejects_oversized_length_before_touching_the_payload_pointer() { + let mut sentinel_storage: c_char = 0x7f; + let mut out_json: *mut c_char = &mut sentinel_storage; + let mut out_id = [0u8; 32]; + let doc_type = CString::new("txMetadata").expect("no interior NUL"); + let owner = [1u8; 32]; + let contract = [2u8; 32]; + let mut signer_storage = 0u8; + let oversized_len = MAX_TX_METADATA_PLAINTEXT_LEN + 1; + + let result = unsafe { + platform_wallet_create_encrypted_document_with_signer( + UNKNOWN_WALLET_HANDLE, + ptr::null_mut(), + owner.as_ptr(), + contract.as_ptr(), + doc_type.as_ptr(), + 0, + 1, + // NULL payload: nothing here may legally be read, so any answer + // other than the size error means the length was not consulted + // first. + ptr::null(), + oversized_len, + opaque_non_null(&mut signer_storage), + out_id.as_mut_ptr(), + &mut out_json, + ) + }; + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "a declared length of {oversized_len} exceeds the \ + {MAX_TX_METADATA_PLAINTEXT_LEN}-byte maximum and is rejectable without \ + reading the payload; reporting any other failure first proves the \ + boundary validates and dereferences the pointer, copies the plaintext, \ + and enters the wallet/resolver path before the length is ever checked" + ); + } + + /// The accepted side of the same boundary, driven with a REAL buffer: a + /// maximum-length payload must pass the size gate and fail only at the + /// (absent) wallet handle, proving the gate rejects the over-limit length + /// without also rejecting the largest legal one. + #[test] + fn create_encrypted_accepts_maximum_payload_at_the_size_gate() { + let mut sentinel_storage: c_char = 0x7f; + let mut out_json: *mut c_char = &mut sentinel_storage; + let mut out_id = [0u8; 32]; + let doc_type = CString::new("txMetadata").expect("no interior NUL"); + let owner = [1u8; 32]; + let contract = [2u8; 32]; + let payload = vec![0xabu8; MAX_TX_METADATA_PLAINTEXT_LEN]; + let mut signer_storage = 0u8; + + let result = unsafe { + platform_wallet_create_encrypted_document_with_signer( + UNKNOWN_WALLET_HANDLE, + ptr::null_mut(), + owner.as_ptr(), + contract.as_ptr(), + doc_type.as_ptr(), + 0, + 1, + payload.as_ptr(), + payload.len(), + opaque_non_null(&mut signer_storage), + out_id.as_mut_ptr(), + &mut out_json, + ) + }; + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::NotFound, + "a {MAX_TX_METADATA_PLAINTEXT_LEN}-byte payload is exactly the maximum \ + and must pass the size gate, reaching the wallet-handle lookup" + ); + } } diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 44532de8638..5cc9f52eef4 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -336,6 +336,25 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AssetLockFundingMismatch { .. } => { PlatformWalletFFIResultCode::ErrorAssetLockFundingMismatch } + // A txMetadata plaintext too large to seal into the encryptedMetadata + // field. Surfaced as a caller-input error (the payload parameter is + // out of range) rather than flattening to ErrorUnknown; the typed + // Display carries the supplied length and the accepted maximum. Maps + // to the already-mirrored ErrorInvalidParameter so no new numeric + // code churns the Swift/Kotlin mirror enums. + PlatformWalletError::TxMetadataPayloadTooLarge { .. } => { + PlatformWalletFFIResultCode::ErrorInvalidParameter + } + // A txMetadata wire version byte the legacy stack cannot decode. + // Like the size cap it is a caller-input error, and it maps to the + // already-mirrored ErrorInvalidParameter so no new numeric code + // churns the Swift/Kotlin mirror enums. Mapped as its own dedicated + // variant — the generic invalid-data error stays on ErrorUnknown, so + // this stays distinguishable and hosts need no version list of their + // own. + PlatformWalletError::UnsupportedTxMetadataVersion { .. } => { + PlatformWalletFFIResultCode::ErrorInvalidParameter + } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; PlatformWalletFFIResult::err(code, error.to_string()) diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 5d80c33ded5..a5265205e9e 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -132,6 +132,10 @@ pub use persistence::*; pub use platform_address_sync::*; pub use platform_address_types::*; pub use platform_addresses::*; +// The txMetadata plaintext ceiling, surfaced here so callers that link this +// crate as an rlib (the JNI layer) can gate on the same value the C exports +// enforce instead of restating it. +pub use platform_wallet::wallet::identity::crypto::tx_metadata::MAX_TX_METADATA_PLAINTEXT_LEN; pub use platform_wallet_info::*; pub use provider_key_at_index::*; #[cfg(feature = "shielded")] diff --git a/packages/rs-platform-wallet-ffi/src/runtime.rs b/packages/rs-platform-wallet-ffi/src/runtime.rs index ee96010db00..7d575d52627 100644 --- a/packages/rs-platform-wallet-ffi/src/runtime.rs +++ b/packages/rs-platform-wallet-ffi/src/runtime.rs @@ -23,41 +23,179 @@ /// affecting memory footprint (we spin up a small number of workers). const WORKER_STACK_BYTES: usize = 8 * 1024 * 1024; -/// Get the shared tokio runtime. +/// Which piece of the shared async machinery failed, independently of the +/// request being served. /// -/// All async FFI functions use this runtime. Prefer -/// [`block_on_worker`] over `runtime().block_on(...)` so the heavy -/// work runs on a worker thread with the larger stack configured -/// here, rather than the (small) calling thread. -pub(crate) fn runtime() -> &'static tokio::runtime::Runtime { - static RT: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { +/// Deliberately a unit-like enum: it names the STAGE and nothing else. The +/// underlying `io::Error`, `JoinError` and panic payload are dropped at the +/// point of mapping, so nothing unbounded or caller-derived travels through +/// this VALUE into an FFI result message or a log. +/// +/// That is a property of the value, not of the process. A panicking worker +/// still runs the default panic hook at the point of the panic — before this +/// mapping happens — and that hook may emit the payload on its own channel. +/// Futures submitted through this module must therefore never panic with +/// sensitive or caller-derived data; the classification here is not a redaction +/// mechanism for panics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkerFailure { + /// The shared runtime could not be built. + RuntimeInit, + /// The worker task did not run to completion (it panicked or was cancelled). + WorkerJoin, +} + +impl std::fmt::Display for WorkerFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + WorkerFailure::RuntimeInit => "async runtime could not be created", + WorkerFailure::WorkerJoin => "async worker did not complete", + }) + } +} + +impl std::error::Error for WorkerFailure {} + +// One-shot failure injection, scoped to the calling thread so parallel tests +// cannot observe or race each other's forcing. Each hook is consumed by the +// first check that sees it and leaves the flag clear, so a forced failure +// affects exactly one call and no state survives the test. +#[cfg(test)] +thread_local! { + static FORCED_RUNTIME_INIT_FAILURE: std::cell::Cell = const { std::cell::Cell::new(false) }; + static FORCED_WORKER_JOIN_FAILURE: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Make the next [`try_runtime`] call on THIS thread report [`WorkerFailure::RuntimeInit`]. +#[cfg(test)] +pub(crate) fn force_runtime_init_failure_once() { + FORCED_RUNTIME_INIT_FAILURE.with(|flag| flag.set(true)); +} + +/// Make the next [`try_block_on_worker`] call on THIS thread report +/// [`WorkerFailure::WorkerJoin`]. +#[cfg(test)] +pub(crate) fn force_worker_join_failure_once() { + FORCED_WORKER_JOIN_FAILURE.with(|flag| flag.set(true)); +} + +#[cfg(test)] +fn take_forced_runtime_init_failure() -> bool { + FORCED_RUNTIME_INIT_FAILURE.with(|flag| flag.replace(false)) +} + +#[cfg(not(test))] +fn take_forced_runtime_init_failure() -> bool { + false +} + +#[cfg(test)] +fn take_forced_worker_join_failure() -> bool { + FORCED_WORKER_JOIN_FAILURE.with(|flag| flag.replace(false)) +} + +#[cfg(not(test))] +fn take_forced_worker_join_failure() -> bool { + false +} + +/// Get the shared tokio runtime, reporting construction failure as a value. +/// +/// Preferred by callers that cross a non-unwinding `extern "C"` boundary: a +/// panic there would unwind into a frame that cannot unwind and be turned into +/// a forced abort, so the failure has to be a value they can map. +pub(crate) fn try_runtime() -> Result<&'static tokio::runtime::Runtime, WorkerFailure> { + // Checked before the shared runtime is touched, so a forced failure never + // builds, caches, replaces or poisons it — the next call still gets the + // real runtime. Kept outside the cell mechanism so it exercises the + // caller's mapping rather than the cell's retry behavior. + if take_forced_runtime_init_failure() { + return Err(WorkerFailure::RuntimeInit); + } + + static RT: once_cell::sync::OnceCell = + once_cell::sync::OnceCell::new(); + + get_or_try_init_runtime(&RT, || { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(WORKER_STACK_BYTES) .build() - .expect("Failed to create tokio runtime for platform-wallet-ffi"); + .map_err(|_| WorkerFailure::RuntimeInit)?; #[cfg(feature = "tokio-metrics")] metrics::spawn_sampler(&rt); - rt - }); - &RT + Ok(rt) + }) +} + +/// Return the cell's runtime, initializing it once if it is empty. +/// +/// A failing initializer is NOT recorded: construction can fail for conditions +/// that pass, such as the OS momentarily refusing to spawn threads, and +/// remembering that first failure would make one transient refusal permanent +/// for the life of the process. The cell therefore stays empty until an +/// initializer succeeds, after which the runtime is shared by every caller. +/// The returned reference borrows from `cell`, so this works for the shared +/// `static` cell and for a local one a test owns — the retry behavior is the +/// same either way and nothing here assumes a `'static` lifetime. +fn get_or_try_init_runtime( + cell: &once_cell::sync::OnceCell, + init: impl FnOnce() -> Result, +) -> Result<&tokio::runtime::Runtime, WorkerFailure> { + cell.get_or_try_init(init) +} + +/// Get the shared tokio runtime. +/// +/// All async FFI functions use this runtime. Prefer +/// [`block_on_worker`] over `runtime().block_on(...)` so the heavy +/// work runs on a worker thread with the larger stack configured +/// here, rather than the (small) calling thread. +pub(crate) fn runtime() -> &'static tokio::runtime::Runtime { + try_runtime().expect("Failed to create tokio runtime for platform-wallet-ffi") +} + +/// Drive `future` to completion on a worker thread, reporting runtime and +/// worker failure as values rather than panicking. +/// +/// The calling thread still blocks (that's what FFI wants); it just parks on a +/// oneshot instead of driving the future itself. +pub(crate) fn try_block_on_worker(future: F) -> Result +where + F: std::future::Future + Send + 'static, + F::Output: Send + 'static, +{ + let rt = try_runtime()?; + + // Consumed on the CALLING thread, before the spawn: the future itself runs + // on a worker, where a thread-local set by the caller is not visible. + if take_forced_worker_join_failure() { + return Err(WorkerFailure::WorkerJoin); + } + + rt.block_on(async move { + // The `JoinError` (and any panic payload it carries) is dropped here — + // only the stage travels onward. + rt.spawn(future) + .await + .map_err(|_| WorkerFailure::WorkerJoin) + }) } /// Drive `future` to completion, moving the actual polling onto a /// worker thread so the caller's stack size doesn't bound the /// computation. /// -/// The calling thread still blocks (that's what FFI wants); it just -/// parks on a oneshot instead of driving the future itself. +/// Panics if the runtime cannot be built or the worker fails to complete. Call +/// sites that cannot afford a panic use [`try_block_on_worker`] instead. pub(crate) fn block_on_worker(future: F) -> F::Output where F: std::future::Future + Send + 'static, F::Output: Send + 'static, { - let rt = runtime(); - rt.block_on(async move { rt.spawn(future).await.expect("tokio worker panicked") }) + try_block_on_worker(future).expect("platform-wallet-ffi async worker failed") } /// Run `f` to completion on a freshly spawned scoped OS thread with the @@ -76,9 +214,13 @@ where /// compiles: it reuses pooled runtime workers instead of paying a /// thread spawn per call. /// -/// A panic inside `f` is propagated as a panic here, matching -/// [`block_on_worker`]'s "tokio worker panicked" convention — a panic -/// in the pass is a bug, not a recoverable condition. +/// A panic inside `f` is propagated as a panic here. This helper and +/// [`block_on_worker`] share that stance: a panic in the passed work, or +/// a worker that fails to complete, is a programmer or runtime fault +/// rather than a recoverable condition, and the infallible helper +/// panics on it. Call sites that must not panic — anything crossing a +/// non-unwinding `extern "C"` frame — use [`try_block_on_worker`] and +/// map [`WorkerFailure`] to a result instead. pub(crate) fn run_on_big_stack_thread(f: impl FnOnce() -> T + Send) -> std::io::Result { std::thread::scope(|scope| { let handle = std::thread::Builder::new() @@ -122,6 +264,132 @@ mod tests { let out = run_on_big_stack_thread(|| recurse(1_000)).expect("spawn should succeed"); assert!(out > 0); } + + // ── Fallible runtime + worker contracts ───────────────────────────────── + // + // `runtime()` and `block_on_worker()` both panic on failure: one `expect`s + // the runtime build, the other `expect`s the join handle. Either way the + // host process goes down. On an unwinding profile the unwind reaches the + // non-unwinding `extern "C"` frame and is stopped there by a forced abort; + // on an aborting profile the panic aborts where it is raised. + // + // The fallible siblings give the export a value to map instead. What that + // covers differs by profile: runtime-construction failure, and a join error + // the runtime reports explicitly, become values on any profile. Recovering + // from a worker that PANICKED requires unwinding — under `panic = "abort"` + // the panic aborts the process at its origin and no join mapping runs. The + // infallible pair stays for callers already built around it. + + /// Runtime construction failure is a distinct outcome from a worker that + /// panicked, and forcing it must not leave the shared runtime poisoned or + /// replaced for anything else in the process. + #[test] + fn try_runtime_surfaces_construction_failure_as_error() { + force_runtime_init_failure_once(); + assert!( + matches!(try_runtime(), Err(WorkerFailure::RuntimeInit)), + "a forced construction failure must surface as RuntimeInit" + ); + + // The one-shot is spent, so the shared runtime is intact and usable. + assert!( + try_runtime().is_ok(), + "forcing the failure must not poison or replace the shared runtime" + ); + } + + /// A future that panics on the worker must reach the caller as a value, not + /// as an unwind. This is the outcome an encrypted C export maps to an + /// ordinary error code rather than letting it reach the C frame. + /// + /// Only meaningful where unwinding exists: under `panic = "abort"` the + /// worker panic aborts the process and there is nothing to observe. + #[cfg(panic = "unwind")] + #[test] + fn try_block_on_worker_surfaces_a_real_worker_panic_as_join_failure() { + let outcome: Result<(), WorkerFailure> = + try_block_on_worker(async { panic!("worker panic under test") }); + + assert!( + matches!(outcome, Err(WorkerFailure::WorkerJoin)), + "a panicking worker future must be reported as WorkerJoin, not re-raised \ + as a panic in the caller" + ); + } + + /// A failed initialization must not be remembered. + /// + /// Runtime construction can fail for reasons that pass — the OS refusing + /// threads under momentary pressure, for instance. Caching that first + /// failure would turn a transient condition into a permanently dead + /// process, so the cell must stay empty until an initializer succeeds and + /// only then hold the runtime. + #[test] + fn runtime_cell_does_not_cache_a_failed_initializer() { + let cell: once_cell::sync::OnceCell = + once_cell::sync::OnceCell::new(); + + let first = get_or_try_init_runtime(&cell, || Err(WorkerFailure::RuntimeInit)); + assert!( + matches!(first, Err(WorkerFailure::RuntimeInit)), + "a failing initializer must surface as RuntimeInit" + ); + assert!( + cell.get().is_none(), + "a failed initialization must leave the cell empty so it can be retried" + ); + + let second = get_or_try_init_runtime(&cell, || { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|_| WorkerFailure::RuntimeInit) + }); + assert!( + second.is_ok(), + "a later successful initializer must succeed" + ); + assert!( + cell.get().is_some(), + "a successful initialization must populate the cell" + ); + } + + /// The ordinary path still returns the future's output untouched. + #[test] + fn try_block_on_worker_round_trips_a_normal_output() { + let out = try_block_on_worker(async { 41 + 1 }).expect("no failure was forced"); + assert_eq!(out, 42); + } + + /// The failure classification carries no future output and no panic payload + /// — only which stage failed — so nothing unbounded or caller-derived can + /// reach an FFI result message through it. + /// + /// This covers the RETURNED value only. The default panic hook still runs + /// at the point of the panic, before any of this mapping, and may print the + /// payload; that is a separate channel this assertion does not constrain. + /// + /// Only meaningful where unwinding exists, for the same reason as the + /// worker-panic test above. + #[cfg(panic = "unwind")] + #[test] + fn worker_failure_message_is_bounded_and_stage_only() { + let forced: Result<(), WorkerFailure> = + try_block_on_worker(async { panic!("payload that must not be echoed") }); + let failure = forced.expect_err("the worker panicked"); + + let rendered = failure.to_string(); + assert!( + !rendered.contains("payload that must not be echoed"), + "the panic payload must not travel in the failure message: {rendered}" + ); + assert!( + !rendered.is_empty() && rendered.len() <= 128, + "the failure message must be present and bounded, got {} chars", + rendered.len() + ); + } } #[cfg(feature = "tokio-metrics")] diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 05d4f933086..91917f589bc 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -32,8 +32,14 @@ bimap = "0.6" tokio = { version = "1", features = ["sync", "rt", "time", "macros"] } tokio-util = { version = "0.7.12" } -# Logging +# Logging. `log` sits alongside `tracing` for on-device (Android) +# diagnostics: the JNI layer installs `android_logger` as the global `log` +# logger (logcat tag `DashSDK`), while the only `tracing` subscriber the +# Kotlin SDK installs (`dash_sdk_enable_logging`) writes to stdout, which +# Android discards — so breadcrumbs that must be visible in logcat are +# emitted through BOTH facades. See `network/encrypted_document.rs`. tracing = "0.1" +log = "0.4" # Encoding hex = "0.4" diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 6e11514beaa..ccf5512ea5b 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -32,6 +32,32 @@ 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 }, + + /// A caller-supplied txMetadata wire version byte outside the set the + /// legacy `decryptTxMetadata` stack can decode. Sealing it would write a + /// document no reader could open, so it is rejected before the envelope is + /// built. Typed (rather than a generic invalid-data error) so hosts can + /// distinguish it at the FFI boundary and need no version list of their own. + #[error( + "txMetadata wire version {version} is not decodable by the legacy stack; \ + only 0 (CBOR) and 1 (protobuf) are understood" + )] + UnsupportedTxMetadataVersion { version: u8 }, + #[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 e91c5ccee0a..0e6f90c8d7c 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -63,8 +63,9 @@ pub use wallet::core::{CoreWallet, SignedCoreTransaction}; // `identity::crypto::*` internally). pub use wallet::core_address_key::CoreAddressPrivateKey; pub use wallet::identity::network::{ - derive_identity_auth_keypair, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, - ContactInfoPublishOutcome, ContactInfoSealed, SeedBindingVerification, IDENTITY_GAP_LIMIT, + derive_identity_auth_keypair, query_owned_encrypted_documents, AutoAcceptProofSource, + ContactCryptoProvider, ContactInfoOpened, ContactInfoPublishOutcome, ContactInfoSealed, + DecryptedEncryptedDocument, SeedBindingVerification, TxMetadataKeySource, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; pub use wallet::identity::{ diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs index c0a0687b44b..bd72b8fe402 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs @@ -7,6 +7,7 @@ pub mod auto_accept; pub mod contact_info; pub mod dip14; pub mod invitation; +pub mod tx_metadata; pub mod validation; pub use auto_accept::derive_auto_accept_private_key; @@ -22,4 +23,9 @@ 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 new file mode 100644 index 00000000000..ec86a235b5c --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs @@ -0,0 +1,1118 @@ +//! 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(()) +} + +/// Reject a `txMetadata` wire version byte the legacy stack cannot decode. +/// +/// Decidable from the argument alone, so entry points run it before touching a +/// payload, a wallet, a host key resolver or the network: consulting a device +/// keychain — which on some hosts prompts the user — for a request that can +/// never be sealed is work nobody asked for. [`seal_tx_metadata`] keeps the +/// same check as its choke-point last line of defense, so a caller that skips +/// the early gate still cannot produce an undecodable document. +pub fn ensure_tx_metadata_version_supported(version: u8) -> Result<(), PlatformWalletError> { + if version != VERSION_CBOR && version != VERSION_PROTOBUF { + return Err(PlatformWalletError::UnsupportedTxMetadataVersion { version }); + } + Ok(()) +} + +/// 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. It +/// is rejected HERE, at the one choke point every layer funnels through, so no +/// caller can produce such a document by reaching this function directly; entry +/// points reject it earlier via [`ensure_tx_metadata_version_supported`]. +/// +/// `payload` must be at most [`MAX_TX_METADATA_PLAINTEXT_LEN`] bytes: a larger +/// plaintext seals into a blob that overflows the `encryptedMetadata` field and +/// 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> { + // Choke-point guards. Entry points reject both conditions earlier, from the + // arguments alone; repeating them here means a caller that reaches this + // function by another route still cannot seal an undecodable or oversized + // document. + ensure_tx_metadata_version_supported(version)?; + ensure_tx_metadata_payload_fits(payload.len())?; + let ciphertext = platform_encryption::encrypt_aes_256_cbc(key, iv, payload); + let mut blob = Vec::with_capacity(BLOB_HEADER_LEN + ciphertext.len()); + 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); + } + } + + /// The choke-point wire-version guard: `seal_tx_metadata` accepts only the + /// two versions the legacy `decryptTxMetadata` understands (0 = CBOR, + /// 1 = protobuf) and rejects everything else, so the guarantee holds for + /// any caller that reaches it directly rather than through an entry point + /// that gates the version earlier. + #[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. The + // rejection carries the dedicated typed variant, which is what lets the + // FFI boundary surface it as a caller-input error instead of flattening + // it into the generic unknown-failure code. + for version in 2u8..=255 { + match seal_tx_metadata(&key, version, &iv, &payload) { + Err(PlatformWalletError::UnsupportedTxMetadataVersion { version: reported }) => { + assert_eq!( + reported, version, + "the rejection must report the version it rejected" + ); + } + other => panic!( + "version {version} must be rejected as UnsupportedTxMetadataVersion, \ + got {other:?}" + ), + } + } + } + + /// Payload-size boundary: 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`. + /// 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: one check that decrypts + /// a blob produced by a real legacy dash-wallet install.** + /// + /// Unlike [`legacy_dashj_wire_compat_vector`] and + /// [`nonzero_identity_index_derivation_slot_is_internally_consistent`] — + /// which are generated by driving dashj-core's crypto primitives from a JVM + /// scratch program (`tests/legacy_wire_compat/LegacyKeyN.java`) — this + /// vector was not produced by this repo at all. It is a blob a real + /// **dash-wallet 11.9 Android install** (the shipping dashj crypto path) + /// created on TESTNET, encrypted, and published to Dash Platform. It was + /// fetched back off testnet once and is decrypted here with the Rust crypto, + /// closing the loop the JVM-generated vectors cannot: those prove Rust ⟷ + /// dashj-core agree on primitives this repo invokes; THIS proves the Rust + /// `open` path decrypts a document that a stock legacy app, running end to + /// end, actually wrote to the network. + /// + /// ## Provenance + /// + /// The wallet is a DESIGNATED THROWAWAY, testnet-only, provided 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 under identity + /// `ESR1nfF3bj4TR2ZkLmDuSeu6r7VzpTurYi47BV6XwsoP`. That document was + /// fetched from testnet once and its values hard-coded below, so this test + /// needs no network: `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 + /// [`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.** 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 new file mode 100644 index 00000000000..70ba036a5f6 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -0,0 +1,1118 @@ +//! 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 +//! for create and decrypt-on-fetch. The encryption ENVELOPE — key derivation, the +//! `version ‖ IV ‖ AES-256-CBC(payload)` blob, and the `keyIndex` / +//! `encryptionKeyIndex` / `encryptedMetadata` document fields — is +//! wire-compatible with the legacy `BlockchainIdentity.publishTxMetaData` / +//! `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, + ensure_tx_metadata_version_supported, open_tx_metadata, seal_tx_metadata, +}; + +use super::*; + +/// Where one encrypted-document call derives the per-document txMetadata AES +/// key from. Selected by the CALLER (the FFI layer) from the wallet's shape — +/// the same capability convention as the identity discovery / key-preview +/// paths (`identity_key_preview.rs`): +/// +/// - a wallet with resident private keys (mnemonic / seed / xprv — test +/// fixtures, desktop wallets) derives in-process +/// ([`TxMetadataKeySource::ResidentWallet`], the historical path); +/// - an external-signable / watch-only wallet (the Android/iOS apps: the seed +/// lives host-side, keys derive on demand through the registered mnemonic +/// resolver) holds NO in-process private keys — the in-wallet derive fails +/// with `External signable wallet has no private key` (the exact on-device +/// failure that zeroed the decrypt-proof). For that shape the FFI resolves +/// the wallet's mnemonic via the host `MnemonicResolverHandle`, builds the +/// master xprv, passes [`TxMetadataKeySource::Master`], and wipes the +/// master after the call — atomic derive + use + zeroize. +/// +/// Both sources derive the IDENTICAL path +/// ([`crate::wallet::identity::crypto::tx_metadata::tx_metadata_derivation_path`]), +/// pinned equal by unit test. +#[derive(Clone, Copy)] +pub enum TxMetadataKeySource<'a> { + /// Derive from the in-process resident wallet's private keys. + ResidentWallet, + /// Derive from this caller-resolved master extended private key + /// (external-signable / watch-only wallet). The caller owns the master's + /// lifecycle and MUST wipe it (`private_key.non_secure_erase()`) once the + /// call returns. + Master(&'a key_wallet::bip32::ExtendedPrivKey), +} + +impl TxMetadataKeySource<'_> { + /// Compact breadcrumb label. + fn label(&self) -> &'static str { + match self { + TxMetadataKeySource::ResidentWallet => "resident-wallet", + TxMetadataKeySource::Master(_) => "resolver-master", + } + } + + /// Derive the AES key for one document from this source. `wallet` is the + /// in-process wallet (only consulted by the resident variant). + fn derive( + &self, + wallet: &key_wallet::wallet::Wallet, + network: key_wallet::Network, + identity_index: u32, + key_index: u32, + encryption_key_index: u32, + ) -> Result, PlatformWalletError> { + match self { + TxMetadataKeySource::ResidentWallet => derive_tx_metadata_key( + wallet, + network, + identity_index, + key_index, + encryption_key_index, + ), + TxMetadataKeySource::Master(master) => derive_tx_metadata_key_from_master( + master, + network, + identity_index, + key_index, + encryption_key_index, + ), + } + } +} + +/// Wallet-contract document field names (wire-compatible with the legacy +/// `TxMetadataDocument` schema — `wallet-utils-contract` `tx_metadata`). +const FIELD_KEY_INDEX: &str = "keyIndex"; +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`. +/// +/// Genuine failures use [`breadcrumb_error`] (WARN) so they stay visible +/// on-device; routine stage lines stay at DEBUG. +/// +/// No breadcrumb on this path may carry an owner, contract or document +/// identifier, or an error's `Display`. Logcat is readable by any process +/// holding READ_LOGS and is captured in bug reports, so a full identifier there +/// correlates a device to an on-chain identity, and an echoed error body is +/// unbounded and can carry query shapes and contract internals. Stage names, +/// [`error_kind`] classifications, counts and booleans are what belong here. +fn breadcrumb(line: &str) { + tracing::debug!("{line}"); + log::debug!("{line}"); +} + +/// 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). The same +/// redaction rules apply at every level. +fn breadcrumb_error(line: &str) { + tracing::warn!("{line}"); + log::warn!("{line}"); +} + +/// A stable, bounded classification of a failure, for breadcrumbs that must not +/// transcribe an error's `Display`. The returned token names the failure class +/// only — it never contains caller data, an identifier, or a message body — and +/// is stable enough to tell the stages apart in a device log. +fn error_kind(error: &PlatformWalletError) -> &'static str { + match error { + PlatformWalletError::Sdk(_) => "sdk", + PlatformWalletError::WalletNotFound(_) => "wallet-not-found", + PlatformWalletError::IdentityNotFound(_) => "identity-not-found", + PlatformWalletError::UnsupportedTxMetadataVersion { .. } => "unsupported-version", + PlatformWalletError::TxMetadataPayloadTooLarge { .. } => "payload-too-large", + PlatformWalletError::InvalidIdentityData(_) => "invalid-identity-data", + _ => "other", + } +} + +/// One decrypted encrypted-document, returned to the caller (serialized to +/// JSON at the FFI boundary). The `payload` is the opaque, decrypted plaintext +/// the app parses itself (a protobuf `TxMetadataBatch` for `version == 1`). +/// +/// `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 + /// Call from a sync context only. The subsequent + /// generic [`Self::create_document_with_signer`] then broadcasts the returned + /// properties with no key material in scope. + /// + /// The caller supplies: + /// - `encryption_key_index`: the per-document index (dash-wallet's monotonic + /// `1 + countAllRequests()` counter). Batching stays app-side. + /// - `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}; + + // Both conditions are decidable from the arguments alone, so they are + // rejected before this call resolves an encryption context, selects a + // key, derives AES material or draws an IV. A payload that cannot fit + // the encryptedMetadata field, or a version the legacy stack cannot + // decode, would otherwise do all of that work and only then fail — the + // size case at broadcast with an opaque schema error, the version case + // at the sealing choke point. + ensure_tx_metadata_payload_fits(payload.len())?; + ensure_tx_metadata_version_supported(version)?; + + 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={} error_kind={}", + key_source.label(), + error_kind(e) + )); + })?; + let mut iv = [0u8; 16]; + thread_rng().fill_bytes(&mut iv); + // Re-checks the wire version and the payload size as the choke-point + // last line of defense, so nothing can seal a document the legacy stack + // cannot decode or one that overflows the field. + 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()) + } + + /// 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}; + + // Stage breadcrumbs for this fetch. An empty result on this path is + // indistinguishable from a failure without them: the query can return + // nothing, a document can fail to materialize, or a decrypt can be + // skipped, and each stage below records which one happened. + breadcrumb(&format!( + "fetch_encrypted_documents: entry 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("fetch_encrypted_documents: contract fetch failed error_kind=sdk"); + PlatformWalletError::Sdk(e) + })? + .ok_or_else(|| { + breadcrumb_error("fetch_encrypted_documents: contract not found on Platform"); + 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 \ + error_kind={}", + error_kind(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 error_kind={}", + error_kind(e) + )); + })?; + + let mut out = Vec::new(); + for (position, (doc_id, maybe_doc)) in raw_docs.iter().enumerate() { + let Some(doc) = maybe_doc else { + // A raw entry the SDK could not materialize (e.g. a proved + // fetch returning an id without a document). Skipped, but never + // silently: under proofs this is exactly the shape that turns + // "documents exist" into an empty result with no error, so it + // must leave a trail. + breadcrumb_error(&format!( + "fetch_encrypted_documents: raw entry NOT materialized \ + position={position}; 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 \ + position={position}; 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 \ + position={position}; 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 \ + position={position} key_source={} error_kind={}; skipping", + key_source.label(), + error_kind(&e) + )); + continue; + } + }; + let opened = match open_tx_metadata(&aes_key, &blob) { + Ok(o) => o, + Err(e) => { + breadcrumb_error(&format!( + "fetch_encrypted_documents: txMetadata decrypt failed \ + position={position} error_kind={}; skipping", + error_kind(&e) + )); + 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 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::platform_value::platform_value; + + const PAGE: u32 = 100; + breadcrumb(&format!( + "query_owned_encrypted_documents: entry since_ms={since_ms}" + )); + 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("query_owned_encrypted_documents: fetch_many failed error_kind=sdk"); + PlatformWalletError::Sdk(e) + })?; + let page_len = page.len(); + let last_id = page.keys().last().copied(); + raw_docs.extend(page); + + if page_len < PAGE as usize { + break; + } + match last_id { + Some(id) => start = Some(Start::StartAfter(id.to_buffer().to_vec())), + None => break, + } + } + + // Both counts are recorded BEFORE any decrypt, so an empty end result can be + // attributed to the query returning nothing, to documents the SDK could not + // materialize, or to the decrypt stage that runs after this — three causes + // that are otherwise indistinguishable from one another. + breadcrumb(&format!( + "query_owned_encrypted_documents: fetched raw encrypted documents \ + 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 tests { + use super::*; + use std::sync::Mutex; + + // ── Breadcrumb redaction ──────────────────────────────────────────────── + // + // The encrypted-document breadcrumbs are dual-emitted to Android logcat. + // Logcat is readable by any process holding READ_LOGS and survives in bug + // reports, so a breadcrumb must never persist data that correlates a device + // to an on-chain identity, nor echo a raw error body (which can carry query + // shapes, contract internals, or decrypted context). Stable codes, booleans + // and bounded non-sensitive context are fine; full identifiers are not. + + /// Captures every `tracing` event's level and rendered `message` so a test + /// can assert on what the breadcrumbs actually emit. + #[derive(Clone, Default)] + struct CapturedBreadcrumbs(Arc>>); + + impl CapturedBreadcrumbs { + fn lines(&self) -> Vec<(tracing::Level, String)> { + self.0.lock().expect("capture buffer not poisoned").clone() + } + } + + /// Pulls the `message` field out of an event, which is where both + /// [`breadcrumb`] and [`breadcrumb_error`] put their whole formatted line. + struct MessageVisitor(String); + + impl tracing::field::Visit for MessageVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.0 = format!("{value:?}"); + } + } + } + + impl tracing_subscriber::Layer for CapturedBreadcrumbs { + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + let mut visitor = MessageVisitor(String::new()); + event.record(&mut visitor); + self.0 + .lock() + .expect("capture buffer not poisoned") + .push((*event.metadata().level(), visitor.0)); + } + } + + /// The owner and contract identifiers the breadcrumbs are given. + const TEST_OWNER: Identifier = Identifier::new([7u8; 32]); + + /// Outcome of one captured, deterministically-failing query run. + struct CapturedQuery { + lines: Vec<(tracing::Level, String)>, + contract_id: Identifier, + error: PlatformWalletError, + } + + /// Drive the real query path against a mock SDK carrying NO registered + /// expectation. The contract is supplied directly, so the query runs and + /// `fetch_many` fails deterministically — exercising the entry breadcrumb + /// and the failure breadcrumb in a single call, with no network. + async fn capture_failing_query_for_type(document_type_name: &str) -> CapturedQuery { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + + let sdk = dash_sdk::Sdk::new_mock(); + let contract = Arc::new( + dpp::tests::fixtures::get_data_contract_fixture(None, 0, dpp::version::LATEST_VERSION) + .data_contract_owned(), + ); + let contract_id = contract.id(); + + let captured = CapturedBreadcrumbs::default(); + let collected = captured.clone(); + let error = { + let _guard = tracing_subscriber::registry().with(captured).set_default(); + query_owned_encrypted_documents(&sdk, contract, &TEST_OWNER, document_type_name, 0) + .await + .expect_err("a mock SDK with no expectation must fail the page fetch") + }; + + let lines = collected.lines(); + assert!( + !lines.is_empty(), + "the query path must emit breadcrumbs for these assertions to mean anything" + ); + CapturedQuery { + lines, + contract_id, + error, + } + } + + /// The ordinary document type this module is written for. + async fn capture_failing_query() -> CapturedQuery { + capture_failing_query_for_type("txMetadata").await + } + + // ── Version gate ordering ─────────────────────────────────────────────── + + use crate::changeset::{PersistenceError, PlatformWalletPersistence}; + use crate::wallet::WalletId; + use crate::ClientStartState; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + + struct NoopPersister; + impl PlatformWalletPersistence for NoopPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: crate::changeset::PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + struct NoopEventHandler; + impl crate::events::EventHandler for NoopEventHandler {} + impl crate::PlatformEventHandler for NoopEventHandler {} + + /// An unsupported wire version is decidable from the argument alone, so it + /// must be rejected before this call resolves the encryption context, + /// selects a key, derives AES material or draws an IV. + /// + /// The wallet here carries no managed identity, so any path that reaches + /// context resolution fails with an identity error instead — which is what + /// makes the ordering observable without a network or a live host. + #[test] + fn prepare_rejects_an_unsupported_version_before_context_or_key_work() { + use key_wallet::mnemonic::{Language, Mnemonic}; + + let runtime = tokio::runtime::Runtime::new().expect("test runtime"); + let seed = Mnemonic::from_entropy(&[0u8; 16], Language::English) + .expect("16 bytes of entropy") + .to_seed(""); + + let wallet = runtime.block_on(async { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let manager = Arc::new(crate::PlatformWalletManager::new( + sdk, + Arc::new(NoopPersister), + Arc::new(NoopEventHandler) as Arc, + )); + manager + .create_wallet_from_seed_bytes( + key_wallet::Network::Testnet, + &seed, + WalletAccountCreationOptions::None, + Some(0), + ) + .await + .expect("wallet creation on a mock sdk") + }); + + // Called outside the runtime: this path resolves its context with a + // blocking read and must not run inside an async context. + let error = wallet + .identity() + .prepare_encrypted_txmetadata_properties( + &TEST_OWNER, + 0, + 2, + b"opaque", + TxMetadataKeySource::ResidentWallet, + ) + .expect_err("version 2 is not wire-decodable"); + + assert!( + matches!( + error, + PlatformWalletError::UnsupportedTxMetadataVersion { version: 2 } + ), + "an unsupported version must be rejected before the encryption context \ + is resolved; got {error:?}" + ); + } + + /// The document type is caller-supplied and travels straight from the host + /// into this module. Nothing bounds its length, character set, or content, + /// so a breadcrumb that interpolates it raw lets a caller write arbitrary + /// text — including secret-looking material and embedded newlines that + /// forge additional log lines — into a device log that any process holding + /// READ_LOGS can read. + #[tokio::test] + async fn query_breadcrumbs_do_not_echo_the_caller_supplied_document_type() { + // A hostile document type: an embedded newline to forge a log line, and + // a marker standing in for whatever the caller chose to put here. + const MARKER: &str = "s3cr3t-marker-do-not-log"; + let hostile = format!("txMetadata\nFORGED WARN line {MARKER}"); + + let captured = capture_failing_query_for_type(&hostile).await; + + for (level, line) in &captured.lines { + assert!( + !line.contains(MARKER), + "{level} breadcrumb echoes caller-supplied document-type content: {line}" + ); + assert!( + !line.contains('\n'), + "{level} breadcrumb contains an embedded newline, letting a caller \ + forge additional log lines: {line}" + ); + } + } + + /// No breadcrumb, at any level, may carry a full owner or contract + /// identifier: logcat is readable by any process holding READ_LOGS and + /// survives in bug reports, so a full identifier there correlates a device + /// to an on-chain identity. + #[tokio::test] + async fn query_breadcrumbs_redact_owner_and_contract_identifiers() { + let captured = capture_failing_query().await; + + // Rendered exactly the way the breadcrumbs interpolate them (`Display`). + let owner_rendered = format!("{TEST_OWNER}"); + let contract_rendered = format!("{}", captured.contract_id); + + for (level, line) in &captured.lines { + assert!( + !line.contains(&owner_rendered), + "{level} breadcrumb carries the full owner identity id: {line}" + ); + assert!( + !line.contains(&contract_rendered), + "{level} breadcrumb carries the full contract id: {line}" + ); + } + } + + /// A failure breadcrumb must classify, not transcribe. The SDK error body + /// is unbounded and carries query and contract internals, so the exact + /// `Display` of the error the call returned must not appear in the WARN + /// line. The label itself is not the problem — the verbatim body is — so + /// this compares against the real error string rather than banning a token. + #[tokio::test] + async fn query_failure_breadcrumb_redacts_the_raw_sdk_error_body() { + let captured = capture_failing_query().await; + + // The exact body the breadcrumb would transcribe: the inner SDK error's + // own `Display`, taken from the very error this call returned. + let raw_body = match &captured.error { + PlatformWalletError::Sdk(sdk_error) => format!("{sdk_error}"), + other => panic!("expected the page fetch to fail as Sdk(_), got {other:?}"), + }; + assert!( + !raw_body.is_empty(), + "the SDK error must render to something for this assertion to bite" + ); + + let warnings: Vec<_> = captured + .lines + .iter() + .filter(|(level, _)| *level == tracing::Level::WARN) + .collect(); + assert!( + !warnings.is_empty(), + "the failed page fetch must emit a WARN breadcrumb" + ); + + for (level, line) in warnings { + assert!( + !line.contains(&raw_body), + "{level} breadcrumb transcribes the raw SDK error body verbatim \ + instead of a stable classification.\n raw body: {raw_body}\n line: {line}" + ); + } + } + + // ── Pagination ────────────────────────────────────────────────────────── + + /// Page size the query paginates at, mirrored from the production loop. + const PAGE_SIZE: usize = 100; + + /// Rebuild the exact `DocumentQuery` the production loop issues for a given + /// cursor, so mock expectations key on the same request the code sends. + fn expected_page_query( + contract: Arc, + owner: &Identifier, + start: Option< + dash_sdk::dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start, + >, + ) -> dash_sdk::platform::DocumentQuery { + use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; + use dpp::platform_value::platform_value; + + dash_sdk::platform::DocumentQuery { + select: dash_sdk::drive::query::SelectProjection::documents(), + data_contract: contract, + document_type_name: "txMetadata".to_string(), + where_clauses: vec![ + WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: platform_value!(owner), + }, + WhereClause { + field: "$updatedAt".to_string(), + operator: WhereOperator::GreaterThanOrEquals, + value: platform_value!(0u64), + }, + ], + group_by: vec![], + having: vec![], + order_by_clauses: vec![OrderClause { + field: "$updatedAt".to_string(), + ascending: true, + }], + limit: PAGE_SIZE as u32, + start, + } + } + + /// A document carrying the given id and `$updatedAt`. + fn document_at(id: Identifier, updated_at_ms: u64) -> Document { + Document::V0(dpp::document::DocumentV0 { + id, + owner_id: TEST_OWNER, + properties: Default::default(), + revision: Some(1), + created_at: None, + updated_at: Some(updated_at_ms), + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + }) + } + + /// Full pagination walk over a boundary-sized first page, offline. + /// + /// The scenario is the one that separates an order-preserving cursor from a + /// sorted one: every document on page one shares the SAME `$updatedAt`, so + /// the `$updatedAt asc` ordering cannot disambiguate them, and the ids are + /// assigned in DESCENDING order so the final returned document is also the + /// numerically smallest. That last entry is additionally unmaterialized + /// (`None`), the shape a proved fetch returns for a document it could not + /// produce. The cursor must still be that final entry's key: a sorted map + /// would hand back the largest id instead and silently skip every document + /// between them on the next page. + /// + /// Termination is proved by construction — only two page requests are + /// registered, so a third would find no expectation and fail the call. + #[tokio::test] + async fn paginates_by_final_insertion_order_key_across_a_full_page() { + use dash_sdk::dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; + + // Pin the protocol version so the wire encoding of page two matches the + // expectation registered for it; an unpinned mock ratchets to the + // latest version after the first response and re-encodes the request. + let mut sdk = dash_sdk::SdkBuilder::new_mock() + .with_version(dpp::version::PlatformVersion::latest()) + .build() + .expect("mock sdk builds"); + + let contract = Arc::new( + dpp::tests::fixtures::get_data_contract_fixture(None, 0, dpp::version::LATEST_VERSION) + .data_contract_owned(), + ); + // Page one: exactly PAGE_SIZE entries, identical timestamps, descending + // ids, final entry unmaterialized. + const SHARED_TIMESTAMP: u64 = 1_700_000_000_000; + let page_one_ids: Vec = (0..PAGE_SIZE) + .map(|i| Identifier::from([(200 - i) as u8; 32])) + .collect(); + let mut page_one: drive_proof_verifier::types::Documents = Default::default(); + for (position, id) in page_one_ids.iter().enumerate() { + let is_final = position == PAGE_SIZE - 1; + page_one.insert( + *id, + if is_final { + None + } else { + Some(document_at(*id, SHARED_TIMESTAMP)) + }, + ); + } + let final_page_one_key = *page_one_ids.last().expect("page one is not empty"); + + // Page two: short, so the loop terminates after consuming it. + let page_two_ids: Vec = (0..3) + .map(|i| Identifier::from([(50 - i) as u8; 32])) + .collect(); + let mut page_two: drive_proof_verifier::types::Documents = Default::default(); + for id in &page_two_ids { + page_two.insert(*id, Some(document_at(*id, SHARED_TIMESTAMP + 1))); + } + + sdk.mock() + .expect_fetch_many( + expected_page_query(Arc::clone(&contract), &TEST_OWNER, None), + Some(page_one), + ) + .await + .expect("register page one"); + sdk.mock() + .expect_fetch_many( + expected_page_query( + Arc::clone(&contract), + &TEST_OWNER, + Some(Start::StartAfter(final_page_one_key.to_buffer().to_vec())), + ), + Some(page_two), + ) + .await + .expect("register page two"); + + let fetched = query_owned_encrypted_documents( + &sdk, + Arc::clone(&contract), + &TEST_OWNER, + "txMetadata", + 0, + ) + .await + .expect( + "both pages are registered; a failure here means the cursor did not select the \ + final insertion-order key, so page two was requested with the wrong StartAfter", + ); + + // Every document, exactly once, in Drive's returned order. + let expected_order: Vec = page_one_ids + .iter() + .chain(page_two_ids.iter()) + .copied() + .collect(); + let actual_order: Vec = fetched.iter().map(|(id, _)| *id).collect(); + assert_eq!( + actual_order, expected_order, + "results must preserve Drive's returned order across the page boundary" + ); + assert_eq!( + fetched.len(), + PAGE_SIZE + page_two_ids.len(), + "every document is returned exactly once" + ); + + // The unmaterialized entry is preserved rather than dropped, so callers + // never silently under-report. + assert!( + fetched[PAGE_SIZE - 1].1.is_none(), + "the final page-one entry was unmaterialized and must be preserved as None" + ); + assert_eq!( + fetched.iter().filter(|(_, doc)| doc.is_none()).count(), + 1, + "exactly one entry was unmaterialized" + ); + } +} 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 bbcc27c09e4..10f6e969277 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -24,6 +24,7 @@ mod contract; mod discovery; mod document; mod dpns; +mod encrypted_document; mod identity_handle; mod loading; mod register_from_addresses; @@ -70,6 +71,9 @@ 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 new file mode 100644 index 00000000000..ef7c6a58daf --- /dev/null +++ b/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java @@ -0,0 +1,79 @@ +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 + * relying solely on prose. + * + * 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 new file mode 100644 index 00000000000..b97bb75f41a --- /dev/null +++ b/packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyKeyN.java @@ -0,0 +1,80 @@ +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 new file mode 100644 index 00000000000..0491b1aa751 --- /dev/null +++ b/packages/rs-platform-wallet/tests/legacy_wire_compat/README.md @@ -0,0 +1,105 @@ +# 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: + `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 + under identity `ESR1nfF3bj4TR2ZkLmDuSeu6r7VzpTurYi47BV6XwsoP` (`keyIndex = 2`, + `encryptionKeyIndex = 1`, version 1/protobuf). That document was fetched from + testnet once and its bytes checked in, so the test is network-free; the Rust + crypto decrypts 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. + +- **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. + +## 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 new file mode 100644 index 00000000000..bddbd146769 --- /dev/null +++ b/packages/rs-platform-wallet/tests/txmetadata_fetch.rs @@ -0,0 +1,128 @@ +//! Testnet integration test for the encrypted `txMetadata` FETCH path. +//! Runs the EXACT production query +//! ([`platform_wallet::query_owned_encrypted_documents`], the network half of +//! `IdentityWallet::fetch_encrypted_documents`) against a real testnet identity +//! that has two legacy-written encrypted `txMetadata` documents, and asserts the +//! query returns both with the expected `keyIndex` / `encryptionKeyIndex` / +//! `encryptedMetadata` fields. +//! +//! 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`; +//! 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)" + ); + } +} diff --git a/packages/rs-unified-sdk-jni/src/support.rs b/packages/rs-unified-sdk-jni/src/support.rs index 19a14d389cf..6b12b7038db 100644 --- a/packages/rs-unified-sdk-jni/src/support.rs +++ b/packages/rs-unified-sdk-jni/src/support.rs @@ -75,12 +75,46 @@ pub fn take_pwffi_error(env: &mut JNIEnv, mut result: PlatformWalletFFIResult) - .to_string_lossy() .into_owned() }; + // Diagnostic breadcrumb (warn-level so it provably reaches logcat). The + // message is NOT logged: it originates below this layer, is unbounded, and + // can carry query shapes, contract internals, caller-supplied text and + // newlines that forge further log lines. It still reaches the caller intact + // through the thrown exception, which is where it can be read deliberately. + log::warn!( + "{}", + platform_wallet_error_breadcrumb(result.code as i32, &message) + ); throw_sdk_exception(env, result.code as i32 + PWFFI_CODE_OFFSET, &message); // SAFETY: `result` is a fresh PlatformWalletFFIResult; free its message. unsafe { platform_wallet_ffi_result_free(&mut result) }; true } +/// The breadcrumb recorded when a platform-wallet result is converted into a +/// thrown exception. +/// +/// Takes the message so the seam sits where the decision is made, and renders +/// only the two codes: the raw platform-wallet value and the offset value the +/// caller actually receives. Keeping both makes a device log enough to line a +/// report up against either side of the mapping without carrying anything +/// unbounded or caller-derived. +pub fn platform_wallet_error_breadcrumb(platform_wallet_code: i32, _message: &str) -> String { + format!( + "take_pwffi_error: platform_wallet_code={} thrown_code={}", + platform_wallet_code, + platform_wallet_code + PWFFI_CODE_OFFSET + ) +} + +/// The breadcrumb recorded for a thrown exception, code only. +/// +/// Same reasoning as [`platform_wallet_error_breadcrumb`]: the message reaches +/// the caller on the exception, so the log records which error was raised +/// rather than what it said. +pub fn thrown_exception_breadcrumb(code: i32, _message: &str) -> String { + format!("throw_sdk_exception: code={code}") +} + /// The process-wide JVM, cached in [`crate::JNI_OnLoad`]. Callback /// trampolines use this to attach Tokio worker threads. pub static JVM: OnceLock = OnceLock::new(); @@ -92,6 +126,12 @@ pub const SDK_EXCEPTION_CLASS: &str = "org/dashfoundation/dashsdk/ffi/DashSDKExc /// `RuntimeException` if the class or constructor lookup fails (e.g. the /// library is loaded outside the Kotlin SDK). pub fn throw_sdk_exception(env: &mut JNIEnv, code: i32, message: &str) { + // Diagnostic breadcrumb (warn-level so it provably reaches logcat): every + // native→Kotlin error conversion is visible even when the Kotlin caller + // contains the exception into a status line. The message travels to the + // caller on the exception rather than into the log — see + // [`thrown_exception_breadcrumb`]. + log::warn!("{}", thrown_exception_breadcrumb(code, message)); // If an exception is already pending we must not call further JNI // functions that would themselves throw. if env.exception_check().unwrap_or(false) { @@ -112,9 +152,16 @@ pub fn throw_sdk_exception(env: &mut JNIEnv, code: i32, message: &str) { } } -/// Run an export body under `catch_unwind` so a Rust panic surfaces as a -/// Java `RuntimeException` instead of unwinding across the JNI boundary -/// (which is undefined behavior). +/// Run an export body under `catch_unwind` so a Rust panic surfaces as a Java +/// `RuntimeException` instead of escaping the export. +/// +/// Exports are declared with the non-unwinding `extern "system"` ABI, so an +/// escaping unwind does not continue into the JVM frame: it reaches a frame +/// that cannot unwind and is stopped there by a forced abort, taking the app +/// process down. Catching here converts it into an exception the caller can +/// handle instead. Under `panic = "abort"` the panic aborts where it is raised +/// and no catch is possible; the Android profiles keep `panic = "unwind"` so +/// this guard is effective there. pub fn guard(env: &mut JNIEnv, default: T, f: impl FnOnce(&mut JNIEnv) -> T) -> T { match catch_unwind(AssertUnwindSafe(|| f(env))) { Ok(value) => value, @@ -151,4 +198,41 @@ mod tests { assert_eq!(net_from_ord(3), FFINetwork::Regtest); assert_eq!(net_from_ord(-1), FFINetwork::Testnet, "unknown → Testnet"); } + + // ── Error breadcrumb seams ────────────────────────────────────────────── + // + // The message an error carries is unbounded and originates below this + // layer, so it can hold query shapes, contract internals, caller-supplied + // text, and newlines that forge additional log lines. It is delivered to + // the host through the thrown exception, which is where a caller can read + // it deliberately. The device log gets codes only. + + /// The whole line is pinned, so any future addition of caller data or an + /// error body is an immediate failure rather than something a substring + /// check could miss. Both the raw platform-wallet code and the offset code + /// the host actually receives are asserted, separately and by value. + #[test] + fn platform_wallet_error_breadcrumb_is_codes_only() { + let hostile = "payload dump s3cr3t-marker-do-not-log\nFORGED WARN line"; + + assert_eq!( + super::platform_wallet_error_breadcrumb(2, hostile), + "take_pwffi_error: platform_wallet_code=2 thrown_code=1002", + "the breadcrumb must be exactly the two codes; the message is delivered \ + through the thrown exception, not the device log" + ); + } + + /// Same contract on the generic throw path. + #[test] + fn thrown_exception_breadcrumb_is_code_only() { + let hostile = "payload dump s3cr3t-marker-do-not-log\nFORGED WARN line"; + + assert_eq!( + super::thrown_exception_breadcrumb(1, hostile), + "throw_sdk_exception: code=1", + "the breadcrumb must be exactly the code; the message is delivered \ + through the thrown exception, not the device log" + ); + } } diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 04552c1e9ea..d9ec9320edb 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -750,6 +750,236 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do }) } +// ── Encrypted document create / fetch (wallet txMetadata contract) ───── + +/// Create + broadcast an ENCRYPTED wallet-contract document (the wire- +/// compatible `txMetadata` shape) — the JNI bridge over +/// `platform_wallet_create_encrypted_document_with_signer`. +/// +/// The SDK derives the identity encryption key, seals `payload` into the +/// legacy `version ‖ IV ‖ AES-256-CBC` blob, and writes +/// `{keyIndex, encryptionKeyIndex, encryptedMetadata}`. `encryptionKeyIndex` is +/// the app's per-document index; `version` is the payload version byte +/// (`1` = protobuf); `payload` is the already-serialized opaque plaintext (a +/// protobuf `TxMetadataBatch`) — the SDK does not parse it. Returns the +/// confirmed document's canonical JSON (its 32-byte id is the base58 `$id` +/// field); null after throwing on error. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_documentCreateEncrypted( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + 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(); + }; + // Narrow the Java-signed arguments to the widths the C ABI takes. + // Anything representable is handed to Rust, which owns the protocol + // policy; only values with no representation stop here. + let validated = match encrypted_create_preflight(encryption_key_index, version) { + Ok(validated) => validated, + Err(error) => { + throw_sdk_exception(env, 1, &error.to_string()); + return ptr::null_mut(); + } + }; + + // Apply the shared size policy to the DECLARED length before the array + // is materialized: an over-large batch is rejectable from the length + // alone, and copying it first would move plaintext for a request that + // cannot succeed. `get_array_length` reads the header only. + 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(); + } + }; + // The JNI-owned plaintext copy, reached only after the size gate + // accepts the declared length. 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 materialized = + match size_gated_payload(payload_len, || env.convert_byte_array(&payload)) { + Ok(materialized) => materialized, + Err(gate) => { + take_pwffi_error(env, gate); + return ptr::null_mut(); + } + }; + let payload_bytes = match materialized { + 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 = 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(), + validated.encryption_key_index, + validated.version, + 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. `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 rendering it would publish a + // heap address into the log. + 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!( + "{}", + fetch_encrypted_call_breadcrumb(&owner, &contract, doc_type.to_string_lossy().as_ref()) + ); + + 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()) + }) +} + // ── Contested-resource vote ─────────────────────────────────────────── /// Cast a masternode contested-resource vote and wait for the response — @@ -871,3 +1101,283 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_ca // `success(null)`), so nothing else to release here. }) } + +/// Java-signed encrypted-create arguments narrowed to the widths the C ABI +/// takes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ValidatedEncryptedCreate { + pub(crate) encryption_key_index: u32, + pub(crate) version: u8, +} + +/// Why a Java-supplied encrypted-create argument could not be narrowed. +/// +/// Each cause is its own variant so a caller — and a future change to one of +/// the conventions — can address exactly one of them without disturbing the +/// other. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EncryptedCreatePreflightError { + /// A negative explicit index, which has no `u32` representation. + NegativeEncryptionKeyIndex { value: jint }, + /// A version outside the byte the wire format carries. + VersionOutOfByteRange { value: jint }, +} + +impl std::fmt::Display for EncryptedCreatePreflightError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EncryptedCreatePreflightError::NegativeEncryptionKeyIndex { value } => { + write!(f, "encryptionKeyIndex must be non-negative, got {value}") + } + EncryptedCreatePreflightError::VersionOutOfByteRange { value } => { + write!(f, "version must fit a single byte (0..=255), got {value}") + } + } + } +} + +/// Narrow the Java-signed encrypted-create arguments. +/// +/// This layer bridges representations; it does not decide protocol. Every value +/// that fits its target width is passed through for the Rust core to accept or +/// reject, so there is no second place where the set of meaningful versions is +/// written down and no way for the two to disagree. +pub(crate) fn encrypted_create_preflight( + encryption_key_index: jint, + version: jint, +) -> Result { + let encryption_key_index = u32::try_from(encryption_key_index).map_err(|_| { + EncryptedCreatePreflightError::NegativeEncryptionKeyIndex { + value: encryption_key_index, + } + })?; + let version = u8::try_from(version) + .map_err(|_| EncryptedCreatePreflightError::VersionOutOfByteRange { value: version })?; + + Ok(ValidatedEncryptedCreate { + encryption_key_index, + version, + }) +} + +/// Apply the shared txMetadata size policy to a declared length, materializing +/// the payload only if it passes. +/// +/// Owns the ordering so it cannot drift: the check runs against the length +/// alone, and `materialize` — the Java array copy — is reached only on success. +/// A rejected length therefore never moves plaintext into a native buffer for a +/// request that cannot succeed. Returns the shared FFI result on rejection so +/// the caller translates it exactly like any other platform-wallet failure. +fn size_gated_payload( + payload_len: usize, + materialize: impl FnOnce() -> T, +) -> Result { + let gate = platform_wallet_ffi::tx_metadata_payload_len_result(payload_len); + if gate.code != platform_wallet_ffi::PlatformWalletFFIResultCode::Success { + return Err(gate); + } + Ok(materialize()) +} + +/// The stage line recorded when the encrypted fetch reaches the native call. +/// +/// Takes the call's arguments so the seam sits where the call does, and +/// deliberately renders none of them: a device log is readable by any process +/// holding the log permission and is captured in bug reports, so an identifier +/// there correlates a device to an on-chain identity, and caller-supplied text +/// can embed a newline to forge further log lines. +pub(crate) fn fetch_encrypted_call_breadcrumb( + _owner: &[u8; 32], + _contract: &[u8; 32], + _document_type: &str, +) -> String { + "documentFetchEncrypted: calling platform_wallet_fetch_encrypted_documents".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Shared payload-size policy ────────────────────────────────────────── + // + // The limit belongs to the Rust core. This layer must consult it through + // the same helper the C export uses, from the declared array length and + // before the Java array is copied into a native buffer — an over-large + // batch is rejectable from the length alone, and copying it first moves + // plaintext for a request that can never succeed. + + /// The maximum plaintext must be accepted by the shared helper, and an + /// accepted length must not allocate: this runs on every create, so a + /// message on the success path would be a per-call allocation the caller + /// then has to free. + #[test] + fn payload_len_helper_accepts_the_maximum_plaintext() { + let result = platform_wallet_ffi::tx_metadata_payload_len_result( + platform_wallet_ffi::MAX_TX_METADATA_PLAINTEXT_LEN, + ); + assert_eq!( + result.code, + platform_wallet_ffi::PlatformWalletFFIResultCode::Success, + "the largest plaintext that still frames into the field must be accepted" + ); + assert!( + result.message.is_null(), + "an accepted length must carry no message" + ); + } + + /// The gate must decide before the payload is materialized. + /// + /// Ordering is the whole point of consulting the declared length: once the + /// Java array has been copied, the plaintext has already been moved into a + /// native buffer for a request that cannot succeed. Pinned through the seam + /// the real call site uses, so moving the gate after materialization fails + /// here rather than silently regressing on device. + #[test] + fn payload_materialization_is_gated_behind_the_size_check() { + let mut materialized = false; + let outcome = size_gated_payload( + platform_wallet_ffi::MAX_TX_METADATA_PLAINTEXT_LEN + 1, + || { + materialized = true; + Vec::::new() + }, + ); + + match outcome { + Err(result) => assert_eq!( + result.code, + platform_wallet_ffi::PlatformWalletFFIResultCode::ErrorInvalidParameter, + "an over-large length must be rejected with the shared caller-input code" + ), + Ok(_) => panic!("an over-large length must not reach materialization"), + } + assert!( + !materialized, + "the payload was materialized despite a length the gate rejects" + ); + } + + /// An accepted length still reaches materialization, so the gate cannot be + /// satisfied by simply never materializing. + #[test] + fn accepted_length_reaches_materialization() { + let mut materialized = false; + let outcome = + size_gated_payload(platform_wallet_ffi::MAX_TX_METADATA_PLAINTEXT_LEN, || { + materialized = true; + vec![0xabu8; 4] + }); + + assert_eq!( + outcome.expect("the maximum length is accepted"), + vec![0xabu8; 4] + ); + assert!(materialized, "an accepted length must be materialized"); + } + + /// One byte over is rejected, mapped exactly as the C export maps it. + #[test] + fn payload_len_helper_rejects_one_byte_over_the_maximum() { + let result = platform_wallet_ffi::tx_metadata_payload_len_result( + platform_wallet_ffi::MAX_TX_METADATA_PLAINTEXT_LEN + 1, + ); + assert_eq!( + result.code, + platform_wallet_ffi::PlatformWalletFFIResultCode::ErrorInvalidParameter, + "an over-large plaintext is a caller-input error, surfaced with the same \ + code the C export produces so the host sees one behavior" + ); + } + + // ── Host-thin value preflight ─────────────────────────────────────────── + // + // This layer converts Java's signed values into the widths the C ABI + // takes. Anything that fits is handed to Rust, which owns the protocol + // policy; the only rejections here are values with no representation. + + /// A negative explicit index has no `u32` representation. + /// + /// Pinned as its own variant carrying the rejected value: negative indices + /// are the one place a future auto-index convention would attach, so it has + /// to be a single, deliberately-edited decision point rather than something + /// folded in with unrelated range failures. + #[test] + fn preflight_rejects_a_negative_encryption_key_index() { + match encrypted_create_preflight(-1, 1) { + Err(EncryptedCreatePreflightError::NegativeEncryptionKeyIndex { value }) => { + assert_eq!(value, -1, "the rejection must report the value it rejected"); + } + other => panic!( + "a negative explicit index must be rejected as its own variant, got {other:?}" + ), + } + } + + /// An ordinary index and version pass through with their widths narrowed. + #[test] + fn preflight_narrows_a_representable_index_and_version() { + let validated = encrypted_create_preflight(7, 1).expect("both values are representable"); + assert_eq!(validated.encryption_key_index, 7u32); + assert_eq!(validated.version, 1u8); + } + + /// Every value a `u8` can hold is passed through, including versions this + /// layer knows nothing about. Which versions are meaningful is Rust's + /// decision, so a version list here would be a second source of truth that + /// silently shadows it. + #[test] + fn preflight_passes_through_every_representable_version() { + for version in [0i32, 1, 2, 255] { + let validated = encrypted_create_preflight(0, version) + .unwrap_or_else(|_| panic!("version {version} fits a u8 and must pass through")); + assert_eq!(validated.version, version as u8); + } + } + + /// Values outside the byte have no representation, so they stop here — + /// under a variant distinct from the index rejection, so a range failure + /// can never be mistaken for an index-convention decision. + #[test] + fn preflight_rejects_versions_that_do_not_fit_a_byte() { + for version in [-1i32, 256] { + match encrypted_create_preflight(0, version) { + Err(EncryptedCreatePreflightError::VersionOutOfByteRange { value }) => { + assert_eq!( + value, version, + "the rejection must report the value it rejected" + ); + } + other => panic!( + "version {version} has no u8 representation and must be rejected as \ + VersionOutOfByteRange, got {other:?}" + ), + } + } + } + + // ── Breadcrumb seams ──────────────────────────────────────────────────── + // + // Device logs are readable by any process holding the log permission and + // are captured in bug reports. A breadcrumb may therefore carry a stage and + // stable codes, but never an identifier or caller-supplied text: the latter + // both correlates a device to an on-chain identity and lets a caller forge + // additional log lines with an embedded newline. + + /// The call breadcrumb is a fixed stage line. Pinning it whole means any + /// future identifier or caller-supplied text is an immediate failure, and + /// no substring check has to anticipate the shape it might take. + #[test] + fn fetch_call_breadcrumb_is_a_static_stage_line() { + let owner = [0xAAu8; 32]; + let contract = [0xBBu8; 32]; + let hostile_type = "txMetadata\nFORGED WARN line s3cr3t-marker-do-not-log"; + + assert_eq!( + fetch_encrypted_call_breadcrumb(&owner, &contract, hostile_type), + "documentFetchEncrypted: calling platform_wallet_fetch_encrypted_documents", + "the breadcrumb must record the stage and nothing that varies with the \ + caller's arguments" + ); + } +}