Skip to content

fix(sdk): limit txMetadata decrypt plaintext lifetime - #4243

Open
shumkov wants to merge 29 commits into
v4.2-devfrom
fix/txmetadata-decrypt-plaintext-lifetime
Open

fix(sdk): limit txMetadata decrypt plaintext lifetime#4243
shumkov wants to merge 29 commits into
v4.2-devfrom
fix/txmetadata-decrypt-plaintext-lifetime

Conversation

@shumkov

@shumkov shumkov commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Issue being fixed or feature implemented

Decrypting encrypted txMetadata could leave plaintext in SDK-owned AES, wallet, JSON/base64, and C-string allocations after those allocations were released. This closes the native lifetime gaps for both Kotlin and Swift while preserving the existing JSON and host String APIs; the terminal runtime-managed host strings remain unsrubbable and are documented symmetrically.

This is the final stacked parity leg. Land the encrypted-document core, Rust allocator, and Swift-wrapper dependencies before this PR.

Related: #4186, #4195, #4194

What was done?

  • Keep the in-place AES decrypt result and both wallet payload owners in Zeroizing<Vec<u8>>, including padding-error and unwind paths.
  • Serialize base64 JSON directly into an exact-size sensitive allocation, avoiding plaintext-bearing Serde trees, scratch base64 strings, and growable output buffers.
  • Add a dedicated platform_wallet_sensitive_string_free contract that wipes the complete C allocation before deallocation.
  • Guard the JNI pointer on every exit and copy its ASCII JSON directly with NewStringUTF; make Swift release the same result through the sensitive free even when result validation throws.
  • Document the unavoidable terminal Kotlin/Swift String residual with identical retention and logging guidance.
  • Record the reviewed design, rejected binary-out-buffer alternative, failure modes, and verification plan in the U7 design spec.

How Has This Been Tested?

  • Red → green regression: the baseline failed typed lifetime assertions because the sensitive AES primitive did not exist and both decrypted payload fields were plain Vec<u8>; the unchanged assertions now compile and pass.
  • cargo test -p platform-encryption — 18 passed.
  • cargo test -p platform-wallet --lib tx_metadata plus the decrypted-document owner regression — 14 passed.
  • cargo test -p platform-wallet-ffi --lib — 225 passed.
  • cargo test -p rs-unified-sdk-jni --lib — 38 passed.
  • Android x86_64 JNI dev build with NDK 28.1 and --verify — 227 JNI exports, C ABI anchor present, and no LOAD segment below 16 KB alignment.
  • ./gradlew :sdk:assembleDebug :sdk:testDebugUnitTest — successful.
  • Rebuilt simulator and macOS XCFramework slices; SwiftExampleApp warnings-as-errors simulator build succeeded.
  • swift test — 282 passed, including the encrypted-document version test; 8 explicitly gated integration tests skipped because RUN_INTEGRATION_TESTS was unset.
  • cargo fmt passes for the changed encryption, FFI, and JNI crates; git diff --check passes. Full-workspace formatting remains red on inherited formatting in the stacked allocator/wallet changes and was not swept into this fix.

New concepts

Fixed-size sensitive serialization

Sensitive output is counted before plaintext writing begins, then written into one exact-size zeroizing allocation:

flowchart LR
  A[Count JSON + base64 bytes] --> B[Allocate sentinel-filled boxed slice]
  B --> C[Write directly within fixed bounds]
  C --> D[Validate exact fill, ASCII, NUL]
  D --> E[Transfer same allocation to CString]
  E --> F[Sensitive free wipes and deallocates]
Loading

This avoids the ordinary Value → String → CString chain, where every growth or conversion can strand another plaintext allocation. It is appropriate for small, security-sensitive outputs whose exact encoded length is computable; ordinary non-sensitive JSON should keep using Serde.

Breaking Changes

