diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt index 5ff637c020..835468e11c 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 @@ -263,23 +263,19 @@ class DocumentTransactions internal constructor( * `keyIndex` field) is chosen SDK-side to match the legacy stack, so the key * never crosses the FFI boundary. * - * ### `encryptionKeyIndex` allocation (dashpay/platform#4186 follow-up) - * Leave [encryptionKeyIndex] `null` (the default) to let the SDK allocate - * the per-document index in Rust from authoritative Platform state — the - * host-thin path. Rust counts the identity's existing txMetadata documents - * on Platform and uses `1 + count` (matching dash-wallet's retired - * `1 + countAllRequests()` semantics EXACTLY), serialized under the wallet's - * allocator mutex so concurrent creates through the same process never pick - * the same index. The index is best-effort unique PER DEVICE; a cross-device - * duplicate is not data-loss (each document stores its own index and the - * reader derives that document's key from it, so both decrypt independently). + * ### `encryptionKeyIndex` selection (dashpay/platform#4186 follow-up) + * Leave [encryptionKeyIndex] `null` (the default) to let the SDK generate + * the per-document index in Rust — the host-thin path. Rust draws a valid + * non-zero 31-bit BIP-32 child index from the operating-system CSPRNG. The + * index is a derivation input stored on each document, not a protocol + * sequence number; a repeated index is non-lossy because each document also + * has a fresh IV and readers derive from that document's stored fields. * * Passing an explicit non-negative [encryptionKeyIndex] is retained ONLY for - * migration / tests and is discouraged: the host must NOT reintroduce a - * caller-supplied `1 + countAllRequests()` counter (concurrent callers / - * devices could collide, and it violates the host-thin key-index rule). + * migration / tests and is discouraged: hosts should not own derivation + * index policy. * - * @param encryptionKeyIndex `null` to let the SDK allocate the index + * @param encryptionKeyIndex `null` to let the SDK generate the index * (preferred); or an explicit non-negative per-document index * (migration / tests only). * @param version payload version byte (`1` = protobuf, as the wallet writes). @@ -325,7 +321,7 @@ class DocumentTransactions internal constructor( ownerId, contractId, documentType, - // -1 is the JNI sentinel for "let Rust allocate the index". + // -1 is the JNI sentinel for "let Rust generate the index". encryptionKeyIndex ?: -1, version, payload, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index bfc00cbaff..597098a2b2 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 @@ -181,11 +181,11 @@ internal object TransactionsNative { * whose txMetadata AES key derives on demand through the resolver. * Ignored for wallets with resident private keys. * @param encryptionKeyIndex the per-document index, OR `-1` to let the SDK - * allocate it in Rust from authoritative Platform state - * (dashpay/platform#4186 follow-up). A non-negative value routes to the - * explicit-index FFI export (migration / tests); `-1` routes to - * `platform_wallet_create_encrypted_document_with_signer_auto_index`, which - * omits the index. Values `< -1` are rejected. + * generate it in Rust with the operating-system CSPRNG. A non-negative + * value routes to the explicit-index FFI export (migration / tests); + * `-1` routes to + * `platform_wallet_create_encrypted_document_with_signer_auto_index`, + * which omits the index. Values `< -1` are rejected. * @param version payload version byte (`1` = protobuf, as the wallet writes). * @param payload the already-serialized opaque plaintext (a protobuf * `TxMetadataBatch`); the SDK does not parse it. diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt index cbc2e05073..b070955e53 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt @@ -19,7 +19,7 @@ import org.junit.Test * an out-of-range byte would silently seal a document the legacy stack can't * decode. * 2. `encryptionKeyIndex` (dashpay/platform#4186 follow-up): `null` is the - * preferred path (Rust allocates the index from Platform state); an explicit + * preferred path (Rust generates the per-document index); an explicit * value, when supplied, must be non-negative. * * Paths that PASS validation proceed into native and can't be fully unit-tested @@ -68,7 +68,7 @@ class DocumentTransactionsVersionValidationTest { /** * An explicit NEGATIVE index (the migration/test-only path) is rejected by - * the `require`. `null` (the allocate-in-Rust path) is the only way to omit + * the `require`. `null` (the generate-in-Rust path) is the only way to omit * an index; a negative explicit value is a caller error. */ @Test @@ -96,7 +96,7 @@ class DocumentTransactionsVersionValidationTest { /** * The no-index path (`encryptionKeyIndex` omitted → `null`, the default and - * preferred allocate-in-Rust route) must PASS the argument guards. With all + * preferred generate-in-Rust route) must PASS the argument guards. With all * other inputs valid, the only failure that can surface is the native call * itself (no JNI library in a JVM unit test), NOT an * [IllegalArgumentException] from our `require`s — proving `null` is a valid @@ -115,7 +115,7 @@ class DocumentTransactionsVersionValidationTest { version = 1, payload = payload, signerHandle = 0L, - // encryptionKeyIndex omitted → null → allocate in Rust. + // encryptionKeyIndex omitted → null → generate in Rust. ) } }.exceptionOrNull() diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index 7e4a48aaac..455d4f0099 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -4,11 +4,16 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; use std::ptr; use std::slice; +use std::sync::Arc; use dpp::document::{Document, DocumentV0Getters}; use dpp::prelude::Identifier; use dpp::serialization::ValueConvertible; use key_wallet::bip32::ExtendedPrivKey; +use platform_wallet::wallet::identity::crypto::tx_metadata::{ + ensure_tx_metadata_payload_fits, + MAX_TX_METADATA_PLAINTEXT_LEN as CORE_MAX_TX_METADATA_PLAINTEXT_LEN, +}; use platform_wallet::{PlatformWalletError, TxMetadataKeySource}; use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle, VTableSigner}; use zeroize::Zeroizing; @@ -21,6 +26,10 @@ use crate::runtime::block_on_worker; use crate::types::read_identifier; use crate::{unwrap_option_or_return, unwrap_result_or_return}; +/// Shared with JNI so the Java-array length can be rejected before either +/// native layer copies an oversized plaintext payload. +pub const MAX_TX_METADATA_PLAINTEXT_LEN: usize = CORE_MAX_TX_METADATA_PLAINTEXT_LEN; + /// RAII guard scrubbing a resolved master xprv's secret scalar on drop. /// `ExtendedPrivKey` has no `Drop`/`Zeroize` of its own, so a resolved master /// would otherwise linger on the stack past its use — and a manual @@ -299,9 +308,8 @@ fn confirmed_document_to_json(document: &Document) -> Result PlatformWalletFFIResult { - // Rust-allocated-index entry point: the host omits encryptionKeyIndex, so - // the shared impl allocates it from Platform state (`None`). + // Rust-generated-index entry point: the host omits encryptionKeyIndex, so + // the shared impl generates it while preparing the encrypted properties. create_encrypted_document_impl( wallet_handle, mnemonic_resolver_handle, @@ -393,18 +399,13 @@ pub unsafe extern "C" fn platform_wallet_create_encrypted_document_with_signer_a /// Shared implementation behind the explicit-index /// ([`platform_wallet_create_encrypted_document_with_signer`], `Some`) and -/// Rust-allocated +/// Rust-generated /// ([`platform_wallet_create_encrypted_document_with_signer_auto_index`], /// `None`) encrypted-document create exports. /// -/// When `index` is `None` the per-document `encryptionKeyIndex` is allocated -/// from Platform state via `IdentityWallet::allocate_encryption_key_index` -/// (serialized under the wallet's allocator mutex) BEFORE any key material is -/// resolved — the allocation touches no secrets and never crosses the broadcast -/// await with the master in scope. That allocation first runs the deterministic, -/// network-free payload-size gate, so an oversized payload fails without -/// reserving (and thus without consuming) an index — no allocator gap -/// (dashpay/platform#4186 review). +/// When `index` is `None`, `IdentityWallet` generates the per-document +/// `encryptionKeyIndex` locally while preparing the encrypted properties. No +/// Platform count, allocator state, or allocation network round trip is needed. /// /// # Safety /// All pointers must be valid for the duration of the call; `payload` may be @@ -436,6 +437,10 @@ unsafe fn create_encrypted_document_impl( let document_type_str = unwrap_result_or_return!(CStr::from_ptr(document_type_name).to_str()).to_string(); + // Reject before either native layer copies the caller's plaintext. This is + // deterministic and needs no wallet, resolver, randomness, or network. + unwrap_result_or_return!(ensure_tx_metadata_payload_fits(payload_len)); + // Copy the payload into an owned buffer. Null is allowed only for a // zero-length payload. It is wrapped in `Zeroizing` so the native plaintext // copy is scrubbed on drop, and it is dropped explicitly the instant the @@ -452,95 +457,60 @@ unsafe fn create_encrypted_document_impl( let owner_id_for_async = owner_id; let contract_id_for_async = contract_id_value; - // `move` so the closure OWNS `payload_vec` and can drop it (scrubbing the - // plaintext) before the broadcast `.await`; the other captures are Copy or - // already moved into the nested `async move` block. - let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, move |wallet| { - let identity_wallet = wallet.identity().clone(); - - // Resolve the per-document encryptionKeyIndex FIRST, before any key - // material is in scope: the host either supplies it explicitly - // (`Some`, migration / tests) or omits it (`None`), in which case Rust - // allocates the next index from authoritative Platform state, serialized - // under the wallet's allocator mutex (dashpay/platform#4186 follow-up). - // The allocation touches no secrets, so it can run on the worker before - // the master is resolved. `allocate_encryption_key_index` runs the - // deterministic payload-size gate (network-free) BEFORE reserving, so an - // oversized payload fails without consuming an index — no allocator gap - // (dashpay/platform#4186 review). - let resolved_index: u32 = match index { - Some(i) => i, - None => { - let iw = identity_wallet.clone(); - let doc_type = document_type_str.clone(); - let payload_len = payload_vec.len(); - block_on_worker(async move { - iw.allocate_encryption_key_index( - &owner_id_for_async, - &contract_id_for_async, - &doc_type, - payload_len, - ) - .await - }) - .map_err(PlatformWalletFFIResult::from)? - } - }; + // Clone the Arc under the global handle-store read lock and release that + // lock immediately. Resolver callbacks and network waits must not block + // lifecycle writes for every wallet handle in the process. + let wallet = + unwrap_option_or_return!(PLATFORM_WALLET_STORAGE.with_item(wallet_handle, Arc::clone)); + let identity_wallet = wallet.identity().clone(); + + // Key-source selection may synchronously call back into the host resolver. + // The master is wrapped in a Drop-wiping guard and resolved only after the + // deterministic payload check above. + let master_opt = unwrap_result_or_return!(unsafe { + tx_metadata_key_master_for_wallet(&wallet, mnemonic_resolver_handle) + }) + .map(WipingMaster); + + // Derive + seal synchronously, then wipe plaintext and master before the + // broadcast await. The auto path generates its index inside platform-wallet + // and the explicit path remains available for migration/tests. + let key_source = match master_opt.as_ref() { + Some(master) => TxMetadataKeySource::Master(&master.0), + None => TxMetadataKeySource::ResidentWallet, + }; + let properties_json = unwrap_result_or_return!(match index { + Some(index) => identity_wallet.prepare_encrypted_txmetadata_properties( + &owner_id_for_async, + index, + version, + &payload_vec, + key_source, + ), + None => identity_wallet.prepare_encrypted_txmetadata_properties_auto_index( + &owner_id_for_async, + version, + &payload_vec, + key_source, + ), + }); + drop(payload_vec); + drop(master_opt); - // Key-source selection by wallet capability (may synchronously call - // back into the host mnemonic resolver for external-signable - // wallets — see `tx_metadata_key_master_for_wallet`). The resolved - // master is wrapped in a Drop-wiping guard. - let master_opt = - unsafe { tx_metadata_key_master_for_wallet(wallet, mnemonic_resolver_handle) }? - .map(WipingMaster); - - // Derive the AES key + seal the wire blob SYNCHRONOUSLY, then wipe the - // master BEFORE any network `.await`: the master xprv never crosses the - // broadcast await (dashpay/platform#4091). Only the sealed properties - // (ciphertext, no key material) cross into the async block below. - let key_source = match master_opt.as_ref() { - Some(master) => TxMetadataKeySource::Master(&master.0), - None => TxMetadataKeySource::ResidentWallet, - }; - let properties_json = identity_wallet - .prepare_encrypted_txmetadata_properties( + let result: Result<(Identifier, String), PlatformWalletError> = block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let confirmed: Document = identity_wallet + .create_document_with_signer( &owner_id_for_async, - resolved_index, - version, - &payload_vec, - key_source, + &contract_id_for_async, + &document_type_str, + &properties_json, + signer, ) - .map_err(PlatformWalletFFIResult::from)?; - // The plaintext is now sealed inside `properties_json` (ciphertext - // only). Scrub the native plaintext copy AND the master immediately — - // neither may cross the broadcast `.await` below. `payload_vec` is - // `Zeroizing`, so the drop also wipes its bytes (dashpay/platform#4091). - drop(payload_vec); - drop(master_opt); - - let result: Result<(Identifier, String), PlatformWalletError> = - 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)) - }); - result.map_err(PlatformWalletFFIResult::from) + .await?; + let json_string = confirmed_document_to_json(&confirmed)?; + Ok::<_, PlatformWalletError>((confirmed.id(), json_string)) }); - 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)); @@ -600,47 +570,37 @@ pub unsafe extern "C" fn platform_wallet_fetch_encrypted_documents( let owner_id_for_async = owner_id; let contract_id_for_async = contract_id_value; - let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { - let identity_wallet = wallet.identity().clone(); - - // 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); - - let result: Result, PlatformWalletError> = - block_on_worker(async move { - // TRADEOFF (dashpay/platform#4091): unlike create, a document's - // (keyIndex, encryptionKeyIndex) are only known AFTER its page is - // fetched, so the master cannot be fully pre-derived before the - // network work. It therefore stays resident across the pagination - // awaits — but inside the `WipingMaster` Drop guard, so a panic or - // early return still scrubs its scalar (a manual post-await erase - // would be skipped on those paths). Per-document key derivation is - // itself synchronous, between page fetches (see - // `fetch_encrypted_documents`). - let key_source = match master_opt.as_ref() { - Some(master) => TxMetadataKeySource::Master(&master.0), - None => TxMetadataKeySource::ResidentWallet, - }; - let fetched = identity_wallet - .fetch_encrypted_documents( - &owner_id_for_async, - &contract_id_for_async, - &document_type_str, - since_ms, - key_source, - ) - .await; - drop(master_opt); // scrub as soon as the fetch completes - fetched - }); - result.map_err(PlatformWalletFFIResult::from) - }); - let result = unwrap_option_or_return!(option); + // Do not retain the process-wide handle-store guard across the resolver + // callback and paginated network fetch. + let wallet = + unwrap_option_or_return!(PLATFORM_WALLET_STORAGE.with_item(wallet_handle, Arc::clone)); + let identity_wallet = wallet.identity().clone(); + let master_opt = unwrap_result_or_return!(unsafe { + tx_metadata_key_master_for_wallet(&wallet, mnemonic_resolver_handle) + }) + .map(WipingMaster); + + let result: Result, PlatformWalletError> = + block_on_worker(async move { + // Unlike create, a document's derivation indices are only known + // after its page is fetched, so the master remains in its wiping + // guard across pagination and is scrubbed immediately afterward. + let key_source = match master_opt.as_ref() { + Some(master) => TxMetadataKeySource::Master(&master.0), + None => TxMetadataKeySource::ResidentWallet, + }; + let fetched = identity_wallet + .fetch_encrypted_documents( + &owner_id_for_async, + &contract_id_for_async, + &document_type_str, + since_ms, + key_source, + ) + .await; + drop(master_opt); + fetched + }); let docs = unwrap_result_or_return!(result); let json_array: Vec = docs @@ -1080,6 +1040,42 @@ mod tests { ); } + /// Oversized plaintext is rejected before the function looks up the wallet + /// handle or copies the payload. The deliberately invalid handle therefore + /// must not mask the deterministic input error. + #[test] + fn encrypted_document_rejects_oversized_payload_before_wallet_lookup() { + let owner_id = [1u8; 32]; + let contract_id = [2u8; 32]; + let document_type = CString::new("txMetadata").unwrap(); + let payload = vec![0u8; MAX_TX_METADATA_PLAINTEXT_LEN + 1]; + let mut document_id = [0u8; 32]; + let mut document_json = ptr::null_mut(); + + let result = unsafe { + platform_wallet_create_encrypted_document_with_signer_auto_index( + NULL_HANDLE, + ptr::null_mut(), + owner_id.as_ptr(), + contract_id.as_ptr(), + document_type.as_ptr(), + 1, + payload.as_ptr(), + payload.len(), + 1usize as *mut SignerHandle, + document_id.as_mut_ptr(), + &mut document_json, + ) + }; + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + assert!(document_json.is_null()); + assert_eq!(document_id, [0u8; 32]); + } + // ── tx_metadata_key_master_for_wallet dispatch (dashpay/platform#4091) ── // // `tx_metadata_key_master_for_wallet` needs a live `PlatformWallet` (wallet diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 7a26933cee..6c7e86a5b8 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -24,6 +24,7 @@ dashcore = { workspace = true } thiserror = "1.0" async-trait = "0.1" arc-swap = "1" +rand = "0.8" # Collections bimap = "0.6" @@ -95,7 +96,6 @@ drive-proof-verifier = { path = "../rs-drive-proof-verifier" } # (via `dashcore/test-utils`) for building funded, signable test wallets. # Dev-only, so the production build stays on the leaner default features. key-wallet = { workspace = true, features = ["test-utils"] } -rand = "0.8" # Drives the parallel decrypt benchmark in `shielded_decrypt_bench.rs`. rayon = "1.10" # Used by `shielded_tree_append_bench.rs` to open SQLite with tuned PRAGMAs diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs index 400ae6a363..e543fe2ef5 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs @@ -32,98 +32,39 @@ use crate::wallet::identity::crypto::tx_metadata::{ derive_tx_metadata_key, derive_tx_metadata_key_from_master, ensure_tx_metadata_payload_fits, open_tx_metadata, seal_tx_metadata, }; +use rand::{rngs::OsRng, RngCore}; use super::*; -/// In-process high-water map for txMetadata `encryptionKeyIndex` allocation, -/// keyed by owner identity id → the NEXT index to hand out for that identity. -/// Wrapped in an `Arc>` so it is shared across every -/// clone of [`IdentityWallet`] and serializes concurrent allocations (see -/// [`reserve_next_index`] / [`IdentityWallet::allocate_encryption_key_index`]). -pub(crate) type EncryptionKeyIndexAllocator = - Arc>>; - -/// The legacy `encryptionKeyIndex` for the NEXT txMetadata document given the -/// count of documents that already exist for the identity — dash-wallet's -/// `1 + countAllRequests()`. -/// -/// `countAllRequests()` was `SELECT COUNT(*) FROM transaction_metadata_platform` -/// (the count of the identity's published txMetadata documents in the app's -/// local cache — see `PlatformSyncService.publishTxMetaData`, -/// `TransactionMetadataDocumentDao.countAllRequests`). Empty state -/// (`count == 0`) → `1`; `n` existing documents → `n + 1`. This is `count + 1`, -/// NOT `max(index) + 1` — it matches the legacy formula byte-for-byte -/// (dashpay/platform#4186). Saturates at `u32::MAX` (an unreachable -/// 4-billion-document wallet) rather than wrapping back to `0`. -pub(crate) fn next_encryption_key_index_from_count(count: u32) -> u32 { - count.saturating_add(1) -} +/// Largest valid non-hardened BIP-32 child index. `encryptionKeyIndex` becomes a +/// hardened child in the txMetadata path, so its raw value must stay below +/// `2^31` (`ChildNumber::from_hardened_idx` enforces the same bound). +const MAX_ENCRYPTION_KEY_INDEX: u32 = 0x7fff_ffff; -/// Atomically reserve the next `encryptionKeyIndex` for `owner` from the shared -/// `allocator`, serializing concurrent callers under its mutex so two creates -/// through the SAME wallet process can never pick the same index. -/// -/// The first allocation for an owner in this process seeds the high-water from -/// `seed` — the Platform-derived `1 + count`, evaluated lazily UNDER the lock so -/// a racing caller blocks on the seed rather than re-computing it — and every -/// subsequent allocation hands out a monotonically increasing index with no -/// further network work. The stored value is always `handed_out + 1`. +/// Convert one random word into a valid txMetadata `encryptionKeyIndex`. /// -/// Cross-DEVICE uniqueness is NOT guaranteed (another device that has not yet -/// reflected its writes on Platform can seed to the same base); see -/// [`IdentityWallet::allocate_encryption_key_index`] for why that stays safe. -/// -/// Two deliberate trade-offs of the single per-wallet mutex + optimistic -/// reservation: allocations for OTHER owners in the same wallet serialize -/// behind a first-time seed fetch (benign for the normal one-identity case), -/// and a create that fails after allocating leaves a harmless index GAP — -/// never a collision — since the high-water is not rolled back. -pub(crate) async fn reserve_next_index( - allocator: &tokio::sync::Mutex>, - owner: &Identifier, - seed: S, -) -> Result -where - S: std::future::Future>, -{ - // Hold the guard across the (first-time only) seed await: this is exactly - // what serializes racing allocators — a second caller that finds the map - // empty blocks here until the first has seeded and inserted its `next + 1`. - let mut guard = allocator.lock().await; - let next = match guard.get(owner).copied() { - Some(n) => n, - None => seed.await?, - }; - guard.insert(*owner, next.saturating_add(1)); - Ok(next) +/// Zero is skipped to preserve the legacy convention that document indices +/// start at one. The high bit is cleared because BIP-32 reserves it for the +/// hardened-child encoding itself. +fn encryption_key_index_candidate(random: u32) -> Option { + let candidate = random & MAX_ENCRYPTION_KEY_INDEX; + (candidate != 0).then_some(candidate) } -/// [`reserve_next_index`] with the deterministic payload-size gate run FIRST, so -/// an over-large payload — one that MUST fail — never consumes an index. +/// Generate a Rust-owned per-document txMetadata derivation index. /// -/// The size check ([`ensure_tx_metadata_payload_fits`]) is a pure, deterministic -/// bound (`payload_len <= MAX_TX_METADATA_PLAINTEXT_LEN`) that needs no network -/// and no key material. Running it before the allocator is touched means an -/// oversized payload returns the typed -/// [`PlatformWalletError::TxMetadataPayloadTooLarge`] WITHOUT seeding the -/// high-water or advancing it — no index is reserved, so the allocator leaves no -/// gap for a request that was always going to be rejected. Only once the payload -/// is known to fit do we (lazily, under the lock) seed/hand out the next index -/// (dashpay/platform#4186 review: validate size before allocating the index). -pub(crate) async fn reserve_next_index_checked( - allocator: &tokio::sync::Mutex>, - owner: &Identifier, - payload_len: usize, - seed: S, -) -> Result -where - S: std::future::Future>, -{ - // Deterministic, network-free size gate BEFORE any allocation: an oversized - // payload fails here, so `seed` is never polled and the high-water is never - // seeded/advanced — no consumed index, no gap. - ensure_tx_metadata_payload_fits(payload_len)?; - reserve_next_index(allocator, owner, seed).await +/// This index is a derivation input carried by the document, not a document id +/// or a protocol uniqueness token. Readers always derive from the document's +/// own `{keyIndex, encryptionKeyIndex}` fields, and every sealed document has a +/// fresh random IV, so an index collision is non-lossy. A CSPRNG-generated +/// 31-bit value therefore gives the host-thin API the property it needs without +/// pretending that a client-side Platform count is an atomic allocator. +fn generate_encryption_key_index() -> u32 { + loop { + if let Some(candidate) = encryption_key_index_candidate(OsRng.next_u32()) { + return candidate; + } + } } /// Where one encrypted-document call derives the per-document txMetadata AES @@ -313,120 +254,6 @@ impl IdentityWallet { }) } - /// Count the identity's existing txMetadata-style documents on Platform — - /// the authoritative equivalent of dash-wallet's local - /// `transactionMetadataDocumentDao.countAllRequests()` - /// (`SELECT COUNT(*) FROM transaction_metadata_platform`). Fetches + - /// registers the contract, then runs the owner-scoped scan with - /// `since_ms == 0` (every document, since `$updatedAt >= 0` always holds) - /// and returns the number of documents found. - /// - /// Every returned entry counts, materialized or not: an un-materialized id - /// still denotes an existing document, so the count never under-reports and - /// the next index never re-collides with an existing one. - /// - /// NOTE: this counts by fetching the owned documents (the same paginated - /// query the fetch path uses) rather than a dedicated drive `COUNT` query — - /// a wallet's txMetadata document set is small, so the extra surface a - /// count-only query would add is not worth it here. - async fn count_owned_txmetadata_documents( - &self, - contract_id: &Identifier, - owner_identity_id: &Identifier, - document_type_name: &str, - ) -> Result { - use dash_sdk::platform::{ContextProvider, Fetch}; - - let contract = DataContract::fetch(&self.sdk, *contract_id) - .await - .map_err(PlatformWalletError::Sdk)? - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData(format!( - "Data contract {contract_id} not found on Platform; \ - cannot allocate encryptionKeyIndex" - )) - })?; - let contract = Arc::new(contract); - if let Some(provider) = self.sdk.context_provider() { - provider.register_data_contract(Arc::clone(&contract)); - } - let raw = - query_owned_encrypted_documents(&self.sdk, contract, owner_identity_id, document_type_name, 0) - .await?; - Ok(u32::try_from(raw.len()).unwrap_or(u32::MAX)) - } - - /// Allocate the next `encryptionKeyIndex` for an encrypted-document create - /// when the host supplies none — moving the index-selection policy off the - /// Kotlin host and into authoritative Rust/Platform state - /// (dashpay/platform#4186 follow-up: the host-thin rule forbids a key-index - /// policy loop in the host; hosts now provide only the opaque payload). - /// - /// Semantics MATCH the retired dash-wallet counter EXACTLY: the index is - /// `1 + countAllRequests()`, where the count is now - /// [`Self::count_owned_txmetadata_documents`] read from Platform at create - /// time instead of the app's local `transaction_metadata_platform` table. - /// Empty state → `1`; `n` existing documents → `n + 1` (see - /// [`next_encryption_key_index_from_count`]). - /// - /// Allocation is serialized through the wallet's shared - /// [`EncryptionKeyIndexAllocator`] mutex (see [`reserve_next_index`]): two - /// concurrent creates through the SAME wallet process can NEVER pick the - /// same index — the first seeds the in-process high-water from Platform, the - /// second hands out the next value without a second query. - /// - /// ## Cross-device caveat (best-effort per device, NOT data-loss) - /// Uniqueness is guaranteed only PER DEVICE. Two devices sharing an identity - /// can seed to the same base before either's write is visible to the other, - /// so both may write a document at the same `encryptionKeyIndex`. This is - /// SAFE, not lossy: every encrypted document stores its OWN `keyIndex` + - /// `encryptionKeyIndex`, and the reader - /// ([`Self::fetch_encrypted_documents`]) derives each document's key from - /// the document's own stored indices — so two documents sharing an index - /// each carry a fresh random IV, decrypt independently, and are BOTH - /// returned. A duplicate index is not even an extra decrypt attempt (the - /// reader never guesses indices); no document is overwritten or shadowed. - /// - /// ## Size validated BEFORE allocating (no index consumed on failure) - /// `payload_len` is the plaintext length of the document about to be sealed. - /// It is checked against - /// [`MAX_TX_METADATA_PLAINTEXT_LEN`](crate::wallet::identity::crypto::tx_metadata::MAX_TX_METADATA_PLAINTEXT_LEN) - /// up front — a pure, - /// network-free bound — via [`reserve_next_index_checked`], so an oversized - /// payload (which the deterministic 4063-byte limit MUST reject) fails with - /// [`PlatformWalletError::TxMetadataPayloadTooLarge`] WITHOUT ever counting on - /// Platform or advancing the allocator's high-water. An always-doomed request - /// therefore leaves no index gap (dashpay/platform#4186 review). - pub async fn allocate_encryption_key_index( - &self, - owner_identity_id: &Identifier, - contract_id: &Identifier, - document_type_name: &str, - payload_len: usize, - ) -> Result { - reserve_next_index_checked( - &self.enc_key_index_allocator, - owner_identity_id, - payload_len, - async { - let count = self - .count_owned_txmetadata_documents( - contract_id, - owner_identity_id, - document_type_name, - ) - .await?; - let index = next_encryption_key_index_from_count(count); - breadcrumb(&format!( - "allocate_encryption_key_index: seeded owner={owner_identity_id} \ - existing_count={count} next_index={index}" - )); - Ok(index) - }, - ) - .await - } - /// 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 @@ -506,8 +333,10 @@ impl IdentityWallet { /// properties with no key material in scope. /// /// The caller supplies: - /// - `encryption_key_index`: the per-document index (dash-wallet's monotonic - /// `1 + countAllRequests()` counter). Batching stays app-side. + /// - `encryption_key_index`: an explicit per-document derivation index. + /// New host APIs should omit it and use + /// [`Self::prepare_encrypted_txmetadata_properties_auto_index`]; this + /// explicit path remains for migration and compatibility tests. /// - `version`: the payload version byte (`1` = protobuf, as the wallet /// writes). /// - `payload`: the already-serialized opaque plaintext (a protobuf @@ -573,6 +402,35 @@ impl IdentityWallet { .to_string()) } + /// Rust-owned-index sibling of + /// [`Self::prepare_encrypted_txmetadata_properties`]. + /// + /// Generates a valid non-zero 31-bit BIP-32 child index with the operating + /// system CSPRNG, then derives and seals the document with that index. No + /// Platform query, allocator mutex, or wallet-facade state is involved. + /// This is deliberate: `encryptionKeyIndex` is stored on every document and + /// consumed from that document during decryption, so it is not a protocol + /// sequence number and does not need globally authoritative allocation. + /// Every document also receives a fresh random IV; an unlikely repeated + /// index therefore remains non-lossy and both documents decrypt normally. + pub fn prepare_encrypted_txmetadata_properties_auto_index( + &self, + owner_identity_id: &Identifier, + version: u8, + payload: &[u8], + key_source: TxMetadataKeySource<'_>, + ) -> Result { + // Keep deterministic rejection ahead of randomness and key derivation. + ensure_tx_metadata_payload_fits(payload.len())?; + self.prepare_encrypted_txmetadata_properties( + owner_identity_id, + generate_encryption_key_index(), + version, + payload, + key_source, + ) + } + /// Fetch every encrypted `txMetadata`-style document owned by /// `owner_identity_id` on `contract_id`'s `document_type_name` updated at or /// after `since_ms`, and DECRYPT each with the identity's derived key. @@ -845,160 +703,34 @@ pub async fn query_owned_encrypted_documents( } #[cfg(test)] -mod allocator_tests { - //! Unit tests for the `encryptionKeyIndex` allocator - //! (dashpay/platform#4186 follow-up). These exercise the index math and the - //! atomic in-process reservation WITHOUT a live SDK: the Platform-derived - //! seed is injected as a plain future, so `1 + count` semantics, per-owner - //! isolation, and the concurrent no-collision guarantee are all pinned here. +mod index_generation_tests { use super::*; - fn empty_allocator() -> EncryptionKeyIndexAllocator { - Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())) - } - - /// The index math is EXACTLY dash-wallet's `1 + countAllRequests()`: - /// empty state → 1, `n` existing → `n + 1` (count+1, not max+1), saturating - /// at the ceiling rather than wrapping to 0. #[test] - fn next_index_matches_legacy_one_plus_count() { - assert_eq!(next_encryption_key_index_from_count(0), 1); - assert_eq!(next_encryption_key_index_from_count(1), 2); - assert_eq!(next_encryption_key_index_from_count(5), 6); - assert_eq!(next_encryption_key_index_from_count(u32::MAX), u32::MAX); - } - - /// Empty state seeds to `1 + count(0) == 1`, then hands out 2, 3 … WITHOUT - /// re-seeding (the seed future must not be polled again once the high-water - /// is established). - #[tokio::test] - async fn empty_state_seeds_to_one_then_increments() { - let alloc = empty_allocator(); - let owner = Identifier::from([7u8; 32]); - - let first = reserve_next_index(&alloc, &owner, async { - Ok(next_encryption_key_index_from_count(0)) - }) - .await - .expect("seed ok"); - assert_eq!(first, 1, "empty state must allocate index 1"); - - // A seed that panics if awaited proves the second/third allocations - // never re-seed — they read the cached high-water instead. - let must_not_seed = - || async { unreachable!("must not re-seed once the high-water is established") }; + fn candidate_is_nonzero_and_within_the_bip32_child_domain() { + assert_eq!(encryption_key_index_candidate(0), None); + assert_eq!(encryption_key_index_candidate(1), Some(1)); assert_eq!( - reserve_next_index(&alloc, &owner, must_not_seed()).await.unwrap(), - 2 + encryption_key_index_candidate(u32::MAX), + Some(MAX_ENCRYPTION_KEY_INDEX), + "the hardened marker bit must not leak into the raw child index" ); assert_eq!( - reserve_next_index(&alloc, &owner, must_not_seed()).await.unwrap(), - 3 + encryption_key_index_candidate(0x8000_0001), + Some(1), + "only the lower 31 bits are part of the raw child index" ); } - /// Distinct owners keep independent high-waters — one identity's allocations - /// never perturb another's. - #[tokio::test] - async fn distinct_owners_seed_independently() { - let alloc = empty_allocator(); - let a = Identifier::from([1u8; 32]); - let b = Identifier::from([2u8; 32]); - - // a: 3 existing docs → 4; b: 0 existing → 1; then a again → 5. - assert_eq!( - reserve_next_index(&alloc, &a, async { Ok(next_encryption_key_index_from_count(3)) }) - .await - .unwrap(), - 4 - ); - assert_eq!( - reserve_next_index(&alloc, &b, async { Ok(next_encryption_key_index_from_count(0)) }) - .await - .unwrap(), - 1 - ); - assert_eq!( - reserve_next_index(&alloc, &a, async { unreachable!("a already seeded") }) - .await - .unwrap(), - 5 - ); - } - - /// The core concurrency guarantee: two allocations racing on the SAME owner - /// through the SAME allocator get DISTINCT indices. The mutex serializes - /// them even though both start from an empty map and both would otherwise - /// seed to 1. `yield_now` inside the seed widens the interleaving window so - /// a broken (non-serialized) allocator would reliably hand out 1 twice. - #[tokio::test] - async fn concurrent_allocations_never_collide() { - let alloc = empty_allocator(); - let owner = Identifier::from([3u8; 32]); - - let seed = || async { - tokio::task::yield_now().await; - Ok(next_encryption_key_index_from_count(0)) - }; - let (r1, r2) = tokio::join!( - reserve_next_index(&alloc, &owner, seed()), - reserve_next_index(&alloc, &owner, seed()), - ); - let (i1, i2) = (r1.expect("task 1"), r2.expect("task 2")); - - assert_ne!(i1, i2, "concurrent allocations must not collide"); - let mut got = [i1, i2]; - got.sort_unstable(); - assert_eq!(got, [1, 2], "the two racing indices must be exactly 1 and 2"); - } - - /// An oversized payload on the auto-index path fails with the typed - /// `TxMetadataPayloadTooLarge` BEFORE the allocator is touched: the seed is - /// never polled, so the high-water is neither seeded nor advanced — no index - /// is consumed and no gap is left (dashpay/platform#4186 review). A - /// subsequent well-sized reservation for the same owner still starts at the - /// legacy `1 + count(0) == 1`, proving nothing was reserved by the doomed - /// request. - #[tokio::test] - async fn oversized_payload_does_not_advance_highwater() { - use crate::wallet::identity::crypto::tx_metadata::MAX_TX_METADATA_PLAINTEXT_LEN; - - let alloc = empty_allocator(); - let owner = Identifier::from([8u8; 32]); - - // 4064 bytes (MAX + 1) is the first rejected length. The seed panics if - // polled — proving the size gate short-circuits before any allocation. - let result = reserve_next_index_checked( - &alloc, - &owner, - MAX_TX_METADATA_PLAINTEXT_LEN + 1, - async { unreachable!("seed must not run when the payload is oversized") }, - ) - .await; - match result { - Err(PlatformWalletError::TxMetadataPayloadTooLarge { len, max }) => { - assert_eq!(len, MAX_TX_METADATA_PLAINTEXT_LEN + 1); - assert_eq!(max, MAX_TX_METADATA_PLAINTEXT_LEN); - } - other => panic!("expected TxMetadataPayloadTooLarge, got {other:?}"), + #[test] + fn generated_indices_are_valid_hardened_children() { + use key_wallet::bip32::ChildNumber; + + for _ in 0..256 { + let index = generate_encryption_key_index(); + assert!((1..=MAX_ENCRYPTION_KEY_INDEX).contains(&index)); + ChildNumber::from_hardened_idx(index) + .expect("generated txMetadata index must be a valid hardened child"); } - - // High-water NOT advanced: the owner was never inserted into the map. - assert!( - alloc.lock().await.get(&owner).is_none(), - "an oversized payload must not seed/advance the allocator high-water" - ); - - // The next well-sized reservation still seeds fresh at 1 — no gap was - // left by the rejected oversized request. - let index = reserve_next_index_checked(&alloc, &owner, 0, async { - Ok(next_encryption_key_index_from_count(0)) - }) - .await - .expect("well-sized reservation seeds ok"); - assert_eq!( - index, 1, - "the first index after a rejected oversized payload must still be 1 (no gap)" - ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index 3e6d7b8601..883e4bae99 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -38,7 +38,6 @@ use zeroize::Zeroizing; use crate::broadcaster::{SpvBroadcaster, TransactionBroadcaster}; use crate::error::PlatformWalletError; use crate::wallet::asset_lock::manager::AssetLockManager; -use crate::wallet::identity::network::encrypted_document::EncryptionKeyIndexAllocator; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; /// Default gap limit for identity discovery scanning. @@ -323,15 +322,6 @@ pub struct IdentityWallet { /// signer-generic `PutDocument` trait) behind two by-value methods /// so the call sites stay simple. pub(crate) sdk_writer: Arc, - /// In-process, per-owner-identity high-water map for allocating the - /// txMetadata `encryptionKeyIndex` when the host omits it — the Rust-side - /// index-allocation policy (dashpay/platform#4186 follow-up). Shared across - /// every clone of this handle (an `Arc`), so two concurrent - /// encrypted-document creates through the SAME wallet process serialize - /// under its mutex and can never pick the same index. Best-effort unique - /// PER DEVICE only; see - /// [`IdentityWallet::allocate_encryption_key_index`](crate::wallet::identity::IdentityWallet::allocate_encryption_key_index). - pub(crate) enc_key_index_allocator: EncryptionKeyIndexAllocator, } // Manual `Debug`: the derive would require `B: Debug`, which is not part @@ -355,7 +345,6 @@ impl Clone for IdentityWallet { persister: self.persister.clone(), broadcaster: Arc::clone(&self.broadcaster), sdk_writer: Arc::clone(&self.sdk_writer), - enc_key_index_allocator: Arc::clone(&self.enc_key_index_allocator), } } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index 1ca05f40d7..bd797eac60 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -3676,7 +3676,6 @@ mod tests { persister: real.persister.clone(), broadcaster: Arc::new(AcceptingBroadcaster), sdk_writer: Arc::clone(&real.sdk_writer), - enc_key_index_allocator: Arc::clone(&real.enc_key_index_allocator), } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 523f2b1ac0..cfb310ea59 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -456,12 +456,6 @@ impl PlatformWallet { sdk_writer: Arc::new( crate::wallet::identity::network::sdk_writer::SdkWriter::new(Arc::clone(&sdk)), ), - // Fresh, empty allocator: encryptionKeyIndex high-water is seeded - // lazily per owner-identity from Platform state on the first - // host-omitted create (dashpay/platform#4186 follow-up). - enc_key_index_allocator: Arc::new(tokio::sync::Mutex::new( - std::collections::HashMap::new(), - )), }; let platform = PlatformAddressWallet::new( diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index f15a38bc30..5232abe00a 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -755,7 +755,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do /// Create + broadcast an ENCRYPTED wallet-contract document (the wire- /// compatible `txMetadata` shape) — the JNI bridge over /// `platform_wallet_create_encrypted_document_with_signer` and its -/// Rust-allocated-index sibling. +/// Rust-generated-index sibling. /// /// The SDK derives the identity encryption key, seals `payload` into the /// legacy `version ‖ IV ‖ AES-256-CBC` blob, and writes @@ -766,7 +766,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do /// `encryption_key_index` carries the per-document index OR the `-1` sentinel /// (dashpay/platform#4186 follow-up): a non-negative value is used verbatim /// (routed to the explicit-index export, retained for migration / tests), while -/// `-1` means "let the SDK allocate the index from authoritative Platform state" +/// `-1` means "let the SDK generate the per-document index" /// and routes to `platform_wallet_create_encrypted_document_with_signer_auto_index`. /// Any value `< -1` is rejected. Returns the confirmed document's canonical JSON /// (its 32-byte id is the base58 `$id` field); null after throwing on error. @@ -795,15 +795,15 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do let Some(doc_type) = read_cstring(env, &document_type, "documentType") else { return ptr::null_mut(); }; - // encryptionKeyIndex == -1 is the "let Rust allocate" sentinel - // (dashpay/platform#4186 follow-up): the host omits the index and the - // SDK derives the next one from Platform state. A non-negative value is + // encryptionKeyIndex == -1 is the "let Rust generate" sentinel: the + // host omits the index and platform-wallet draws a valid BIP-32 child + // index from the operating-system CSPRNG. A non-negative value is // an explicit caller-supplied index; anything below -1 is invalid. if encryption_key_index < -1 { throw_sdk_exception( env, 1, - "encryptionKeyIndex must be >= 0, or -1 to let the SDK allocate it", + "encryptionKeyIndex must be >= 0, or -1 to let the SDK generate it", ); return ptr::null_mut(); } @@ -821,6 +821,27 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do ); return ptr::null_mut(); } + // Reject by Java-array length before `convert_byte_array` creates the + // JNI-owned plaintext copy. The FFI/core repeat the same shared bound. + let payload_len = match env.get_array_length(&payload) { + Ok(len) => len as usize, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "payload byte[] was null/invalid"); + return ptr::null_mut(); + } + }; + if payload_len > platform_wallet_ffi::document::MAX_TX_METADATA_PLAINTEXT_LEN { + throw_sdk_exception( + env, + 1, + &format!( + "txMetadata payload is {payload_len} bytes; maximum is {} bytes", + platform_wallet_ffi::document::MAX_TX_METADATA_PLAINTEXT_LEN, + ), + ); + return ptr::null_mut(); + } // The JNI-owned plaintext copy. Wrapped in `Zeroizing` so it is scrubbed // on drop, mirroring the inner FFI copy (`payload_vec` in // `rs-platform-wallet-ffi/src/document.rs`). The inner copy is dropped @@ -842,7 +863,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_do let mut out_json: *mut c_char = ptr::null_mut(); let result = if auto_index { // Host omitted the index: route to the ABI-additive sibling that - // takes no encryptionKeyIndex and lets Rust allocate it. + // takes no encryptionKeyIndex and lets Rust generate it. unsafe { platform_wallet_ffi::platform_wallet_create_encrypted_document_with_signer_auto_index( wallet_handle as Handle,