feat(platform-wallet): allocate txMetadata encryptionKeyIndex in Rust, not the host (stacked on #4186)#4195
Conversation
|
Warning Review limit reached
Next review available in: 55 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (22)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
✅ Review complete (commit 4f2eb06) |
|
Reviewed (two independent passes). The allocation design is sound and legacy-compatible:
Minor: |
…e + decrypt-on-fetch Adds the wallet-contract encrypted-document surface the Android wallet needs to retire the legacy org.dashj.platform stack (closes dashpay#4086 create/update, closes The encryption ENVELOPE is byte-for-byte wire-compatible with the legacy BlockchainIdentity.publishTxMetaData / getTxMetaData so documents written by either stack decrypt with the other (migrated users keep their history): - AES-256 key = the raw 32-byte secp256k1 private scalar of a hardened HD child (mirrors KeyCrypterAESCBC.deriveKey(ECKey); no ECDH, no HKDF). - Path: identity-auth path of the identity's encryption key (its id = the document's keyIndex) extended by / 32769' / encryptionKeyIndex'. - Cipher: AES-256-CBC / PKCS7, random 16-byte IV. - encryptedMetadata blob = version(1) ‖ IV(16) ‖ AES-256-CBC(payload); the version byte is OUTSIDE the ciphertext (0 = CBOR, 1 = protobuf). The plaintext payload stays OPAQUE to the SDK — the app owns the protobuf TxMetadataBatch item schema and the batching policy, exactly as on the legacy stack. The SDK owns only the crypto envelope + the {keyIndex, encryptionKeyIndex, encryptedMetadata} document fields. Layers: - rs-platform-wallet: crypto/tx_metadata.rs (derive/seal/open + tests); network/encrypted_document.rs (create_encrypted_document_with_signer reusing the tested create path; fetch_encrypted_documents mirroring the contactInfo paginated decrypt loop). - rs-platform-wallet-ffi: platform_wallet_create_encrypted_document_with_signer, platform_wallet_fetch_encrypted_documents (JSON-out; payload as base64). - rs-unified-sdk-jni: documentCreateEncrypted / documentFetchEncrypted. - kotlin-sdk: DocumentTransactions.createEncryptedDocument / fetchEncryptedDocuments (additive; no existing signatures change). Tests: seal↔open round-trip, key-derivation determinism + index separation, wrong-key/ malformed-blob fail cleanly, and a NIST SP 800-38A CBC-AES256 cross-stack vector pinning the cipher core + blob framing. cc @QuantumExplorer Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d vector
The encrypted-txMetadata scheme claims byte-for-byte wire compatibility with
the legacy org.dashj.platform stack, but the mnemonic->AES-key HD derivation
prefix was previously only "sample-confirmed" (the module's NIST test pinned
the AES-CBC envelope but explicitly could NOT pin the derivation-path account
prefix). That gap is now closed by reconstructing the exact legacy recipe from
the shipped jars and running it under a JVM.
Recovered legacy derivation (the wire-compat reference this crate mirrors):
AES-256-CBC key = raw private-key bytes of the ECKey at absolute HD path
m / 9' / coinType' / 5' / 0' / 0' / 0' / keyId' / 32769' / encryptionKeyIndex'
- account path 9'/coinType'/5'/0'/0'/0' is
DerivationPathFactory.blockchainIdentityECDSADerivationPath() (dashj-core
22.0.3), the path the BLOCKCHAIN_IDENTITY AuthenticationKeyChain is built
with (AuthenticationGroupExtension.getDefaultPath).
- keyId' / 32769' / encryptionKeyIndex' are appended by
BlockchainIdentity.privateKeyAtPath; keyId is the id of the identity's
ENCRYPTION/MEDIUM ECDSA key (id 2 in createIdentityPublicKeys), 32769' is
TxMetadataDocument.childNumber, encryptionKeyIndex is the app's per-document
counter (dash-sdk-kotlin 4.0.0-RC2).
- AES key bytes = KeyCrypterAESCBC.deriveKey(ecKey) = new KeyParameter(
ecKey.getPrivKeyBytes()) (raw scalar, no ECDH / no KDF); framing is
version(1) ‖ IV(16) ‖ AES-256-CBC/PKCS7(payload).
This is an exact match to Rust's identity_auth_derivation_path_for_type(ECDSA,
identity_index=0, key_index=keyId) extended by /32769'/encryptionKeyIndex': the
three legacy zeros correspond to [subfeature-auth, keytype=ECDSA=0,
identity_index=0]. No derivation-logic change is needed — verified by generating
the key AND a full encryptedMetadata blob with the real dashj stack for the
BIP-39 "abandon … about" mnemonic and asserting Rust reproduces the key and
decrypts the blob to the original plaintext.
- add legacy_dashj_wire_compat_vector: dashj-generated (key, blob, plaintext)
vector with full provenance, so the derivation prefix + envelope are now
CI-enforced rather than device-sample-confirmed.
- retarget the NIST test's doc comment as the narrower cipher-conformance leg
and drop the now-obsolete "cannot be reconstructed from the jars" caveat.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tnet docs + query diagnostics The on-device decrypt-proof reported `sdkFetched=0` with ZERO decrypt-skip warnings, i.e. the fetch query returned nothing while the legacy stack sees 2 encrypted `txMetadata` documents for the same owner. This isolates the FETCH half of `IdentityWallet::fetch_encrypted_documents` and pins it against the real documents so a wire-query regression is caught in CI, and adds an on-device breadcrumb to localize any future empty result to the query vs the decrypt stage. What this proves (executable evidence): the exact production query — `$ownerId == owner AND $updatedAt >= since_ms`, ordered `$updatedAt asc`, paginated — returns BOTH real testnet documents (owner 532rVHxLD6Z3MNiu5LZyNqn55Ybz4bydZozXU4cqqp1L, wallet-utils contract 7CSFGeF4WNzgDmx94zwvHkYaG3Dx4XEe5LFsFgJswLbm, type `txMetadata`, since_ms 0) fully materialized, with `keyIndex=2`, `encryptionKeyIndex=1`, `encryptedMetadata` 3585 bytes. This holds for the exact production shape (`&Identifier` owner value, `U64` since bound), with and without the range clause, across pinned platform versions 1..=12 and the default, and including the production `register_data_contract` step. The query construction, value encoding, contract resolution (7CSFGeF4… is the built-in wallet-utils system contract), and JNI param marshalling (`read_id32`, `sinceMs` jlong→u64) are therefore all wire-correct; the on-device empty result is not reproducible from the query and points outside it (e.g. a stale native lib). Changes: - extract the paginated wire query into `query_owned_encrypted_documents` (takes the `Sdk` + fetched contract, no resident wallet/identity), re-exported so the new testnet integration test drives the SAME code the FFI path runs. Query logic unchanged. - add `tests/txmetadata_fetch.rs` (`#[ignore]`, testnet): asserts the query returns the 2 documents and that each decodes `keyIndex`/`encryptionKeyIndex` (u32) + `encryptedMetadata` (bytes) — i.e. the pipeline reaches decrypt for both, without needing the owner mnemonic. - log `raw_count`/`materialized` at INFO before the decrypt loop, so the next `adb logcat` run during the probe pins an empty result to the query (raw_count=0), a proof-materialization gap (raw_count>0, materialized=0), or the decrypt/JSON stage (materialized>0) — no guessing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-document fetch path The dash-wallet decrypt probe reported sdkFetched=0 on-device with NO Rust breadcrumb in logcat — either the JNI call never reached Rust or platform-wallet INFO tracing is filtered from logcat. Make every stage of the fetch path provably visible at WARN: - rs-platform-wallet fetch_encrypted_documents: entry log (owner/contract b58, type, since_ms), warn on every early-return (contract fetch failure, contract-not-found, encryption-context resolution failure, query failure) and a final raw/decrypted count; query_owned_encrypted_documents gets an entry log and a fetch_many error log, and the raw_count/materialized breadcrumb is raised from info! to warn!. - rs-unified-sdk-jni documentFetchEncrypted: entry log (wallet_handle nonzero?, since_ms), parsed-args log (owner/contract hex, doc type), per-early-return warns, and a success log with the returned JSON size. - take_pwffi_error / throw_sdk_exception warn-log every native->Kotlin error conversion (raw + offset code, full message), so a contained exception still leaves a logcat trail. Diagnostic only — no behavior change; cargo check -p rs-unified-sdk-jni and clippy on both touched crates are clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…h log AND tracing so they reach Android logcat
The 2026-07 on-device forensic tap proved the two logging facades diverge on
Android: JNI_OnLoad installs android_logger as the global `log` logger
(logcat tag DashSDK), so the JNI layer's log::warn! lines were visible —
while the only tracing subscriber the Kotlin SDK installs
(dash_sdk_enable_logging, a tracing_subscriber::fmt layer) writes to STDOUT,
which Android discards. Every tracing::warn! breadcrumb in
fetch_encrypted_documents therefore never reached logcat, even at WARN.
Fix: a `breadcrumb()` helper in encrypted_document.rs emits each diagnostic
line through BOTH facades — `tracing` for host tests / desktop, `log` for
logcat — and every stage of the fetch path now uses it (entry, contract
fetch/not-found, encryption-context resolution, query entry, fetch_many
error, raw_count/materialized, per-document skips, final raw/decrypted
counts). The previously SILENT skip of a raw-but-unmaterialized document
(`let Some(doc) = maybe_doc else { continue }`) now leaves a trail too:
under proofs that shape is exactly what turns "2 documents exist" into an
empty result with no error.
Root-cause status of the on-device sdkFetched=0: NOT locally reproducible.
The device path was config-identical to the Mac repro (SdkBuilder::
new_testnet + TrustedHttpContextProvider::new(Testnet, None, 100), proofs on
by default, platform version auto, since_ms=0, contract registered with the
provider — the register step is now mirrored in tests/txmetadata_fetch.rs),
and that repro still returns raw_count=2 materialized=2 from this Mac. A
stale device lib is ruled out: the JNI warns visible in the tap were added
in d29d523, so the device ran current code. The next on-device tap will
pin the failing stage: query-empty (raw_count=0) vs materialization drop
(raw>0, NOT-materialized lines) vs decrypt skip (per-doc skip lines).
cargo test -p platform-wallet --lib: 427 passed; testnet integration test
txmetadata_fetch passes with the production-parity register step; clippy
clean on platform-wallet + rs-unified-sdk-jni.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…olver for external-signable wallets
The on-device decrypt-proof breadcrumbs delivered the verdict: the query was
never broken (raw=3 materialized=3), but every document skipped with
"txMetadata key derivation failed ... External signable wallet has no private
key". The app's SDK wallet is EXTERNAL-SIGNABLE — no private keys in the Rust
wallet; every key derives on demand through the registered mnemonic resolver
(the app's security architecture) — while derive_tx_metadata_key derived from
in-wallet private keys, which exist in test fixtures but never on-device. The
CREATE path had the identical flaw.
Fix, following the identity_key_preview / discovery capability convention:
- rs-platform-wallet: new derive_tx_metadata_key_from_master (same path,
caller-supplied master xprv; path construction shared via
tx_metadata_derivation_path so the two sources can never drift) and a
TxMetadataKeySource {ResidentWallet, Master} selector on both
create_encrypted_document_with_signer and fetch_encrypted_documents;
key-derivation breadcrumbs now name the active source.
- rs-platform-wallet-ffi: both encrypted-document entry points take a
nullable mnemonic_resolver_handle. Capability check under a short guard
(never held across the host resolver callback), resolver consulted ONLY
for external-signable / watch-only wallets (resident wallets keep the
historical in-process derive and skip the Keystore read), master scalar
wiped (non_secure_erase) before the result crosses back — atomic
derive + use + zeroize.
- rs-unified-sdk-jni + kotlin-sdk: documentCreateEncrypted /
documentFetchEncrypted and DocumentTransactions.createEncryptedDocument /
fetchEncryptedDocuments thread mnemonicResolverHandle through (the app
passes PlatformWalletManager.mnemonicResolverHandle, as discoverIdentities
already does).
Regression tests (network-free):
- master_derivation_matches_resident_wallet_derivation — both key sources
agree at every probed (identity, key, encryptionKey) slot;
- external_signable_wallet_derives_via_resolver_master — the device shape:
in-wallet derive fails with the exact no-private-key error, the
resolver-master path (stubbed with the test mnemonic) round-trips
seal/open against a resident wallet in both directions;
- legacy_dashj_wire_compat_vector now pins the resolver-master path to the
dashj-generated vector too (both sources hit the legacy key
byte-for-byte).
cargo test -p platform-wallet --lib: 429 passed; -p platform-wallet-ffi
--lib: 145 passed; clippy clean on all three crates; kotlin-sdk
:sdk:compileDebugKotlin green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…off the await; redact plaintext Debug; quiet breadcrumbs Addresses the latest review on dashpay#4091. Wire-compat vector (BLOCKING): the existing legacy_dashj_wire_compat_vector pinned identity_index=0, indistinguishable from KeyDerivationType::ECDSA=0 at the adjacent path slot, so it could not prove identity_index is wired correctly. Add legacy_dashj_wire_compat_vector_nonzero_identity_index, generated by the REAL legacy stack (dashj-core 22.0.3 + blockchainIdentityECDSADerivationPath / KeyCrypterAESCBC, run under a JVM) at identity_index=1 (m/9'/1'/5'/0'/0'/1'/2'/32769'/1'). Its key (8cda…5196) is provably distinct from the index-0 key (4a2e…84d7); both the resident-wallet and resolver-master derivations are asserted to hit it. The reproducible generator (LegacyKeyN.java) and a README are checked in under tests/legacy_wire_compat/ so the vector's provenance is independently verifiable. Master-key exposure across await (document.rs create+fetch): the resolved master xprv previously lived across the network broadcast/pagination awaits and was wiped only afterwards (skipped on panic/early-return). Create now derives the AES key + seals the wire blob SYNCHRONOUSLY (new IdentityWallet:: prepare_encrypted_txmetadata_properties) and wipes the master before the async broadcast, so no key material crosses the await. Fetch cannot pre-derive (per-doc keyIndex/encryptionKeyIndex are discovered during pagination), so the master is wrapped in a WipingMaster Drop guard that scrubs on every exit path, with the tradeoff documented. FFI decision tests (decide_key_source) cover null-handle, external-signable dispatch, and resolver-required. Hygiene: manual Debug impls redacting the decrypted payload on DecryptedEncryptedDocument and OpenedTxMetadata; transactions.rs no longer logs the raw mnemonic_resolver_handle pointer (nonzero=bool only); per-poll informational breadcrumbs downgraded warn!->debug! (genuine error/skip paths stay warn!), with the android_logger Info-level visibility implication noted so identity-correlated data stops reaching logcat on every successful fetch now that the sdkFetched=0 root cause is fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…deep-cloning it Addresses review nitpick bbb24591b025 on dashpay#4091. fetch_encrypted_documents wrapped the fetched DataContract in one Arc for the context provider (Arc::new(contract.clone())) and then moved the original into a SECOND Arc — a redundant deep clone of the whole contract (document-type/index metadata) on every fetch. Wrap once and hand the provider a cheap Arc::clone of the same handle. Verified: cargo test -p platform-wallet green (430 lib + 9 integration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…'t decode Addresses review findings 0dd9fc55de07 / CodeRabbit a783199f on dashpay#4091. createEncryptedDocument bounded `version` to 0..255, but only 0 (CBOR) and 1 (protobuf) are wire-meaningful: seal_tx_metadata writes the byte verbatim into the envelope and the legacy dashj decryptTxMetadata switches on exactly those two values. Accepting 2..255 would silently seal a document the legacy stack cannot decode, breaking the bidirectional wire-compat guarantee this PR exists to establish. Tighten to `require(version == 0 || version == 1)` with a message that names both wire versions. Adds DocumentTransactionsVersionValidationTest pinning the rejection of 2..255 and negative bytes (the `require` runs before the native call, so the rejection paths are exercised on the JVM). Full :sdk unit suite green (113). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ore + JNI, not just Kotlin The version-byte wire-compat guard previously landed only as a Kotlin `require` (DocumentTransactions.kt); the JNI entry point still accepted the stale 0..=255 range and `seal_tx_metadata` wrote the byte verbatim, so a caller reaching the FFI/JNI directly could still seal a document with a version (2..=255) the legacy dashj `decryptTxMetadata` can't decode — silently breaking wire-compat (dashpay#4091, findings 9c0ce58c3bb7 and 79595960d201). - seal_tx_metadata now returns Result and rejects any version != 0 (CBOR) / 1 (protobuf) at the one choke point every layer (JNI, FFI, resident wallet) funnels through; the FFI create path propagates the error. Added seal_rejects_non_wire_versions (asserts 0/1 seal, 2..=255 rejected). - The JNI create entry point replaces the `0..=255` check with `0..=1` and a message naming both wire versions, failing fast before the native call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…el nonzero vector as internal slot check (dashpay#4091) The reviewer was right on both threads. The legacy dashj createTxMetadata flow has NO identity-index component — it always derives against the primary identity via blockchainIdentityECDSADerivationPath() (index 0) — so legacy wire-compat is only defined at identity_index=0, and no legacy wallet ever wrote a document keyed at a nonzero index. Apply option (a) — vector VALUES unchanged, provenance corrected: 1. legacy_dashj_wire_compat_vector (identity_index=0, 4a2e…84d7): document that the account prefix was verified against the REAL dashj DerivationPathFactory.blockchainIdentityECDSADerivationPath() (path m/9'/1'/5'/0'/0'/0'/keyId'/32769'/encryptionKeyIndex'), not mirrored back from Rust's own tx_metadata_derivation_path (finding dd246b5e17d0). 2. Rename legacy_dashj_wire_compat_vector_nonzero_identity_index -> nonzero_identity_index_derivation_slot_is_internally_consistent and rewrite its docs/asserts: the 8cda…5196 value is SELF-REFERENTIAL (LegacyKeyN.java hand-builds the same path Rust constructs; it does not call the real DerivationPathFactory), so it pins internal slot placement + resident/master agreement only — explicitly NOT a legacy wire-compat claim (finding 4c0754158cc6). 3. Add a doc note on derive_tx_metadata_key and the module header stating wire-compat holds only at identity_index=0. Correct LegacyKeyN.java's header/inline comments and the README to state the generator hand-builds the account path and that the nonzero vector is an internal consistency cross-check, not a legacy sample. cargo test -p platform-wallet --lib: 431 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rifier Addresses blocker 989be307db0f on dashpay#4091 (its still-open core ask: "check in the actual JVM repro script/tool for independent verification"). b7319b5 corrected the vectors' provenance in prose (scoped wire-compat to identity_index=0; relabeled the nonzero vector as an internal slot check), but the "confirmed against the REAL dashj DerivationPathFactory" claim was still only asserted — LegacyKeyN.java hand-builds its account path and never drives the factory, so a maintainer could not reproduce the equality from checked-in code. Adds LegacyDerivationPathCheck.java: it drives the real org.bitcoinj.wallet.DerivationPathFactory (dashj-core 22.0.3, Testnet) and asserts its primary-identity path — blockchainIdentityECDSADerivationPath() (no-arg) = m/9'/1'/5'/0'/0'/0' — equals LegacyKeyN's hand-built account path at identity_index 0, printing WIRE_COMPAT_ANCHOR_OK = true (verified: true). It also prints the factory's INDEXED overload m/9'/1'/5'/0'/0'/0'/i' beside the hand-built nonzero path m/9'/1'/5'/0'/0'/i', making the shape difference visible so the nonzero vector is self-evidently NOT a factory-produced legacy sample (cross-refs dd246b5e17d0 / 4c0754158cc6). README: document the verifier + run command, and add the missing de.sfuhrm/saphir-hash-core/3.0.10 jar (TestNet3Params.get() needs X11 genesis-block hashing — the factory path fails with NoClassDefFoundError without it; LegacyKeyN alone never touches network params so it was omitted before). Verified end to end: LegacyDerivationPathCheck 0 -> WIRE_COMPAT_ANCHOR_OK=true; LegacyKeyN 0 2 1 -> 4a2e…84d7 and LegacyKeyN 1 2 1 -> 8cda…5196, both matching the hard-coded Rust vectors exactly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rdownGate
createEncryptedDocument and fetchEncryptedDocuments borrow the wallet /
mnemonic-resolver / signer handles but opened with plain
withContext(Dispatchers.IO), bypassing the TeardownGate: a concurrent wallet
shutdown could free the borrowed native handles mid-call (freed-handle UB) and
the source-scanning GateCoverageLintTest.everyHandleBorrowingSuspendFunIsGated
failed on both.
Both now open with gate.op { } like their six sibling document methods (gate.op
already runs the body on Dispatchers.IO, so the bodies are unchanged). Dropped
the now-unused Dispatchers/withContext imports. GateCoverageLintTest green; full
:sdk:testDebugUnitTest suite green (174 tests, 0 failures).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion/network
seal_tx_metadata had no client-side size check: a payload too large for the
encryptedMetadata field (maxItems 4096) derived the key and sealed only to be
rejected at broadcast with an opaque DPP schema error.
Add a typed PlatformWalletError::TxMetadataPayloadTooLarge { len, max } and a
shared ensure_tx_metadata_payload_fits() precheck. It runs FIRST in
prepare_encrypted_txmetadata_properties (before resolve/derive/broadcast) so an
over-large batch fails fast with the length + accepted max, and again inside
seal_tx_metadata as the choke-point last line of defense. The FFI maps the new
variant to ErrorInvalidParameter (already mirrored in Swift/Kotlin — no new
numeric code), so it surfaces sensibly across JNI/FFI with the typed Display.
True envelope math, derived from the code (not hardcoded): the blob is
version(1) + IV(16) + AES-256-CBC/PKCS7(plaintext). PKCS7 always adds a full
block when the plaintext is block-aligned, so ciphertext = 16*(L/16 + 1) and
blob = 17 + that. The largest ciphertext that fits 4096 is ((4096-17)/16)*16 =
254*16 = 4064, and since PKCS7 spends >=1 byte on padding the max plaintext is
one less -> MAX_TX_METADATA_PLAINTEXT_LEN = 4063. That plaintext frames to a
4081-byte blob (NOT 4096 as the review's arithmetic stated); 4064 jumps to 4097
and is the first rejected length. The 4063/4064 boundary itself matches the
reviewer; the "4063 -> 4096" envelope size does not (see the new boundary test,
which pins the real 4081-byte blob).
Boundary test seal_rejects_payload_above_size_limit: 4063 seals (blob == 4081,
round-trips), 4064 rejected as TxMetadataPayloadTooLarge, and the standalone
precheck agrees at the boundary.
Also strips the co-located "finding <hex>" tracker tokens from the comments in
these two files (rationale text kept); they move to the PR description.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…broadcast await platform_wallet_create_encrypted_document_with_signer copied the caller payload into a plain Vec<u8> that stayed live in the outer scope across the network broadcast .await — the native plaintext lingered in memory the whole time the document was being broadcast. Wrap the copy in Zeroizing<Vec<u8>> and make the with_item closure `move` so it OWNS the buffer, then drop(payload_vec) the instant the encrypted properties are prepared (right beside the existing master-key drop), before block_on_worker. The plaintext is now scrubbed and gone before any .await; only the sealed ciphertext properties cross into the async block. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gated check The txmetadata_fetch doc claimed the wire-query regression is "caught in CI (against testnet)", but the test is #[ignore = "hits testnet"] and nothing runs `--ignored`, so CI never executes it. Soften the wording: it is a MANUAL, testnet-gated check, run explicitly with `--ignored`, NOT part of the default `cargo test`/CI run and with no scheduled job running `--ignored` today — a local/pre-release regression gate. (Wiring a scheduled `--ignored` job is out of scope for this PR.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the "finding <hex>" / "blocker <hex>" internal tracking tokens from the remaining code comments and docs (the co-located tokens in tx_metadata.rs and encrypted_document.rs were stripped in the size-precheck commit). Rationale text and the dashpay#4091 issue reference are kept; the tracker refs belong in the PR description, not the source. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tract schema Review round 2: the 4096 field limit was a local const silently duplicating the wallet-utils contract's encryptedMetadata maxItems; pin them together so a contract-side limit change fails a test instead of drifting past the size precheck. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the reviewer-requested independent check (dashpay#4186): decrypt a txMetadata blob produced by a REAL legacy dash-wallet 11.9 install, not one this repo generated. A designated-throwaway testnet wallet (DPNS name `yabba2`, identity ESR1nfF3bj4TR2ZkLmDuSeu6r7VzpTurYi47BV6XwsoP) running stock dash-wallet 11.9 registered a username, did a send + receive, saved metadata, and published one encrypted `txMetadata` document to Platform. It was fetched back off testnet and decrypted with the NEW Rust crypto. - `legacy_install_yabba2_wire_compat_vector` (network-free fixture in tx_metadata.rs): hard-codes the recovery phrase, the real captured blob hex (version 1/protobuf, keyIndex 2, encryptionKeyIndex 1), and the expected protobuf `TxMetadataBatch` plaintext (two items, memos "username"/"faucet", USD exchange rates), and asserts the new `derive_tx_metadata_key` + `open_tx_metadata` path decrypts it byte-for-byte via both the resident and resolver-master key sources. Doc-commented as the independent legacy-install vector, distinct from the self-generated dashj-core scratch vectors. - `capture_legacy_yabba2_txmetadata_blobs` (testnet-gated helper in tests/txmetadata_fetch.rs): resolves the DPNS name, runs the exact production query, derives from the phrase, and prints the capture used to build the fixture. - README: documents the new real-install vector alongside the JVM-generated ones. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The JNI-owned `payload_bytes` in `documentCreateEncrypted` was a plain `Vec<u8>` held across the entire synchronous FFI call — including the network broadcast that runs inside it — and freed unscrubbed at end of scope. The inner FFI copy (`payload_vec` in rs-platform-wallet-ffi's document.rs) already got `Zeroizing` + an explicit pre-broadcast drop; mirror that discipline for the JNI copy so the plaintext-lifetime guarantee holds end-to-end. - Wrap `payload_bytes` in `zeroize::Zeroizing` so it is scrubbed on drop. - Drop it explicitly the instant the FFI call returns (the earliest point reachable from JNI, since the broadcast completes inside that call), before result/JSON handling. It is the only plaintext copy in the fn. Also strip the two surviving `dashpay#4091` tracker tokens from the version-guard and fetch-breadcrumb comments (rationale text kept). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, not the host Follow-up to dashpay#4186 (shumkov review on the encrypted-documents port): the encryptionKeyIndex allocation policy was left in the Kotlin host, which told callers to supply the legacy `1 + countAllRequests()` counter. Concurrent callers/devices could pick the same index, and a caller-side key-index policy loop violates the Kotlin SDK host-thin rule. Move the policy into Rust; hosts now provide only the opaque payload. Rust core (rs-platform-wallet): - IdentityWallet::allocate_encryption_key_index counts the identity's existing txMetadata documents on Platform and returns `1 + count`, matching dash-wallet's retired `1 + countAllRequests()` (SELECT COUNT(*) FROM transaction_metadata_platform) semantics EXACTLY (count+1, not max+1; empty state -> 1). - Allocation is serialized through a shared per-wallet allocator mutex (EncryptionKeyIndexAllocator on IdentityWallet, an Arc<Mutex<HashMap>> shared across handle clones): two concurrent creates through the SAME process seed the in-process high-water once from Platform and then hand out monotonically increasing indices, so they can never pick the same index. - Cross-device uniqueness is best-effort only and is NOT data-loss: every document stores its own keyIndex/encryptionKeyIndex and the reader derives each document's key from its own stored indices, so two documents sharing an index each carry a fresh IV and both decrypt independently. - Unit tests: legacy 1+count math (incl. saturation), empty-state seed + increment, per-owner isolation, and a concurrent no-collision test. FFI (rs-platform-wallet-ffi): - Add ABI-additive sibling platform_wallet_create_encrypted_document_with_signer_auto_index (identical params minus encryption_key_index); the existing explicit-index export is unchanged and both share one impl taking Option<u32>. When None, the index is allocated from Platform state before any key material is resolved. JNI (rs-unified-sdk-jni): - documentCreateEncrypted treats encryptionKeyIndex == -1 as the "let Rust allocate" sentinel (routes to the auto-index export); a non-negative value routes to the explicit export; < -1 is rejected. Kotlin SDK: - DocumentTransactions.createEncryptedDocument takes encryptionKeyIndex: Int? = null (null -> allocate in Rust); removed the `1 + countAllRequests()` guidance and deprecated the caller-supplied counter in KDoc. Null maps to the -1 JNI sentinel. - Tests: explicit-negative rejection and no-index-path acceptance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review round 2 (doc-only): the per-wallet mutex serializes cross-owner allocations during a first-time seed fetch, and a create that fails after allocating leaves an index gap, never a collision — both now stated at the allocator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing encryptionKeyIndex Addresses shumkov's dashpay#4195 review: on the auto-index create path the network count / high-water reservation (`allocate_encryption_key_index` -> `reserve_next_index`) ran BEFORE the deterministic 4063-byte payload gate, so an oversized payload — which must fail — still consumed an index and left a gap. Run the size check first. It is a pure, network-free bound (`ensure_tx_metadata_payload_fits` / `MAX_TX_METADATA_PLAINTEXT_LEN`) and needs no key material: - new `reserve_next_index_checked(allocator, owner, payload_len, seed)` runs `ensure_tx_metadata_payload_fits(payload_len)?` before `reserve_next_index`, so an oversized payload returns the typed `TxMetadataPayloadTooLarge` without polling the seed — the high-water is never seeded or advanced (no consumed index, no gap). - `IdentityWallet::allocate_encryption_key_index` gains a `payload_len` param and routes through the checked variant; the FFI auto-index create path passes `payload_vec.len()`. Explicit-index path is unchanged (it never allocates). - new unit test `oversized_payload_does_not_advance_highwater`: asserts the typed error, that the owner is absent from the allocator map, and that the next well-sized reservation still seeds at 1 (no gap). cargo test (platform-wallet allocator 5/5, platform-wallet-ffi 204/204) + clippy green; Kotlin :sdk:compileDebugKotlin + :sdk:testDebugUnitTest green (JNI/Kotlin ABI unchanged — payload_len plumbing is internal to Rust). Note (source-breaking, positional Kotlin callers): the dashpay#4186 stack moved `SDK.Documents.createEncryptedDocument`'s `encryptionKeyIndex` to the last positional slot (`Int? = null`). Positional callers must drop the argument (let Rust allocate) or switch to a named argument; named callers are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7018287 to
4f2eb06
Compare
Mechanical Swift wrappers for the encrypted-document C-ABI exports added by dashpay#4091 (port/v4.1/encrypted-documents). Re-stacked onto dashpay#4195 (followup/v4.1/keyindex-rust-allocation) per the reviewer sequencing decision so the Rust-side auto-index export is available: - platform_wallet_create_encrypted_document_with_signer_auto_index - platform_wallet_fetch_encrypted_documents Adds ManagedPlatformWallet.createEncryptedDocument(...) and .fetchEncryptedDocuments(...) to Sources/SwiftDashSDK/PlatformWallet, mirroring the existing createDocument (signer + byte-buffer marshalling, withExtendedLifetime pinning, result-code .check(), string_free) and previewIdentityRegistrationKeys (internal MnemonicResolver construction + pinning) patterns. The plaintext payload is handed straight to Rust's Zeroizing buffer with no extra Swift-side copy, as the neighboring seed path does. createEncryptedDocument now calls the AUTO-INDEX export and DROPS the host-supplied encryptionKeyIndex parameter: Rust allocates the per-document encryptionKeyIndex from authoritative Platform state (dashpay#4195), so hosts no longer assign it (host-side assignment risked cross-device collisions). This matches the Android auto-index path, where Kotlin's createEncryptedDocument omits the index (encryptionKeyIndex = null). The version-byte {0,1} guard and argument-order/nullability parity with the Kotlin counterpart are preserved; fetchEncryptedDocuments is unchanged. Updates EncryptedDocumentVersionValidationTests (the Swift mirror of the Kotlin DocumentTransactionsVersionValidationTest) to the new signature: the wire-meaningless version bytes (2/3/127/255) are rejected before any FFI dispatch. Verified: cbindgen regenerates the platform-wallet-ffi header with the auto-index export at the expected signature (no encryption_key_index arg); `swift build` of SwiftDashSDK type-checks the reworked call site against that header. `swift test` currently fails only at link because the checked-in prebuilt DashSDKFFI.xcframework static archive predates dashpay#4195 and lacks the auto-index symbol; a framework rebuild against dashpay#4195 resolves it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Fixed: the size gate now runs before index allocation on the auto-index path — an oversized payload returns Sequencing per your decision: this PR (#4195) lands first; #4194 has been reworked onto the new auto-index export. |
|
Round-3 verification (two independent passes, reconciled): the fix is verified — the payload size gate runs strictly before the allocator mutex and before any Platform seed polling on the auto-index path (and before derivation on the explicit path), all three gates share the single computed constant via one function (defense-in-depth, not drift), and the new Code-ready. Two asks before merge:
|
|
Covered by the same-repo stack dispatch run (29994291221) — full Rust workspace suite green, including the oversized-allocator test. No further asks beyond the earlier body-refresh notes; merge order #4195 → #4194 stands. |
There was a problem hiding this comment.
Final validation — Codex + Sonnet
This PR correctly moves txMetadata encryptionKeyIndex allocation from the Kotlin host into Rust: the tokio::sync::Mutex-based allocator is properly serialized across awaits, the size-gate-before-allocation fix is sound and unit-tested, and the duplicate-index-is-safe cryptographic reasoning holds up. Remaining issues are non-blocking: the FFI boundary copies the plaintext payload before the deterministic size check runs, the authoritative Platform-count seed path lacks automated test coverage, two locks (the FFI handle-storage RwLock and the allocator's own tokio Mutex) are now held across longer network round trips, and there is a duplicated contract-fetch block plus a couple of nitpicks.
Corrected in place after the final gate was rerun against the checkpointed Codex canonical output; three Codex-only findings already closed by the precheck verifier were retracted.
Review provenance
- Codex reviewers:
gpt-5.6-sol— general (completed),gpt-5.6-sol— security-auditor (completed),gpt-5.6-sol— rust-quality (completed),gpt-5.6-sol— ffi-engineer (completed) - Verifier:
claude-sonnet-5— final-verifier - Sonnet reviewers:
claude-sonnet-5— general (failed),claude-sonnet-5— security-auditor (failed),claude-sonnet-5— rust-quality (completed),claude-sonnet-5— ffi-engineer (completed),claude-sonnet-5— general (failed),claude-sonnet-5— security-auditor (failed),claude-sonnet-5— general (completed),claude-sonnet-5— security-auditor (failed),claude-sonnet-5— security-auditor (failed),claude-sonnet-5— security-auditor (completed)
🟡 7 suggestion(s) | 💬 2 nitpick(s)
Nitpick details
- Comment edit merged two unrelated sentences onto one run-on line —
packages/rs-unified-sdk-jni/src/transactions.rs:928-931
Removing the '(#4091)' reference collapsed two separate comment thoughts onto a single line: '...so these no longer need to be loud. Android visibility:JNI_OnLoadinstallsandroid_loggerat'.cargo fmtdoes not reflow comments, so this run-on will persist as-is; split it back across lines for readability. - Explicit-index create path bypasses the allocator entirely, so the no-collision guarantee only holds when a process never mixes both entry points —
packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs:401-429
allocate_encryption_key_index/reserve_next_index_checkedonly runs when the host omits the index. The explicit-index export (platform_wallet_create_encrypted_document_with_signer) never touchesenc_key_index_allocator, so a caller mixing the explicit-index (migration/tests) and auto-index paths for the SAME owner in the SAME process can still race: an in-flight explicit create at index N doesn't update the in-process high-water, and a concurrent auto-index call whose Platform-count seed hasn't yet observed N's unconfirmed broadcast can allocate N again. Per the PR's own design (duplicate indices are safe since each document stores and decrypts from its own indices), this isn't exploitable for data loss or key compromise, but the advertised guarantee ('two concurrent creates through the SAME wallet process can never pick the same index') only holds when all callers in that process exclusively use the auto-index path — worth a one-line doc note on the explicit-index export making that scope limit explicit.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-ffi/src/document.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/document.rs:435-445: Oversized payload is copied into an owned buffer before the deterministic size gate rejects it
create_encrypted_document_impl unconditionally copies `payload_len` bytes via `slice::from_raw_parts(payload, payload_len).to_vec()` (wrapped in `Zeroizing`) before any size validation runs. The network-free `ensure_tx_metadata_payload_fits` check only executes later — inside `allocate_encryption_key_index`/`reserve_next_index_checked` on the auto-index path, or inside `prepare_encrypted_txmetadata_properties` on the explicit-index path — both of which run strictly after this copy. A caller passing an oversized `payload_len` (host bug, fuzzed input, or a misbehaving JNI caller) forces a full allocation and memcopy proportional to the invalid input before the typed `TxMetadataPayloadTooLarge` error is returned; an allocation failure for a very large length aborts the process rather than surfacing the typed error. The length is known before any bytes are touched, so hoist the pure length check ahead of the copy — and mirror it in the JNI wrapper's `convert_byte_array` call so a Java caller doesn't pay for the doomed copy on its side either.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/document.rs:454-538: Auto-index path performs a second network round trip (plus a reentrant host callback) inside the shared handle-storage read lock
The whole `create_encrypted_document_impl` body runs inside `PLATFORM_WALLET_STORAGE.with_item(wallet_handle, move |wallet| {...})`, which holds a `parking_lot::RwLock` read guard over the entire global `HashMap<Handle, T>` (see `HandleStorage::with_item` in handle.rs) for the whole closure. Before this PR that closure did one blocking network round trip (the broadcast). Now, on the host-omitted-index path, it does a second one first — `allocate_encryption_key_index`, which itself may paginate through every existing txMetadata document for the owner on the first call — followed by a synchronous reentrant call into the host's mnemonic resolver for external-signable wallets. Any thread needing the write half of this lock for the same handle (`with_item_mut`/`remove`, e.g. during wallet teardown) now blocks for roughly double the previous worst case, plus however long the resolver callback (Keychain/biometric prompt) takes. This extends a pre-existing accepted pattern rather than introducing a new bug class, but since `Arc<PlatformWallet>` is already cheaply cloneable, cloning it and dropping the guard before any network/resolver work would remove the exposure rather than lengthen it.
In `packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs:329-425: The authoritative Platform-count seed path has no automated test coverage
`allocator_tests` thoroughly pins `next_encryption_key_index_from_count`, `reserve_next_index`, and `reserve_next_index_checked` by injecting the seed as a bare future — none of them call `count_owned_txmetadata_documents` or `allocate_encryption_key_index`, so the pagination loop, contract-fetch/register step, contract-not-found error path, raw-entry counting (including un-materialized `None` entries), and the `u32::try_from` conversion are never exercised by `cargo test`. Per the PR description this path is only covered by a manual/testnet-gated integration test. Add a mock-SDK test that drives the real first-allocation path end to end (including a multi-page and a `None`-entry case) and asserts the resulting index equals `raw_count + 1`, plus that a second allocation for the same owner reuses the cached high-water without a second query. This is genuinely new logic introduced by this PR and is exactly the kind of regression CI won't currently catch.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs:81-97: Wallet-wide allocator mutex held across an un-timeboxed network round trip
`reserve_next_index` acquires `allocator.lock().await` and holds it across the entire first-time seed future — which, via `allocate_encryption_key_index` → `count_owned_txmetadata_documents`, does a `DataContract::fetch` plus a paginated `Document::fetch_many` against Platform. `enc_key_index_allocator` is a single mutex shared across every owner identity in the wallet process, and rs-sdk's default `RequestSettings` leaves `timeout`/`connect_timeout` unset. A slow or unresponsive DAPI node can therefore stall the seed indefinitely, and because the lock is held for the duration, every OTHER identity's auto-index encrypted-document create in the same wallet process blocks behind it too — a single bad node turns into a wallet-wide freeze of the encrypted-document-create feature rather than a failure scoped to the identity that triggered the fetch. Scoping the lock per-owner (e.g. a `HashMap<Identifier, Mutex<u32>>` or an entry-level lock) or bounding the seed future with a timeout would contain the blast radius to the one owner actually waiting on Platform.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs:329-354: count_owned_txmetadata_documents duplicates the fetch+register-contract block from fetch_encrypted_documents
`count_owned_txmetadata_documents` (lines 337-349: `DataContract::fetch` → `Arc::new` → `context_provider().register_data_contract`) is nearly a line-for-line copy of the same three steps in `fetch_encrypted_documents` (lines 604-626) further down in the same `impl` block. This PR adds a third instance, in spirit, of a pattern already duplicated once — and the two existing copies already drift slightly (different 'contract not found' message text, and only one emits a `breadcrumb_error` on fetch failure). Extract a small helper, e.g. `async fn fetch_and_register_contract(&self, contract_id: &Identifier) -> Result<Arc<DataContract>, PlatformWalletError>`, to stop the duplication and the wording drift.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs:43-44: Allocator high-water map is keyed only by owner identity, not by (owner, contract, document type)
`EncryptionKeyIndexAllocator` is `HashMap<Identifier, u32>`, keyed solely by `owner_identity_id`. `allocate_encryption_key_index` takes `contract_id` and `document_type_name` as first-class parameters and seeds the high-water from a count scoped to that specific contract/document-type pair — but once seeded, the stored high-water is shared across ANY subsequent call for that owner regardless of `contract_id`/`document_type_name`. Today there is exactly one wallet-contract document type (`txMetadata`), so this is dormant, but the API is deliberately generalized (both the FFI exports and the Kotlin surface accept an arbitrary `document_type_name`). If this is ever reused for a second encrypted-document type on the same identity, the second type's allocation would silently continue from the first type's high-water instead of reseeding from `1 + count` for its own contract/type — producing an `encryptionKeyIndex` that no longer matches the documented `1 + count` semantics for that type.
In `packages/rs-unified-sdk-jni/src/transactions.rs`:
- [SUGGESTION] packages/rs-unified-sdk-jni/src/transactions.rs:809-818: Zeroization effort covers only the native-side plaintext copies, not the originating JVM byte[]
`documentCreateEncrypted` wraps its native copy of the txMetadata plaintext in `Zeroizing` and drops it as soon as the FFI call returns, and `create_encrypted_document_impl` does the same for its own copy — both explicitly documented as guarding against the plaintext lingering across the broadcast await. But the actual origin of that plaintext is the Kotlin-side `payload: ByteArray` (`DocumentTransactions.kt createEncryptedDocument`), which is passed straight through to `TransactionsNative.documentCreateEncrypted`. `env.convert_byte_array(&payload)` copies out of the JVM array into the Rust `Vec`, but the JVM array itself — and any Kotlin-side protobuf-serialization buffers that produced it — remain resident in the JVM heap indefinitely, subject to ordinary GC rather than scrubbing, with no wipe performed by or requested of the Kotlin caller after the native call returns. Given how much care this PR takes to scrub the two Rust-side copies, the asymmetry is worth a one-line doc note on `createEncryptedDocument` so future auditors don't assume the whole cross-language plaintext lifecycle is scrubbed.
| // The JNI-owned plaintext copy. Wrapped in `Zeroizing` so it is scrubbed | ||
| // on drop, mirroring the inner FFI copy (`payload_vec` in | ||
| // `rs-platform-wallet-ffi/src/document.rs`). The inner copy is dropped | ||
| // before its broadcast `.await`; from here the whole broadcast happens | ||
| // synchronously *inside* the single FFI call below, so the earliest this | ||
| // buffer can be released is the instant that call returns — dropped | ||
| // explicitly there rather than left to linger (unscrubbed) to end of | ||
| // scope. This is the only plaintext copy in this function. | ||
| let payload_bytes = match env.convert_byte_array(&payload) { | ||
| Ok(b) => zeroize::Zeroizing::new(b), | ||
| Err(_) => { | ||
| let _ = env.exception_clear(); | ||
| throw_sdk_exception(env, 1, "payload byte[] was null/invalid"); | ||
| return ptr::null_mut(); | ||
| } | ||
| }; |
There was a problem hiding this comment.
🟡 Suggestion: Oversized txMetadata payloads are copied twice across the JNI/FFI boundary before the size limit is enforced
documentCreateEncrypted calls env.convert_byte_array(&payload) to copy the full JVM byte[] into a native Vec before any size check runs. The downstream FFI call (rs-platform-wallet-ffi/src/document.rs ~line 440-445, create_encrypted_document_impl) copies it a second time via slice::from_raw_parts(...).to_vec(). Only after both copies exist does ensure_tx_metadata_payload_fits (invoked from allocate_encryption_key_index/prepare_encrypted_txmetadata_properties) reject payloads over the 4063-byte limit. A caller supplying a large valid buffer (a compromised host component, or a direct C/JNI caller bypassing normal app logic) forces two full native allocations before receiving the typed TxMetadataPayloadTooLarge/ErrorInvalidParameter error, risking an OOM abort of the host process instead of a clean typed failure. Check the Java array length via env.get_array_length (already used elsewhere in this file, e.g. line 195) before convert_byte_array, and check payload_len against the same limit at the C FFI entry point before constructing the slice, so non-JNI callers get the same protection.
source: ['codex']
| 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)? | ||
| } | ||
| }; | ||
|
|
||
| // Key-source selection by wallet capability (may synchronously call | ||
| // back into the host mnemonic resolver for external-signable | ||
| // wallets — see `tx_metadata_key_master_for_wallet`). The resolved | ||
| // master is wrapped in a Drop-wiping guard. | ||
| let master_opt = unsafe { | ||
| tx_metadata_key_master_for_wallet(wallet, mnemonic_resolver_handle) | ||
| }? | ||
| .map(WipingMaster); | ||
|
|
||
| // Derive the AES key + seal the wire blob SYNCHRONOUSLY, then wipe the | ||
| // master BEFORE any network `.await`: the master xprv never crosses the | ||
| // broadcast await (dashpay/platform#4091). Only the sealed properties | ||
| // (ciphertext, no key material) cross into the async block below. | ||
| let key_source = match master_opt.as_ref() { | ||
| Some(master) => TxMetadataKeySource::Master(&master.0), | ||
| None => TxMetadataKeySource::ResidentWallet, | ||
| }; | ||
| let properties_json = identity_wallet | ||
| .prepare_encrypted_txmetadata_properties( | ||
| &owner_id_for_async, | ||
| resolved_index, | ||
| version, | ||
| &payload_vec, | ||
| key_source, | ||
| ) | ||
| .map_err(PlatformWalletFFIResult::from)?; | ||
| // The plaintext is now sealed inside `properties_json` (ciphertext | ||
| // only). Scrub the native plaintext copy AND the master immediately — | ||
| // neither may cross the broadcast `.await` below. `payload_vec` is | ||
| // `Zeroizing`, so the drop also wipes its bytes (dashpay/platform#4091). | ||
| drop(payload_vec); | ||
| drop(master_opt); | ||
|
|
||
| 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) |
There was a problem hiding this comment.
🟡 Suggestion: Auto-index create path extends how long the global wallet-handle-storage lock is held, now across a network round trip and a reentrant host callback
The entire create body runs inside the closure passed to PLATFORM_WALLET_STORAGE.with_item(wallet_handle, ...), which holds a parking_lot::RwLock read guard over the process-wide HashMap<Handle, Arc<PlatformWallet>> shared by every wallet handle. Before this PR, that closure performed one network round trip (the broadcast). This PR adds two more phases inside the held guard on the None-index path: allocate_encryption_key_index (a full network fetch — contract lookup plus a possibly-paginated document count on the first call per owner) and, for external-signable/watch-only wallets, a synchronous reentrant call into the host's Kotlin mnemonic resolver via tx_metadata_key_master_for_wallet (which may block on Keychain/Keystore or a biometric prompt). Any thread needing the write half of this lock for ANY handle (wallet dispose/create via with_item_mut/insert/remove) now blocks for the duration of all three phases plus the broadcast. Since Arc<PlatformWallet> is cheap to clone, the guard could be dropped immediately after cloning it, before any network or resolver work, removing the exposure. This extends a pre-existing accepted pattern (also present unmodified in identity_discovery.rs/identity_key_preview.rs) rather than introducing it, but it measurably lengthens the window during which unrelated wallet-lifecycle calls can stall (a potential ANR on Android).
source: ['claude']
| pub async fn allocate_encryption_key_index( | ||
| &self, | ||
| owner_identity_id: &Identifier, | ||
| contract_id: &Identifier, | ||
| document_type_name: &str, | ||
| payload_len: usize, | ||
| ) -> Result<u32, PlatformWalletError> { | ||
| 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 | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: The authoritative Platform-derived allocator seed path has no automated regression coverage
allocator_tests (module starting ~line 842) thoroughly pins next_encryption_key_index_from_count, reserve_next_index, and reserve_next_index_checked — but every one of those tests injects the seed as a bare async { Ok(N) } future, never calling allocate_encryption_key_index or count_owned_txmetadata_documents (lines 329-354, 397-424), the code that actually fetches/registers the contract, runs the paginated owner-scoped query, and turns the raw result into 1 + count. Per the PR description, this path is only exercised by a manual/testnet-gated integration test that is #[ignore]d in CI and doesn't invoke the allocator. A regression in the query construction, pagination, contract registration, or the count→index wiring (e.g. an off-by-one, a missed page, or miscounting un-materialized entries) would ship undetected. A mock-SDK test that returns a known owner-scoped count (including a multi-page case and a None document entry) and asserts the resulting allocated index would close this gap without a live network.
source: ['claude', 'codex']
| 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)) |
There was a problem hiding this comment.
Retracted after final-gate correction. The Codex precheck verifier had already refuted this finding: the deployed wallet contract's $ownerId,$updatedAt index is neither countable nor rangeCountable, while DocumentCount::fetch requires a matching countable/range-countable index. Those flags are immutable after contract creation, so the suggested aggregate query would fail rather than improve this path.
| #[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<u64>, | ||
| /// The decrypted, opaque payload bytes. | ||
| pub payload: Vec<u8>, |
There was a problem hiding this comment.
Retracted after final-gate correction. The precheck verifier correctly classified this as pre-existing, out-of-scope decrypt-side memory-hygiene debt. The result types, AES helper behavior, and fetch-side serialization copies predate this allocator PR; fixing the full ownership/zeroization pipeline belongs in a separate redesign.
| async fn count_owned_txmetadata_documents( | ||
| &self, | ||
| contract_id: &Identifier, | ||
| owner_identity_id: &Identifier, | ||
| document_type_name: &str, | ||
| ) -> Result<u32, PlatformWalletError> { | ||
| 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)) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: count_owned_txmetadata_documents duplicates the fetch-and-register-contract block already present in fetch_encrypted_documents
Lines 337-349 (DataContract::fetch → Arc::new → context_provider().register_data_contract) are nearly a line-for-line copy of the same three steps in fetch_encrypted_documents at lines 604-626 further down in the same impl block. The two copies have already drifted: only the fetch_encrypted_documents copy emits a breadcrumb_error on fetch failure and includes the identifying context in its 'not found' message, while the new copy in count_owned_txmetadata_documents has a slightly different message text and no breadcrumb. Extracting a shared async fn fetch_and_register_contract(&self, contract_id: &Identifier) -> Result<Arc<DataContract>, PlatformWalletError> would remove the duplication this PR adds and stop the error-message/breadcrumb wording from drifting further between the two call sites.
source: ['claude']
| pub(crate) type EncryptionKeyIndexAllocator = | ||
| Arc<tokio::sync::Mutex<std::collections::HashMap<Identifier, u32>>>; |
There was a problem hiding this comment.
🟡 Suggestion: Allocator high-water map is keyed only by owner identity, not by (owner, contract, document type)
EncryptionKeyIndexAllocator is HashMap<Identifier, u32>, keyed solely by owner_identity_id. allocate_encryption_key_index takes contract_id and document_type_name as first-class parameters and seeds the high-water from a count that IS scoped to a specific contract/document-type pair — but once seeded, the stored high-water is reused for ANY subsequent call for that owner regardless of contract_id/document_type_name. Today there is exactly one wallet-contract document type (txMetadata), so this is dormant. But both the FFI exports and the Kotlin surface already accept an arbitrary document_type_name, generalizing the API; if a second encrypted-document type is ever added for the same identity, its allocation would silently continue from the first type's high-water instead of reseeding from 1 + count for its own contract/type, producing an index that no longer matches the documented 1 + count semantics for that type.
source: ['claude']
| pub(crate) async fn reserve_next_index<S>( | ||
| allocator: &tokio::sync::Mutex<std::collections::HashMap<Identifier, u32>>, | ||
| owner: &Identifier, | ||
| seed: S, | ||
| ) -> Result<u32, PlatformWalletError> | ||
| where | ||
| S: std::future::Future<Output = Result<u32, PlatformWalletError>>, | ||
| { | ||
| // 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)); |
There was a problem hiding this comment.
🟡 Suggestion: Wallet-wide allocator mutex is held across an un-timeboxed network round trip, letting one slow node stall every identity's encrypted-document creates
reserve_next_index acquires allocator.lock().await and holds it across the entire Platform fetch used to seed the high-water on a caller's first allocation (seed.await? at line 96). enc_key_index_allocator is a single Arc<tokio::sync::Mutex<..>> shared across every identity in the wallet process (cloned via Arc::clone in identity_handle.rs), and rs-sdk's default RequestSettings sets timeout: None / connect_timeout: None (packages/rs-sdk/src/sdk.rs:80-82), so nothing at the SDK layer bounds this await. A DAPI node that accepts the connection but never completes the response can stall the seed indefinitely, and because the mutex is held for the duration, EVERY OTHER identity's auto-index create in the same wallet process blocks behind it too — a single unresponsive node turns into a wallet-wide freeze of the encrypted-document-create feature, not just a failure for the identity that triggered the fetch. Scoping the lock per-owner, or wrapping the seed future with a bounded timeout, would contain the blast radius to the affected identity.
source: ['claude']
| /// 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(); |
There was a problem hiding this comment.
Retracted after final-gate correction. This synchronous preparation API and its blocking_read implementation predate the allocator change, its rustdoc explicitly requires a synchronous context, and the production FFI call site satisfies that precondition before entering the broadcast future. It is not an in-scope defect introduced by this PR.
| #[allow(clippy::too_many_arguments)] | ||
| pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_documentCreateEncrypted( | ||
| mut env: JNIEnv, | ||
| _class: JClass, | ||
| wallet_handle: jlong, | ||
| mnemonic_resolver_handle: jlong, | ||
| owner_id: JByteArray, | ||
| contract_id: JByteArray, | ||
| document_type: JString, | ||
| encryption_key_index: jint, | ||
| version: jint, | ||
| payload: JByteArray, | ||
| signer_handle: jlong, | ||
| ) -> jstring { | ||
| guard(&mut env, ptr::null_mut(), |env| { | ||
| let Some(owner) = read_id32(env, &owner_id, "ownerId") else { | ||
| return ptr::null_mut(); | ||
| }; | ||
| let Some(contract) = read_id32(env, &contract_id, "contractId") else { | ||
| return ptr::null_mut(); | ||
| }; | ||
| let Some(doc_type) = read_cstring(env, &document_type, "documentType") else { | ||
| return ptr::null_mut(); | ||
| }; | ||
| // encryptionKeyIndex == -1 is the "let Rust allocate" sentinel | ||
| // (dashpay/platform#4186 follow-up): the host omits the index and the | ||
| // SDK derives the next one from Platform state. A non-negative value is | ||
| // an explicit caller-supplied index; anything below -1 is invalid. | ||
| if encryption_key_index < -1 { | ||
| throw_sdk_exception( | ||
| env, | ||
| 1, | ||
| "encryptionKeyIndex must be >= 0, or -1 to let the SDK allocate it", | ||
| ); | ||
| return ptr::null_mut(); | ||
| } | ||
| let auto_index = encryption_key_index == -1; | ||
| // Only 0 (CBOR) and 1 (protobuf) are wire-decodable by the legacy dashj | ||
| // decryptTxMetadata; anything else seals a document the legacy stack | ||
| // can't read. Fail fast here with the correct bound instead of the stale | ||
| // 0..=255 range. The Rust core `seal_tx_metadata` enforces the same | ||
| // invariant as the last line of defense. | ||
| if !(0..=1).contains(&version) { | ||
| throw_sdk_exception( | ||
| env, |
There was a problem hiding this comment.
🟡 Suggestion: Plaintext-zeroization effort covers only the native-side copies, not the originating JVM byte[]
documentCreateEncrypted wraps its native copy of the txMetadata plaintext in Zeroizing and drops it as soon as the FFI call returns, and create_encrypted_document_impl does the same for its own copy — both explicitly guard against the plaintext lingering across the broadcast await. But the actual origin of that plaintext is the Kotlin-side payload: ByteArray, which is passed straight through from DocumentTransactions.createEncryptedDocument to the native call. env.convert_byte_array copies OUT of the JVM array into the Rust Vec, but the JVM array itself — and any Kotlin-side protobuf-serialization buffers that produced it — remain resident in the JVM heap subject to ordinary GC, not scrubbing, with no wipe requested of the Kotlin caller after the native call returns. Given how carefully the two Rust-side copies are scrubbed, the JVM-side copy is the one that actually lives longest and is entirely outside Rust's control for a heap-dump/memory-scraping threat model. If that boundary is intentionally out of scope, a one-line doc note on createEncryptedDocument would prevent future auditors from assuming the whole cross-language plaintext lifecycle is scrubbed.
source: ['claude']
Stacked on #4186 (encrypted documents) — do not merge before it.
Addresses shumkov's #4186 review: the
encryptionKeyIndexallocation policywas left in the Kotlin host, which asked callers to supply the legacy
1 + countAllRequests()counter. Concurrent callers/devices could pick thesame index, and a caller-side key-index loop breaks the SDK host-thin rule.
This moves the policy into Rust; hosts now pass only the opaque payload.
What changed
IdentityWallet::allocate_encryption_key_indexderives the next index fromauthoritative Platform state — it counts the identity's existing txMetadata
documents and returns
1 + count, matching dash-wallet's retired1 + countAllRequests()(SELECT COUNT(*) FROM transaction_metadata_platform)exactly in FORMULA (count+1, not max+1; empty → 1) — but the count SOURCE is now
authoritative Platform state rather than the local Room table, so produced numbers
legitimately diverge from legacy under restore-from-seed (new is strictly SAFER:
legacy restarted at 1 and collided) and under publish-lag (safe; collisions are
non-lossy per the IV argument above). Coverage note: the Platform-count seed path
itself is testnet-integration-only; unit tests inject the seed.
creates through the same process can never pick the same index (seed once
from Platform, then hand out increasing indices in-process).
platform_wallet_create_encrypted_document_with_signer_auto_index(existingexplicit-index export unchanged); both share one impl over
Option<u32>.encryptionKeyIndex == -1is the "allocate in Rust" sentinel.createEncryptedDocument(encryptionKeyIndex: Int? = null)—nulllets Rust allocate; removed the
1 + countAllRequests()guidance anddeprecated the caller-supplied counter. Explicit index kept for migration/tests.
Cross-device caveat (documented in code): the index is best-effort unique
per device. A cross-device duplicate is not data-loss: each document stores
its own
keyIndex/encryptionKeyIndexand the reader derives each document'skey from its own stored indices, so both documents decrypt independently.
Tests: Rust unit tests for the legacy
1+countmath, empty-state seeding,per-owner isolation, and concurrent no-collision; Kotlin tests for the no-index
path and explicit-negative rejection.
cargo test/clippygreen on the threetouched crates;
:sdk:compileDebugKotlin+:sdk:testDebugUnitTestgreen.