Rust source compatibility only: OpenedTxMetadata.payload and DecryptedEncryptedDocument.payload change from Vec<u8> to Zeroizing<Vec<u8>>. The C fetch signature, JNI descriptor, Kotlin/Swift signatures, and JSON shape are unchanged; the sensitive free symbol is ABI-additive.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Summary by CodeRabbit

  • New Features
    • Added encrypted wallet-document create and fetch APIs to the Kotlin and Swift SDKs, including automatic encryption-key index assignment and JSON metadata results.
  • Bug Fixes
    • Enforced wire-compatible encrypted metadata versioning, validated key/index and timestamp inputs, and added payload-size guarding.
    • Decrypt failures no longer abort the full fetch; malformed entries are skipped.
  • Security
    • Improved plaintext lifetime handling with zeroizing decrypt buffers and safer sensitive-string release across the FFI/JNI/Swift bridges.
  • Tests
    • Added version-validation unit tests for both Kotlin and Swift.

bfoss765 and others added 27 commits July 22, 2026 09:14
…e + decrypt-on-fetch

Adds the wallet-contract encrypted-document surface the Android wallet needs to
retire the legacy org.dashj.platform stack (closes #4086 create/update, closes

The encryption ENVELOPE is byte-for-byte wire-compatible with the legacy
BlockchainIdentity.publishTxMetaData / getTxMetaData so documents written by
either stack decrypt with the other (migrated users keep their history):

  - AES-256 key = the raw 32-byte secp256k1 private scalar of a hardened HD
    child (mirrors KeyCrypterAESCBC.deriveKey(ECKey); no ECDH, no HKDF).
  - Path: identity-auth path of the identity's encryption key (its id = the
    document's keyIndex) extended by / 32769' / encryptionKeyIndex'.
  - Cipher: AES-256-CBC / PKCS7, random 16-byte IV.
  - encryptedMetadata blob = version(1) ‖ IV(16) ‖ AES-256-CBC(payload); the
    version byte is OUTSIDE the ciphertext (0 = CBOR, 1 = protobuf).

The plaintext payload stays OPAQUE to the SDK — the app owns the protobuf
TxMetadataBatch item schema and the batching policy, exactly as on the legacy
stack. The SDK owns only the crypto envelope + the {keyIndex, encryptionKeyIndex,
encryptedMetadata} document fields.

Layers:
  - rs-platform-wallet: crypto/tx_metadata.rs (derive/seal/open + tests);
    network/encrypted_document.rs (create_encrypted_document_with_signer reusing
    the tested create path; fetch_encrypted_documents mirroring the contactInfo
    paginated decrypt loop).
  - rs-platform-wallet-ffi: platform_wallet_create_encrypted_document_with_signer,
    platform_wallet_fetch_encrypted_documents (JSON-out; payload as base64).
  - rs-unified-sdk-jni: documentCreateEncrypted / documentFetchEncrypted.
  - kotlin-sdk: DocumentTransactions.createEncryptedDocument /
    fetchEncryptedDocuments (additive; no existing signatures change).

Tests: seal↔open round-trip, key-derivation determinism + index separation,
wrong-key/ malformed-blob fail cleanly, and a NIST SP 800-38A CBC-AES256
cross-stack vector pinning the cipher core + blob framing.

cc @QuantumExplorer

Co-Authored-By: Claude Fable 5 <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 #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 #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
#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
(#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 (#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 #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 #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 (#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 `#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 #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 #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 #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>
Mechanical Swift wrappers for the encrypted-document C-ABI exports added
by #4091 (port/v4.1/encrypted-documents). Re-stacked onto
#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
(#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
#4195 and lacks the auto-index symbol; a framework rebuild against #4195
resolves it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keep decrypted txMetadata payloads in zeroizing owners through AES, wallet, and FFI serialization, then release the shared C result through a dedicated zeroizing free on Kotlin/JNI and Swift.

Preserve the existing JSON and host String APIs while documenting that runtime-managed host strings and parsed copies cannot be reliably scrubbed.

Test would have caught this in CI: ✖ baseline lifetime assertions did not compile because decrypted payloads were plain Vec values and no sensitive decrypt primitive existed; ✔ the unchanged assertions and focused encryption, wallet, FFI, and JNI suites pass with zeroizing owners and sensitive release coverage.
@shumkov
shumkov requested a review from ZocoLini as a code owner July 28, 2026 05:37
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b60fbb9d-96d2-40ec-9ae9-9fa074fd2839

📥 Commits

Reviewing files that changed from the base of the PR and between e39703d and 9870f4f.

📒 Files selected for processing (7)
  • docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md
  • packages/rs-platform-wallet/src/lib.rs
  • packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/mod.rs
  • packages/rs-platform-wallet/tests/txmetadata_fetch.rs
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/rs-platform-wallet/src/lib.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/mod.rs
  • packages/rs-platform-wallet/tests/txmetadata_fetch.rs
  • docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md
  • packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs

📝 Walkthrough

Walkthrough

Adds legacy-compatible encrypted txMetadata document creation and fetching across Rust, FFI, JNI, Kotlin, and Swift, including key derivation, index allocation, zeroizing plaintext handling, bounded sensitive JSON serialization, and bridge-level validation.

Changes

Encrypted txMetadata flow

Layer / File(s) Summary
Crypto and wire-compatible contract
packages/rs-platform-encryption/*, packages/rs-platform-wallet/src/wallet/identity/crypto/*
Adds AES zeroizing decryption, txMetadata key derivation, versioned framing, payload limits, encrypted payload ownership, and legacy compatibility tests.
Wallet encrypted-document orchestration
packages/rs-platform-wallet/src/wallet/identity/network/*, packages/rs-platform-wallet/src/wallet/platform_wallet.rs, packages/rs-platform-wallet/tests/*
Adds encrypted document preparation and fetching, per-owner encryption-key index allocation, resident/resolver key routing, wallet wiring, and legacy test utilities.
FFI sensitive boundary
packages/rs-platform-wallet-ffi/src/*
Adds encrypted-document FFI entry points, bounded ASCII JSON serialization, sensitive C-string freeing, zeroization helpers, and typed size-error mapping.
JNI, Kotlin, and Swift integration
packages/rs-unified-sdk-jni/*, packages/kotlin-sdk/*, packages/swift-sdk/*
Adds encrypted-document APIs and native bridges with argument validation, sensitive-pointer guards, plaintext handling, logging, and version-validation tests.

Estimated code review effort: 5 (Critical) | ~120 minutes

Suggested reviewers: lklimek, llbartekll, quantumexplorer

Sequence Diagram(s)

sequenceDiagram
  participant KotlinOrSwift
  participant JNIOrSwiftBridge
  participant WalletFFI
  participant IdentityWallet
  participant Platform
  KotlinOrSwift->>JNIOrSwiftBridge: request encrypted document fetch
  JNIOrSwiftBridge->>WalletFFI: call fetch FFI
  WalletFFI->>Platform: query owned encrypted documents
  Platform-->>IdentityWallet: encrypted document records
  IdentityWallet->>IdentityWallet: derive keys and decrypt metadata
  IdentityWallet-->>WalletFFI: decrypted records
  WalletFFI-->>JNIOrSwiftBridge: sensitive JSON pointer
  JNIOrSwiftBridge->>WalletFFI: zeroize and free pointer
  JNIOrSwiftBridge-->>KotlinOrSwift: JSON string
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main change: reducing txMetadata decrypt plaintext lifetime in the SDK.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/txmetadata-decrypt-plaintext-lifetime

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added this to the v4.2.0 milestone Jul 28, 2026
@shumkov

shumkov commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (6)
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt (1)

189-194: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Mirror the fetch-side plaintext-residual caveat here.

documentFetchEncrypted's KDoc (Lines 223-227) tells callers the host String is plaintext-equivalent and unscrubuable. The create side takes a caller-owned plaintext ByteArray with the same property and no equivalent note — worth documenting symmetrically since the JNI copy is Zeroizing but the Kotlin-owned array is not.

📝 Proposed KDoc addition
      * `@param` payload the already-serialized opaque plaintext (a protobuf
      *   `TxMetadataBatch`); the SDK does not parse it.
+     *   The native copy is zeroized after the broadcast completes, but this
+     *   caller-owned `ByteArray` is not — overwrite it yourself once the call
+     *   returns if the plaintext must not linger on the JVM heap.
      * `@return` the confirmed document's canonical JSON (its 32-byte id is the
      *   base58 `$id` field).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt`
around lines 189 - 194, Update the KDoc for the create-side transaction method
near the payload parameter to state that the caller-owned plaintext ByteArray is
plaintext-equivalent and cannot be scrubbed by the SDK; clarify that JNI
zeroization does not erase the Kotlin-owned array. Keep the existing parameter
and return documentation unchanged.
packages/rs-unified-sdk-jni/src/transactions.rs (1)

945-956: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Optional: reuse a pointer guard for the create path's JSON too.

out_json is freed manually here, so a panic between the FFI return and line 956 leaks the allocation (ciphertext-only, so no plaintext exposure). A small non-sensitive analogue of SensitivePlatformWalletString would make both bridges structurally identical.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-unified-sdk-jni/src/transactions.rs` around lines 945 - 956, Add
a non-sensitive RAII pointer guard for the encrypted document create path and
use it for out_json in place of the manual platform_wallet_string_free call.
Ensure the guard frees the FFI allocation on normal completion and during
unwinding, while preserving the existing null handling and JSON conversion in
the surrounding create flow.
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift (1)

3482-3489: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Swift-side wire-version constant duplicates a Rust-owned protocol invariant.

seal_tx_metadata already rejects non-0/1 versions, so this guard hard-codes a wire constant in the Swift bridge purely for a faster error. If you keep it (the existing EncryptedDocumentVersionValidationTests rely on it), consider surfacing the accepted set from Rust rather than literals so the two can't drift.

As per coding guidelines: "In Swift, do not perform ... any reimplementation of protocol constants such as gap limits, key indices, or path shapes."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift`
around lines 3482 - 3489, The version guard in seal_tx_metadata duplicates
Rust-owned protocol constants by hard-coding 0 and 1 in Swift. Remove this
Swift-side validation and let the Rust FFI enforce accepted versions, updating
EncryptedDocumentVersionValidationTests to assert the resulting propagated error
behavior without relying on the duplicated Swift guard.

Source: Coding guidelines

packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs (1)

226-234: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

WARN breadcrumbs contradict the stated "no identity-correlated data in logcat" rule.

breadcrumb's doc (Lines 216-220) justifies DEBUG precisely so identity/contract/document ids never reach logcat, yet every breadcrumb_error call site embeds owner= and doc= and lands at WARN (logcat-visible). The skip paths (un-materialized entry, missing fields, decrypt failure) are not exceptional enough to treat as one-off diagnostics. Consider truncating the ids (e.g. first 8 hex chars) in the WARN lines so on-device logs stay non-correlatable while remaining actionable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs`
around lines 226 - 234, Update breadcrumb_error and its call sites to prevent
full identity, contract, and document identifiers from reaching WARN-level
logcat output. Truncate each embedded identifier to a non-correlatable short
prefix, such as the first eight hexadecimal characters, while retaining enough
context to distinguish failure or skip paths; leave normal breadcrumb handling
unchanged.
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt (1)

313-320: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The {0, 1} version set is a protocol constant now duplicated in the host.

seal_tx_metadata already rejects any other byte, so this guard restates wire knowledge that the Kotlin SDK is supposed to leave in Rust — and it will silently reject a future version 2 that Rust accepts. If the fail-fast is worth keeping, consider exposing the allowed set from Rust (or a small FFI validation entry point) rather than hardcoding the literals here.

As per coding guidelines, "Do not implement derivation-path construction, policy-loop orchestration, mnemonic/seed processing across JNI, protocol constants... implement these in Rust instead."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt`
around lines 313 - 320, The version validation in the document transaction flow
duplicates Rust-owned protocol knowledge. Remove the hardcoded require check for
version values 0 and 1 from the surrounding transaction-sealing method, leaving
validation to seal_tx_metadata; do not introduce another Kotlin-side constant or
validation path.

Source: Coding guidelines

packages/rs-platform-wallet/tests/txmetadata_fetch.rs (1)

241-258: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Capture scaffolding permanently prints decrypted plaintext and a recovery phrase.

The hardcoded mnemonic and the PLAINTEXT_HEX / PLAINTEXT_UTF8_LOSSY prints are justified for a designated-throwaway fixture wallet, and the doc comment says so explicitly. Once the vectors are captured into legacy_install_yabba2_wire_compat_vector, this helper's job is done — worth deleting it (or moving it to examples/) rather than leaving a permanent plaintext-dumping code path in the test tree that a future maintainer might re-point at a real wallet.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet/tests/txmetadata_fetch.rs` around lines 241 -
258, Remove the temporary plaintext-dumping capture helper and its associated
hardcoded mnemonic/scaffolding from the test tree now that
legacy_install_yabba2_wire_compat_vector contains the vectors. Delete the
PLAINTEXT_HEX and PLAINTEXT_UTF8_LOSSY output along with the helper’s other
diagnostic prints, or move the complete capture utility to examples/ without
leaving a reusable decrypted-data path in tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md`:
- Line 66: Correct the spelling of “unsrubbable” to “unscrubbable” in both the
prose description and the Mermaid node, preserving all other wording and
behavior.

In `@packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs`:
- Around line 254-265: Update the derivation flow around derived.private_key in
the transaction metadata key function to explicitly call non_secure_erase()
after copying secret_bytes() and before returning the Zeroizing-wrapped scalar.
Revise the adjacent hygiene comment to remove the incorrect claim that SecretKey
is memzeroed on drop.

In `@packages/rs-platform-wallet/tests/txmetadata_fetch.rs`:
- Around line 57-59: Remove live testnet dependencies from the ignored tests,
including fetch_returns_both_legacy_txmetadata_documents and the second related
test. Mock the SDK/DAPI interactions so the wire query’s where-clause and
order-by shape are asserted offline; alternatively relocate the live probes
outside tests/ and keep hermetic coverage for the query behavior.

In `@packages/rs-unified-sdk-jni/src/support.rs`:
- Around line 78-86: In packages/rs-unified-sdk-jni/src/support.rs lines 78-86,
update take_pwffi_error to keep only the raw and offset codes in the warn! log,
and emit message separately with log::debug!. Apply the same change at lines
104-107: keep only code at warn! and move message to log::debug!, preserving the
existing code values and removing duplicate message emission.

---

Nitpick comments:
In
`@packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt`:
- Around line 313-320: The version validation in the document transaction flow
duplicates Rust-owned protocol knowledge. Remove the hardcoded require check for
version values 0 and 1 from the surrounding transaction-sealing method, leaving
validation to seal_tx_metadata; do not introduce another Kotlin-side constant or
validation path.

In
`@packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt`:
- Around line 189-194: Update the KDoc for the create-side transaction method
near the payload parameter to state that the caller-owned plaintext ByteArray is
plaintext-equivalent and cannot be scrubbed by the SDK; clarify that JNI
zeroization does not erase the Kotlin-owned array. Keep the existing parameter
and return documentation unchanged.

In
`@packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs`:
- Around line 226-234: Update breadcrumb_error and its call sites to prevent
full identity, contract, and document identifiers from reaching WARN-level
logcat output. Truncate each embedded identifier to a non-correlatable short
prefix, such as the first eight hexadecimal characters, while retaining enough
context to distinguish failure or skip paths; leave normal breadcrumb handling
unchanged.

In `@packages/rs-platform-wallet/tests/txmetadata_fetch.rs`:
- Around line 241-258: Remove the temporary plaintext-dumping capture helper and
its associated hardcoded mnemonic/scaffolding from the test tree now that
legacy_install_yabba2_wire_compat_vector contains the vectors. Delete the
PLAINTEXT_HEX and PLAINTEXT_UTF8_LOSSY output along with the helper’s other
diagnostic prints, or move the complete capture utility to examples/ without
leaving a reusable decrypted-data path in tests.

In `@packages/rs-unified-sdk-jni/src/transactions.rs`:
- Around line 945-956: Add a non-sensitive RAII pointer guard for the encrypted
document create path and use it for out_json in place of the manual
platform_wallet_string_free call. Ensure the guard frees the FFI allocation on
normal completion and during unwinding, while preserving the existing null
handling and JSON conversion in the surrounding create flow.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift`:
- Around line 3482-3489: The version guard in seal_tx_metadata duplicates
Rust-owned protocol constants by hard-coding 0 and 1 in Swift. Remove this
Swift-side validation and let the Rust FFI enforce accepted versions, updating
EncryptedDocumentVersionValidationTests to assert the resulting propagated error
behavior without relying on the duplicated Swift guard.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d8bf60e2-0712-41c6-8571-fd95bd90e173

📥 Commits

Reviewing files that changed from the base of the PR and between e1fb911 and e39703d.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (31)
  • docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactions.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt
  • packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/documents/DocumentTransactionsVersionValidationTest.kt
  • packages/rs-platform-encryption/Cargo.toml
  • packages/rs-platform-encryption/src/aes.rs
  • packages/rs-platform-encryption/src/lib.rs
  • packages/rs-platform-wallet-ffi/Cargo.toml
  • packages/rs-platform-wallet-ffi/src/document.rs
  • packages/rs-platform-wallet-ffi/src/error.rs
  • packages/rs-platform-wallet-ffi/src/lib.rs
  • packages/rs-platform-wallet-ffi/src/tx_metadata_json.rs
  • packages/rs-platform-wallet-ffi/src/types.rs
  • packages/rs-platform-wallet/Cargo.toml
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/lib.rs
  • packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/crypto/tx_metadata.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/encrypted_document.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/payments.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyDerivationPathCheck.java
  • packages/rs-platform-wallet/tests/legacy_wire_compat/LegacyKeyN.java
  • packages/rs-platform-wallet/tests/legacy_wire_compat/README.md
  • packages/rs-platform-wallet/tests/txmetadata_fetch.rs
  • packages/rs-unified-sdk-jni/src/support.rs
  • packages/rs-unified-sdk-jni/src/transactions.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift
  • packages/swift-sdk/SwiftTests/SwiftDashSDKTests/EncryptedDocumentVersionValidationTests.swift

Comment thread docs/sdk/TXMETADATA_DECRYPT_PLAINTEXT_LIFETIME_SPEC.md Outdated
Comment thread packages/rs-platform-wallet/tests/txmetadata_fetch.rs
Comment thread packages/rs-unified-sdk-jni/src/support.rs
@shumkov

shumkov commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Nitpick comments (6)

Declined on this U7 branch: all six suggestions target inherited dependency-stack code rather than the decrypt plaintext-lifetime change—#4186 owns the Kotlin create KDoc/version guard, JNI create JSON cleanup, wallet breadcrumbs, and capture helper; #4194 owns the Swift version guard. U7 is intentionally limited to the shared decrypt path and host residual documentation, so applying these here would duplicate parent/host fixes. They should land in their originating PRs and flow forward into this branch.

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.55%. Comparing base (bfc8024) to head (9870f4f).
⚠️ Report is 3 commits behind head on v4.2-dev.

Additional details and impacted files
@@            Coverage Diff             @@
##           v4.2-dev    #4243    +/-   ##
==========================================
  Coverage     87.54%   87.55%            
==========================================
  Files          2670     2671     +1     
  Lines        338763   338901   +138     
==========================================
+ Hits         296583   296718   +135     
- Misses        42180    42183     +3     
Components Coverage Δ
dpp 88.49% <ø> (ø)
drive 86.33% <ø> (ø)
drive-abci 89.57% <ø> (+<0.01%) ⬆️
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.88% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 49.79% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Rust 1.92 formatting rejected dependency-stack code before the macOS workspace job could reach its tests.

CI transition: cargo fmt --check --all failed before formatting and passes afterward.
@thepastaclaw

thepastaclaw commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

🕓 Ready for review — 7 ahead in queue (commit 9870f4f)
Queue position: 8/12 · 2 reviews active
ETA: start ~21:04 UTC · complete ~21:25 UTC (median 21m across 30 recent reviews; 2 slots)
Queued 57s ago · Last checked: 2026-07-28 19:40 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants