diff --git a/.github/package-filters/js-packages-no-workflows.yml b/.github/package-filters/js-packages-no-workflows.yml index 1ecb9012439..6fe45a7e76a 100644 --- a/.github/package-filters/js-packages-no-workflows.yml +++ b/.github/package-filters/js-packages-no-workflows.yml @@ -36,7 +36,7 @@ - packages/rs-dpp/** # NOTE: do not add `!packages/rs-dpp/**/tests.rs` style negation # patterns here. The dispatcher in `tests.yml` runs these filters - # via `dorny/paths-filter@v3` with the default + # via `dorny/paths-filter@v4` with the default # `predicate-quantifier: some`, under which each pattern (including # `!`-prefixed ones) is OR'd independently. A `!` pattern then # "matches" every file that doesn't match the negated path — i.e. @@ -103,7 +103,7 @@ dashmate: - packages/rs-dash-platform-macros/** - packages/dapi-grpc/** # NOTE: do not add `!path` negation patterns here — see the long - # explanation on `@dashevo/wasm-dpp` above. Same `dorny/paths-filter@v3` + # explanation on `@dashevo/wasm-dpp` above. Same `dorny/paths-filter@v4` # + `predicate-quantifier: some` interaction trips this filter on # unrelated changes and cascades into every consumer (`*wasm-sdk`). diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f04c71c92ae..af787939bb2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -52,6 +52,7 @@ jobs: release wasm-sdk platform-wallet + wallet-storage swift-example-app kotlin-sdk kotlin-example-app diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index 27e9d99906d..b62f70f986a 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -166,6 +166,7 @@ jobs: --package platform-value \ --package rs-dapi \ --package platform-wallet \ + --package platform-wallet-storage \ --package rs-sdk-ffi \ --package platform-wallet-ffi \ --package rs-dapi-client \ @@ -339,6 +340,7 @@ jobs: --package platform-value \ --package rs-dapi \ --package platform-wallet \ + --package platform-wallet-storage \ --package rs-sdk-ffi \ --package platform-wallet-ffi \ --package rs-dapi-client \ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2afaacd4ef4..b5971dcfbd8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,25 +55,25 @@ jobs: with: fetch-depth: 0 - - uses: dorny/paths-filter@v3 + - uses: dorny/paths-filter@v4 id: filter-js if: ${{ github.event_name != 'workflow_dispatch' }} with: filters: .github/package-filters/js-packages-no-workflows.yml - - uses: dorny/paths-filter@v3 + - uses: dorny/paths-filter@v4 id: filter-js-direct if: ${{ github.event_name != 'workflow_dispatch' }} with: filters: .github/package-filters/js-packages-direct.yml - - uses: dorny/paths-filter@v3 + - uses: dorny/paths-filter@v4 id: filter-rs if: ${{ github.event_name != 'workflow_dispatch' }} with: filters: .github/package-filters/rs-packages-no-workflows.yml - - uses: dorny/paths-filter@v3 + - uses: dorny/paths-filter@v4 id: filter-rs-workflows if: ${{ github.event_name != 'workflow_dispatch' }} with: @@ -116,7 +116,7 @@ jobs: - name: Check for Swift SDK changes id: filter-swift-sdk if: ${{ github.event_name != 'workflow_dispatch' }} - uses: dorny/paths-filter@v3 + uses: dorny/paths-filter@v4 with: filters: | swift-sdk-changed: diff --git a/Cargo.lock b/Cargo.lock index e7022fb027f..32f0e349634 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7be2f067ccd8d4b4d4a66ddafe0f32a5dff31732f32dbff85fefc40929b1f72" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -158,6 +169,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -170,6 +193,21 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "assert_matches" version = "1.5.0" @@ -552,7 +590,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -561,6 +608,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitcoin-internals" version = "0.3.0" @@ -607,6 +660,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "blake2b_simd" version = "1.0.4" @@ -778,6 +840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -1409,6 +1472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array 0.14.7", + "rand_core 0.6.4", "typenum", ] @@ -1767,6 +1831,46 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "openssl", + "sha2", + "zeroize", +] + +[[package]] +name = "dbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d8f54da401bb5eb2a4d873ac4b359f4a599df2ca8634bb5b8c045e5ee78757" +dependencies = [ + "dbus-secret-service", + "keyring-core", +] + [[package]] name = "delegate" version = "0.13.5" @@ -1858,6 +1962,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -2306,7 +2416,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex-automata", "regex-syntax", ] @@ -2317,6 +2427,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -2353,6 +2474,16 @@ dependencies = [ "flate2", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2376,6 +2507,15 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2682,7 +2822,7 @@ dependencies = [ "memuse", "rand 0.8.6", "rand_core 0.6.4", - "rand_xorshift", + "rand_xorshift 0.3.0", "subtle", ] @@ -3840,6 +3980,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "keyword-search-contract" version = "4.0.0-beta.2" @@ -3884,6 +4033,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -4004,6 +4163,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "masternode-reward-shares-contract" version = "4.0.0-beta.2" @@ -4298,6 +4466,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "549e471b99ccaf2f89101bec68f4d244457d5a95a9c3d0672e9564124397741d" +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4517,6 +4691,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.116" @@ -4525,6 +4708,7 @@ checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -4611,6 +4795,17 @@ dependencies = [ "regex", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pasta_curves" version = "0.5.1" @@ -4883,6 +5078,51 @@ dependencies = [ "zeroize", ] +[[package]] +name = "platform-wallet-storage" +version = "4.0.0-beta.2" +dependencies = [ + "apple-native-keyring-store", + "argon2", + "assert_cmd", + "bincode", + "chacha20poly1305", + "chrono", + "clap", + "dash-sdk", + "dashcore", + "dbus-secret-service-keyring-store", + "dpp", + "fd-lock", + "filetime", + "getrandom 0.2.17", + "hex", + "humantime", + "key-wallet", + "keyring-core", + "libc", + "platform-wallet", + "platform-wallet-storage", + "predicates", + "proptest", + "refinery", + "region", + "rusqlite", + "serde", + "serde_json", + "serial_test", + "sha2", + "static_assertions", + "subtle", + "tempfile", + "thiserror 1.0.69", + "tracing", + "tracing-subscriber", + "tracing-test", + "windows-native-keyring-store", + "zeroize", +] + [[package]] name = "plotters" version = "0.3.7" @@ -4981,7 +5221,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", "predicates-core", + "regex", ] [[package]] @@ -5085,6 +5329,25 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.11.1", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift 0.4.0", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "prost" version = "0.13.5" @@ -5251,6 +5514,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick_cache" version = "0.6.22" @@ -5431,6 +5700,15 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rand_xoshiro" version = "0.7.0" @@ -5525,6 +5803,47 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "refinery" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee5133e5b207e5703c2a4a9dc9bd8c8f2cc74c4ac04ca5510acaa907012c77ac" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a2a96d959c9b5b5da78e965bfdb1363b365bf5e84531a67d0eee827a702a3" +dependencies = [ + "async-trait", + "cfg-if", + "log", + "regex", + "rusqlite", + "siphasher", + "thiserror 2.0.18", + "time", + "url", + "walkdir", +] + +[[package]] +name = "refinery-macros" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56c2e960c8e47c7c5c30ad334afea8b5502da796a59e34d640d6239d876d924" +dependencies = [ + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -5554,6 +5873,18 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "region" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7" +dependencies = [ + "bitflags 1.3.2", + "libc", + "mach2", + "windows-sys 0.52.0", +] + [[package]] name = "rend" version = "0.4.2" @@ -6141,6 +6472,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -7691,6 +8034,27 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-wasm" version = "0.2.1" @@ -7764,6 +8128,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61faa33dc26b2851a37da5390a1a4cac015887b1e97ecd77ce7b4f987431de9f" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -7966,6 +8336,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -8406,6 +8785,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5fd986f648459dd29aa252ed3a5ad11a60c0b1251bf81625fb03a86c69d274e" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-registry" version = "0.6.1" diff --git a/Cargo.toml b/Cargo.toml index 701313874a5..9294041f0e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ members = [ "packages/rs-dash-event-bus", "packages/rs-platform-wallet", "packages/rs-platform-wallet-ffi", + "packages/rs-platform-wallet-storage", "packages/rs-platform-encryption", "packages/wasm-sdk", "packages/rs-unified-sdk-ffi", diff --git a/Dockerfile b/Dockerfile index b31776de9c8..6e68c5e1a48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -409,6 +409,7 @@ COPY --parents \ packages/rs-context-provider \ packages/rs-sdk-trusted-context-provider \ packages/rs-platform-wallet \ + packages/rs-platform-wallet-storage \ packages/wasm-dpp \ packages/wasm-dpp2 \ packages/wasm-drive-verify \ @@ -533,6 +534,7 @@ COPY --parents \ packages/rs-context-provider \ packages/rs-sdk-trusted-context-provider \ packages/rs-platform-wallet \ + packages/rs-platform-wallet-storage \ packages/wasm-dpp \ packages/wasm-dpp2 \ packages/wasm-drive-verify \ @@ -944,6 +946,7 @@ COPY --parents \ packages/rs-sdk-ffi \ packages/rs-unified-sdk-ffi \ packages/rs-platform-wallet \ + packages/rs-platform-wallet-storage \ packages/check-features \ packages/dash-platform-balance-checker \ packages/wasm-sdk \ diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 5a860e3bed9..c6f3fab7b5b 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1233,11 +1233,9 @@ impl PlatformWalletPersistence for FFIPersister { } if !round_success { - return Err( - "one or more persistence callbacks failed; changeset was rolled back" - .to_string() - .into(), - ); + return Err(PersistenceError::backend( + "one or more persistence callbacks failed; changeset was rolled back", + )); } // Merge into pending changesets. @@ -1251,9 +1249,10 @@ impl PlatformWalletPersistence for FFIPersister { if let Some(cb) = self.callbacks.on_store_fn { let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; if result != 0 { - return Err( - format!("Persistence store callback returned error code {}", result).into(), - ); + return Err(PersistenceError::backend(format!( + "Persistence store callback returned error code {}", + result + ))); } } @@ -1261,13 +1260,19 @@ impl PlatformWalletPersistence for FFIPersister { } fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { + // TODO: deferred — FFI callback failures are classified as + // `Fatal` (no transient-retry signal across the C ABI), and + // trailing-byte validation on decoded FFI payloads is not yet + // applied here. Both are tracked for a follow-up; no behavior + // change in this change. // Notify caller. if let Some(cb) = self.callbacks.on_flush_fn { let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; if result != 0 { - return Err( - format!("Persistence flush callback returned error code {}", result).into(), - ); + return Err(PersistenceError::backend(format!( + "Persistence flush callback returned error code {}", + result + ))); } } @@ -1293,7 +1298,10 @@ impl PlatformWalletPersistence for FFIPersister { let mut count: usize = 0; let rc = unsafe { load_cb(self.callbacks.context, &mut entries_ptr, &mut count) }; if rc != 0 { - return Err(format!("on_load_wallet_list_fn returned error code {}", rc).into()); + return Err(PersistenceError::backend(format!( + "on_load_wallet_list_fn returned error code {}", + rc + ))); } let _guard = LoadGuard { context: self.callbacks.context, @@ -1343,12 +1351,10 @@ impl PlatformWalletPersistence for FFIPersister { if self.callbacks.on_load_shielded_notes_fn.is_some() != self.callbacks.on_load_shielded_notes_free_fn.is_some() { - return Err( + return Err(PersistenceError::backend( "on_load_shielded_notes_fn and on_load_shielded_notes_free_fn must be \ - provided together" - .to_string() - .into(), - ); + provided together", + )); } if self.callbacks.on_load_shielded_sync_states_fn.is_some() != self @@ -1356,12 +1362,10 @@ impl PlatformWalletPersistence for FFIPersister { .on_load_shielded_sync_states_free_fn .is_some() { - return Err( + return Err(PersistenceError::backend( "on_load_shielded_sync_states_fn and on_load_shielded_sync_states_free_fn \ - must be provided together" - .to_string() - .into(), - ); + must be provided together", + )); } // 1) notes @@ -1371,9 +1375,10 @@ impl PlatformWalletPersistence for FFIPersister { let rc = unsafe { load_notes(self.callbacks.context, &mut notes_ptr, &mut notes_count) }; if rc != 0 { - return Err( - format!("on_load_shielded_notes_fn returned error code {}", rc).into(), - ); + return Err(PersistenceError::backend(format!( + "on_load_shielded_notes_fn returned error code {}", + rc + ))); } struct NotesGuard { context: *mut c_void, @@ -1436,11 +1441,10 @@ impl PlatformWalletPersistence for FFIPersister { load_states(self.callbacks.context, &mut states_ptr, &mut states_count) }; if rc != 0 { - return Err(format!( + return Err(PersistenceError::backend(format!( "on_load_shielded_sync_states_fn returned error code {}", rc - ) - .into()); + ))); } struct StatesGuard { context: *mut c_void, @@ -2267,14 +2271,17 @@ fn build_wallet_start_state( let xpub_bytes = unsafe { slice_from_raw(spec.account_xpub_bytes, spec.account_xpub_bytes_len) }; let (account_xpub, _): (ExtendedPubKey, usize) = - bincode::decode_from_slice(xpub_bytes, config::standard()) - .map_err(|e| format!("failed to decode account xpub: {}", e))?; + bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| { + PersistenceError::backend(format!("failed to decode account xpub: {}", e)) + })?; let account = Account::from_xpub(Some(entry.wallet_id), account_type, account_xpub, network) - .map_err(|e| format!("Account::from_xpub failed: {:?}", e))?; - accounts - .insert(account) - .map_err(|e| format!("AccountCollection::insert failed: {}", e))?; + .map_err(|e| { + PersistenceError::backend(format!("Account::from_xpub failed: {:?}", e)) + })?; + accounts.insert(account).map_err(|e| { + PersistenceError::backend(format!("AccountCollection::insert failed: {}", e)) + })?; } // External-signable wallet — the mnemonic / seed lives in the @@ -2725,6 +2732,10 @@ fn build_wallet_start_state( } } + // TODO: this per-account reconstruction mirrors the SQLite backend's + // `platform_addrs::build_per_account`. Deferred dedup — once a shared + // helper crate hosts the reconstruction, both backends should call it + // instead of keeping parallel copies. let mut per_account = PerWalletPlatformAddressState::new(); for (&account_key, account) in &wallet.accounts.platform_payment_accounts { per_account.entry(account_key.account).or_insert_with(|| { @@ -2915,7 +2926,7 @@ fn build_unused_asset_locks( for spec in specs { // Decode the outpoint: 32-byte raw txid + 4-byte LE vout. let txid = dashcore::Txid::from_slice(&spec.out_point[..32]).map_err(|e| { - PersistenceError::from(format!( + PersistenceError::backend(format!( "tracked asset lock: invalid txid in outpoint: {}", e )) @@ -2928,8 +2939,8 @@ fn build_unused_asset_locks( // Decode the consensus-encoded transaction. if spec.transaction_bytes.is_null() || spec.transaction_bytes_len == 0 { - return Err(PersistenceError::from( - "tracked asset lock: empty transaction bytes".to_string(), + return Err(PersistenceError::backend( + "tracked asset lock: empty transaction bytes", )); } // SAFETY: Swift guarantees the buffer is valid for the @@ -2939,7 +2950,7 @@ fn build_unused_asset_locks( unsafe { slice::from_raw_parts(spec.transaction_bytes, spec.transaction_bytes_len) }; let transaction: dashcore::Transaction = dashcore::consensus::deserialize(tx_bytes) .map_err(|e| { - PersistenceError::from(format!( + PersistenceError::backend(format!( "tracked asset lock: failed to decode transaction: {}", e )) @@ -2959,7 +2970,10 @@ fn build_unused_asset_locks( config::standard(), ) .map_err(|e| { - PersistenceError::from(format!("tracked asset lock: failed to decode proof: {}", e)) + PersistenceError::backend(format!( + "tracked asset lock: failed to decode proof: {}", + e + )) })?; Some(proof) }; @@ -3010,7 +3024,7 @@ fn funding_type_from_u8( 4 => AssetLockFundingType::AssetLockAddressTopUp, 5 => AssetLockFundingType::AssetLockShieldedAddressTopUp, other => { - return Err(PersistenceError::from(format!( + return Err(PersistenceError::backend(format!( "tracked asset lock: unknown funding_type discriminant {}", other ))) @@ -3027,7 +3041,7 @@ fn status_from_u8(b: u8) -> Result AssetLockStatus::ChainLocked, 4 => AssetLockStatus::Consumed, other => { - return Err(PersistenceError::from(format!( + return Err(PersistenceError::backend(format!( "tracked asset lock: unknown status discriminant {}", other ))) @@ -3226,7 +3240,7 @@ fn account_type_from_spec(spec: &AccountSpecFFI) -> Result Result { let standard_tag = StandardAccountTypeTagFFI::try_from_u8(spec.standard_tag) .ok_or_else(|| { - PersistenceError::Backend(format!( + PersistenceError::backend(format!( "AccountSpecFFI(Standard) carries unknown standard_tag byte {}", spec.standard_tag )) @@ -3290,7 +3304,7 @@ fn account_type_from_spec(spec: &AccountSpecFFI) -> Result { - return Err(PersistenceError::Backend(format!( + return Err(PersistenceError::backend(format!( "AccountTypeTagFFI {:?} is no longer mappable to a key-wallet AccountType after the upstream event-bus refactor (TODO(events))", type_tag ))); @@ -3393,10 +3407,10 @@ fn restore_unresolved_asset_lock_tx_records( let context = match rec.context_raw { 2 => { let block_hash = dashcore::BlockHash::from_slice(&rec.block_hash).map_err(|e| { - format!( + PersistenceError::backend(format!( "load: malformed block_hash on unresolved asset-lock tx record: {}", e - ) + )) })?; TransactionContext::InBlock(BlockInfo::new( rec.block_height, @@ -3406,10 +3420,10 @@ fn restore_unresolved_asset_lock_tx_records( } 3 => { let block_hash = dashcore::BlockHash::from_slice(&rec.block_hash).map_err(|e| { - format!( + PersistenceError::backend(format!( "load: malformed block_hash on unresolved asset-lock tx record: {}", e - ) + )) })?; TransactionContext::InChainLockedBlock(BlockInfo::new( rec.block_height, diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml new file mode 100644 index 00000000000..43bf9a0fb0f --- /dev/null +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -0,0 +1,214 @@ +[package] +name = "platform-wallet-storage" +version.workspace = true +rust-version.workspace = true +edition = "2021" +authors = ["Dash Core Team"] +license = "MIT" +description = "Storage backends for platform-wallet: SQLite persistence and keyring_core secret backends (encrypted-file + OS keyring)." + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "platform-wallet-storage" +path = "src/bin/platform-wallet-storage.rs" +required-features = ["cli"] + +[dependencies] +# Truly cross-cutting deps (always on regardless of features). +thiserror = "1" +tracing = "0.1" +hex = "0.4" + +# SQLite-backed persister deps (gated by the `sqlite` feature). +# `platform-wallet` types are reachable through the `sqlite` submodule +# only; without the feature the bare crate ships no items that mention +# them, so the wallet/serde graph stays out of the build. +# `dpp` types reach the persister via `IdentityPublicKey` (identity_keys +# writer), `AssetLockProof` (asset_locks writer) and `Identifier` +# (dashpay writer). `dash-sdk` is here for the `AddressFunds` re-export +# in `schema/platform_addrs.rs`. Feature set mirrors sibling +# `rs-platform-wallet` so the resolver picks identical hashes. +platform-wallet = { path = "../rs-platform-wallet", features = [ + "serde", +], optional = true } +serde = { version = "1", features = ["derive"], optional = true } +key-wallet = { workspace = true, optional = true } +dashcore = { workspace = true, optional = true } +dpp = { path = "../rs-dpp", optional = true } +dash-sdk = { path = "../rs-sdk", default-features = false, features = [ + "dashpay-contract", + "dpns-contract", +], optional = true } +rusqlite = { version = "0.38", features = [ + "bundled", + "backup", + "blob", + "hooks", + "trace", +], optional = true } +refinery = { version = "0.9", default-features = false, features = [ + "rusqlite", +], optional = true } +# bincode 2 is required directly: we encode `dpp::IdentityPublicKey` +# (which derives bincode 2 `Encode`/`Decode`) and decode +# `dpp::AssetLockProof` from the asset-lock blob column. Exact-pinned to +# the lock-resolved version to match the crate's `=`-pin discipline. +bincode = { version = "=2.0.1", optional = true } +tempfile = { version = "3", optional = true } +chrono = { version = "0.4", default-features = false, features = [ + "clock", +], optional = true } +sha2 = { version = "0.10", optional = true } + +# Secret-storage deps (gated by the `secrets` feature). RustSec-clean +# pins (Smythe §7); `aes-gcm` is deliberately omitted. `keyring`'s +# library is `keyring-core` + per-platform store crates (the `keyring` +# crate itself is sample/CLI). Verified to build under MSRV 1.92. +argon2 = { version = "=0.5.3", optional = true } +chacha20poly1305 = { version = "=0.10.1", optional = true } +zeroize = { version = "=1.8.2", features = ["derive"], optional = true } +subtle = { version = "=2.6.1", optional = true } +# CSPRNG for AEAD nonces and the vault salt; exact-pinned like the rest +# of the crypto stack so the random source is not the loose one. +getrandom = { version = "=0.2.17", optional = true } +region = { version = "=3.0.2", optional = true } +keyring-core = { version = "=1.0.0", optional = true } +# Cross-process advisory file lock for the vault read-modify-write. +# `fd-lock` 4.x is pure-rustix and replaces the `fs2`/`fs4` family that +# was removed from the sqlite arm — those tests grep for `fs2`/`fs4` +# literals in this crate's source/manifest and would re-trigger on the +# older crates. `fd-lock` has no such collision. +fd-lock = { version = "4.0.4", optional = true } + +# CLI deps (gated by the `cli` feature) +clap = { version = "4", features = ["derive"], optional = true } +humantime = { version = "2", optional = true } +serde_json = { version = "1", optional = true } +tracing-subscriber = { version = "0.3", features = [ + "env-filter", +], optional = true } + +# Per-platform OS-keyring credential stores. `keyring-core 1.0.0` is +# the API; these crates provide the platform backends (the `keyring` +# 4.x crate is the sample CLI and is intentionally not depended on). +# Gated by `secrets` via `dep:`. Target-specific tables MUST follow all +# `[dependencies]` entries. +[target.'cfg(unix)'.dependencies] +# `O_NOFOLLOW` open flag for vault read TOCTOU defence. +libc = { version = "0.2", optional = true } + +[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] +dbus-secret-service-keyring-store = { version = "=1.0.0", features = [ + "crypto-rust", + "vendored", +], optional = true } + +[target.'cfg(target_os = "macos")'.dependencies] +# macOS crate requires one of `keychain` / `protected` features or it +# emits a compile_error! (latent — only fires on macOS targets; Linux CI +# never tripped it). `keychain` = standard Keychain; `protected` is the +# user-presence-gated variant. We want the standard one for the v1 +# SecretStore SPI; the protected variant can be opt-in later. +apple-native-keyring-store = { version = "=1.0.0", features = ["keychain"], optional = true } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-native-keyring-store = { version = "=1.0.0", optional = true } + +[dev-dependencies] +proptest = "1" +assert_cmd = "2" +predicates = "3" +static_assertions = "1" +filetime = "0.2" +tracing-test = { version = "0.2", features = ["no-env-filter"] } +serial_test = "3" +# `default-features = false` so the off-state CI invocation +# (`--no-default-features --features sqlite,cli`) actually exercises a +# build with `secrets`/`kv` disabled — otherwise the dev-dep view would +# silently re-enable the default feature set for every integration test. +# Test surface is opted into explicitly: `secrets` and `kv` are listed +# so the plain `cargo test -p platform-wallet-storage` invocation runs +# both feature paths (the `kv`-gated `sqlite_object_metadata.rs` +# integration test and the `secrets`-gated unit tests). +platform-wallet-storage = { path = ".", default-features = false, features = ["sqlite", "cli", "secrets", "kv", "__test-helpers"] } +tempfile = "3" +# `sqlite_hardening_3625.rs`, `sqlite_persist_roundtrip.rs`, and +# `sqlite_load_reconstruction.rs` import `dash_sdk::platform::address_sync::AddressFunds`. +# Mocks feature lets the consumer↔persister boundary tests stand up a +# real SDK without network. (`round_trip_consumer.rs` was extracted into +# the consumer-hardening PR; tokio is no longer needed here.) +dash-sdk = { path = "../rs-sdk", default-features = false, features = [ + "dashpay-contract", + "dpns-contract", + "wallet", + "mocks", +] } + +[features] +default = ["sqlite", "cli", "secrets", "kv"] +# SQLite-backed persister (`platform_wallet_storage::sqlite`). +sqlite = [ + "dep:platform-wallet", + "dep:serde", + "dep:key-wallet", + "dep:dashcore", + "dep:dpp", + "dep:dash-sdk", + "dep:rusqlite", + "dep:refinery", + "dep:bincode", + "dep:tempfile", + "dep:chrono", + "dep:sha2", +] +# Maintenance CLI binary. Requires `sqlite` because the only subcommands +# in scope today operate on the SQLite persister. +cli = [ + "sqlite", + "dep:clap", + "dep:humantime", + "dep:serde_json", + "dep:tracing-subscriber", +] +# `secrets` submodule (`platform_wallet_storage::secrets`): zeroizing +# secret wrappers + EncryptedFile backend + OS-keyring construction +# helper, all built on the upstream `keyring_core::api` SPI. Default-on +# so `Cargo.lock` unconditionally pins the RustSec-clean crypto stack. +# Disable explicitly via `--no-default-features` to build the storage +# crate without the crypto graph. +secrets = [ + "dep:argon2", + "dep:chacha20poly1305", + # secrets uses serde directly (vault format + crypto envelope derive + # `Serialize`/`Deserialize`); declare the dep here so + # `--no-default-features --features secrets` builds without leaning + # on the `sqlite` feature also having `dep:serde`. + "dep:serde", + "dep:serde_json", + "dep:tempfile", + "dep:zeroize", + "dep:subtle", + "dep:getrandom", + "dep:region", + "dep:keyring-core", + "dep:fd-lock", + "dep:libc", + "dep:dbus-secret-service-keyring-store", + "dep:apple-native-keyring-store", + "dep:windows-native-keyring-store", +] +# Per-object-type key/value metadata API +# (`platform_wallet_storage::{KvStore, KvError, ObjectId}`) plus the +# SQLite-backed impl. Requires `sqlite` because the only shipped backend +# is on `SqlitePersister`. The six `meta_*` tables are always created by +# V001 so DB files stay interoperable across feature combos; this gate +# only controls the Rust API surface. +kv = ["sqlite"] +# Exposes `lock_conn_for_test` / `config_for_test` accessors on +# `SqlitePersister` so this crate's own integration tests can probe +# the write connection. The double-underscore prefix follows Cargo's +# convention for "MUST NOT enable from downstream" features +# (https://doc.rust-lang.org/cargo/reference/features.html#feature-resolver-version-2). +__test-helpers = ["sqlite"] diff --git a/packages/rs-platform-wallet-storage/README.md b/packages/rs-platform-wallet-storage/README.md new file mode 100644 index 00000000000..34917c10e03 --- /dev/null +++ b/packages/rs-platform-wallet-storage/README.md @@ -0,0 +1,298 @@ +# platform-wallet-storage + +## Why this crate exists + +A wallet on Dash Platform carries a lot of **public** state — UTXOs, +transactions, account registrations, address pools, identities and their +public keys, contacts, asset locks, token balances, DashPay overlays, and +platform-address sync snapshots. A client needs all of it on disk so it +can restart and pick up where it left off instead of re-scanning the chain +from genesis. Until now every integrator built that storage themselves. + +`platform-wallet-storage` is the ready-to-use, embeddable answer: a SQLite +persistence backend for [`platform-wallet`](../rs-platform-wallet), plus a +small set of operational tools around it. One `.db` file holds many +wallets, durable across restarts, with online backup, restore, and +migration handled for you — and a contract you can lean on: **no +private-key material is ever written to that file.** + +## What integrators get + +- **Durable multi-wallet storage** in a single SQLite file. Every + per-wallet row is keyed by `wallet_id`, so one file is the home for as + many wallets as the host app manages. Writers use `prepare_cached` and + the database runs WAL journaling by default. +- **The private-key boundary, in writing.** Mnemonics, seeds, and raw + private keys never touch this file. Public-only material goes to SQLite; + signing material stays in the OS keyring or the encrypted vault. See + [SECRETS.md](./SECRETS.md). +- **Backup, restore, and migration handled for you.** Backups use + SQLite's online backup API (safe under a concurrent writer); restores + run under `BEGIN EXCLUSIVE` so peers back off instead of racing the + swap; schema migrations apply automatically on every open. +- **A flush contract you can build retries on.** Transient SQLite + failures return a *retryable* error with the buffered changeset intact; + fatal and constraint failures are reported distinctly and drop the + buffer. A corrupt database surfaces as a typed error on load rather than + silently losing rows. +- **Crypto on by default.** The `secrets` backends ship in the default + feature set, so `Cargo.lock` unconditionally pins the reviewed crypto + stack. + +## Features + +### SQLite persister + +The flagship: an implementation of `platform-wallet`'s +`PlatformWalletPersistence` over a single SQLite file. One database, many +wallets, every per-wallet row carrying a `wallet_id BLOB` primary-key +component. The persister is `Send + Sync` and usable behind +`Arc`. Wallet removal +([`SqlitePersister::delete_wallet`]) and explicit Manual-mode commit +([`SqlitePersister::commit_writes`]) are inherent methods on the persister, +not part of the trait. + +### KV / ObjectId metadata + +The `kv` feature adds a per-object key/value store ([`KvStore`](src/kv.rs)) +for stashing app-managed metadata — aliases, flags, notes, sync hints, +ordering — alongside wallet objects. It is independent of +`PlatformWalletPersistence`: reads and writes go straight to the store +without flowing through the wallet changeset buffer. A no-foreign-key soft +cascade means metadata can be attached *ahead* of sync and still gets +cleaned up when its wallet is deleted. + +### Secrets + +The `secrets` module ([SECRETS.md](./SECRETS.md)) is where signing material +that the persister refuses to touch actually lives. One `SecretStore` front +door fronts two backends: an in-house encrypted-file vault (Argon2id + +XChaCha20-Poly1305) and the OS keyring. Wrappers zeroize on drop and the +error surface is typed and secret-free. It is fully implemented and **on by +default.** + +### Maintenance CLI + +The `cli` feature ships the `platform-wallet-storage` binary with four +subcommands — `migrate`, `backup`, `restore`, `prune` — for operating the +database without writing custom code. + +--- + +## Technical details + +### Library usage + +```rust,no_run +use std::sync::Arc; +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +let config = SqlitePersisterConfig::new("/tmp/wallets.db"); +let persister: Arc = + Arc::new(SqlitePersister::open(config)?); +# Ok::<_, platform_wallet_storage::WalletStorageError>(()) +``` + +The same types are also reachable via their canonical submodule path — +`platform_wallet_storage::sqlite::SqlitePersister` — for callers that want +to be explicit about the backend. + +`SqlitePersisterConfig::new(path)` produces sensible defaults: `Immediate` +flush, 5 s busy timeout, WAL journal, `NORMAL` synchronous, and an +auto-backup dir at `/backups/auto/`. + +The trait surface is `store` / `flush` / `load` / `get_core_tx_record`. +Schema migrations are append-only Rust files under `migrations/`, applied +via [`refinery`](https://github.com/rust-db/refinery) on every `open`. + +#### Flush semantics (store / flush) + +`flush()` and `Immediate`-mode `store()` succeed-or-restore: on a transient +SQLite failure (`SQLITE_BUSY` / `SQLITE_LOCKED`) the buffered changeset is +merged back into the per-wallet buffer (LWW with anything `store()`-d during +the failed transaction) and the call returns a +`PersistenceError::Backend { kind: Transient, source }` whose source carries +the marker `flush failed transiently`. **Retry the call** — do not discard +state. Fatal failures (integrity check, encode error, mutex poison, …) +return `kind: Fatal` (or `kind: Constraint` for SQL constraint violations) +and drop the buffer. + +The full classification lives on +[`WalletStorageError::is_transient`](src/sqlite/error.rs) and the companion +[`WalletStorageError::persistence_kind`](src/sqlite/error.rs) that selects +the trait-side kind. The `source` field is a +`Box` over the original `WalletStorageError` — +operators can walk `Error::source()` for the full typed chain; the outer +`Display` carries the variant marker + hex wallet id so production-log greps +still work. + +A `SqlitePersister` configured with [`FlushMode::Manual`] does NOT +auto-flush from `Drop`; it emits a `tracing::error!` on drop if the buffer +still holds uncommitted writes (with `dirty_wallets` and `total_fields` +fields). Call [`SqlitePersister::commit_writes`] (or per-wallet `flush`) +before drop to make Manual-mode writes durable. +[`SqlitePersister::commit_writes`] returns a `CommitReport` whose +`succeeded` / `failed` / `still_pending` vectors classify each dirty wallet +so one failed wallet does not hide its siblings. + +#### load() reconstruction + +`SqlitePersister::load()` returns the base `ClientStartState` (plain struct, +two slots — no `#[non_exhaustive]`): + +| Slot | Reader | Status | +|---|---|---| +| `platform_addresses` | `schema::platform_addrs::load_all` (a fixed set of grouped scans over `platform_address_sync`, `platform_addresses`, and `account_registrations`, driven by the `wallet_meta::list_ids` wallet universe) | populated | +| `wallets` | — | empty pending upstream `Wallet::from_persisted` | + +The `identities` / `contacts` / `asset_locks` per-area readers exist as +hardened dormant helpers (`schema::::load_state`) but are not wired +into `load()` — `ClientStartState` carries no slot for them. + +Loading is **fail-hard**: any row that fails to decode, or a stored +`wallet_id` that is not exactly 32 bytes, aborts the whole call with a typed +[`WalletStorageError`](src/sqlite/error.rs) +(`BincodeDecode` / `BlobDecode` / `InvalidWalletIdLength`). There is no +corruption tolerance, no per-row skip, and no partial `Ok` — a corrupt +database surfaces as an error rather than silently losing rows. + +The summary `tracing::info!` carries `wallets_seen`, `addresses_loaded`, +`wallets_rehydrated`, and `wallets_pending_rehydration` (the count of +wallets that *would* be rehydrated once upstream provides +`Wallet::from_persisted`). + +### KV metadata API + +Each [`ObjectId`](src/kv.rs) variant addresses a dedicated `meta_*` table +across six scopes: + +| `ObjectId` | Table | Scope | +|---|---|---| +| `Global` | `meta_global` | App-wide; no parent, survives wallet deletion | +| `Wallet(wid)` | `meta_wallet` | Per wallet | +| `Identity(id)` | `meta_identity` | Per identity | +| `Token { identity_id, token_id }` | `meta_token` | Per token balance | +| `Contact { wallet_id, owner_id, contact_id }` | `meta_contact` | Per contact (any lifecycle state) | +| `PlatformAddress { wallet_id, address }` | `meta_platform_address` | Per platform address | + +**No-FK soft cascade.** Except for `Global`, a `put` does NOT require the +parent object to exist yet — metadata may be attached ahead of sync. When a +wallet is deleted, `AFTER DELETE` triggers broom every `meta_*` row keyed to +that wallet (by `wallet_id`, or by `identity_id` via the identity cascade) — +including rows whose typed parent was never written. `Global` is the only +scope that survives a wallet delete. Values are opaque `Vec` (the app +picks its own serialization); keys are 1..=128 chars and values are capped +at 16 MiB (`MAX_VALUE_LEN`), enforced by `put` before the write. For the +orphan-metadata limitation and future garbage-collection semantics, see +[SCHEMA.md](./SCHEMA.md#orphan-metadata-and-future-garbage-collection). + +The four `KvStore` methods: + +```rust,ignore +fn get(&self, scope: &ObjectId, key: &str) -> Result>, KvError>; +fn put(&self, scope: &ObjectId, key: &str, value: &[u8]) -> Result<(), KvError>; +fn delete(&self, scope: &ObjectId, key: &str) -> Result<(), KvError>; // idempotent +fn list_keys(&self, scope: &ObjectId, prefix: Option<&str>) -> Result, KvError>; +``` + +```rust,no_run +use platform_wallet_storage::{KvStore, ObjectId, SqlitePersister, SqlitePersisterConfig}; + +let persister = SqlitePersister::open(SqlitePersisterConfig::new("/tmp/wallets.db"))?; +persister.put(&ObjectId::Global, "ui.theme", b"dark")?; +let theme: Option> = persister.get(&ObjectId::Global, "ui.theme")?; +let keys = persister.list_keys(&ObjectId::Global, Some("ui."))?; +# Ok::<_, Box>(()) +``` + +### CLI usage + +```text +platform-wallet-storage --db migrate [--no-auto-backup] +platform-wallet-storage --db backup --out +platform-wallet-storage --db restore --from --yes +platform-wallet-storage prune --in [--keep-last N] [--max-age 30d] +``` + +Destructive subcommands (`restore`) REQUIRE `--yes` — invoking them without +it exits 2 with a usage error. `--no-auto-backup` opts out of the +pre-restore (or pre-migration) auto-backup; it is the only supported way to +disable auto-backup. + +Wallet removal is a library-only API +([`SqlitePersister::delete_wallet`] / `delete_wallet_skip_backup`); no CLI +subcommand exposes it. `delete_wallet` returns a `DeleteWalletReport` +carrying the deleted `wallet_id` and the pre-delete `backup_path` — the +rows themselves are removed by the native FK cascade plus the `meta_*` +soft-cascade triggers, so there is no per-table receipt. + +Logging: `-v` / `-vv` / `-vvv` enable `info` / `debug` / `trace` +respectively on stderr; `-q` suppresses non-error output. + +Exit codes: `0` success, `1` runtime error, `2` usage error, `3` validation +failure (e.g. corrupt backup source). + +**Restore exclusion.** `restore` opens a short-lived writer connection on +the destination DB and holds a SQLite-native `BEGIN EXCLUSIVE` transaction +across the entire restore body. This interlocks with every other SQLite +peer — sibling `SqlitePersister` handles, bare `rusqlite::Connection` +instances, the CLI — so concurrent writes back off via SQLite's +`busy_timeout` instead of racing the atomic swap. If a peer holds the +destination busy for longer than the timeout, `restore` returns +`WalletStorageError::RestoreDestinationLocked`. The lock conn is released +BEFORE the rename so SQLite's file handle on the old inode goes away before +the new DB takes its place. + +### Cargo features + +`default = ["sqlite", "cli", "secrets", "kv"]` + +| Feature | Default | What it brings | +|---|---|---| +| `sqlite` | yes | SQLite persister (`platform_wallet_storage::sqlite`) and all of its native deps (`rusqlite`, `refinery`, `dpp`, `dash-sdk`, `key-wallet`, etc.) | +| `cli` | yes | Maintenance binary `platform-wallet-storage`. Implies `sqlite`. | +| `secrets` | yes | `platform_wallet_storage::secrets` submodule — zeroizing secret wrappers (`SecretBytes`, `SecretString`), the `EncryptedFileStore` Argon2id + XChaCha20-Poly1305 vault backend, and the `default_credential_store()` OS-keyring constructor. Implements the upstream `keyring_core::api::{CredentialApi, CredentialStoreApi}` SPI. | +| `kv` | yes | Per-object-type key/value metadata API (`KvStore`, `KvError`, `ObjectId`) plus its SQLite-backed impl on `SqlitePersister`. Implies `sqlite`. The `meta_*` tables are always created by V001 so DB files stay interoperable across feature combos; this gate only controls the Rust API surface. | +| `__test-helpers` | no | Crate-private `lock_conn_for_test` / `config_for_test` accessors. The double-underscore prefix follows Cargo's "do not enable from downstream" convention; the methods are also `#[doc(hidden)]`. | + +`cargo build -p platform-wallet-storage --no-default-features` builds a +minimal core with neither the SQLite backend, the CLI, nor the secrets +submodule. `--no-default-features --features sqlite,cli` is the +"persister-only" build mode (no crypto dependencies). + +### Persistence error model + +A failed write tells the caller exactly what to do next via +`PersistenceErrorKind`: + +```rust,ignore +pub enum PersistenceErrorKind { + Transient, // not committed, buffer preserved — caller MAY retry + Fatal, // unrecoverable — caller MUST NOT retry, buffer dropped + Constraint, // SQL constraint violation — buffer dropped (fatal for retry) +} +``` + +`PersistenceErrorKind` is intentionally **not** `#[non_exhaustive]`: a +future variant must force every consumer `match` to update explicitly. The +SQLite side classifies its native errors through +`WalletStorageError::persistence_kind` and exposes the retry decision +directly via `WalletStorageError::is_transient`. + +### Schema + +The canonical schema is [`migrations/V001__initial.rs`](./migrations/V001__initial.rs) +— 23 tables of hand-written `CREATE TABLE … FOREIGN KEY …` SQL with native +`ON DELETE CASCADE`. Foreign-key enforcement is enabled and +read-back-asserted on every connection open. For the full table reference, +the cascade triggers, the no-FK `meta_*` soft cascade, the orphan-metadata +limitation, and the enum-domain CHECK constraints, see +[SCHEMA.md](./SCHEMA.md). + +### Secrets + +The crate writes no secrets to SQLite. Signing material lives in the +`secrets` backends instead. For the private-key boundary, the Argon2id + +XChaCha20-Poly1305 vault, the OS-keyring arm, the `SecretStore` API, the +error surface, and the threat model, see [SECRETS.md](./SECRETS.md). diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md new file mode 100644 index 00000000000..8149fb16e23 --- /dev/null +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -0,0 +1,670 @@ +# SQLite schema — `platform-wallet-storage` + +## Why this schema exists + +A wallet's **public** state has to survive a restart. This schema is the +on-disk shape of that state: one SQLite file holding many wallets, every +per-wallet row anchored to a `wallet_id`, so a client can reload its UTXOs, +identities, contacts, balances, and sync watermarks without re-scanning the +chain. + +## What it stores — and the boundary + +The persister stores **public** wallet-state material (UTXOs, transactions, +account registrations, address pools, identities, identity public keys, +contacts, asset locks, token balances, DashPay overlays, and +platform-address sync snapshots) in a SQLite database managed by +[refinery](https://crates.io/crates/refinery) migrations. + +**No secrets are stored here.** Mnemonics, seeds, and raw private keys never +appear in any column of any table — that is a deliberate boundary, not an +accident of the current row set. The secret-bearing backends live elsewhere; +see [SECRETS.md](./SECRETS.md). + +## How integrity is kept + +Schema evolution is version-gated by refinery. Every read-write connection turns on `PRAGMA foreign_keys = ON` at open time (`src/sqlite/conn.rs`), so every `ON DELETE CASCADE` clause is active. Deleting a `wallet_metadata` row cleans that wallet's metadata along two paths: + +- **`wallet_id`-scoped meta** (`meta_wallet`, `meta_contact`, `meta_platform_address`) carries a `wallet_id` column, so `cascade_meta_on_wallet_delete` brooms it directly — regardless of the lifecycle state of any typed parent and even for rows written ahead of (or without) a typed parent. +- **identity-scoped meta** (`meta_identity`, `meta_token`) carries no `wallet_id` — only `identity_id` (+ `token_id`). It is cleaned by `cascade_meta_on_identity_delete` (AFTER DELETE ON `identities`), which fires for the wallet's own identities when the FK cascade removes them on a wallet delete. + +### Orphan metadata and future garbage collection + +Any `meta_*` row whose parent object does not exist — because it was never created, or because it was removed via a path the cascade does not cover — may persist indefinitely. This is an accepted limitation that applies to all metadata types and scopes. Examples: + +- `meta_identity` / `meta_token` rows written for an `identity_id` that is never synced into `identities`: the cascade fires on `AFTER DELETE ON identities`, so if no `identities` row ever existed the trigger never fires and the metadata remains. +- Any `meta_*` row whose parent object is removed by a mechanism outside the trigger paths (e.g. direct SQL, a future schema path, or a partial-migration edge case). + +A future garbage-collection pass is expected to reap orphan metadata — rows with no live parent object older than approximately one week — but no such GC is implemented yet. Callers should not rely on orphan metadata persisting forever, nor assume it will be cleaned up promptly. `meta_global` is intentionally parentless and always survives. + +The 23 tables are split into five domain diagrams below. `WALLET_METADATA` is the root anchor and appears in each diagram. For full column listings see the [Tables](#tables) section. + +## Diagram 1 — Core / L1 (Bitcoin/Dash layer) + +Account registrations, address-pool snapshots, transactions, UTXOs, instant locks, derived addresses, and SPV sync state. + +```mermaid +erDiagram + WALLET_METADATA ||--o{ ACCOUNT_REGISTRATIONS : "registers" + WALLET_METADATA ||--o{ ACCOUNT_ADDRESS_POOLS : "snapshots" + WALLET_METADATA ||--o{ CORE_TRANSACTIONS : "records" + WALLET_METADATA ||--o{ CORE_UTXOS : "owns" + WALLET_METADATA ||--o{ CORE_INSTANT_LOCKS : "holds" + WALLET_METADATA ||--o{ CORE_DERIVED_ADDRESSES : "derives" + WALLET_METADATA ||--o| CORE_SYNC_STATE : "tracks" + CORE_TRANSACTIONS ||--o{ CORE_UTXOS : "spends" + + WALLET_METADATA { + BLOB wallet_id PK "32-byte WalletId" + TEXT network "mainnet | testnet | devnet | regtest" + INTEGER birth_height "SPV scan start height" + } + + ACCOUNT_REGISTRATIONS { + BLOB wallet_id PK + TEXT account_type PK "standard | coinjoin | identity_registration | ..." + INTEGER account_index PK + BLOB account_xpub_bytes "bincode-encoded AccountRegistrationEntry" + } + + ACCOUNT_ADDRESS_POOLS { + BLOB wallet_id PK + TEXT account_type PK + INTEGER account_index PK + TEXT pool_type PK "external | internal | absent | absent_hardened" + BLOB snapshot_blob "bincode-encoded AccountAddressPoolEntry" + } + + CORE_TRANSACTIONS { + BLOB wallet_id PK + BLOB txid PK "32-byte Txid" + INTEGER height "NULL if unconfirmed" + BLOB block_hash "NULL if unconfirmed" + INTEGER block_time "NULL if unconfirmed" + INTEGER finalized "0 | 1" + BLOB record_blob "bincode-encoded TransactionRecord" + } + + CORE_UTXOS { + BLOB wallet_id PK + BLOB outpoint PK "bincode-encoded OutPoint" + INTEGER value "satoshis" + BLOB script "scriptPubKey bytes" + INTEGER height "NULL if unconfirmed" + INTEGER account_index + INTEGER spent "0 | 1" + BLOB spent_in_txid "NULL until spend; cleared by trigger on tx delete" + } + + CORE_INSTANT_LOCKS { + BLOB wallet_id PK + BLOB txid PK + BLOB islock_blob "bincode-encoded InstantLock" + } + + CORE_DERIVED_ADDRESSES { + BLOB wallet_id PK + TEXT account_type PK + TEXT address PK "bech32 / Base58 address string" + INTEGER account_index + TEXT derivation_path "pool_type/derivation_index" + INTEGER used "0 | 1" + } + + CORE_SYNC_STATE { + BLOB wallet_id PK "one row per wallet" + INTEGER last_processed_height "NULL until first block processed" + INTEGER synced_height "NULL until first sync" + } +``` + +> Note: the `CORE_TRANSACTIONS → CORE_UTXOS` edge shown above is enforced by the +> `setnull_core_utxos_on_tx_delete` SQLite trigger, not a declared `FOREIGN KEY`. +> A native `ON DELETE SET NULL` composite FK would also null the NOT NULL `wallet_id` +> column — the trigger nulls only `spent_in_txid`, preserving the intended semantics. + +## Diagram 2 — Identities + DashPay (Platform L2 identity tree) + +Platform identities, their public keys, token balances, and DashPay profiles/payments. Identity-owned tables have no direct `wallet_id` column; cascade flows `wallet_metadata → identities → child`. + +```mermaid +erDiagram + WALLET_METADATA ||--o{ IDENTITIES : "parents" + IDENTITIES ||--o{ IDENTITY_KEYS : "has" + IDENTITIES ||--o{ TOKEN_BALANCES : "holds" + IDENTITIES ||--o| DASHPAY_PROFILES : "has" + IDENTITIES ||--o{ DASHPAY_PAYMENTS_OVERLAY : "overlays" + + WALLET_METADATA { + BLOB wallet_id PK "32-byte WalletId" + TEXT network + INTEGER birth_height + } + + IDENTITIES { + BLOB identity_id PK "32-byte Platform Identifier" + BLOB wallet_id FK "NULL = orphan identity (no parent wallet yet)" + INTEGER wallet_index "BIP-32 index; NULL for out-of-wallet identities" + BLOB entry_blob "bincode-encoded IdentityEntry" + INTEGER tombstoned "0 | 1 (logical delete)" + } + + IDENTITY_KEYS { + BLOB identity_id PK + INTEGER key_id PK "KeyID" + BLOB public_key_blob "bincode-encoded IdentityKeyWire (public material only)" + BLOB public_key_hash "20-byte HASH160 of the key" + } + + TOKEN_BALANCES { + BLOB identity_id PK + BLOB token_id PK "32-byte token contract Identifier" + INTEGER balance + INTEGER updated_at "Unix timestamp" + } + + DASHPAY_PROFILES { + BLOB identity_id PK "one row per identity" + BLOB profile_blob "bincode-encoded DashPayProfile" + } + + DASHPAY_PAYMENTS_OVERLAY { + BLOB identity_id PK + TEXT payment_id PK "transaction-level string key" + BLOB overlay_blob "bincode-encoded PaymentEntry" + } +``` + +## Diagram 3 — Contacts (DashPay social graph) + +One unified table for all three states of a DashPay contact relationship — the `state` column (`sent` / `received` / `established`) records the lifecycle stage. It roots on `wallet_id`; `IDENTITIES` is repeated here as a minimal placeholder to show that the `owner_id` / `contact_id` columns are Platform identity identifiers (32-byte blobs), not FK-enforced columns. + +```mermaid +erDiagram + WALLET_METADATA ||--o{ CONTACTS : "has" + IDENTITIES ||--o{ CONTACTS : "relates" + + WALLET_METADATA { + BLOB wallet_id PK "32-byte WalletId" + TEXT network + INTEGER birth_height + } + + IDENTITIES { + BLOB identity_id PK + } + + CONTACTS { + BLOB wallet_id PK + BLOB owner_id PK "32-byte identity owned by this wallet" + BLOB contact_id PK "32-byte counterparty identity" + TEXT state "sent | received | established" + BLOB outgoing_request "ContactRequest; set for sent + established" + BLOB incoming_request "ContactRequest; set for received + established" + TEXT alias "established-only (NULL when pending)" + TEXT note "established-only (NULL when pending)" + INTEGER is_hidden "established-only (NULL when pending)" + BLOB accepted_accounts "bincode-encoded Vec u32; established-only" + INTEGER updated_at "unixepoch() default" + } +``` + +> Note: `owner_id` and `contact_id` are Platform identity identifiers stored as BLOBs; they +> are NOT declared `FOREIGN KEY` columns. The relationship to `IDENTITIES` shown above is +> logical — enforced at the application layer, not by SQLite constraints. A pending row is +> `sent` XOR `received` and carries only the matching request blob; an `established` row sets +> both request blobs plus the four metadata columns. + +## Diagram 4 — Platform addresses + Asset locks (Platform L2 funding) + +Platform P2PKH address pool with its sync watermark, and the asset-lock lifecycle table. + +```mermaid +erDiagram + WALLET_METADATA ||--o{ PLATFORM_ADDRESSES : "tracks" + WALLET_METADATA ||--o| PLATFORM_ADDRESS_SYNC : "syncs" + WALLET_METADATA ||--o{ ASSET_LOCKS : "issues" + + WALLET_METADATA { + BLOB wallet_id PK "32-byte WalletId" + TEXT network + INTEGER birth_height + } + + PLATFORM_ADDRESSES { + BLOB wallet_id PK + BLOB address PK "20-byte HASH160 of the platform P2PKH address" + INTEGER account_index + INTEGER address_index + INTEGER balance "credits" + INTEGER nonce + } + + PLATFORM_ADDRESS_SYNC { + BLOB wallet_id PK "one row per wallet" + INTEGER sync_height "monotonically increasing" + INTEGER sync_timestamp + INTEGER last_known_recent_block + } + + ASSET_LOCKS { + BLOB wallet_id PK + BLOB outpoint PK "bincode-encoded OutPoint" + TEXT status "built | broadcast | is_locked | chain_locked | consumed" + INTEGER account_index + INTEGER identity_index + INTEGER amount_duffs + BLOB lifecycle_blob "bincode-encoded AssetLockEntry" + } +``` + +## Diagram 5 — Per-object metadata (KV) + +Per-object-type key/value metadata for arbitrary application-managed +data (aliases, flags, notes, sync hints, ordering — anything the host +app wants to stash alongside a wallet object). One dedicated `meta_*` +table per [`ObjectId`](./src/kv.rs) variant. `meta_global` has no parent +and survives wallet deletion. The other five carry **no foreign key**: +metadata may be written before its parent object is synced into its +typed table. `AFTER DELETE` triggers provide a soft cascade so metadata +never outlives its wallet. Deleting a `wallet_metadata` row brooms every +wallet-scoped `meta_*` row by `wallet_id` directly, and the FK cascade +through `identities` brooms the identity-scoped `meta_*` rows by +`identity_id`; both legs key on the id alone, so cleanup is independent +of whether the typed parent ever existed and of any contact's lifecycle +state. Direct deletes of a single `token_balances`, `contacts`, or +`platform_addresses` row also drop the matching metadata. The dashed +edges below denote trigger-based cleanup, not an FK relationship. + +```mermaid +erDiagram + WALLET_METADATA ||..o{ META_WALLET : "trigger cleanup (by wallet_id)" + WALLET_METADATA ||..o{ META_CONTACT : "trigger cleanup (by wallet_id)" + WALLET_METADATA ||..o{ META_PLATFORM_ADDRESS : "trigger cleanup (by wallet_id)" + IDENTITIES ||..o{ META_IDENTITY : "trigger cleanup (by identity_id)" + IDENTITIES ||..o{ META_TOKEN : "trigger cleanup (by identity_id)" + + META_GLOBAL { + TEXT key PK "1..=128 chars; no parent (survives wallet delete)" + BLOB value "opaque bytes; app picks its own serialization" + INTEGER updated_at "Unix epoch seconds; defaults to unixepoch()" + } + + META_WALLET { + BLOB wallet_id PK "no FK; trigger cleanup on wallet_metadata delete" + TEXT key PK + BLOB value + INTEGER updated_at + } + + META_IDENTITY { + BLOB identity_id PK "no FK; trigger cleanup on identities delete" + TEXT key PK + BLOB value + INTEGER updated_at + } + + META_TOKEN { + BLOB identity_id PK "no FK; trigger cleanup on identities delete" + BLOB token_id PK + TEXT key PK + BLOB value + INTEGER updated_at + } + + META_CONTACT { + BLOB wallet_id PK "no FK; trigger cleanup on wallet_metadata delete" + BLOB owner_id PK + BLOB contact_id PK + TEXT key PK + BLOB value + INTEGER updated_at + } + + META_PLATFORM_ADDRESS { + BLOB wallet_id PK "no FK; trigger cleanup on wallet_metadata delete" + BLOB address PK + TEXT key PK + BLOB value + INTEGER updated_at + } +``` + +> Note: every `meta_*` table's uniqueness comes straight from its +> composite `PRIMARY KEY` (id column(s) + `key`) — no partial indexes +> and no nullable scope column. The five typed tables carry no FK. On a +> wallet delete the wallet-rooted `AFTER DELETE` trigger brooms the +> wallet-scoped tables (`meta_wallet`, `meta_contact`, +> `meta_platform_address`) by `wallet_id`, and the FK cascade through +> `identities` fires the per-identity trigger that brooms `meta_identity` +> + `meta_token` by `identity_id` — so cleanup reaches every `meta_*` +> row keyed to the wallet even when no typed parent was ever written. + +## Tables + +### `wallet_metadata` + +Root anchor for every per-wallet table. Deleting a row cascades to all +direct children; identity-owned children cascade through `identities`. + +- `wallet_id` — 32-byte `WalletId` blob; PRIMARY KEY. +- `network` — `"mainnet"` | `"testnet"` | `"devnet"` | `"regtest"`. +- `birth_height` — SPV scan start height; `0` when unknown. + +### `account_registrations` + +One row per account registered on a wallet (xpub + account type + index). +The `account_xpub_bytes` blob carries the full `AccountRegistrationEntry`; +the typed `account_type` / `account_index` columns mirror it for SQL +lookups without blob decoding. + +- PK: `(wallet_id, account_type, account_index)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. + +### `account_address_pools` + +Address-pool snapshot per `(wallet, account, pool_type)`. `pool_type` is +one of `external`, `internal`, `absent`, `absent_hardened`. + +- PK: `(wallet_id, account_type, account_index, pool_type)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. + +### `core_transactions` + +One row per transaction the wallet has seen. `height`, `block_hash`, and +`block_time` are NULL while the transaction is unconfirmed. `finalized` +is `1` once block context is present. + +- PK: `(wallet_id, txid)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- Index: `idx_core_transactions_height(wallet_id, height)`. + +### `core_utxos` + +One row per UTXO, spent or unspent. `spent_in_txid` is set to NULL +by a trigger when its referenced `core_transactions` row is deleted +(instead of a native `ON DELETE SET NULL`, which would also null the +NOT NULL `wallet_id` column). + +- PK: `(wallet_id, outpoint)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- Index: `idx_core_utxos_spent(wallet_id, spent)`. + +### `core_instant_locks` + +Instant-lock blobs for transactions that are broadcast but not yet +finalized. Rows are removed when the transaction becomes confirmed. + +- PK: `(wallet_id, txid)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. + +### `core_derived_addresses` + +Address-to-account-index map. Written before UTXOs in the same +transaction so the UTXO writer can resolve `account_index` by address. + +- PK: `(wallet_id, account_type, address)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- Index: `idx_core_derived_addresses_addr(wallet_id, address)`. + +### `core_sync_state` + +One row per wallet, holding monotonically-advancing SPV sync watermarks. +`last_processed_height` and `synced_height` are NULL until the first +block is processed. + +- PK: `wallet_id` (single-row-per-wallet). +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. + +### `identities` + +Platform identities, wallet-parented or orphan. `wallet_id` is nullable: +NULL means the identity was written before a parent wallet was registered +(orphan-to-parented promotion via COALESCE on upsert). `tombstoned = 1` +marks a logical delete; the row is retained for cascade integrity. + +- PK: `identity_id`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE` (nullable). +- Index: `idx_identities_wallet(wallet_id)`. + +### `identity_keys` + +Public identity keys only — no private material. The +`public_key_blob` is a custom wire format (`IdentityKeyWire`) that +pre-encodes the `IdentityPublicKey` via bincode 2 native `Encode/Decode` +to work around a serde-tag incompatibility. + +- PK: `(identity_id, key_id)`. +- FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. +- Index: `idx_identity_keys_identity(identity_id)`. + +### `contacts` + +All DashPay contact relationships in one table, keyed by lifecycle +`state`. `owner_id` is always the wallet's identity; `contact_id` is the +counterparty. A pending relationship is `sent` (we sent the request) XOR +`received` (we received it) and carries only the matching request blob; an +`established` relationship carries both `outgoing_request` and +`incoming_request` plus the four metadata columns (`alias`, `note`, +`is_hidden`, `accepted_accounts`, NULL while pending). The request columns +hold a bincode-encoded `ContactRequest`; `accepted_accounts` holds a +bincode-encoded `Vec`. + +- PK: `(wallet_id, owner_id, contact_id)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- `state` CHECK: sourced from `sqlite::schema::contacts::CONTACT_STATE_LABELS`. + +### `platform_addresses` + +Platform P2PKH address pool entries. `address` stores the 20-byte +HASH160; `balance` and `nonce` are the last-synced values from the +Platform layer. + +- PK: `(wallet_id, address)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. + +### `platform_address_sync` + +Per-wallet watermark for platform address sync. All three height/timestamp +fields advance monotonically (new values are `max(current, incoming)`). + +- PK: `wallet_id` (single-row-per-wallet). +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. + +### `asset_locks` + +Lifecycle tracking for asset-lock outpoints. `status` is a queryable +text column; `lifecycle_blob` carries the full `AssetLockEntry`. Consumed +locks are removed via `AssetLockChangeSet::removed`, not retained with a +consumed status. + +- PK: `(wallet_id, outpoint)`. +- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. + +### `token_balances` + +Per-identity token balance cache, keyed by `(identity_id, token_id)`. +Cascade flows `wallet_metadata → identities → token_balances` through the +nullable `identities.wallet_id` link; no direct `wallet_id` column exists. + +- PK: `(identity_id, token_id)`. +- FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. + +### `dashpay_profiles` + +At most one DashPay profile blob per identity. `None` profile maps to a +DELETE rather than a NULL blob — the row is absent, not nulled. + +- PK: `identity_id` (single-row-per-identity). +- FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. + +### `dashpay_payments_overlay` + +Payment overlay entries for DashPay, keyed by transaction-level +`payment_id` string. Cascade flows through `identities` as with +`token_balances`. + +- PK: `(identity_id, payment_id)`. +- FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. + +### Per-object metadata (`meta_*`) + +Six dedicated key/value tables for app-managed metadata, one per +[`ObjectId`](./src/kv.rs) variant. Values are opaque BLOBs — the host app +picks its own serialization (bincode, JSON, protobuf, raw bytes). Shared +across all six: `key` is `TEXT` with `CHECK (length(key) BETWEEN 1 AND +128)`; `value` is `BLOB NOT NULL`; `updated_at` defaults to `unixepoch()` +and is refreshed on every `INSERT … ON CONFLICT DO UPDATE`. Uniqueness +comes from each table's composite `PRIMARY KEY` (id column(s) + `key`). +Public API lives in [`src/kv.rs`](./src/kv.rs); the SQLite implementation +is in [`src/sqlite/kv.rs`](./src/sqlite/kv.rs). + +Unlike every other per-wallet table, the five typed `meta_*` tables carry +**no foreign key**: a write succeeds before its parent object exists, so +host apps can attach metadata independently of sync ordering (and a +global-config persister can write to typed scopes whose parent tables +stay empty). Cleanup is instead a soft cascade. Deleting a +`wallet_metadata` row fires a wallet-rooted `AFTER DELETE` trigger that +brooms the wallet-scoped tables (`meta_wallet`, `meta_contact`, +`meta_platform_address`) by `wallet_id`, and the FK cascade through +`identities` fires a per-identity trigger that brooms `meta_identity` + +`meta_token` by `identity_id`. Both legs key on the id alone, so a wallet +delete cleans its metadata transitively whether or not the typed parent +was ever written and regardless of any contact's lifecycle state. +Additional triggers handle direct deletes of a single `token_balances`, +`contacts`, or `platform_addresses` row. + +#### `meta_global` + +Global metadata with no parent — survives every wallet delete. + +- PK: `key`. +- No foreign key, no trigger. + +#### `meta_wallet` + +Per-wallet metadata. Writable before the wallet exists. + +- PK: `(wallet_id, key)`. +- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallet_metadata`, by `wallet_id`). + +#### `meta_identity` + +Per-identity metadata. Writable before the identity exists. + +- PK: `(identity_id, key)`. +- No FK, no `wallet_id` column. Cleanup: `cascade_meta_on_identity_delete` (AFTER DELETE ON `identities`, by `identity_id`). Reached on a wallet delete only via the wallet's own `identities` rows; meta for an `identity_id` never synced into `identities` is not wallet-reachable and may persist as an orphan (see [Orphan metadata and future garbage collection](#orphan-metadata-and-future-garbage-collection)). + +#### `meta_token` + +Per-token-balance metadata. Writable before the token balance exists. + +- PK: `(identity_id, token_id, key)`. +- No FK, no `wallet_id` column. Cleanup: `cascade_meta_on_identity_delete` (AFTER DELETE ON `identities`, by `identity_id`) on a wallet/identity delete, plus `cascade_meta_token_on_token_balance_delete` (AFTER DELETE ON `token_balances`) for a direct balance delete. As with `meta_identity`, meta for an `identity_id` never synced into `identities` is not wallet-reachable and may persist as an orphan (see [Orphan metadata and future garbage collection](#orphan-metadata-and-future-garbage-collection)). + +#### `meta_contact` + +Per-contact metadata for any lifecycle state. Writable before the contact exists. + +- PK: `(wallet_id, owner_id, contact_id, key)`. +- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallet_metadata`, by `wallet_id`) on a wallet delete, plus `cascade_meta_contact_on_contact_delete` (AFTER DELETE ON `contacts`, any state) for a direct contact delete. + +#### `meta_platform_address` + +Per-platform-address metadata. `address` is an opaque `BLOB`. Writable +before the address exists. + +- PK: `(wallet_id, address, key)`. +- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallet_metadata`, by `wallet_id`) on a wallet delete, plus `cascade_meta_platform_address_on_address_delete` (AFTER DELETE ON `platform_addresses`) for a direct address delete. + +## Enum-domain CHECK constraints + +Six TEXT columns carry a `CHECK (col IN (...))` clause whose IN-list is +built at migration time from `pub(crate) const *_LABELS` arrays declared +next to each writer function. Five mirror an upstream Rust enum; the +sixth (`contacts.state`) is a synthetic lifecycle label naming which +`ContactChangeSet` slot a row came from: + +| Table | Column | Source-of-truth const | +|---|---|---| +| `wallet_metadata` | `network` | `sqlite::schema::wallet_meta::NETWORK_LABELS` | +| `account_registrations` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | +| `account_address_pools` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | +| `account_address_pools` | `pool_type` | `sqlite::schema::accounts::POOL_TYPE_LABELS` | +| `core_derived_addresses` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | +| `asset_locks` | `status` | `sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS` | +| `contacts` | `state` | `sqlite::schema::contacts::CONTACT_STATE_LABELS` | + +The const arrays are the single source of truth shared by the writer +mapping functions (`network_to_str`, `account_type_db_label`, +`pool_type_db_label`, `status_str`, `contact_state_db_label`) and the +migration's CHECK clauses. +Per-module `*_labels_match_enum` unit tests enforce set-equality +between each const and the writer's codomain — drift (a renamed/added +upstream variant) fails the test rather than landing as silent garbage +in the database. The label inventories are intentionally not duplicated +in this document; the source files are canonical. + +### Upstream-enum coupling + +Three of the persisted enums live in the external `rust-dashcore` +crate (`key_wallet::Network`, `key_wallet::account::AccountType`, +`key_wallet::managed_account::address_pool::AddressPoolType`); the +fourth (`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`) +is in-tree and carries a `# Schema coupling` rustdoc block. + +Because the upstream definitions cannot be edited from this repository, +the coupling is enforced from the local side instead, by three +mechanisms working together: + +1. **Writer rustdoc** in each `sqlite::schema::*` module names the + upstream enum path so an IDE jump-to-definition lands at it. +2. **Exhaustive `match` arms** in the parity-test variant lists + (`all_*_variants` functions) cause an upstream-added variant to + fail compilation here, forcing a writer + LABELS update. +3. **`*_labels_match_enum` unit tests** assert set-equality between + each `*_LABELS` array and the writer's codomain. + +TODO(rust-dashcore): once the upstream `key_wallet` crate is vendored +or the project gains push access there, mirror the in-tree +`AssetLockStatus` `# Schema coupling` doc block on the three upstream +enums so a developer editing them upstream sees the constraint without +having to grep this repo. + +## Foreign-key conventions + +- All direct-child `wallet_id` columns are `BLOB(32)` references to + `wallet_metadata.wallet_id` with `ON DELETE CASCADE`. +- `identities.wallet_id` is the single nullable FK: NULL means orphan + (no parent wallet registered yet). The orphan-to-parented promotion + uses `COALESCE(identities.wallet_id, excluded.wallet_id)` on upsert. +- Identity-owned tables (`identity_keys`, `token_balances`, + `dashpay_profiles`, `dashpay_payments_overlay`) have no `wallet_id` + column. Cascade reaches them via `identities(identity_id)`. +- `core_utxos.spent_in_txid` is cleared by the `setnull_core_utxos_on_tx_delete` + trigger rather than a native `ON DELETE SET NULL` FK, because SQLite would null + every column of a composite FK on SET NULL — including the NOT NULL `wallet_id`. +- The five typed `meta_*` tables carry **no FK** (writes may precede the parent); + cleanup is an `AFTER DELETE` soft cascade. A wallet delete fires a wallet-rooted + trigger that brooms the wallet-scoped `meta_*` tables by `wallet_id`, and the + FK cascade through `identities` fires a per-identity trigger that brooms the + identity-scoped ones by `identity_id` — so it cleans transitively and + parentless rows included. +- `PRAGMA foreign_keys = ON` is set and verified on every read-write connection open. + +## Triggers + +| Trigger | Fires | Action | +|---|---|---| +| `setnull_core_utxos_on_tx_delete` | AFTER DELETE ON `core_transactions` | NULL `core_utxos.spent_in_txid` for the deleted tx | +| `cascade_meta_on_wallet_delete` | AFTER DELETE ON `wallet_metadata` | delete `meta_wallet`, `meta_contact`, `meta_platform_address` rows by `wallet_id` | +| `cascade_meta_on_identity_delete` | AFTER DELETE ON `identities` | delete `meta_identity`, `meta_token` rows by `identity_id` | +| `cascade_meta_token_on_token_balance_delete` | AFTER DELETE ON `token_balances` | delete matching `meta_token` rows (direct balance delete) | +| `cascade_meta_contact_on_contact_delete` | AFTER DELETE ON `contacts` | delete matching `meta_contact` rows (any state; direct contact delete) | +| `cascade_meta_platform_address_on_address_delete` | AFTER DELETE ON `platform_addresses` | delete matching `meta_platform_address` rows (direct address delete) | + +## Migrations + +| Version | File | Description | +|---|---|---| +| V001 | `V001__initial.rs` | Full schema: all 23 tables (including the six `meta_*` per-object metadata tables), every index, and six triggers (`setnull_core_utxos_on_tx_delete` + the five `meta_*` soft-cascade triggers) | diff --git a/packages/rs-platform-wallet-storage/SECRETS.md b/packages/rs-platform-wallet-storage/SECRETS.md new file mode 100644 index 00000000000..7f983aa071c --- /dev/null +++ b/packages/rs-platform-wallet-storage/SECRETS.md @@ -0,0 +1,241 @@ +# Secret storage and the private-key boundary + +## Why secrets are handled this way + +A wallet's public state and its signing material have very different risk +profiles. The persister's SQLite file is meant to be copied, backed up, and +restored freely — so the one thing it must never contain is a key that could +move funds. Keeping signing material out of that file by construction is what +makes the rest of the crate safe to operate casually: you can back up the +`.db` without backing up your keys. + +So secrets get their own home, their own crypto, and their own typed, +secret-free error surface — separate from the persister entirely. + +## The value: a hard private-key boundary + +The SQLite persister in `platform-wallet-storage::sqlite` is the +canonical persistence backend for the data carried by +`PlatformWalletPersistence` — UTXOs, identities, identity public keys, +contacts, asset locks, token balances, DashPay overlays, address-pool +snapshots. **None of that is secret material.** + +Mnemonics, seeds, raw private keys, and any other long-lived signing +material live exclusively on the client side (iOS Keychain, Android +Keystore, OS keyring, encrypted file vault). They are re-derived as +needed via the wallet's BIP-32/BIP-39 plumbing and never touch the +SQLite file the persister writes. + +The rest of this document is the technical detail behind that boundary: the +`secrets` backends, the `SecretStore` API, the error surface, and the threat +model. + +## The `secrets` submodule + +`platform_wallet_storage::secrets` is part of the crate's default +feature set. The consumer entry point is `SecretStore`; the upstream +`keyring_core::api::{CredentialApi, CredentialStoreApi}` (shipped by +`keyring-core 1.0.0`) is the internal backend SPI. This crate +contributes backends and zeroizing wrappers, not the trait surface. + +### Consumer API: `SecretStore` + +`SecretStore` is the public, never-leaking front door. `get` yields a +zeroizing `SecretBytes` (a raw `Vec` never crosses the boundary); +`set` takes `&SecretBytes` so a caller cannot pass an unwrapped buffer. +Errors surface as the typed `SecretStoreError` — losslessly for the file +arm, so `WrongPassphrase` vs `Corruption` vs `AlreadyLocked` stay distinct. + +```rust +use platform_wallet_storage::secrets::{SecretBytes, SecretStore, SecretString, WalletId}; + +let store = SecretStore::file("/var/lib/wallet/secrets.pwsvault", SecretString::new("pw"))?; +let wallet = WalletId::from(wallet_id); +store.set(&wallet, "mnemonic", &SecretBytes::from_slice(b"abandon ability ..."))?; +let plaintext: Option = store.get(&wallet, "mnemonic")?; // never a bare Vec +store.delete(&wallet, "mnemonic")?; // idempotent +``` + +`SecretStore::file` takes the vault FILE path (operator picks the +filename); the parent directory is materialized on the first write. +Use `SecretStore::os()` for the platform OS keyring arm instead of +`SecretStore::file(..)`. + +### Internal SPI + +Below `SecretStore`, `EncryptedFileStore` and `default_credential_store` +expose the raw `keyring_core` SPI directly; their `keyring_core::Error` +projection is **lossy and string-only** (the typed distinction lives on +the `SecretStore` path). SPI consumers re-wrap the bare `Vec` from +`CredentialApi::get_secret` via `SecretBytes::new(...)` at the seam. + +### Key shape + +| upstream field | this crate's mapping | +|---|---| +| `service` | `"dash.platform-wallet-storage/" + hex(wallet_id)` (`SERVICE_PREFIX` + 64 hex chars) — one keyring "service" namespace per wallet | +| `user` | `label`, validated against `^[A-Za-z0-9._-]{1,64}$` before reaching the SPI; allowlist excludes `/`, `:`, space, NUL, non-ASCII | + +`WalletId` is a fixed 32-byte newtype. `validated_label` runs at +`CredentialStoreApi::build` time AND at every `CredentialApi` +operation (defence in depth — credentials are long-lived). + +### Memory hygiene at the seam + +`SecretStore::get` returns `Option` — a raw `Vec` +never crosses the public boundary. Internally, the upstream SPI returns +plaintext as `Vec` from `CredentialApi::get_secret`; that result is +wrapped into `SecretBytes::new(...)` **immediately**, with no named +intermediate `Vec` binding. `SecretBytes::new` takes the +`Vec` by value and `std::mem::take`s it into a `Zeroizing>` — +no copy of the bare buffer ever survives past the constructor +expression, so the bare-`Vec` exposure window is zero statements. The +wrapper is also best-effort `mlock`ed and `Debug` is redacted. + +`SecretStore::set` takes `&SecretBytes`, exposing the wrapped bytes to +the SPI's `set_secret(&[u8])` only at the last moment; no long-lived +unwrapped copy is allocated. + +### Backends + +- **File vault (`SecretStore::file` / `EncryptedFileStore`)** — Argon2id + (memory ≥ 19 MiB, t ≥ 2, p = 1; defaults 64 MiB / t=3; ceilings 1 GiB / + t=16 — header parameters above the ceiling are refused before any + derivation or allocation runs, so a crafted vault cannot force a + multi-GiB allocation or unbounded-time derivation) + XChaCha20-Poly1305 + AEAD with a random 24-byte XNonce per entry. AAD binds ciphertext to + `format_version ‖ wallet_id ‖ label` so a blob moved between slots + (or across wallets) fails the tag. A header-stored passphrase- + verification token is unsealed before any entry is touched + (mixed-key-corruption guard). The vault is ONE `serde_json` document + covering every wallet in the store — a single passphrase, a single + KDF salt, a single cross-process advisory lock (`.lock` + sidecar). Inside, entries are nested `BTreeMap>`. The file is written atomically via + `tempfile::NamedTempFile::persist` (cross-platform + replace-over-existing) at mode 0600 on Unix; rekey rotates the WHOLE + store under a fresh passphrase + salt atomically with no `.bak`. + One file, one passphrase, one lock — a multi-wallet + store cannot lock its other wallets out by construction. Errors + surface as the typed `SecretStoreError` through `SecretStore`. +- **OS keyring (`SecretStore::os` / `default_credential_store`)** — + returns an `Arc` over the + platform's default credential store. The backend on Linux/FreeBSD is + `dbus-secret-service-keyring-store`; on macOS + `apple-native-keyring-store`; on Windows + `windows-native-keyring-store`. Fail-closed with + `keyring_core::Error::NoDefaultStore` on headless / unknown OS + — never a silent plaintext fallback. Through + `SecretStore`, keyring failures project to + `SecretStoreError::OsKeyring { kind }`, a non-secret discriminant. + + **Headless caveat (Linux/FreeBSD).** Secret Service requires a D-Bus + session and an unlocked collection; headless / SSH / CI hosts + frequently lack it, in which case `SecretStore::os()` fails closed + with `NoDefaultStore`. Callers that need durable storage on a + headless host should pin `SecretStore::file(...)` (encrypted-file + vault) instead of relying on the OS keyring. +- **Tests** — integration tests construct a tempdir-backed + `EncryptedFileStore` directly via + `EncryptedFileStore::open(tempfile::tempdir()?.path().join("vault.pwsvault"), SecretString::new("..."))`, + or use the public `SecretStore::file(path, passphrase)` constructor. + No special feature flag is required; both are available under the default + `secrets` feature. + +Backend selection is an explicit operator decision; there is no +automatic fallback between backends. + +### Error surface + +`SecretStore` returns the typed `SecretStoreError`. For the file arm this +is **lossless**: `WrongPassphrase`, `Corruption`, `AlreadyLocked`, +`KdfFailure`, `VersionUnsupported`, `MalformedVault`, `InsecurePermissions`, +`VaultTooLarge`, and `InvalidLabel` are distinct typed variants +(`VaultTooLarge` surfaces when the on-disk vault exceeds the 128 MiB +ceiling). For the OS arm, +`keyring_core::Error` projects best-effort into +`SecretStoreError::OsKeyring { kind: OsKeyringErrorKind }`, a payload-free +discriminant — keyring variants carrying raw bytes (`BadEncoding`, +`BadDataFormat`) are collapsed so their bytes never enter the error +(CWE-209/CWE-532). + +The internal SPI projection `From for +keyring_core::Error` keeps the `WrongPassphrase` / `AlreadyLocked` variants +recoverable: they ride in `NoStorageAccess` with the typed +`SecretStoreError` boxed as the source, so an SPI-only consumer can recover +them via `err.source().and_then(|s| s.downcast_ref::())`. +The `BadStoreFormat` group (`Corruption`, `KdfFailure`, +`VersionUnsupported`, `MalformedVault`, `InsecurePermissions`, +`VaultTooLarge`, `Decrypt`, `OsKeyring`) has no box slot and carries only a +secret-free string; those remain fully typed on the `SecretStore` path +(so `VaultTooLarge` is not losslessly recoverable through the SPI downcast). + +`keyring_core::Error` is safe to `Display` (`{ }`-format), but +`{:?}`-format embeds `BadEncoding(Vec)` / `BadDataFormat(Vec, _)` +payloads — those variants are NEVER constructed by our backends with +secret bytes, and `tests/secrets_guard.rs` enforces that no debug-format +pairs with `keyring_core::Error` inside `src/secrets/`. + +## What the SQLite backend WILL refuse to store + +The `identity_keys` table is for **public** material only — DPP +public keys, public-key hashes, optional DIP-9 derivation breadcrumbs. +If a sub-changeset ever gains a `private_key_bytes`-style field, the +trait conversation must reopen: the persister boundary stays +secret-free. + +## Audit hooks + +- **`tests/secrets_scan.rs`**: greps every file under + `src/sqlite/schema/` and `migrations/` for the substrings `private`, + `mnemonic`, `seed`, `xpriv`, `secret`. A new column, blob field, or + comment that uses any of those words breaks the test — forcing the + author to either rename, or add their phrase to the file's + allow-list with a rationale. The `src/secrets/` directory is exempt + by design (its own positive guard below covers it). +- **`tests/secrets_guard.rs`**: positive secret-leak guard for + `src/secrets/`. Forbids logging/formatting sinks that pair with + `expose_secret(...)` on the same logical statement, AND forbids + `{:?}`-debug-format paired with `keyring_core::Error`. +- **`tests/secrets_api.rs`**: shape guards — `CredentialApi::get_secret` + re-wraps through `SecretBytes::new`, redacting `Debug` on + `SecretBytes`/`SecretString`, no `Box` in `src/secrets/`. +- **`tests/secrets_default_on_compiles.rs`**: build-time guard + (gated `#![cfg(feature = "secrets")]`) that the default feature set + exposes the secrets surface as public re-exports. It names + `EncryptedFileStore`, `SecretBytes`, `SecretString`, + `SecretStoreError`, `WalletId`, `SERVICE_PREFIX`, and + `default_credential_store` from the crate root; the body never + exercises a backend, so the proof is that it compiles. The negative + direction — `--no-default-features --features sqlite,cli` must build + the persister without the `secrets` module — is enforced by the + feature gate plus the CI off-state build, not by a test file. +- **`tests/sqlite_persist_roundtrip.rs::tc082_no_box_dyn_error_in_src`**: + all public method signatures use concrete error types + (`WalletStorageError`, `PersistenceError`) — never + `Box` — so a future leak is caught by `grep`. + +The CI advisory check runs `rustsec/audit-check` over `Cargo.lock`; +because `secrets` is in the default feature set, the pinned +`argon2` / `chacha20poly1305` / `zeroize` / `subtle` / `getrandom` +(the `OsRng` source for the salt + per-entry nonces, specified as the +exact pin `getrandom = "=0.2.17"`) / `region` / `keyring-core` / +per-platform store crate versions are unconditionally in the lockfile +and therefore unconditionally in audit scope. + +## Backup retention and secrets + +Manual / auto backups are byte-for-byte copies of the live DB. They +inherit the same "no secrets in the file" invariant. Operators may +still want to encrypt backups at rest using a file-system level tool +(GnuPG, age, encfs); this crate does not do that for them and never +ships SQLCipher. + +## Future work — maintenance CLI + +A unified `platform-wallet-storage secrets ` CLI is planned as a follow-up to give operators a way to inspect and manage the secret backends without writing custom code; it is tracked as a separate follow-up work item. Two commands matter: + +- **`secrets probe`** — set/get/delete a `__probe__` entry under `SERVICE_PREFIX`. Works uniformly on **all** backends (Secret Service, macOS Keychain, Windows Credential Manager) because it only uses single-entry CRUD. Confirms backend liveness + write-path responsiveness — the canary command for "is the keyring actually wired up on this machine?". Cheap to implement (~30 lines). +- **`secrets list [--filter ]`** — enumerate `(wallet_id, label)` pairs in the store. Trivial on the file vault (iterate the in-memory `BTreeMap`). On the OS arm: works on Secret Service, macOS Keychain, and Windows Credential Manager via `CredentialStoreApi::search`. Operators on headless Linux without a Secret Service session must select the file vault explicitly. + +Other planned subcommands: `secrets put