Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3415,6 +3415,245 @@ extension ManagedPlatformWallet {
}.value
}

/// Create + broadcast an ENCRYPTED wallet-contract document (the
/// wire-compatible `txMetadata` shape) on `contractId`'s
/// `documentType`, owned by `ownerIdentityId`, signed via `signer`.
/// Returns the 32-byte document id and the confirmed document's
/// canonical query-side JSON once Platform confirms the transition.
///
/// Sibling to `createDocument` — the encrypted counterpart that
/// bridges `platform_wallet_create_encrypted_document_with_signer_auto_index`.
/// The Rust side selects the identity's ENCRYPTION key id (the
/// `keyIndex` field), GENERATES the per-document `encryptionKeyIndex`,
/// derives the AES key from the
/// wallet HD tree, and seals `payload` into the legacy
/// `version ‖ IV ‖ AES-256-CBC` blob, then broadcasts
/// `{keyIndex, encryptionKeyIndex, encryptedMetadata}` via the generic
/// create-with-signer path. The written document is decryptable by the
/// legacy `org.dashj.platform` stack and vice versa. The resolved master
/// xprv is wiped BETWEEN the (synchronous) derivation and the (async)
/// broadcast, so no key material crosses the network `.await`
/// (dashpay/platform#4091).
///
/// The `encryptionKeyIndex` is NOT a host parameter: Rust generates it
/// (dashpay/platform#4277), matching the Android auto-index path where
/// Kotlin's `createEncryptedDocument` omits it (`encryptionKeyIndex =
/// null`). Host-side index assignment risked cross-device collisions and
/// put derivation-index policy in the host, so both platforms defer to
/// Rust. Rust draws a non-zero 31-bit BIP-32 child index from the
/// operating-system CSPRNG. The index is a per-document derivation INPUT
/// stored on the document itself — not a protocol sequence number — so a
/// repeated index is non-lossy: every document also carries a fresh IV,
/// and readers derive each document's key from that document's own
/// `{keyIndex, encryptionKeyIndex}` fields.
///
/// The explicit-index C export
/// (`platform_wallet_create_encrypted_document_with_signer`) still exists
/// for migration/compat tests but is deliberately NOT surfaced in Swift.
///
/// Batching stays app-side: the caller serializes its items into
/// `payload` (a protobuf `TxMetadataBatch` for `version == 1`). The
/// plaintext `payload` is copied directly into a Rust-owned `Zeroizing`
/// buffer
/// (scrubbed on drop, before the broadcast await) — this wrapper keeps
/// no extra Swift-side copy, the same handling as the seed bytes that
/// flow through `MnemonicResolver`. Callers that hold sensitive
/// plaintext should scrub their own buffer after the call returns.
///
/// `version` MUST be `0` (CBOR) or `1` (protobuf): `seal_tx_metadata`
/// writes the byte verbatim and the legacy dashj `decryptTxMetadata`
/// switches on exactly those two values, so an out-of-range byte would
/// silently seal a document the legacy stack can't decode. The guard
/// runs before any FFI call (mirrors the Kotlin
/// `DocumentTransactions.createEncryptedDocument` `require`).
///
/// # Key source: chosen by wallet capability (Rust-side)
///
/// A `MnemonicResolver` is always passed, but Rust decides whether to
/// use it: a key-resident wallet derives the AES key in-process; an
/// external-signable / Keychain-backed wallet (the app's shape)
/// derives on demand through the resolver. The resolver is pinned
/// across the synchronous FFI call with `withExtendedLifetime`, same as
/// `previewIdentityRegistrationKeys`.
///
/// Lifetime contract: the `signer` instance MUST stay alive for the
/// duration of the synchronous FFI call (Rust holds a `passUnretained`
/// ctx pointer). It is pinned with `withExtendedLifetime` around the
/// full marshalling chain, matching the other `*_with_signer` wrappers.
public func createEncryptedDocument(
ownerIdentityId: Identifier,
contractId: Identifier,
documentType: String,
version: UInt8,
payload: Data,
signer: KeychainSigner,
storage: WalletStorage = WalletStorage()
) async throws -> (Identifier, String) {
// Reject wire-meaningless version bytes before touching the FFI so
// a bad byte never seals a document the legacy stack can't decode
// (dashpay/platform#4091). Mirrors the Kotlin `require`.
guard version == 0 || version == 1 else {
throw PlatformWalletError.invalidParameter(
"version must be 0 (CBOR) or 1 (protobuf), got \(version)"
)
}

let handle = self.handle
let signerHandle = signer.handle
// Rust pulls the BIP-39 mnemonic on demand for external-signable
// wallets (the seed never round-trips into a Swift `String`); a
// key-resident wallet ignores it. Pinned below across the FFI call.
let resolver = MnemonicResolver(storage: storage)
let resolverHandle = resolver.handle
let ownerBytes: [UInt8] = ownerIdentityId.withFFIBytes { ptr in
Array(UnsafeBufferPointer(start: ptr, count: 32))
}
let contractBytes: [UInt8] = contractId.withFFIBytes { ptr in
Array(UnsafeBufferPointer(start: ptr, count: 32))
}
return try await Task.detached(priority: .userInitiated) {
var documentIdBytes = [UInt8](repeating: 0, count: 32)
// Receives an owned canonical-document JSON C string on
// success; freed with `platform_wallet_string_free` below.
var documentJsonPtr: UnsafeMutablePointer<CChar>? = nil

// Pin BOTH the signer and the resolver for the whole FFI call
// (see `createDocument` / `previewIdentityRegistrationKeys` for
// why a bare `_ = signer` is unreliable under -O). Rust
// dereferences both ctx pointers synchronously inside
// `block_on_worker`.
let result = withExtendedLifetime(resolver) {
withExtendedLifetime(signer) {
ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in
contractBytes.withUnsafeBufferPointer { contractBp -> PlatformWalletFFIResult in
documentType.withCString { typePtr -> PlatformWalletFFIResult in
// Borrow the plaintext bytes in place — no
// extra Swift copy. `baseAddress` is nil for
// an empty payload, which the FFI accepts
// only when `payload_len == 0`.
payload.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in
let payloadPtr = raw.bindMemory(to: UInt8.self).baseAddress
return documentIdBytes.withUnsafeMutableBufferPointer { outBp in
// Auto-index export: Rust generates the
// per-document `encryptionKeyIndex`
// (OS CSPRNG), so no index argument is
// passed (dashpay/platform#4277).
platform_wallet_create_encrypted_document_with_signer_auto_index(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Encrypted-document wrappers reference exports absent from this base

This call and the platform_wallet_fetch_encrypted_documents call at line 3630 reference functions that do not exist in the exact-head Rust FFI. Commit 746b34d93b removed both exports from packages/rs-platform-wallet-ffi/src/document.rs, and no later commit in this branch restores them. Regenerating the header with cargo check -p platform-wallet-ffi at 0fa7539bf21f8d373e60fc70f28b96ed964bb3a2 produces platform-wallet-ffi.h with neither declaration, while build_ios.sh includes that generated header in the DashSDKFFI module. Consequently, a source-built framework cannot type-check these Swift calls; a stale header would instead defer the failure to linking or loading. Restore the encrypted-document Rust implementation and both C exports on this branch, or remove these Swift wrappers until the native API is available.

source: ['codex']

handle,
resolverHandle,
ownerBp.baseAddress!,
contractBp.baseAddress!,
typePtr,
version,
payloadPtr,
UInt(payload.count),
signerHandle,
outBp.baseAddress!,
&documentJsonPtr
)
}
}
}
}
}
}
}

try result.check()
// Take ownership of the JSON and release the Rust allocation.
defer { if let p = documentJsonPtr { platform_wallet_string_free(p) } }
// On a successful broadcast the Rust side always writes the
// canonical JSON; a null pointer here is an FFI/ABI contract
// violation. Fail loudly rather than persist an empty body.
guard let jsonPtr = documentJsonPtr else {
throw PlatformWalletError.walletOperation(
"create_encrypted_document_with_signer_auto_index returned no canonical document JSON"
)
}
let canonicalJSON = String(cString: jsonPtr)
return (Data(documentIdBytes), canonicalJSON)
}.value
}

/// Fetch + DECRYPT every encrypted wallet-contract document owned by
/// `ownerIdentityId` on `contractId`'s `documentType` updated at or
/// after `sinceMs` (epoch-millis). Returns an owned JSON array string.
///
/// The wire-compatible read counterpart of the legacy
/// `getTxMetaData(since, key)` — bridges
/// `platform_wallet_fetch_encrypted_documents`. Each document's
/// `encryptedMetadata` blob is decrypted with the identity's derived
/// key; documents that can't be derived/decrypted are skipped Rust-side
/// (a bad document never aborts the fetch).
///
/// Each element of the returned array is
/// `{ "id": base58, "ownerId": base58, "keyIndex": UInt32,
/// "encryptionKeyIndex": UInt32, "version": UInt8,
/// "updatedAt": UInt64|null, "payload": base64 }`, where `payload` is
/// the decrypted opaque plaintext the caller parses itself (a protobuf
/// `TxMetadataBatch` for `version == 1`).
///
/// # Key source: chosen by wallet capability (Rust-side)
///
/// A `MnemonicResolver` is always passed, but Rust consults it only
/// when the in-process wallet lacks resident keys (the app's
/// external-signable shape). The resolver is pinned across the
/// synchronous FFI call with `withExtendedLifetime`, same as
/// `previewIdentityRegistrationKeys`.
public func fetchEncryptedDocuments(
ownerIdentityId: Identifier,
contractId: Identifier,
documentType: String,
sinceMs: UInt64,
storage: WalletStorage = WalletStorage()
) async throws -> String {
let handle = self.handle
let resolver = MnemonicResolver(storage: storage)
let resolverHandle = resolver.handle
let ownerBytes: [UInt8] = ownerIdentityId.withFFIBytes { ptr in
Array(UnsafeBufferPointer(start: ptr, count: 32))
}
let contractBytes: [UInt8] = contractId.withFFIBytes { ptr in
Array(UnsafeBufferPointer(start: ptr, count: 32))
}
return try await Task.detached(priority: .userInitiated) {
// Receives an owned JSON-array C string on success; freed with
// `platform_wallet_string_free` below.
var documentsJsonPtr: UnsafeMutablePointer<CChar>? = nil

// Pin the resolver for the whole FFI call — Rust dereferences
// its ctx pointer synchronously inside `block_on_worker`.
let result = withExtendedLifetime(resolver) {
ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in
contractBytes.withUnsafeBufferPointer { contractBp -> PlatformWalletFFIResult in
documentType.withCString { typePtr in
platform_wallet_fetch_encrypted_documents(
handle,
resolverHandle,
ownerBp.baseAddress!,
contractBp.baseAddress!,
typePtr,
sinceMs,
&documentsJsonPtr
)
}
}
}
}

try result.check()
defer { if let p = documentsJsonPtr { platform_wallet_string_free(p) } }
// On success the Rust side always writes a JSON array (even
// `"[]"`); a null pointer here is an FFI/ABI contract violation.
guard let jsonPtr = documentsJsonPtr else {
throw PlatformWalletError.walletOperation(
"fetch_encrypted_documents returned no JSON array"
)
}
return String(cString: jsonPtr)
}.value
}

/// Replace + broadcast `documentId`'s properties on `contractId`'s
/// `documentType`, owned by `ownerIdentityId`, signed with the
/// explicit AUTHENTICATION + ECDSA key `signingKeyId`. Returns the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import XCTest
@testable import SwiftDashSDK

/// Version-byte validation for
/// `ManagedPlatformWallet.createEncryptedDocument` (dashpay/platform#4091).
/// Only `0` (CBOR) and `1` (protobuf) are wire-meaningful — `seal_tx_metadata`
/// writes the byte verbatim and the legacy dashj `decryptTxMetadata` switches
/// on exactly those two values, so an out-of-range byte would silently seal a
/// document the legacy stack can't decode.
///
/// The `guard` runs before any FFI call (no `platform_wallet_*` symbol is
/// dereferenced and the wallet `handle` is never used), so the REJECTION paths
/// are exercised with a dummy handle and no live wallet — the Swift mirror of
/// the Kotlin `DocumentTransactionsVersionValidationTest`
/// (`walletHandle = 0L`). The accepted values `0` / `1` would proceed into
/// native and can't be unit-tested here.
final class EncryptedDocumentVersionValidationTests: XCTestCase {

/// A dummy, never-dispatched wallet handle (matches Kotlin's `0L`). The
/// version guard throws before the handle is read, so no FFI dispatch
/// occurs on the rejection paths under test.
private func makeWallet() -> ManagedPlatformWallet {
ManagedPlatformWallet(handle: 0, walletId: Data(count: 32))
}

private let id32 = Data(count: 32)
private let payload = Data([0, 1, 2, 3])

/// A signer is a required argument, but the version guard throws before it
/// is ever dereferenced — an in-memory-backed instance is enough to
/// satisfy the type. Built per-test.
private func makeSigner() throws -> KeychainSigner {
let container = try DashModelContainer.createInMemory()
return KeychainSigner(modelContainer: container, network: .testnet)
}

/// Bytes `2...255` (every value the legacy `0..=255` range once accepted
/// beyond the two wire-meaningful ones) are rejected with a message that
/// names them.
func testRejectsVersionBytesTheLegacyStackCannotDecode() async throws {
let wallet = makeWallet()
let signer = try makeSigner()
for version: UInt8 in [2, 3, 127, 255] {
do {
_ = try await wallet.createEncryptedDocument(
ownerIdentityId: id32,
contractId: id32,
documentType: "txMetadata",
version: version,
payload: payload,
signer: signer
)
XCTFail("version=\(version) must be rejected")
} catch let error as PlatformWalletError {
guard case let .invalidParameter(message) = error else {
XCTFail("expected .invalidParameter for version=\(version), got \(error)")
continue
}
XCTAssertTrue(
message.contains("0 (CBOR) or 1 (protobuf)"),
"message should name the wire-meaningful versions, got: \(message)"
)
} catch {
XCTFail("expected PlatformWalletError for version=\(version), got \(error)")
}
}
}
}
Loading