diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f549cc4..5aaec23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,47 @@ jobs: working-directory: contracts run: cargo test --workspace --locked + contracts-legacy: + name: legacy contract tests + canonical hash + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - uses: dtolnay/rust-toolchain@d1031067263f94b142dd6c0ce24c5eb9d02d52a0 # master + with: + toolchain: 1.89.0 + targets: wasm32v1-none + components: rustfmt + + - name: Check legacy contract formatting + working-directory: contracts-legacy + run: cargo fmt --all -- --check + + # The committed artifact is the upgrade target published in SECURITY.md + # and docs/legacy-wallet-upgrade.md. Its hash must not drift, and the + # end-to-end tests run against exactly those bytes. + - name: Verify the canonical artifact and run the tests + working-directory: contracts-legacy + run: make test + + # Informational: rebuild on this host and report. rustc's wasm codegen is + # host-dependent, so this hash is not expected to equal the canonical one; + # the step exists so a source change that silently alters the contract is + # visible in the log. + - name: Install stellar CLI 27.1.0 + run: | + set -euo pipefail + curl -fsSL -o stellar-cli.tar.gz \ + https://github.com/stellar/stellar-cli/releases/download/v27.1.0/stellar-cli-27.1.0-x86_64-unknown-linux-gnu.tar.gz + tar -xzf stellar-cli.tar.gz + sudo install -m 0755 stellar /usr/local/bin/stellar + + - name: Rebuild on this host (informational) + working-directory: contracts-legacy + run: | + stellar contract build --locked --package smart-wallet --out-dir rebuilt + shasum -a 256 rebuilt/smart_wallet.wasm out/smart_wallet.wasm + build: name: test & build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index c0210b4..bde665d 100644 --- a/.gitignore +++ b/.gitignore @@ -186,3 +186,11 @@ target/ ext/js-stellar-sdk/ # Local audit/review scratch — never committed docs/history/ + +# legacy-line contract workspace: generated test snapshots +contracts-legacy/smart-wallet/test_snapshots/ + + +# The canonical legacy-line artifact is committed (its hash is the published upgrade target). +!contracts-legacy/out/ +!contracts-legacy/out/smart_wallet.wasm diff --git a/CHANGELOG.md b/CHANGELOG.md index 240b8ef..82b3607 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to `passkey-kit` are recorded here. The `0.13.0` entry cover ## Unreleased +- **Added a safe in-place upgrade target for pre-fix legacy wallets.** New + `contracts-legacy/` workspace builds the last pre-1.0 wallet (`e45c42b9…`) + with signer reads that accept both pre-1.0 storage layouts, plus + `migrate_signers` and `get_signer`. Wallets on the four mainnet WASM hashes + whose `update_signer` lacks `require_auth` (`0c0a264d…`, `19868df3…`, + `b62f6221…`, `c5509dfa…`) can upgrade to + `c079d3a4136eb6ca68eb724acd3d8af11b0be4a0ed82605925a6dfd4dd83a97c` without + bricking. The artifact is committed at `contracts-legacy/out/smart_wallet.wasm` + and CI verifies its hash. Earlier post-fix builds brick the two bare-layout hashes. See + [`docs/legacy-wallet-upgrade.md`](./docs/legacy-wallet-upgrade.md) and the + new "Known-vulnerable wallet WASM hashes" section in `SECURITY.md`. The v1 + contract is unchanged. +- **Named legacy wallets on connect.** `connectWallet` now throws + `LegacyWalletError` (code `2006`, `WALLET_LEGACY_CODE`) when a candidate + wallet runs pre-1.0 code, before birth verification would fail it for a less + useful reason. `vulnerable: true` carries the in-place upgrade target and the + guide URL; `vulnerable: false` points at the 0.10.20–0.12.x kit line. The + constructor rejects a known-vulnerable `walletWasmHash` or accepted hash. + New exports: `LegacyWalletError`, `KNOWN_VULNERABLE_WALLET_WASM_HASHES`, + `LEGACY_WALLET_WASM_HASHES`, `LEGACY_UPGRADE_TARGET_WASM_HASH`, + `LEGACY_WALLET_UPGRADE_GUIDE_URL`. - **Updated the relayer development image dependency.** The standalone relayer lock now requires `sharp@0.35.4`, which removes the development-only `GHSA-rgj7-g3m4-5g8c` alert. This change does not affect the published package diff --git a/README.md b/README.md index dd06b8f..1d1af5c 100644 --- a/README.md +++ b/README.md @@ -580,6 +580,7 @@ pnpm verify:bindings # assert the committed bindings match the canonical WASM - **[CHANGELOG.md](./CHANGELOG.md)** — the v1 overhaul, by component. - **[SECURITY.md](./SECURITY.md)** — supported versions and private vulnerability reporting. - **[docs/migration-v1.md](./docs/migration-v1.md)** — upgrading from 0.12.x, with Before/After and a gap analysis. +- **[docs/legacy-wallet-upgrade.md](./docs/legacy-wallet-upgrade.md)** — in-place upgrade for pre-fix legacy wallets (known-vulnerable WASM hashes in [SECURITY.md](./SECURITY.md)). - **[docs/releasing.md](./docs/releasing.md)** — the dependency-ordered publish flow. ## Resources diff --git a/SECURITY.md b/SECURITY.md index bed06a4..bf903f6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,6 +14,25 @@ Only the latest npm releases and the canonical smart-wallet WASM receive securit The current [`deployment manifest`](./docs/deployments-2026-09-01.md) identifies the canonical WASM. Existing wallet instances do not upgrade automatically. +## Known-vulnerable wallet WASM hashes + +Wallets deployed from the following mainnet WASM hashes run code in which `update_signer` has no authorization check. Anyone who knows a signer key can overwrite that signer. The defect was fixed in source in commit [`dcc6e3dc9c`](https://github.com/kalepail/passkey-kit/commit/dcc6e3dc9cfd32e64b98f23541cd2d96812b64c4) on 2025-03-27. Deployed wallets keep the old code until their owner upgrades them. + +| WASM hash | Storage layout | +|---|---| +| `0c0a264d4cc0b3e79b8533e2a2e1f0ed21501a5a3f9f2455d2f18c232940b865` | bare (pre-`6a27d48`) | +| `19868df3653d427cafa1c30bdb6cec1ca5c8c815eeabab8a8bae6d83efb1fedd` | bare (pre-`6a27d48`) | +| `b62f62217ff256d557513793e9e44317b25b14401a8a6b6149a04d38d72d6c7c` | wrapped | +| `c5509dfa5f022deb8ae621f073adac5fd788feca0b24d15b5f392ab22c2ff222` | wrapped (not built from this repo) | + +Do not deploy new wallets from these hashes, or from any Makefile or `.env` pin older than commit `da472f9`. + +Upgrade affected wallets in place to the legacy-line build `c079d3a4136eb6ca68eb724acd3d8af11b0be4a0ed82605925a6dfd4dd83a97c`, built from [`contracts-legacy/`](./contracts-legacy). It reads both storage layouts. The earlier post-fix builds `ecd990f0…` and `e45c42b9…` and the v1 build read only the wrapped layout and brick a bare-layout wallet. See [`docs/legacy-wallet-upgrade.md`](./docs/legacy-wallet-upgrade.md) for the procedure. + +Move funds out of any affected wallet you do not intend to upgrade. + +The current SDK refuses to deploy from these hashes and throws `LegacyWalletError` with the upgrade guidance when `connectWallet` meets a wallet running one of them. + ## Cargo advisory status The lockfile retains `serde_with@3.14.0` and its inactive `time@0.3.41` optional dependency. diff --git a/contracts-legacy/Cargo.lock b/contracts-legacy/Cargo.lock new file mode 100644 index 0000000..49a64cd --- /dev/null +++ b/contracts-legacy/Cargo.lock @@ -0,0 +1,1718 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "ark-bls12-381" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c775f0d12169cba7aae4caeb547bb6a50781c7449a8aa53793827c9ec4abf488" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest", + "itertools", + "num-bigint", + "num-traits", + "paste", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes-lit" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0adabf37211a5276e46335feabcbb1530c95eb3fdf85f324c7db942770aa025d" +dependencies = [ + "num-bigint", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "cc" +version = "1.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "cfg_eval" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crate-git-revision" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c521bf1f43d31ed2f73441775ed31935d77901cb3451e44b38a1c1612fcbaf98" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.106", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "escape-bytes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" + +[[package]] +name = "ethnum" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +dependencies = [ + "equivalent", + "hashbrown 0.15.5", + "serde", +] + +[[package]] +name = "indexmap-nostd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.106", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-json-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b81787e655bd59cecadc91f7b6b8651330b2be6c33246039a65e5cd6f4e0828" +dependencies = [ + "ryu", + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.0", + "schemars 0.8.22", + "schemars 0.9.0", + "schemars 1.0.4", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smart-wallet" +version = "0.5.1" +dependencies = [ + "ed25519-dalek", + "serde", + "serde-json-core", + "smart-wallet-interface", + "soroban-sdk", +] + +[[package]] +name = "smart-wallet-interface" +version = "0.5.0" +dependencies = [ + "soroban-sdk", +] + +[[package]] +name = "soroban-builtin-sdk-macros" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9336adeabcd6f636a4e0889c8baf494658ef5a3c4e7e227569acd2ce9091e85" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "soroban-env-common" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00067f52e8bbf1abf0de03fe3e2fbb06910893cfbe9a7d9093d6425658833ff3" +dependencies = [ + "arbitrary", + "crate-git-revision", + "ethnum", + "num-derive", + "num-traits", + "serde", + "soroban-env-macros", + "soroban-wasmi", + "static_assertions", + "stellar-xdr", + "wasmparser", +] + +[[package]] +name = "soroban-env-guest" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd1e40963517b10963a8e404348d3fe6caf9c278ac47a6effd48771297374d6" +dependencies = [ + "soroban-env-common", + "static_assertions", +] + +[[package]] +name = "soroban-env-host" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9766c5ad78e9d8ae10afbc076301f7d610c16407a1ebb230766dbe007a48725" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-serialize", + "curve25519-dalek", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "generic-array", + "getrandom", + "hex-literal", + "hmac", + "k256", + "num-derive", + "num-integer", + "num-traits", + "p256", + "rand", + "rand_chacha", + "sec1", + "sha2", + "sha3", + "soroban-builtin-sdk-macros", + "soroban-env-common", + "soroban-wasmi", + "static_assertions", + "stellar-strkey", + "wasmparser", +] + +[[package]] +name = "soroban-env-macros" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0e6a1c5844257ce96f5f54ef976035d5bd0ee6edefaf9f5e0bcb8ea4b34228c" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "serde", + "serde_json", + "stellar-xdr", + "syn 2.0.106", +] + +[[package]] +name = "soroban-ledger-snapshot" +version = "23.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3823372b72cab2e7ff2ced62bbffa11fce8da0713a224f122141558cab174647" +dependencies = [ + "serde", + "serde_json", + "serde_with", + "soroban-env-common", + "soroban-env-host", + "thiserror", +] + +[[package]] +name = "soroban-sdk" +version = "23.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af0e5bf6702f5952d78c5b2bcd05b0349f9a570cc62028d90dac3710b40cbe65" +dependencies = [ + "arbitrary", + "bytes-lit", + "crate-git-revision", + "ctor", + "derive_arbitrary", + "ed25519-dalek", + "rand", + "rustc_version", + "serde", + "serde_json", + "soroban-env-guest", + "soroban-env-host", + "soroban-ledger-snapshot", + "soroban-sdk-macros", + "stellar-strkey", +] + +[[package]] +name = "soroban-sdk-macros" +version = "23.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38abe20199c5d9fbff232381aa4e8e83302b34e82e38fbb090f41f1284fc920" +dependencies = [ + "darling", + "heck", + "itertools", + "macro-string", + "proc-macro2", + "quote", + "sha2", + "soroban-env-common", + "soroban-spec", + "soroban-spec-rust", + "stellar-xdr", + "syn 2.0.106", +] + +[[package]] +name = "soroban-spec" +version = "23.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72526d30f8825b859afa7e0b94549dad05c58a6c928b0763620412744512d7e2" +dependencies = [ + "base64", + "stellar-xdr", + "thiserror", + "wasmparser", +] + +[[package]] +name = "soroban-spec-rust" +version = "23.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9088cb8307dad026cda494971c4f13c76f9427ab26becb7cd691da95dc5e9b1d" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "sha2", + "soroban-spec", + "stellar-xdr", + "syn 2.0.106", + "thiserror", +] + +[[package]] +name = "soroban-wasmi" +version = "0.31.1-soroban.20.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710403de32d0e0c35375518cb995d4fc056d0d48966f2e56ea471b8cb8fc9719" +dependencies = [ + "smallvec", + "spin", + "wasmi_arena", + "wasmi_core", + "wasmparser-nostd", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stellar-strkey" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee1832fb50c651ad10f734aaf5d31ca5acdfb197a6ecda64d93fcdb8885af913" +dependencies = [ + "crate-git-revision", + "data-encoding", +] + +[[package]] +name = "stellar-xdr" +version = "23.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89d2848e1694b0c8db81fd812bfab5ea71ee28073e09ccc45620ef3cf7a75a9b" +dependencies = [ + "arbitrary", + "base64", + "cfg_eval", + "crate-git-revision", + "escape-bytes", + "ethnum", + "hex", + "serde", + "serde_with", + "sha2", + "stellar-strkey", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmi_arena" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "104a7f73be44570cac297b3035d76b169d6599637631cf37a1703326a0727073" + +[[package]] +name = "wasmi_core" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a" +dependencies = [ + "downcast-rs", + "libm", + "num-traits", + "paste", +] + +[[package]] +name = "wasmparser" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" +dependencies = [ + "indexmap 2.11.0", + "semver", +] + +[[package]] +name = "wasmparser-nostd" +version = "0.100.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa" +dependencies = [ + "indexmap-nostd", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] diff --git a/contracts-legacy/Cargo.toml b/contracts-legacy/Cargo.toml new file mode 100644 index 0000000..5cd5e83 --- /dev/null +++ b/contracts-legacy/Cargo.toml @@ -0,0 +1,29 @@ +# Legacy-line smart-wallet workspace. +# +# This workspace is deliberately separate from `contracts/` (the v1 line, +# soroban-sdk 27). It pins soroban-sdk 23 and Rust 1.89 so that the produced +# WASM stays as close as possible to the last legacy canonical build +# (`e45c42b9…`, commit aeb04d7). Its only purpose is a safe in-place upgrade +# target for pre-1.0 wallets that still run a WASM without `require_auth` in +# `update_signer`. See `docs/legacy-wallet-upgrade.md`. +[workspace] +resolver = "2" + +members = ["smart-wallet", "smart-wallet-interface"] + +[workspace.dependencies] +soroban-sdk = "23.0" +serde = { version = "1.0", default-features = false } +serde-json-core = { version = "0.6", default-features = false } +ed25519-dalek = { version = "2.1" } +smart-wallet-interface = { path = "smart-wallet-interface" } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true diff --git a/contracts-legacy/Makefile b/contracts-legacy/Makefile new file mode 100644 index 0000000..aef26bd --- /dev/null +++ b/contracts-legacy/Makefile @@ -0,0 +1,33 @@ +# Legacy-line smart wallet. Separate workspace from ../contracts (v1). +# +# out/smart_wallet.wasm is the CANONICAL artifact and is committed. Its sha256 +# is the published upgrade-target hash. It was built with the toolchain in +# ./rust-toolchain.toml (Rust 1.89, wasm32v1-none) and stellar CLI 27.1.0 on +# macOS aarch64. `stellar contract build` remaps source paths, but rustc's +# wasm codegen still differs between host platforms, so another host produces +# a functionally equivalent artifact with a different hash. Do not overwrite +# out/ unless you intend to publish a new canonical hash. + +WASM = out/smart_wallet.wasm +CANONICAL_SHA256 = c079d3a4136eb6ca68eb724acd3d8af11b0be4a0ed82605925a6dfd4dd83a97c + +.PHONY: build verify test fmt clean + +# Rebuilds the artifact in place. Compare with `make verify` afterwards. +build: + stellar contract build --locked --package smart-wallet --out-dir out + +# Asserts the committed artifact is the canonical one. +verify: + @echo "$(CANONICAL_SHA256) $(WASM)" | shasum -a 256 -c - + +# The end-to-end tests upgrade real pre-fix wallets (the mainnet WASM under +# smart-wallet/src/tests/fixtures) to the committed canonical artifact. +test: verify + cargo test --locked -p smart-wallet -- --nocapture + +fmt: + cargo fmt --all + +clean: + cargo clean diff --git a/contracts-legacy/out/smart_wallet.wasm b/contracts-legacy/out/smart_wallet.wasm new file mode 100644 index 0000000..8cb1c8a Binary files /dev/null and b/contracts-legacy/out/smart_wallet.wasm differ diff --git a/contracts-legacy/rust-toolchain.toml b/contracts-legacy/rust-toolchain.toml new file mode 100644 index 0000000..29a0c9a --- /dev/null +++ b/contracts-legacy/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.89" +targets = ["wasm32v1-none"] +components = ["rustc", "cargo", "rustfmt", "clippy", "rust-src"] \ No newline at end of file diff --git a/contracts-legacy/smart-wallet-interface/Cargo.toml b/contracts-legacy/smart-wallet-interface/Cargo.toml new file mode 100644 index 0000000..c3b086a --- /dev/null +++ b/contracts-legacy/smart-wallet-interface/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "smart-wallet-interface" +version = "0.5.0" +authors = ["Stellar Development Foundation "] +license = "Apache-2.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[features] +testutils = [] + +[dependencies] +soroban-sdk = { workspace = true } \ No newline at end of file diff --git a/contracts-legacy/smart-wallet-interface/src/lib.rs b/contracts-legacy/smart-wallet-interface/src/lib.rs new file mode 100644 index 0000000..8d4927a --- /dev/null +++ b/contracts-legacy/smart-wallet-interface/src/lib.rs @@ -0,0 +1,20 @@ +#![no_std] + +use soroban_sdk::{auth::Context, contractclient, Address, BytesN, Env, Vec}; +use types::{Signer, SignerKey}; + +pub mod types; + +#[contractclient(name = "SmartWalletClient")] +pub trait SmartWalletInterface { + fn __constructor(env: Env, signer: Signer); + fn add_signer(env: Env, signer: Signer); + fn update_signer(env: Env, signer: Signer); + fn remove_signer(env: Env, signer_key: SignerKey); + fn update_contract_code(env: Env, hash: BytesN<32>); +} + +#[contractclient(name = "PolicyClient")] +pub trait PolicyInterface { + fn policy__(env: Env, source: Address, signer: SignerKey, contexts: Vec); +} diff --git a/contracts-legacy/smart-wallet-interface/src/types.rs b/contracts-legacy/smart-wallet-interface/src/types.rs new file mode 100644 index 0000000..b6601b7 --- /dev/null +++ b/contracts-legacy/smart-wallet-interface/src/types.rs @@ -0,0 +1,117 @@ +use soroban_sdk::{contracterror, contracttype, Address, Bytes, BytesN, Map, Vec}; + +#[contracterror(export = false)] +#[derive(Copy, Clone, Debug, PartialEq)] +#[repr(u32)] +pub enum Error { + NotFound = 1, + AlreadyExists = 2, + MissingContext = 3, + SignerExpired = 4, + FailedSignerLimits = 5, + FailedPolicySignerLimits = 6, + SignatureKeyValueMismatch = 7, + ClientDataJsonChallengeIncorrect = 8, + JsonParseError = 9, +} + +#[contracttype(export = false)] +#[derive(Clone, Debug, PartialEq)] +pub struct SignerExpiration(pub Option); + +#[contracttype(export = false)] +#[derive(Clone, Debug, PartialEq)] +// Map of contexts this signer can authorize if present in the __check_auth auth_contexts list +// Map value is a list of SignerKeys which must all be present in the __check_auth signatures list in order for the signer to authorize the context +// e.g. a policy runs on a SAC token to check how much it's withdrawing and also requires a signature from an additional ed25519 signer +// e.g. an ed25519 signer can only be used to authorize a specific contract's invocations and no further keys are required +pub struct SignerLimits(pub Option>>>); + +#[contracttype(export = false)] +#[derive(Clone, Debug, PartialEq)] +pub enum SignerStorage { + Persistent, + Temporary, +} + +#[contracttype(export = false)] +#[derive(Clone, Debug, PartialEq)] +pub enum Signer { + Policy(Address, SignerExpiration, SignerLimits, SignerStorage), + Ed25519(BytesN<32>, SignerExpiration, SignerLimits, SignerStorage), + Secp256r1( + Bytes, + BytesN<65>, + SignerExpiration, + SignerLimits, + SignerStorage, + ), +} + +#[contracttype(export = false)] +#[derive(Clone, Debug, PartialEq)] +pub enum SignerKey { + Policy(Address), + Ed25519(BytesN<32>), + Secp256r1(Bytes), +} + +#[contracttype(export = false)] +#[derive(Clone, Debug, PartialEq)] +pub enum SignerVal { + Policy(SignerExpiration, SignerLimits), + Ed25519(SignerExpiration, SignerLimits), + Secp256r1(BytesN<65>, SignerExpiration, SignerLimits), +} + +#[contracttype(export = false)] +#[derive(Clone, Debug, PartialEq)] +pub struct Secp256r1Signature { + pub authenticator_data: Bytes, + pub client_data_json: Bytes, + pub signature: BytesN<64>, +} + +#[contracttype(export = false)] +#[derive(Clone, Debug, PartialEq)] +pub enum Signature { + Policy, + Ed25519(BytesN<64>), + Secp256r1(Secp256r1Signature), +} + +#[contracttype(export = false)] +#[derive(Clone, Debug, PartialEq)] +pub struct Signatures(pub Map); + +/// The on-chain `SignerVal` layout used by wallets built from source older +/// than commit `6a27d48` (2024-12-13): mainnet WASM `0c0a264d…` and +/// `19868df3…`. Expiration was a bare `Option` (encoded `void`/`u32`) +/// and limits were a bare `Map` (encoded as the same one-element vec that +/// `SignerLimits(Some(map))` produces today). Only the expiration differs, +/// so a stored value of this shape fails to decode as `SignerVal` and a +/// stored `SignerVal` fails to decode as this shape. The wallet reads both and +/// only ever writes `SignerVal`. Not exported to the contract spec. +#[contracttype(export = false)] +#[derive(Clone, Debug, PartialEq)] +pub enum LegacySignerVal { + Policy(Option, SignerLimits), + Ed25519(Option, SignerLimits), + Secp256r1(BytesN<65>, Option, SignerLimits), +} + +impl From for SignerVal { + fn from(legacy: LegacySignerVal) -> Self { + match legacy { + LegacySignerVal::Policy(exp, limits) => { + SignerVal::Policy(SignerExpiration(exp), limits) + } + LegacySignerVal::Ed25519(exp, limits) => { + SignerVal::Ed25519(SignerExpiration(exp), limits) + } + LegacySignerVal::Secp256r1(pk, exp, limits) => { + SignerVal::Secp256r1(pk, SignerExpiration(exp), limits) + } + } + } +} diff --git a/contracts-legacy/smart-wallet/Cargo.toml b/contracts-legacy/smart-wallet/Cargo.toml new file mode 100644 index 0000000..2f03385 --- /dev/null +++ b/contracts-legacy/smart-wallet/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "smart-wallet" +version = "0.5.1" +authors = ["Stellar Development Foundation "] +license = "Apache-2.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[features] +testutils = [] + +[dependencies] +soroban-sdk = { workspace = true } +smart-wallet-interface = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde-json-core = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +ed25519-dalek = { workspace = true } diff --git a/contracts-legacy/smart-wallet/src/base64_url.rs b/contracts-legacy/smart-wallet/src/base64_url.rs new file mode 100644 index 0000000..76c2da7 --- /dev/null +++ b/contracts-legacy/smart-wallet/src/base64_url.rs @@ -0,0 +1,73 @@ +// Ported from https://github.com/golang/go/blob/26b5783b72376acd0386f78295e678b9a6bff30e/src/encoding/base64/base64.go#L53-L192 +// +// Modifications: +// * Removed logic supporting padding. +// * Hardcoded the Base64 URL alphabet. +// * Use a fixed length pre-allocated destination. +// * Ported to Rust. +// +// Original Copyright notice: +// +// Copyright (c) 2009 The Go Authors. All rights reserved. +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +pub fn encode(dst: &mut [u8], src: &[u8]) { + let mut di: usize = 0; + let mut si: usize = 0; + let n = (src.len() / 3) * 3; // (.. / 3 * 3) to ensure a % 3 `n` + + while si < n { + let val = (src[si] as usize) << 16 | (src[si + 1] as usize) << 8 | (src[si + 2] as usize); + dst[di] = ALPHABET[val >> 18 & 0x3F]; + dst[di + 1] = ALPHABET[val >> 12 & 0x3F]; + dst[di + 2] = ALPHABET[val >> 6 & 0x3F]; + dst[di + 3] = ALPHABET[val & 0x3F]; + si += 3; + di += 4; + } + + let remain = src.len() - si; + + if remain == 0 { + return; + } + + let mut val = (src[si] as usize) << 16; + + if remain == 2 { + val |= (src[si + 1] as usize) << 8; + } + + dst[di] = ALPHABET[val >> 18 & 0x3F]; + dst[di + 1] = ALPHABET[val >> 12 & 0x3F]; + + if remain == 2 { + dst[di + 2] = ALPHABET[val >> 6 & 0x3F]; + } +} diff --git a/contracts-legacy/smart-wallet/src/context.rs b/contracts-legacy/smart-wallet/src/context.rs new file mode 100644 index 0000000..5bbec68 --- /dev/null +++ b/contracts-legacy/smart-wallet/src/context.rs @@ -0,0 +1,91 @@ +use smart_wallet_interface::types::{Signatures, SignerKey, SignerLimits}; +use soroban_sdk::{ + auth::{Context, ContractContext}, + Env, FromVal, Symbol, +}; + +use crate::signer::verify_signer_limit_keys; + +pub fn verify_context( + env: &Env, + context: &Context, + signer_key: &SignerKey, + signer_limits: &SignerLimits, + signatures: &Signatures, +) -> bool { + match &signer_limits.0 { + None => true, // Signer has no limits, it can do anything + Some(signer_limits) => { + if signer_limits.is_empty() { + return true; + } + + match context { + Context::Contract(ContractContext { + contract, + fn_name, + args, + }) => { + match signer_limits.get(contract.clone()) { + None => false, // signer limitations not met + Some(signer_limits_keys) => { + // If this signer has a smart wallet context limit, limit that context to only removing itself + if *contract == env.current_contract_address() + && *fn_name != Symbol::new(&env, "remove_signer") + || (*fn_name == Symbol::new(&env, "remove_signer") + && SignerKey::from_val(env, &args.get_unchecked(0)) + != *signer_key) + { + return false; // self trying to do something other than remove itself + } + + verify_signer_limit_keys( + env, + signer_key, + signatures, + &signer_limits_keys, + &context, + ); + + true + } + } + } + Context::CreateContractHostFn(_) => { + // Only signers with the smart wallet context signer limit can deploy contracts + match signer_limits.get(env.current_contract_address()) { + None => false, // signer limitations not met + Some(signer_limits_keys) => { + verify_signer_limit_keys( + env, + signer_key, + signatures, + &signer_limits_keys, + &context, + ); + + true + } + } + } + Context::CreateContractWithCtorHostFn(_) => { + // Only signers with the smart wallet context signer limit can deploy contracts + match signer_limits.get(env.current_contract_address()) { + None => false, // signer limitations not met + Some(signer_limits_keys) => { + verify_signer_limit_keys( + env, + signer_key, + signatures, + &signer_limits_keys, + &context, + ); + + true + } + } + } + } + } + } +} diff --git a/contracts-legacy/smart-wallet/src/lib.rs b/contracts-legacy/smart-wallet/src/lib.rs new file mode 100644 index 0000000..2f64a81 --- /dev/null +++ b/contracts-legacy/smart-wallet/src/lib.rs @@ -0,0 +1,259 @@ +//! Legacy-line smart wallet: the last pre-1.0 contract (`e45c42b9…`, commit +//! aeb04d7) plus two additions that make it a safe in-place upgrade target for +//! every pre-fix wallet still on mainnet: +//! +//! 1. Signer reads accept both pre-1.0 storage layouts (see +//! `LegacySignerVal`). Wallets built before commit `6a27d48` (2024-12-13) +//! store a different encoding that every later build fails to decode, which +//! is why an upgrade to `ecd990f0…`/`e45c42b9…` bricks them. +//! 2. `migrate_signers` re-encodes listed entries into the current layout, and +//! `get_signer` exposes a read so tooling can verify the result. +//! +//! Everything else, including `require_auth` on `update_signer` (the fix from +//! commit dcc6e3dc9c), the `sw_v1` events, the error codes, and the +//! ledger-sequence expiration semantics, is unchanged from `e45c42b9…`. +#![no_std] + +use context::verify_context; +use signer::{ + get_signer_val_storage, normalize_signer, process_signer, store_signer, + verify_signer_expiration, +}; +use smart_wallet_interface::{ + types::{Error, Signature, Signatures, Signer, SignerKey, SignerStorage, SignerVal}, + PolicyClient, SmartWalletInterface, +}; +use soroban_sdk::{ + auth::{Context, CustomAccountInterface}, + contract, contractimpl, + crypto::Hash, + panic_with_error, symbol_short, BytesN, Env, Symbol, Vec, +}; +use storage::extend_instance; +use verify::verify_secp256r1_signature; + +mod base64_url; +mod context; +mod signer; +mod storage; +mod types; +mod verify; + +#[cfg(test)] +mod tests; + +#[contract] +pub struct Contract; + +const EVENT_TAG: Symbol = symbol_short!("sw_v1"); +const INITIALIZED: Symbol = symbol_short!("init"); + +#[contractimpl] +impl SmartWalletInterface for Contract { + fn __constructor(env: Env, signer: Signer) { + Self::add_signer(env, signer); + } + fn add_signer(env: Env, signer: Signer) { + if env + .storage() + .instance() + .get::(&INITIALIZED) + .unwrap_or(false) + { + env.current_contract_address().require_auth(); + } else { + env.storage() + .instance() + .set::(&INITIALIZED, &true); + } + + let (signer_key, signer_val, signer_storage) = process_signer(signer); + + store_signer(&env, &signer_key, &signer_val, &signer_storage, false); + + extend_instance(&env); + + env.events().publish( + (EVENT_TAG, symbol_short!("add"), signer_key), + (signer_val, signer_storage), + ); + } + fn update_signer(env: Env, signer: Signer) { + env.current_contract_address().require_auth(); + + let (signer_key, signer_val, signer_storage) = process_signer(signer); + + store_signer(&env, &signer_key, &signer_val, &signer_storage, true); + + extend_instance(&env); + + env.events().publish( + (EVENT_TAG, symbol_short!("update"), signer_key), + (signer_val, signer_storage), + ); + } + fn remove_signer(env: Env, signer_key: SignerKey) { + env.current_contract_address().require_auth(); + + match get_signer_val_storage(&env, &signer_key, false) { + Some((_, signer_storage)) => match signer_storage { + SignerStorage::Persistent => { + env.storage().persistent().remove::(&signer_key); + } + SignerStorage::Temporary => { + env.storage().temporary().remove::(&signer_key); + } + }, + None => panic_with_error!(env, Error::NotFound), + } + + extend_instance(&env); + + env.events() + .publish((EVENT_TAG, symbol_short!("remove"), signer_key), ()); + } + fn update_contract_code(env: Env, hash: BytesN<32>) { + env.current_contract_address().require_auth(); + + env.deployer().update_current_contract_wasm(hash); + + extend_instance(&env); + } +} + +#[contractimpl] +impl Contract { + /// Re-encode the listed signer entries from the pre-`6a27d48` storage + /// layout into the current one. Returns how many entries were rewritten. + /// + /// Deliberately unauthenticated: the rewrite is value-preserving (same + /// key, public key, expiration, limits, and durability), so there is + /// nothing for a third party to gain, and it lets an operator normalize + /// wallets in bulk without each owner's passkey. Reads are tolerant of + /// both layouts anyway; this exists so a wallet can later move to a + /// strict build (`ecd990f0…`, `e45c42b9…`, or v1) without decode failures. + /// Keys that are absent or already current are skipped. Soroban has no + /// storage iteration, so the caller supplies the keys (the `sw_v1` events + /// or the passkey indexer list them). + pub fn migrate_signers(env: Env, signer_keys: Vec) -> u32 { + let mut migrated: u32 = 0; + + for signer_key in signer_keys.iter() { + if normalize_signer(&env, &signer_key) { + migrated += 1; + } + } + + extend_instance(&env); + + migrated + } + + /// Read a signer entry in the current layout, whatever layout it is + /// stored in. `None` when no entry exists under the key. + pub fn get_signer(env: Env, signer_key: SignerKey) -> Option { + get_signer_val_storage(&env, &signer_key, false).map(|(signer_val, _)| signer_val) + } +} + +#[contractimpl] +impl CustomAccountInterface for Contract { + type Error = Error; + type Signature = Signatures; + + #[allow(non_snake_case)] + fn __check_auth( + env: Env, + signature_payload: Hash<32>, + signatures: Signatures, + auth_contexts: Vec, + ) -> Result<(), Error> { + // Check all contexts for an authorizing signature + for context in auth_contexts.iter() { + 'check: loop { + for (signer_key, _) in signatures.0.iter() { + if let Some((signer_val, _)) = get_signer_val_storage(&env, &signer_key, false) + { + let (signer_expiration, signer_limits) = match signer_val { + SignerVal::Policy(signer_expiration, signer_limits) => { + (signer_expiration, signer_limits) + } + SignerVal::Ed25519(signer_expiration, signer_limits) => { + (signer_expiration, signer_limits) + } + SignerVal::Secp256r1(_, signer_expiration, signer_limits) => { + (signer_expiration, signer_limits) + } + }; + + verify_signer_expiration(&env, signer_expiration); + + if verify_context(&env, &context, &signer_key, &signer_limits, &signatures) + { + break 'check; + } else { + continue; + } + } + } + + panic_with_error!(env, Error::MissingContext); + } + } + + // Check all signatures for a matching context + for (signer_key, signature) in signatures.0.iter() { + // This is probably the only right place to verify_signer_expiration for crypto keys + + match get_signer_val_storage(&env, &signer_key, true) { + None => panic_with_error!(env, Error::NotFound), + Some((signer_val, _)) => { + match signature { + Signature::Policy => { + // If there's a policy signer in the signatures map we call it as a full forward of this __check_auth's Vec + if let SignerKey::Policy(policy) = &signer_key { + PolicyClient::new(&env, policy).policy__( + &env.current_contract_address(), + &signer_key, + &auth_contexts, + ); + continue; + } + + panic_with_error!(&env, Error::SignatureKeyValueMismatch) + } + Signature::Ed25519(signature) => { + if let SignerKey::Ed25519(public_key) = &signer_key { + env.crypto().ed25519_verify( + &public_key, + &signature_payload.clone().into(), + &signature, + ); + continue; + } + + panic_with_error!(&env, Error::SignatureKeyValueMismatch) + } + Signature::Secp256r1(signature) => { + if let SignerVal::Secp256r1(public_key, _, _) = signer_val { + verify_secp256r1_signature( + &env, + &signature_payload, + &public_key, + signature, + ); + continue; + } + + panic_with_error!(&env, Error::SignatureKeyValueMismatch) + } + } + } + }; + } + + extend_instance(&env); + + Ok(()) + } +} diff --git a/contracts-legacy/smart-wallet/src/signer.rs b/contracts-legacy/smart-wallet/src/signer.rs new file mode 100644 index 0000000..7fbf0ba --- /dev/null +++ b/contracts-legacy/smart-wallet/src/signer.rs @@ -0,0 +1,237 @@ +use smart_wallet_interface::{ + types::{ + Error, LegacySignerVal, Signatures, Signer, SignerExpiration, SignerKey, SignerStorage, + SignerVal, + }, + PolicyClient, +}; +use soroban_sdk::{auth::Context, panic_with_error, vec, Env, TryFromVal, Val, Vec}; + +use crate::{context::verify_context, storage::extend_signer_key}; + +pub fn process_signer(signer: Signer) -> (SignerKey, SignerVal, SignerStorage) { + match signer { + Signer::Policy(policy, signer_expiration, signer_limits, signer_storage) => ( + SignerKey::Policy(policy), + SignerVal::Policy(signer_expiration, signer_limits), + signer_storage, + ), + Signer::Ed25519(public_key, signer_expiration, signer_limits, signer_storage) => ( + SignerKey::Ed25519(public_key), + SignerVal::Ed25519(signer_expiration, signer_limits), + signer_storage, + ), + Signer::Secp256r1(id, public_key, signer_expiration, signer_limits, signer_storage) => ( + SignerKey::Secp256r1(id), + SignerVal::Secp256r1(public_key, signer_expiration, signer_limits), + signer_storage, + ), + } +} + +pub fn store_signer( + env: &Env, + signer_key: &SignerKey, + signer_val: &SignerVal, + signer_storage: &SignerStorage, + update: bool, +) { + // Include this before the `.set` calls so it doesn't read them as previous values + let previous_signer_val_and_storage: Option<(SignerVal, SignerStorage)> = + get_signer_val_storage(env, signer_key, false); + + // Add and extend the signer key in the appropriate storage + let is_persistent = match signer_storage { + SignerStorage::Persistent => { + env.storage() + .persistent() + .set::(signer_key, signer_val); + + true + } + SignerStorage::Temporary => { + env.storage() + .temporary() + .set::(signer_key, signer_val); + + false + } + }; + + extend_signer_key(env, signer_key, is_persistent); + + match previous_signer_val_and_storage { + Some((_, previous_signer_storage)) => { + // Panic if the signer key already exists and we're not update it + if !update { + panic_with_error!(env, Error::AlreadyExists); + } + + // Remove signer key in the opposing storage if it exists + match previous_signer_storage { + SignerStorage::Persistent => { + if !is_persistent { + env.storage().persistent().remove::(signer_key); + } + } + SignerStorage::Temporary => { + if is_persistent { + env.storage().temporary().remove::(signer_key); + } + } + } + } + None => { + // Panic if we're update a signer key that doesn't exist + if update { + panic_with_error!(env, Error::NotFound); + } + } + } +} + +/// Decode a stored signer value written by any pre-1.0 build. +/// +/// Returns the value plus whether it was stored in the legacy (pre-`6a27d48`) +/// layout. Neither shape decodes as the other, so the order of attempts does +/// not matter for correctness; the current shape is tried first because it is +/// the common case after migration. +fn decode_signer_val(env: &Env, raw: &Val) -> Option<(SignerVal, bool)> { + if let Ok(val) = SignerVal::try_from_val(env, raw) { + return Some((val, false)); + } + if let Ok(legacy) = LegacySignerVal::try_from_val(env, raw) { + return Some((legacy.into(), true)); + } + None +} + +/// Read a signer entry from one durability, tolerating both layouts. +fn read_signer_val( + env: &Env, + signer_key: &SignerKey, + persistent: bool, +) -> Option<(SignerVal, bool)> { + let raw: Option = if persistent { + env.storage().persistent().get::(signer_key) + } else { + env.storage().temporary().get::(signer_key) + }; + + match raw { + None => None, + Some(raw) => match decode_signer_val(env, &raw) { + Some(decoded) => Some(decoded), + // An entry exists under this key but is not a signer value of any + // known shape. Treat it as corrupt rather than as absent. + None => panic_with_error!(env, Error::NotFound), + }, + } +} + +pub fn get_signer_val_storage( + env: &Env, + signer_key: &SignerKey, + extend_ttl: bool, +) -> Option<(SignerVal, SignerStorage)> { + match read_signer_val(env, signer_key, false) { + Some((signer_val, _)) => { + if extend_ttl { + extend_signer_key(env, signer_key, false); + } + + Some((signer_val, SignerStorage::Temporary)) + } + None => match read_signer_val(env, signer_key, true) { + Some((signer_val, _)) => { + if extend_ttl { + extend_signer_key(env, signer_key, true); + } + + Some((signer_val, SignerStorage::Persistent)) + } + None => None, + }, + } +} + +/// Rewrite a signer entry stored in the legacy layout as a current +/// `SignerVal`, in place, in the same durability. Returns `true` when a +/// rewrite happened. The decoded value is identical either way, so this is a +/// pure re-encoding: no expiration, limits, public key, or durability changes. +pub fn normalize_signer(env: &Env, signer_key: &SignerKey) -> bool { + for persistent in [false, true] { + if let Some((signer_val, was_legacy)) = read_signer_val(env, signer_key, persistent) { + if was_legacy { + if persistent { + env.storage() + .persistent() + .set::(signer_key, &signer_val); + } else { + env.storage() + .temporary() + .set::(signer_key, &signer_val); + } + extend_signer_key(env, signer_key, persistent); + } + return was_legacy; + } + } + false +} + +pub fn verify_signer_expiration(env: &Env, signer_expiration: SignerExpiration) { + if let Some(signer_expiration) = signer_expiration.0 { + if env.ledger().sequence() > signer_expiration { + // Note we're not removing this expired signer. Probably fine but storage will fill up with expired signers + // This is fine from the protocol perspective because persistent entries will be archived and temporary entries will be evicted + // However on the indexer side we'll want to filter out signers which are expired + panic_with_error!(env, Error::SignerExpired); + } + } +} + +pub fn verify_signer_limit_keys( + env: &Env, + signer_key: &SignerKey, + signatures: &Signatures, + signer_limits_keys: &Option>, + context: &Context, +) { + if let Some(signer_limits_keys) = signer_limits_keys { + for signer_limits_key in signer_limits_keys.iter() { + // Policies SignerLimits don't need to exist in the signatures map, or be stored on the smart wallet for that matter, they can be adjacent as long as they pass their own policy__ check + if let SignerKey::Policy(policy) = &signer_limits_key { + // In the case of a policy signer in the SignerLimits map we need to verify it if that key has been saved to the smart wallet + // NOTE watch out for infinity loops. If a policy calls itself this will indefinitely recurse + if let Some((signer_limits_val, _)) = + get_signer_val_storage(env, &signer_limits_key, true) + { + if let SignerVal::Policy(signer_expiration, signer_limits) = signer_limits_val { + verify_signer_expiration(env, signer_expiration); + + if !verify_context( + env, + context, + &signer_limits_key, + &signer_limits, + signatures, + ) { + panic_with_error!(env, Error::FailedPolicySignerLimits) + } + } + } + + PolicyClient::new(&env, policy).policy__( + &env.current_contract_address(), + signer_key, + &vec![env, context.clone()], + ); + // For every other SignerLimits key, it must exist in the signatures map and thus exist as a signer on the smart wallet + } else if !signatures.0.contains_key(signer_limits_key.clone()) { + // if any required key is missing this contract invocation is invalid + panic_with_error!(env, Error::FailedSignerLimits) + } + } + } +} diff --git a/contracts-legacy/smart-wallet/src/storage.rs b/contracts-legacy/smart-wallet/src/storage.rs new file mode 100644 index 0000000..46f8784 --- /dev/null +++ b/contracts-legacy/smart-wallet/src/storage.rs @@ -0,0 +1,29 @@ +use smart_wallet_interface::types::SignerKey; +use soroban_sdk::Env; + +const WEEK_OF_LEDGERS: u32 = 60 * 60 * 24 / 5 * 7; + +pub fn extend_instance(env: &Env) { + let max_ttl = env.storage().max_ttl(); + + env.storage() + .instance() + .extend_ttl(max_ttl - WEEK_OF_LEDGERS, max_ttl); +} +pub fn extend_signer_key(env: &Env, signer_key: &SignerKey, persistent: bool) { + let max_ttl = env.storage().max_ttl(); + + if persistent { + env.storage().persistent().extend_ttl::( + signer_key, + max_ttl - WEEK_OF_LEDGERS, + max_ttl, + ); + } else { + env.storage().temporary().extend_ttl::( + signer_key, + max_ttl - WEEK_OF_LEDGERS, + max_ttl, + ); + } +} diff --git a/contracts-legacy/smart-wallet/src/tests/fixtures/0c0a264d.wasm b/contracts-legacy/smart-wallet/src/tests/fixtures/0c0a264d.wasm new file mode 100644 index 0000000..beaa20c Binary files /dev/null and b/contracts-legacy/smart-wallet/src/tests/fixtures/0c0a264d.wasm differ diff --git a/contracts-legacy/smart-wallet/src/tests/fixtures/README.md b/contracts-legacy/smart-wallet/src/tests/fixtures/README.md new file mode 100644 index 0000000..103f0ae --- /dev/null +++ b/contracts-legacy/smart-wallet/src/tests/fixtures/README.md @@ -0,0 +1,18 @@ +# Test fixtures + +Byte-exact copies of the mainnet `ContractCode` entries for three of the four +pre-fix smart-wallet builds, fetched on 2026-09-17 through +`getLedgerEntries` and verified with `sha256(code) == hash` before saving. +The end-to-end tests deploy a wallet from each and upgrade it in place to the +WASM built from this crate. + +| File | sha256 (= mainnet WASM hash) | Bytes | +|---|---|---| +| `0c0a264d.wasm` | `0c0a264d4cc0b3e79b8533e2a2e1f0ed21501a5a3f9f2455d2f18c232940b865` | 20524 | +| `b62f6221.wasm` | `b62f62217ff256d557513793e9e44317b25b14401a8a6b6149a04d38d72d6c7c` | 23193 | +| `c5509dfa.wasm` | `c5509dfa5f022deb8ae621f073adac5fd788feca0b24d15b5f392ab22c2ff222` | 21660 | + +`19868df3…` (one wallet) has the same source shape as `0c0a264d…` and no +fixture. To re-fetch: build the ledger key as `base64(0x00000007 || hash)` +and call `getLedgerEntries` on any mainnet RPC; the code bytes follow the hash +inside the `ContractCode` entry XDR. diff --git a/contracts-legacy/smart-wallet/src/tests/fixtures/b62f6221.wasm b/contracts-legacy/smart-wallet/src/tests/fixtures/b62f6221.wasm new file mode 100644 index 0000000..dde7758 Binary files /dev/null and b/contracts-legacy/smart-wallet/src/tests/fixtures/b62f6221.wasm differ diff --git a/contracts-legacy/smart-wallet/src/tests/fixtures/c5509dfa.wasm b/contracts-legacy/smart-wallet/src/tests/fixtures/c5509dfa.wasm new file mode 100644 index 0000000..559f8ac Binary files /dev/null and b/contracts-legacy/smart-wallet/src/tests/fixtures/c5509dfa.wasm differ diff --git a/contracts-legacy/smart-wallet/src/tests/mod.rs b/contracts-legacy/smart-wallet/src/tests/mod.rs new file mode 100644 index 0000000..9e11095 --- /dev/null +++ b/contracts-legacy/smart-wallet/src/tests/mod.rs @@ -0,0 +1,780 @@ +//! Tests for the legacy-line upgrade target. +//! +//! The end-to-end tests load the real mainnet WASM of two vulnerable builds +//! (`fixtures/`), create a wallet on each, upgrade it in place to the WASM +//! built from this crate, and check that the wallet keeps working, that the +//! hole is closed, and that `migrate_signers` re-encodes the pre-`6a27d48` +//! entries. `make test` builds the WASM first. +extern crate std; + +use ed25519_dalek::{Signer as _, SigningKey}; +use smart_wallet_interface::types::{ + Signature, Signatures, Signer, SignerExpiration, SignerKey, SignerLimits, SignerStorage, + SignerVal, +}; +use soroban_sdk::{ + map, symbol_short, + testutils::{Address as _, Ledger as _}, + vec, + xdr::{ + HashIdPreimage, HashIdPreimageSorobanAuthorization, InvokeContractArgs, Limits, ScAddress, + ScSymbol, ScVal, SorobanAddressCredentials, SorobanAuthorizationEntry, + SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanCredentials, WriteXdr, + }, + Address, Bytes, BytesN, Env, IntoVal, Map, Symbol, TryFromVal, Val, Vec, +}; + +use crate::{Contract, ContractClient}; + +/// Mainnet `0c0a264d…`: pre-`6a27d48` layout, no constructor (born via +/// `CreateContract` + an unauthenticated first `add_signer`). +const WASM_0C0A264D: &[u8] = include_bytes!("fixtures/0c0a264d.wasm"); +/// Mainnet `b62f6221…`: current layout, has `__constructor`, no auth on +/// `update_signer`. +const WASM_B62F6221: &[u8] = include_bytes!("fixtures/b62f6221.wasm"); +/// Mainnet `c5509dfa…`: an integrator build not in this repo; same spec as +/// `b62f6221…` (constructor, wrapped layout, no auth on `update_signer`). +const WASM_C5509DFA: &[u8] = include_bytes!("fixtures/c5509dfa.wasm"); +/// This crate, built by `make build` (`stellar contract build`): the artifact +/// that ships, whose sha256 is the published upgrade-target hash. +const WASM_NEW: &[u8] = include_bytes!("../../../out/smart_wallet.wasm"); +/// Alias kept so the "optimized artifact" test name stays meaningful: the CLI +/// build is the optimized build. +const WASM_NEW_OPTIMIZED: &[u8] = WASM_NEW; + +const SEED: [u8; 32] = [7u8; 32]; + +/// Every signed auth entry needs a distinct nonce per address. +static NONCE: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(11); + +fn ed25519() -> (SigningKey, [u8; 32]) { + let sk = SigningKey::from_bytes(&SEED); + let pk = sk.verifying_key().to_bytes(); + (sk, pk) +} + +fn empty_limits(env: &Env) -> SignerLimits { + SignerLimits(Some(Map::new(env))) +} + +/// A `SignerVal::Ed25519` encoded the way pre-`6a27d48` builds stored it: +/// `["Ed25519", , []]` (bare expiration, one-element limits). +fn bare_ed25519_val(env: &Env, expiration: Option) -> Val { + let exp: Val = match expiration { + None => ().into_val(env), + Some(n) => n.into_val(env), + }; + let limits: Vec = vec![ + env, + Map::>>::new(env).into_val(env), + ]; + let v: Vec = vec![ + env, + symbol_short!("Ed25519").into_val(env), + exp, + limits.into_val(env), + ]; + v.into_val(env) +} + +/// A `Signer::Ed25519` argument encoded the way pre-`6a27d48` clients sent it: +/// `["Ed25519", , , [], ]`. +fn bare_ed25519_signer_arg(env: &Env, pk: &BytesN<32>, expiration: Option) -> Val { + let exp: Val = match expiration { + None => ().into_val(env), + Some(n) => n.into_val(env), + }; + let limits: Vec = vec![ + env, + Map::>>::new(env).into_val(env), + ]; + let v: Vec = vec![ + env, + symbol_short!("Ed25519").into_val(env), + pk.into_val(env), + exp, + limits.into_val(env), + SignerStorage::Persistent.into_val(env), + ]; + v.into_val(env) +} + +/// Build a real `SorobanAuthorizationEntry` for `wallet.(args)` signed by +/// the Ed25519 signer, so `__check_auth` runs for real (no mock auths). +fn signed_auth( + env: &Env, + wallet: &Address, + sk: &SigningKey, + pk: &BytesN<32>, + fn_name: &str, + args: std::vec::Vec, +) -> SorobanAuthorizationEntry { + let nonce: i64 = NONCE.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let signature_expiration_ledger = env.ledger().sequence() + 100; + let root_invocation = SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::try_from(wallet.clone()).unwrap(), + function_name: ScSymbol(fn_name.try_into().unwrap()), + args: args.try_into().unwrap(), + }), + sub_invocations: std::vec::Vec::new().try_into().unwrap(), + }; + let payload = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { + network_id: env.ledger().network_id().to_array().into(), + nonce, + signature_expiration_ledger, + invocation: root_invocation.clone(), + }); + let payload = payload.to_xdr(Limits::none()).unwrap(); + let payload = Bytes::from_slice(env, payload.as_slice()); + let payload = env.crypto().sha256(&payload); + + let sig = Signature::Ed25519(BytesN::from_array( + env, + &sk.sign(payload.to_array().as_slice()).to_bytes(), + )); + let signatures = Signatures(map![env, (SignerKey::Ed25519(pk.clone()), sig)]); + + SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::try_from(wallet.clone()).unwrap(), + nonce, + signature_expiration_ledger, + signature: { + let v: Val = signatures.into_val(env); + ScVal::try_from_val(env, &v).unwrap() + }, + }), + root_invocation, + } +} + +fn invoke>(env: &Env, wallet: &Address, f: &str, args: Vec) -> T { + env.invoke_contract::(wallet, &Symbol::new(env, f), args) +} + +fn try_invoke(env: &Env, wallet: &Address, f: &str, args: Vec) -> bool { + env.try_invoke_contract::(wallet, &Symbol::new(env, f), args) + .is_ok() +} + +// --------------------------------------------------------------------------- + +#[test] +fn bare_layout_reads_and_migrates() { + let env = Env::default(); + let (_, pk) = ed25519(); + let pk = BytesN::from_array(&env, &pk); + let key = SignerKey::Ed25519(pk.clone()); + + // A wallet on this crate's code, constructed normally. + let genesis = Signer::Ed25519( + BytesN::from_array(&env, &[9u8; 32]), + SignerExpiration(None), + empty_limits(&env), + SignerStorage::Persistent, + ); + let wallet = env.register(Contract, (genesis,)); + let client = ContractClient::new(&env, &wallet); + + // Inject a persistent entry in the pre-6a27d48 layout, expiration Some(42). + env.as_contract(&wallet, || { + env.storage() + .persistent() + .set::(&key, &bare_ed25519_val(&env, Some(42))); + // Sanity: the strict decoder rejects it, i.e. this is what bricks + // ecd990f0/e45c42b9/v1. + let raw: Val = env + .storage() + .persistent() + .get::(&key) + .unwrap(); + assert!(SignerVal::try_from_val(&env, &raw).is_err()); + }); + + // Tolerant read. + assert_eq!( + client.get_signer(&key), + Some(SignerVal::Ed25519( + SignerExpiration(Some(42)), + empty_limits(&env) + )) + ); + + // Migrate: one rewrite, missing key skipped, second pass is a no-op. + let missing = SignerKey::Ed25519(BytesN::from_array(&env, &[1u8; 32])); + assert_eq!( + client.migrate_signers(&vec![&env, key.clone(), missing.clone()]), + 1 + ); + assert_eq!(client.migrate_signers(&vec![&env, key.clone()]), 0); + + // Now the strict decoder accepts it and the value is unchanged. + env.as_contract(&wallet, || { + let v = env + .storage() + .persistent() + .get::(&key) + .unwrap(); + assert_eq!( + v, + SignerVal::Ed25519(SignerExpiration(Some(42)), empty_limits(&env)) + ); + }); + assert_eq!( + client.get_signer(&key), + Some(SignerVal::Ed25519( + SignerExpiration(Some(42)), + empty_limits(&env) + )) + ); + assert_eq!(client.get_signer(&missing), None); + + // Temporary entries too. + let tkey = SignerKey::Ed25519(BytesN::from_array(&env, &[2u8; 32])); + env.as_contract(&wallet, || { + env.storage() + .temporary() + .set::(&tkey, &bare_ed25519_val(&env, None)); + }); + assert_eq!( + client.get_signer(&tkey), + Some(SignerVal::Ed25519( + SignerExpiration(None), + empty_limits(&env) + )) + ); + assert_eq!(client.migrate_signers(&vec![&env, tkey.clone()]), 1); + env.as_contract(&wallet, || { + assert!(env + .storage() + .temporary() + .get::(&tkey) + .is_some()); + assert!(env + .storage() + .persistent() + .get::(&tkey) + .is_none()); + }); +} + +#[test] +fn update_signer_requires_auth() { + let env = Env::default(); + let (_, pk) = ed25519(); + let pk = BytesN::from_array(&env, &pk); + let signer = Signer::Ed25519( + pk.clone(), + SignerExpiration(None), + empty_limits(&env), + SignerStorage::Persistent, + ); + let wallet = env.register(Contract, (signer.clone(),)); + let client = ContractClient::new(&env, &wallet); + + assert!(client.try_update_signer(&signer).is_err()); + env.mock_all_auths(); + assert!(client.try_update_signer(&signer).is_ok()); +} + +#[test] +fn upgrade_from_0c0a264d_end_to_end() { + let env = Env::default(); + env.ledger().set_sequence_number(1_000); + let (sk, pk_raw) = ed25519(); + let pk = BytesN::from_array(&env, &pk_raw); + let key = SignerKey::Ed25519(pk.clone()); + + // Born the way 0c0a264d wallets were: no constructor, first add_signer + // needs no auth, argument in the bare shape. + let wallet = env.register(WASM_0C0A264D, ()); + invoke::<()>( + &env, + &wallet, + "add_signer", + vec![&env, bare_ed25519_signer_arg(&env, &pk, None)], + ); + + // The vulnerability: update_signer with no auth succeeds on the old code + // (expiration set to Some(5_000) by "anyone"). + assert!(try_invoke( + &env, + &wallet, + "update_signer", + vec![&env, bare_ed25519_signer_arg(&env, &pk, Some(5_000))], + )); + + // The stored entry is in the bare layout (this is what bricks a strict build). + env.as_contract(&wallet, || { + let raw: Val = env + .storage() + .persistent() + .get::(&key) + .unwrap(); + assert!(SignerVal::try_from_val(&env, &raw).is_err()); + }); + + // In-place upgrade to this crate, authorized by the owner's real signature + // through the OLD code's __check_auth. + let new_hash = env + .deployer() + .upload_contract_wasm(Bytes::from_slice(&env, WASM_NEW)); + let hash_val: Val = new_hash.into_val(&env); + let auth = signed_auth( + &env, + &wallet, + &sk, + &pk, + "update_contract_code", + std::vec![ScVal::try_from_val(&env, &hash_val).unwrap()], + ); + env.set_auths(&[auth]); + invoke::<()>(&env, &wallet, "update_contract_code", vec![&env, hash_val]); + + // Now on the new code. The hole is closed. + let client = ContractClient::new(&env, &wallet); + let typed_signer = Signer::Ed25519( + pk.clone(), + SignerExpiration(Some(6_000)), + empty_limits(&env), + SignerStorage::Persistent, + ); + env.set_auths(&[]); + assert!(client.try_update_signer(&typed_signer).is_err()); + + // The bare entry is still readable, unchanged. + assert_eq!( + client.get_signer(&key), + Some(SignerVal::Ed25519( + SignerExpiration(Some(5_000)), + empty_limits(&env) + )) + ); + + // __check_auth on the NEW code over the still-bare entry: a real signed + // update_contract_code (re-upgrade to the same hash) must pass. + let auth = signed_auth( + &env, + &wallet, + &sk, + &pk, + "update_contract_code", + std::vec![ScVal::try_from_val(&env, &hash_val).unwrap()], + ); + env.set_auths(&[auth]); + invoke::<()>(&env, &wallet, "update_contract_code", vec![&env, hash_val]); + + // Migrate re-encodes it; value unchanged; strict decode now works. + assert_eq!(client.migrate_signers(&vec![&env, key.clone()]), 1); + assert_eq!(client.migrate_signers(&vec![&env, key.clone()]), 0); + env.as_contract(&wallet, || { + let v = env + .storage() + .persistent() + .get::(&key) + .unwrap(); + assert_eq!( + v, + SignerVal::Ed25519(SignerExpiration(Some(5_000)), empty_limits(&env)) + ); + }); + + // A properly authorized update_signer still works after migration. + let auth = signed_auth( + &env, + &wallet, + &sk, + &pk, + "update_signer", + std::vec![{ + let v: Val = typed_signer.clone().into_val(&env); + ScVal::try_from_val(&env, &v).unwrap() + }], + ); + env.set_auths(&[auth]); + client.update_signer(&typed_signer); + assert_eq!( + client.get_signer(&key), + Some(SignerVal::Ed25519( + SignerExpiration(Some(6_000)), + empty_limits(&env) + )) + ); +} + +#[test] +fn upgrade_from_b62f6221_end_to_end() { + let env = Env::default(); + env.ledger().set_sequence_number(1_000); + let (sk, pk_raw) = ed25519(); + let pk = BytesN::from_array(&env, &pk_raw); + let key = SignerKey::Ed25519(pk.clone()); + + // Born the way b62f6221 wallets were: constructor with a typed Signer. + let genesis = Signer::Ed25519( + pk.clone(), + SignerExpiration(None), + empty_limits(&env), + SignerStorage::Persistent, + ); + let wallet = env.register(WASM_B62F6221, (genesis.clone(),)); + + // The vulnerability on the old code. + let tampered = Signer::Ed25519( + pk.clone(), + SignerExpiration(Some(5_000)), + empty_limits(&env), + SignerStorage::Persistent, + ); + assert!(try_invoke( + &env, + &wallet, + "update_signer", + vec![&env, tampered.clone().into_val(&env)], + )); + + // Upgrade with a real signature through the old __check_auth. + let new_hash = env + .deployer() + .upload_contract_wasm(Bytes::from_slice(&env, WASM_NEW)); + let hash_val: Val = new_hash.into_val(&env); + let auth = signed_auth( + &env, + &wallet, + &sk, + &pk, + "update_contract_code", + std::vec![ScVal::try_from_val(&env, &hash_val).unwrap()], + ); + env.set_auths(&[auth]); + invoke::<()>(&env, &wallet, "update_contract_code", vec![&env, hash_val]); + + let client = ContractClient::new(&env, &wallet); + env.set_auths(&[]); + assert!(client.try_update_signer(&tampered).is_err()); + assert_eq!( + client.get_signer(&key), + Some(SignerVal::Ed25519( + SignerExpiration(Some(5_000)), + empty_limits(&env) + )) + ); + // Already in the current layout: nothing to migrate. + assert_eq!(client.migrate_signers(&vec![&env, key.clone()]), 0); + + // Authorized path still works. + let auth = signed_auth( + &env, + &wallet, + &sk, + &pk, + "update_signer", + std::vec![{ + let v: Val = genesis.clone().into_val(&env); + ScVal::try_from_val(&env, &v).unwrap() + }], + ); + env.set_auths(&[auth]); + client.update_signer(&genesis); + assert_eq!( + client.get_signer(&key), + Some(SignerVal::Ed25519( + SignerExpiration(None), + empty_limits(&env) + )) + ); +} + +/// A bare-layout value for any variant: `[, (pk,)? , []]`. +fn bare_val(env: &Env, tag: &str, pk: Option<&BytesN<65>>, expiration: Option) -> Val { + let exp: Val = match expiration { + None => ().into_val(env), + Some(n) => n.into_val(env), + }; + let limits: Vec = vec![ + env, + Map::>>::new(env).into_val(env), + ]; + let mut v: Vec = vec![env, Symbol::new(env, tag).into_val(env)]; + if let Some(pk) = pk { + v.push_back(pk.into_val(env)); + } + v.push_back(exp); + v.push_back(limits.into_val(env)); + v.into_val(env) +} + +#[test] +fn bare_layout_secp256r1_and_policy_decode() { + let env = Env::default(); + let genesis = Signer::Ed25519( + BytesN::from_array(&env, &[9u8; 32]), + SignerExpiration(None), + empty_limits(&env), + SignerStorage::Persistent, + ); + let wallet = env.register(Contract, (genesis,)); + let client = ContractClient::new(&env, &wallet); + + // Passkey: credential id key, 65-byte SEC-1 public key in the value. + let cred = Bytes::from_slice(&env, &[0xAB; 16]); + let pk65 = BytesN::from_array(&env, &[0x04; 65]); + let pkey = SignerKey::Secp256r1(cred.clone()); + // Policy: the policy address is the key; value has no public key. + let policy = Address::generate(&env); + let polkey = SignerKey::Policy(policy.clone()); + + env.as_contract(&wallet, || { + env.storage() + .persistent() + .set::(&pkey, &bare_val(&env, "Secp256r1", Some(&pk65), Some(777))); + env.storage() + .temporary() + .set::(&polkey, &bare_val(&env, "Policy", None, None)); + for k in [&pkey, &polkey] { + let raw: Val = if *k == pkey { + env.storage().persistent().get::(k).unwrap() + } else { + env.storage().temporary().get::(k).unwrap() + }; + assert!(SignerVal::try_from_val(&env, &raw).is_err()); + } + }); + + assert_eq!( + client.get_signer(&pkey), + Some(SignerVal::Secp256r1( + pk65.clone(), + SignerExpiration(Some(777)), + empty_limits(&env) + )) + ); + assert_eq!( + client.get_signer(&polkey), + Some(SignerVal::Policy( + SignerExpiration(None), + empty_limits(&env) + )) + ); + assert_eq!( + client.migrate_signers(&vec![&env, pkey.clone(), polkey.clone()]), + 2 + ); + assert_eq!( + client.migrate_signers(&vec![&env, pkey.clone(), polkey.clone()]), + 0 + ); + env.as_contract(&wallet, || { + assert_eq!( + env.storage() + .persistent() + .get::(&pkey) + .unwrap(), + SignerVal::Secp256r1( + pk65.clone(), + SignerExpiration(Some(777)), + empty_limits(&env) + ) + ); + assert_eq!( + env.storage() + .temporary() + .get::(&polkey) + .unwrap(), + SignerVal::Policy(SignerExpiration(None), empty_limits(&env)) + ); + }); + + // A wrapped value must never be mistaken for a legacy one (no rewrite). + let wkey = SignerKey::Ed25519(BytesN::from_array(&env, &[3u8; 32])); + env.as_contract(&wallet, || { + env.storage().persistent().set::( + &wkey, + &SignerVal::Ed25519(SignerExpiration(Some(1)), SignerLimits(None)), + ); + let raw: Val = env + .storage() + .persistent() + .get::(&wkey) + .unwrap(); + assert!(smart_wallet_interface::types::LegacySignerVal::try_from_val(&env, &raw).is_err()); + }); + assert_eq!(client.migrate_signers(&vec![&env, wkey.clone()]), 0); +} + +#[test] +fn update_signer_over_still_bare_entry() { + // An authorized update_signer on a bare entry (no migrate first) must + // work and leave a wrapped value. + let env = Env::default(); + let (_, pk) = ed25519(); + let pk = BytesN::from_array(&env, &pk); + let key = SignerKey::Ed25519(pk.clone()); + let genesis = Signer::Ed25519( + BytesN::from_array(&env, &[9u8; 32]), + SignerExpiration(None), + empty_limits(&env), + SignerStorage::Persistent, + ); + let wallet = env.register(Contract, (genesis,)); + let client = ContractClient::new(&env, &wallet); + env.as_contract(&wallet, || { + env.storage() + .persistent() + .set::(&key, &bare_ed25519_val(&env, Some(42))); + }); + env.mock_all_auths(); + // Flip durability to Temporary while updating: the bare persistent entry + // must be found (so this is an update, not an insert) and removed. + client.update_signer(&Signer::Ed25519( + pk.clone(), + SignerExpiration(Some(99)), + empty_limits(&env), + SignerStorage::Temporary, + )); + env.as_contract(&wallet, || { + assert!(env + .storage() + .persistent() + .get::(&key) + .is_none()); + assert_eq!( + env.storage() + .temporary() + .get::(&key) + .unwrap(), + SignerVal::Ed25519(SignerExpiration(Some(99)), empty_limits(&env)) + ); + }); + assert_eq!(client.migrate_signers(&vec![&env, key.clone()]), 0); +} + +#[test] +fn upgrade_from_c5509dfa_end_to_end() { + let env = Env::default(); + env.ledger().set_sequence_number(1_000); + let (sk, pk_raw) = ed25519(); + let pk = BytesN::from_array(&env, &pk_raw); + let key = SignerKey::Ed25519(pk.clone()); + let genesis = Signer::Ed25519( + pk.clone(), + SignerExpiration(None), + empty_limits(&env), + SignerStorage::Persistent, + ); + let wallet = env.register(WASM_C5509DFA, (genesis.clone(),)); + let tampered = Signer::Ed25519( + pk.clone(), + SignerExpiration(Some(5_000)), + empty_limits(&env), + SignerStorage::Persistent, + ); + // Vulnerable on the old code. + assert!(try_invoke( + &env, + &wallet, + "update_signer", + vec![&env, tampered.clone().into_val(&env)], + )); + let new_hash = env + .deployer() + .upload_contract_wasm(Bytes::from_slice(&env, WASM_NEW)); + let hash_val: Val = new_hash.into_val(&env); + let auth = signed_auth( + &env, + &wallet, + &sk, + &pk, + "update_contract_code", + std::vec![ScVal::try_from_val(&env, &hash_val).unwrap()], + ); + env.set_auths(&[auth]); + invoke::<()>(&env, &wallet, "update_contract_code", vec![&env, hash_val]); + let client = ContractClient::new(&env, &wallet); + env.set_auths(&[]); + assert!(client.try_update_signer(&tampered).is_err()); + assert_eq!( + client.get_signer(&key), + Some(SignerVal::Ed25519( + SignerExpiration(Some(5_000)), + empty_limits(&env) + )) + ); + assert_eq!(client.migrate_signers(&vec![&env, key.clone()]), 0); + let auth = signed_auth( + &env, + &wallet, + &sk, + &pk, + "update_signer", + std::vec![{ + let v: Val = genesis.clone().into_val(&env); + ScVal::try_from_val(&env, &v).unwrap() + }], + ); + env.set_auths(&[auth]); + client.update_signer(&genesis); +} + +#[test] +fn upgrade_from_0c0a264d_to_optimized_artifact() { + // Same path as the unoptimized test, against the bytes that ship. + let env = Env::default(); + env.ledger().set_sequence_number(1_000); + let (sk, pk_raw) = ed25519(); + let pk = BytesN::from_array(&env, &pk_raw); + let key = SignerKey::Ed25519(pk.clone()); + let wallet = env.register(WASM_0C0A264D, ()); + invoke::<()>( + &env, + &wallet, + "add_signer", + vec![&env, bare_ed25519_signer_arg(&env, &pk, None)], + ); + let new_hash = env + .deployer() + .upload_contract_wasm(Bytes::from_slice(&env, WASM_NEW_OPTIMIZED)); + let hash_val: Val = new_hash.into_val(&env); + let auth = signed_auth( + &env, + &wallet, + &sk, + &pk, + "update_contract_code", + std::vec![ScVal::try_from_val(&env, &hash_val).unwrap()], + ); + env.set_auths(&[auth]); + invoke::<()>(&env, &wallet, "update_contract_code", vec![&env, hash_val]); + let client = ContractClient::new(&env, &wallet); + env.set_auths(&[]); + let typed = Signer::Ed25519( + pk.clone(), + SignerExpiration(Some(6_000)), + empty_limits(&env), + SignerStorage::Persistent, + ); + assert!(client.try_update_signer(&typed).is_err()); + assert_eq!( + client.get_signer(&key), + Some(SignerVal::Ed25519( + SignerExpiration(None), + empty_limits(&env) + )) + ); + // Real signed call through the optimized code's __check_auth over the bare entry. + let auth = signed_auth( + &env, + &wallet, + &sk, + &pk, + "update_signer", + std::vec![{ + let v: Val = typed.clone().into_val(&env); + ScVal::try_from_val(&env, &v).unwrap() + }], + ); + env.set_auths(&[auth]); + client.update_signer(&typed); + assert_eq!(client.migrate_signers(&vec![&env, key.clone()]), 0); +} diff --git a/contracts-legacy/smart-wallet/src/types.rs b/contracts-legacy/smart-wallet/src/types.rs new file mode 100644 index 0000000..e8eb6a8 --- /dev/null +++ b/contracts-legacy/smart-wallet/src/types.rs @@ -0,0 +1,85 @@ +use soroban_sdk::{contracterror, contracttype, Address, Bytes, BytesN, Map, Vec}; + +#[contracterror] +#[derive(Copy, Clone, Debug, PartialEq)] +#[repr(u32)] +pub enum Error { + NotFound = 1, + AlreadyExists = 2, + MissingContext = 3, + SignerExpired = 4, + FailedSignerLimits = 5, + FailedPolicySignerLimits = 6, + SignatureKeyValueMismatch = 7, + ClientDataJsonChallengeIncorrect = 8, + JsonParseError = 9, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SignerExpiration(pub Option); + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +// Map of contexts this signer can authorize if present in the __check_auth auth_contexts list +// Map value is a list of SignerKeys which must all be present in the __check_auth signatures list in order for the signer to authorize the context +// e.g. a policy runs on a SAC token to check how much it's withdrawing and also requires a signature from an additional ed25519 signer +// e.g. an ed25519 signer can only be used to authorize a specific contract's invocations and no further keys are required +pub struct SignerLimits(pub Option>>>); + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum SignerStorage { + Persistent, + Temporary, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum Signer { + Policy(Address, SignerExpiration, SignerLimits, SignerStorage), + Ed25519(BytesN<32>, SignerExpiration, SignerLimits, SignerStorage), + Secp256r1( + Bytes, + BytesN<65>, + SignerExpiration, + SignerLimits, + SignerStorage, + ), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum SignerKey { + Policy(Address), + Ed25519(BytesN<32>), + Secp256r1(Bytes), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum SignerVal { + Policy(SignerExpiration, SignerLimits), + Ed25519(SignerExpiration, SignerLimits), + Secp256r1(BytesN<65>, SignerExpiration, SignerLimits), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Secp256r1Signature { + pub authenticator_data: Bytes, + pub client_data_json: Bytes, + pub signature: BytesN<64>, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum Signature { + Policy, + Ed25519(BytesN<64>), + Secp256r1(Secp256r1Signature), +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Signatures(pub Map); diff --git a/contracts-legacy/smart-wallet/src/verify.rs b/contracts-legacy/smart-wallet/src/verify.rs new file mode 100644 index 0000000..e9f977b --- /dev/null +++ b/contracts-legacy/smart-wallet/src/verify.rs @@ -0,0 +1,48 @@ +use smart_wallet_interface::types::{Error, Secp256r1Signature}; +use soroban_sdk::{crypto::Hash, panic_with_error, BytesN, Env}; + +use crate::base64_url; + +#[derive(serde::Deserialize)] +struct ClientDataJson<'a> { + challenge: &'a str, +} + +pub fn verify_secp256r1_signature( + env: &Env, + signature_payload: &Hash<32>, + public_key: &BytesN<65>, + signature: Secp256r1Signature, +) { + let Secp256r1Signature { + mut authenticator_data, + client_data_json, + signature, + } = signature; + + authenticator_data.extend_from_array(&env.crypto().sha256(&client_data_json).to_array()); + + env.crypto().secp256r1_verify( + &public_key, + &env.crypto().sha256(&authenticator_data), + &signature, + ); + + // Parse the client data JSON, extracting the base64 url encoded challenge. + let client_data_json = client_data_json.to_buffer::<1024>(); // <- TODO why 1024? + let client_data_json = client_data_json.as_slice(); + let (client_data_json, _): (ClientDataJson, _) = + serde_json_core::de::from_slice(client_data_json) + .unwrap_or_else(|_| panic_with_error!(env, Error::JsonParseError)); + + // Build what the base64 url challenge is expecting. + let mut expected_challenge = [0u8; 43]; + + base64_url::encode(&mut expected_challenge, &signature_payload.to_array()); + + // Check that the challenge inside the client data JSON that was signed is identical to the expected challenge. + // TODO is this check actually necessary or is the secp256r1_verify sufficient? + if client_data_json.challenge.as_bytes() != expected_challenge { + panic_with_error!(env, Error::ClientDataJsonChallengeIncorrect) + } +} diff --git a/docs/legacy-wallet-upgrade.md b/docs/legacy-wallet-upgrade.md new file mode 100644 index 0000000..ec87d32 --- /dev/null +++ b/docs/legacy-wallet-upgrade.md @@ -0,0 +1,187 @@ +# Upgrading pre-fix legacy wallets + +This document is for operators of applications that deployed passkey-kit +smart wallets on mainnet from one of the WASM hashes listed below (the last +such deployment was on 2025-06-29). It explains which deployed wallets +still run vulnerable code, why a naive upgrade bricks some of them, and the +exact in-place upgrade that closes the hole without moving funds or changing +the wallet address. + +## The defect + +Before commit [`dcc6e3dc9c`](https://github.com/kalepail/passkey-kit/commit/dcc6e3dc9cfd32e64b98f23541cd2d96812b64c4) +(2025-03-27), `update_signer` had no `require_auth`. Anyone who knows a +wallet's signer key (a passkey credential id, an Ed25519 public key, or a +policy address) can overwrite that signer's stored value. For a passkey that +means replacing the public key and taking the wallet over. Signer keys are +public: they appear in the `sw_v1` events and in the passkey indexer. + +Wallet instances do not upgrade themselves. A wallet deployed from a pre-fix +WASM keeps that code until its owner calls `update_contract_code`. + +## Affected mainnet WASM hashes + +| WASM hash | Repo pin | Storage layout | Wallets (2026-09-17) | +|---|---|---|---| +| `0c0a264d4cc0b3e79b8533e2a2e1f0ed21501a5a3f9f2455d2f18c232940b865` | `contracts/Makefile` @ `48bda61` | **bare** | ~829 | +| `19868df3653d427cafa1c30bdb6cec1ca5c8c815eeabab8a8bae6d83efb1fedd` | `contracts/Makefile` @ `42f2b52` | **bare** | 1 | +| `b62f62217ff256d557513793e9e44317b25b14401a8a6b6149a04d38d72d6c7c` | `contracts/Makefile` @ `df5efb8` | wrapped | ~812 | +| `c5509dfa5f022deb8ae621f073adac5fd788feca0b24d15b5f392ab22c2ff222` | not in repo (integrator build) | wrapped | 66 | + +Seven other pre-fix hashes appear in repository history but were never +uploaded to mainnet. Do not deploy from any Makefile or `.env` pin older than +commit `da472f9`. + +## Two storage layouts + +Commit `6a27d48` (2024-12-13) changed how a signer's value is stored: + +- **bare**: expiration is a plain `Option` (`void` or `u32`). +- **wrapped**: expiration is `SignerExpiration(Option)`, a one-element + vector. + +The signer *key* is identical in both. Every build after `6a27d48`, including +the post-fix legacy builds `ecd990f0…` and `e45c42b9…` and the v1 build +`97ce0478…`, decodes only the wrapped layout. A bare-layout wallet upgraded to +any of them succeeds as a transaction and then fails to decode its own signers +on every subsequent call. The wallet is bricked and its funds are locked. + +## The upgrade target + +`contracts-legacy/` builds a legacy-line wallet from the last pre-1.0 source +(`e45c42b9…`, commit `aeb04d7`) with two additions: + +1. Signer reads accept both layouts. Nothing else about storage, events, + error codes, or expiration semantics changes. +2. `migrate_signers(keys)` re-encodes listed entries into the wrapped layout, + and `get_signer(key)` returns a stored entry for verification. + +`update_signer` requires authorization, as in every post-fix build. + +| Artifact | Value | +|---|---| +| WASM sha256 (`out/smart_wallet.wasm`) | `c079d3a4136eb6ca68eb724acd3d8af11b0be4a0ed82605925a6dfd4dd83a97c` | +| Source | `contracts-legacy/` (soroban-sdk 23.0.2, Rust 1.89, `wasm32v1-none`) | +| Canonical artifact | `contracts-legacy/out/smart_wallet.wasm`, committed. CI asserts its hash and runs the tests against it. Built with Rust 1.89 and stellar CLI 27.1.0 on macOS aarch64. `stellar contract build` remaps source paths, but rustc's wasm codegen differs across host platforms, so a rebuild on another host is functionally equivalent with a different hash. Doc comments on exported functions are embedded in the contract spec, so editing them also changes the hash. | +| Testnet upload | tx `ae5e9439ad044b6c6d3cb491ff6f0e5bd60cfcc1eefbc8a56cc6f1f89b93acaf` | +| Mainnet upload | not yet uploaded | +| Verify / test | `cd contracts-legacy && make verify` / `make test` | + +The tests load the real mainnet `0c0a264d…`, `b62f6221…`, and `c5509dfa…` +WASM, create a wallet on each, upgrade it to this build with a real signed authorization, +and check that the hole is closed, the wallet still authorizes, and +`migrate_signers` re-encodes bare entries. + +This target is safe for **all four** affected hashes. The v1 WASM is not a +drop-in target for any legacy wallet: it expects UNIX-second expirations, +treats an empty limits map as no permissions, and the v1 SDK does not connect +to wallets born without a constructor. + +## Upgrade procedure + +Each step is one contract invocation on the wallet, authorized by a signer +that is still valid on that wallet. The wallet address does not change and no +funds move. Because the hole is a race, upgrade funded wallets first. + +1. **Restore archived entries.** Three of the four vulnerable code entries are + archived on mainnet, and many wallet instances and signer entries are too. + Submit a `RestoreFootprint` operation for the wallet's **current** code + entry (archived for `0c0a264d…`, `19868df3…`, and `c5509dfa…`; nothing on + the wallet can run until it is live), the wallet instance, its signer + entries, and the code entry of the target hash if needed. Restore is + permissionless and any funded account can pay for it. The legacy SDK line + has no restore helper; build the operation with `@stellar/stellar-sdk` + (`Operation.restoreFootprint` plus a simulated footprint). +2. **Upgrade.** Invoke `update_contract_code(c079d3a4…)` on the wallet, + authorized by an existing signer. The wallet's current code checks the + authorization, so the signature format is the one that code expects. +3. **Normalize (bare cohort).** Invoke `migrate_signers([keys…])` with every + signer key the wallet holds. The call needs no authorization; it is a + value-preserving re-encoding whose only side effect is a TTL extension. + It returns the number of entries rewritten. It emits no event. + Signer keys come from the `sw_v1` `add`/`update` events or from the + hosted passkey indexer. +4. **Verify.** Invoke `get_signer(key)` for each key and confirm the value, + or check the wallet's code hash on an explorer. + +Step 3 is optional for the wrapped cohort (`migrate_signers` returns 0) and +recommended for the bare cohort, so that the wallet can later move to a +strict build if its operator chooses to. + +### With the stellar CLI + +```bash +# once per network: upload the code. As of 2026-09-17 it is on testnet only. +stellar contract upload --wasm contracts-legacy/out/smart_wallet.wasm \ + --source --network mainnet + +# per wallet: update_contract_code needs the WALLET's own authorization, which +# is a Signatures map inside a Soroban auth entry checked by the wallet's +# __check_auth. The CLI cannot produce that map: --source is only the +# fee-paying envelope source. Build and sign the update_contract_code call +# from an application (see "From an application" below). The two calls +# below need no wallet authorization and work from the CLI: +stellar contract invoke --id --source --network mainnet \ + -- migrate_signers --signer_keys '[{"Secp256r1":""}]' + +stellar contract invoke --id --source --network mainnet \ + -- get_signer --signer_key '{"Secp256r1":""}' +``` + +### From an application + +Wallet authorizations are produced by the client. For a passkey signer that +means the browser. Use the last legacy SDK line (`passkey-kit` `0.10.20` +through `0.12.x`) to connect to the wallet, build a transaction that invokes +`update_contract_code` with the hash above, sign it with the passkey (or +with an Ed25519 signer key the wallet holds), and submit it through your +relayer or a funded source. The current SDK (`0.17.0` and later) does not +connect to these wallets and cannot be used for this step. + +## What the current kit does with a legacy wallet + +`passkey-kit` `0.19.0` and later cannot operate a pre-1.0 wallet, but it +recognizes one. `connectWallet` throws `LegacyWalletError` (code `2006`) +before birth verification when a candidate's current code is one of the +four vulnerable hashes or a patched legacy build. The error's `vulnerable` +flag, `upgradeTarget`, and `guideUrl` fields carry this document's guidance, +and its message says what to do. The constructor also refuses a +known-vulnerable `walletWasmHash`, so no new wallet can be deployed from one. +Perform the upgrade itself with the 0.10.20–0.12.x kit line. + +## Client compatibility after the upgrade + +- Applications on the wrapped-era SDK (`0.10.20` through `0.12.x`) keep + working unchanged on both cohorts after the upgrade. +- Applications still on a bare-era SDK (`passkey-kit` releases before `0.10.7`, 2024-12-13) keep + signing transactions after the upgrade, because a signature map entry + encodes the same way in both eras. Their `add_signer` and `update_signer` + calls send the bare argument shape, which the upgraded wallet rejects. + Move those applications to a wrapped-era SDK for signer management. +- A bare-era client that signs with a policy signer sends `void` where the + upgraded wallet expects `Signature::Policy`. Such calls fail after the + upgrade. Move that client to a wrapped-era SDK. +- The reverse holds before the upgrade: a bare-layout wallet whose only + usable signer is a policy must authorize the upgrade with a bare-era + signature map (`void` for the policy entry), because the old code has no + `Signature::Policy` variant. Passkey and Ed25519 signatures encode the + same way in both eras and need no special handling. + +## Boundaries + +- A wallet whose only signers have expired, or whose only signers were + temporary entries that the network has since evicted, cannot authorize the + upgrade. Nothing can be done for it now; an expired-but-present entry stays + writable through the old bug until the wallet is upgraded, so such a wallet + should have been drained while a signer was still valid. +- A constructor-less wallet that was created but never received its first + `add_signer` has no signers and is not exploitable through `update_signer`. + Its first `add_signer` needs no authorization on every legacy build, + including this one. Do not fund such a wallet. + +## What was not done + +- The v1 line is unchanged. Moving a legacy wallet to v1 is a separate, + future migration that needs SDK support for constructor-less births. +- No wallet was upgraded by the maintainers. Owners and application operators + authorize upgrades; the shared deterministic deployer cannot. diff --git a/src/constants.ts b/src/constants.ts index afe6420..432b2f3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -159,3 +159,44 @@ export const MERCURY_PASSKEY_INDEXER_URLS = { /** Stellar Friendbot URL for testnet funding. */ export const FRIENDBOT_URL = "https://friendbot.stellar.org"; + +// ============================================================================ +// Legacy (pre-1.0) wallet code identities +// ============================================================================ + +/** + * Mainnet smart-wallet WASM hashes whose `update_signer` has no authorization + * check (fixed in source by commit dcc6e3dc9c, 2025-03-27). Any signer on a + * wallet running one of these can be overwritten by anyone. The kit refuses to + * deploy from them and names them in {@link LegacyWalletError} on connect. + * See SECURITY.md and docs/legacy-wallet-upgrade.md. + */ +export const KNOWN_VULNERABLE_WALLET_WASM_HASHES: readonly string[] = [ + "0c0a264d4cc0b3e79b8533e2a2e1f0ed21501a5a3f9f2455d2f18c232940b865", + "19868df3653d427cafa1c30bdb6cec1ca5c8c815eeabab8a8bae6d83efb1fedd", + "b62f62217ff256d557513793e9e44317b25b14401a8a6b6149a04d38d72d6c7c", + "c5509dfa5f022deb8ae621f073adac5fd788feca0b24d15b5f392ab22c2ff222", +]; + +/** + * Patched pre-1.0 smart-wallet WASM hashes. Wallets on these are not + * vulnerable but predate the v1 contract, so this kit cannot connect to them; + * the 0.10.20–0.12.x kit line can. + */ +export const LEGACY_WALLET_WASM_HASHES: readonly string[] = [ + "ecd990f0b45ca6817149b6175f79b32efb442f35731985a084131e8265c4cd90", + "e45c42b944a767bd5f37f8c4a469b48917d28e23481dbfd550419c84cdacde92", + "c079d3a4136eb6ca68eb724acd3d8af11b0be4a0ed82605925a6dfd4dd83a97c", +]; + +/** + * The in-place upgrade target for every vulnerable legacy wallet. Built from + * `contracts-legacy/`; it reads both pre-1.0 storage layouts, which the other + * patched builds do not (they brick `0c0a264d…`/`19868df3…` wallets). + */ +export const LEGACY_UPGRADE_TARGET_WASM_HASH = + "c079d3a4136eb6ca68eb724acd3d8af11b0be4a0ed82605925a6dfd4dd83a97c"; + +/** Operator guide for upgrading legacy wallets in place. */ +export const LEGACY_WALLET_UPGRADE_GUIDE_URL = + "https://github.com/stellar/passkey-kit/blob/main/docs/legacy-wallet-upgrade.md"; diff --git a/src/errors.ts b/src/errors.ts index e800016..0119085 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -40,6 +40,8 @@ export enum PasskeyKitErrorCode { WALLET_NOT_FOUND = 2003, WALLET_OWNERSHIP_MISMATCH = 2004, WALLET_AMBIGUOUS = 2005, + /** The wallet runs pre-1.0 contract code; see {@link LegacyWalletError}. */ + WALLET_LEGACY_CODE = 2006, // WebAuthn (3xxx) WEBAUTHN_REGISTRATION_FAILED = 3001, @@ -172,6 +174,55 @@ export class WalletOwnershipError extends PasskeyKitError { } } +/** + * Thrown when a wallet runs pre-1.0 (legacy) contract code, which this kit + * cannot operate. Two cases, distinguished by {@link LegacyWalletError.vulnerable}: + * + * - `vulnerable: true` — the code is one of the known-vulnerable builds whose + * `update_signer` has no authorization check. Anyone can take the wallet + * over. It must be upgraded in place to {@link upgradeTarget} (or drained) + * using the 0.10.20–0.12.x kit line; see {@link guideUrl}. + * - `vulnerable: false` — the code is a patched legacy build. The wallet is + * safe but only the 0.10.20–0.12.x kit line can connect to it. + */ +export class LegacyWalletError extends PasskeyKitError { + /** The wallet's current code hash (hex). */ + readonly wasmHash: string; + /** Whether the code has the unauthenticated `update_signer` defect. */ + readonly vulnerable: boolean; + /** WASM hash to upgrade to in place (safe for every legacy layout). */ + readonly upgradeTarget: string; + /** Operator guide for the upgrade. */ + readonly guideUrl: string; + + constructor(options: { + contractId: string; + wasmHash: string; + vulnerable: boolean; + upgradeTarget: string; + guideUrl: string; + }) { + const { contractId, wasmHash, vulnerable, upgradeTarget, guideUrl } = options; + const message = vulnerable + ? `Wallet ${contractId} runs known-vulnerable legacy code ${wasmHash.slice(0, 8)}…: ` + + `its update_signer has no authorization check, so anyone can take the wallet over. ` + + `Upgrade it in place to ${upgradeTarget} with the passkey-kit 0.10.20–0.12.x line ` + + `(update_contract_code, then migrate_signers), or move its funds out. ` + + `This kit version cannot connect to it. Guide: ${guideUrl}` + : `Wallet ${contractId} runs patched legacy code ${wasmHash.slice(0, 8)}…, ` + + `which this kit version cannot connect to. Use the passkey-kit 0.10.20–0.12.x ` + + `line for this wallet. Guide: ${guideUrl}`; + super(message, PasskeyKitErrorCode.WALLET_LEGACY_CODE, { + context: { contractId, wasmHash, vulnerable, upgradeTarget, guideUrl }, + }); + this.name = "LegacyWalletError"; + this.wasmHash = wasmHash; + this.vulnerable = vulnerable; + this.upgradeTarget = upgradeTarget; + this.guideUrl = guideUrl; + } +} + /** Thrown when more than one verified wallet contains the connecting passkey. */ export class WalletAmbiguousError extends PasskeyKitError { /** Every candidate that passed the configured wallet checks. */ diff --git a/src/index.ts b/src/index.ts index 74515b1..ba21aac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,6 +58,7 @@ export { WalletNotConnectedError, WalletOwnershipError, WalletAmbiguousError, + LegacyWalletError, WebAuthnError, SigningError, SignerNotFoundError, @@ -70,6 +71,14 @@ export { wrapError, } from "./errors.js"; +// Legacy (pre-1.0) wallet code identities +export { + KNOWN_VULNERABLE_WALLET_WASM_HASHES, + LEGACY_WALLET_WASM_HASHES, + LEGACY_UPGRADE_TARGET_WASM_HASH, + LEGACY_WALLET_UPGRADE_GUIDE_URL, +} from "./constants.js"; + // Contract-error decoding export { CONTRACT_ERROR_REGISTRY, diff --git a/src/kit.test.ts b/src/kit.test.ts index fbccfd3..c88159f 100644 --- a/src/kit.test.ts +++ b/src/kit.test.ts @@ -31,7 +31,17 @@ import { Api } from "@stellar/stellar-sdk/rpc"; import { PasskeyKit, type PasskeyKitConfig } from "./kit.js"; import { SignerStore, type StoredPasskey } from "./types.js"; import { MemoryStorage } from "./storage/memory.js"; -import { ConfigurationError, WalletAmbiguousError, WalletOwnershipError } from "./errors.js"; +import { + ConfigurationError, + LegacyWalletError, + WalletAmbiguousError, + WalletOwnershipError, +} from "./errors.js"; +import { + KNOWN_VULNERABLE_WALLET_WASM_HASHES, + LEGACY_UPGRADE_TARGET_WASM_HASH, + LEGACY_WALLET_WASM_HASHES, +} from "./constants.js"; import base64url from "./base64url.js"; const WASM_HASH = "ab".repeat(32); // accepted birth + current code @@ -654,3 +664,119 @@ describe("addSecp256r1 persistence", () => { ).rejects.toBeInstanceOf(WalletOwnershipError); }); }); + +// --------------------------------------------------------------------------- +// Legacy (pre-1.0) wallet code +// --------------------------------------------------------------------------- + +describe("legacy wallet code", () => { + const VULNERABLE = KNOWN_VULNERABLE_WALLET_WASM_HASHES[0]!; + const PATCHED_LEGACY = LEGACY_WALLET_WASM_HASHES[0]!; + let birth: Birth; + + beforeEach(() => { + birth = makeBirth(0x31, WASM_HASH, 40); + }); + + it("refuses to be configured with a known-vulnerable walletWasmHash", () => { + const error = (() => { + try { + makeKit(undefined, { walletWasmHash: VULNERABLE }); + return undefined; + } catch (caught) { + return caught; + } + })(); + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).message).toContain(LEGACY_UPGRADE_TARGET_WASM_HASH); + }); + + it("refuses a known-vulnerable hash in acceptedWasmHashes", () => { + expect(() => + makeKit(undefined, { acceptedWasmHashes: [WASM_HASH, VULNERABLE.toUpperCase()] }) + ).toThrow(ConfigurationError); + }); + + it("names the upgrade path when the only candidate runs vulnerable code", async () => { + const kit = makeKit(); + stubProvenance(kit); + vi.spyOn(kit.rpc, "getContractData").mockResolvedValue( + instanceWithWasm(VULNERABLE) as never + ); + vi.spyOn(kit.rpc, "getLatestLedger").mockResolvedValue({ sequence: 40 } as never); + + const error = await kit + .connectWallet({ + keyId: KEY_ID_B64, + getWalletCandidates: async () => completeLookup([birth], 40), + }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(LegacyWalletError); + const legacy = error as LegacyWalletError; + expect(legacy.vulnerable).toBe(true); + expect(legacy.wasmHash).toBe(VULNERABLE); + expect(legacy.upgradeTarget).toBe(LEGACY_UPGRADE_TARGET_WASM_HASH); + expect(legacy.message).toContain("update_contract_code"); + expect(legacy.message).toContain(LEGACY_UPGRADE_TARGET_WASM_HASH); + expect(legacy.message).toContain("legacy-wallet-upgrade.md"); + expect(legacy.context).toMatchObject({ contractId: birth.contractId, vulnerable: true }); + expect(kit.wallet).toBeUndefined(); + }); + + it("names the legacy kit line when the candidate runs patched legacy code", async () => { + const kit = makeKit(); + stubProvenance(kit); + vi.spyOn(kit.rpc, "getContractData").mockResolvedValue( + instanceWithWasm(PATCHED_LEGACY) as never + ); + vi.spyOn(kit.rpc, "getLatestLedger").mockResolvedValue({ sequence: 40 } as never); + + const error = await kit + .connectWallet({ + keyId: KEY_ID_B64, + getWalletCandidates: async () => completeLookup([birth], 40), + }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(LegacyWalletError); + expect((error as LegacyWalletError).vulnerable).toBe(false); + expect((error as LegacyWalletError).message).toContain("0.10.20"); + }); + + it("prefers the legacy error over a generic mismatch when nothing verifies", async () => { + const kit = makeKit(); + stubProvenance(kit); + const evilBirth = makeBirth(9, EVIL_HASH, 20); + // First candidate: unaccepted birth (generic mismatch). Second: vulnerable code. + vi.spyOn(kit.rpc, "getContractData").mockImplementation((async (id: string) => + instanceWithWasm(id === evilBirth.contractId ? WASM_HASH : VULNERABLE)) as never); + vi.spyOn(kit.rpc, "getTransaction").mockImplementation(txStub([evilBirth, birth]) as never); + vi.spyOn(kit.rpc, "getLatestLedger").mockResolvedValue({ sequence: 40 } as never); + + const error = await kit + .connectWallet({ + keyId: KEY_ID_B64, + getWalletCandidates: async () => completeLookup([evilBirth, birth], 40), + }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(LegacyWalletError); + }); + + it("still connects a v1 wallet when a legacy sibling shares the passkey", async () => { + const kit = makeKit(); + stubProvenance(kit); + const legacyBirth = makeBirth(8, WASM_HASH, 21); + vi.spyOn(kit.rpc, "getContractData").mockImplementation((async (id: string) => + instanceWithWasm(id === legacyBirth.contractId ? VULNERABLE : WASM_HASH)) as never); + vi.spyOn(kit.rpc, "getTransaction").mockImplementation(txStub([legacyBirth, birth]) as never); + vi.spyOn(kit.rpc, "getLatestLedger").mockResolvedValue({ sequence: 40 } as never); + + const result = await kit.connectWallet({ + keyId: KEY_ID_B64, + getWalletCandidates: async () => completeLookup([legacyBirth, birth], 40), + }); + expect(result.contractId).toBe(birth.contractId); + }); +}); diff --git a/src/kit.ts b/src/kit.ts index e261db2..5b01351 100644 --- a/src/kit.ts +++ b/src/kit.ts @@ -47,10 +47,17 @@ import { WalletNotConnectedError, WalletOwnershipError, WalletAmbiguousError, + LegacyWalletError, } from "./errors.js"; import { PasskeyEventEmitter } from "./events.js"; import { isDefaultDeployer } from "./utils.js"; -import { DEFAULT_TIMEOUT_SECONDS } from "./constants.js"; +import { + DEFAULT_TIMEOUT_SECONDS, + KNOWN_VULNERABLE_WALLET_WASM_HASHES, + LEGACY_UPGRADE_TARGET_WASM_HASH, + LEGACY_WALLET_UPGRADE_GUIDE_URL, + LEGACY_WALLET_WASM_HASHES, +} from "./constants.js"; import { PasskeySigner, type Signer, type SignerContext } from "./signers.js"; import type { WebAuthnClient } from "./kit/webauthn-ops.js"; import type { CreatedPasskey } from "./kit/webauthn-ops.js"; @@ -206,6 +213,22 @@ export class PasskeyKit { PasskeyKitErrorCode.MISSING_CONFIG ); } + for (const configured of [ + config.walletWasmHash, + ...(config.acceptedWasmHashes ?? []), + ...(config.acceptedBirthWasmHashes ?? []), + ]) { + if (KNOWN_VULNERABLE_WALLET_WASM_HASHES.includes(configured.toLowerCase())) { + throw new ConfigurationError( + `walletWasmHash ${configured.slice(0, 8)}… is a known-vulnerable legacy build ` + + `(update_signer has no authorization check). Do not deploy from it or accept it. ` + + `Existing wallets on it must upgrade to ${LEGACY_UPGRADE_TARGET_WASM_HASH}; ` + + `see ${LEGACY_WALLET_UPGRADE_GUIDE_URL}`, + PasskeyKitErrorCode.INVALID_CONFIG, + { wasmHash: configured.toLowerCase() } + ); + } + } this.rpc = new Server(config.rpcUrl); this.rpcUrl = config.rpcUrl; @@ -530,6 +553,9 @@ export class PasskeyKit { // The last definitive rejection, rethrown verbatim when nothing verifies so // its context (e.g. the rejected wasm hash) is not replaced by a summary. let lastMismatch: WalletOwnershipError | undefined; + // A candidate on pre-1.0 code is a definitive answer with its own guidance. + // It is reported in preference to any generic mismatch when nothing verifies. + let legacyMismatch: LegacyWalletError | undefined; for (const candidate of candidates) { const wallet = new PasskeyClient({ @@ -541,6 +567,10 @@ export class PasskeyKit { this.keyId = keyIdBase64; try { + // Before birth verification: a legacy wallet fails that check for + // reasons that would hide the real problem (no constructor birth, an + // unaccepted hash). Name the legacy code and the upgrade path instead. + await this.assertNotLegacyWallet(candidate.contractId); const birth = await verifyWalletBirth( { rpc: this.rpc, @@ -615,6 +645,10 @@ export class PasskeyKit { } catch (err) { this.wallet = undefined; this.keyId = undefined; + if (err instanceof LegacyWalletError) { + legacyMismatch = err; + continue; + } if (err instanceof WalletOwnershipError) { lastMismatch = err; continue; @@ -628,6 +662,7 @@ export class PasskeyKit { if (verified.size === 0) { throw ( + legacyMismatch ?? lastMismatch ?? new WalletOwnershipError( "The passkey is not a signer on any resolved wallet", @@ -661,6 +696,27 @@ export class PasskeyKit { return { rawResponse, keyId: keyIdBuffer, keyIdBase64, contractId }; } + /** + * Reject a wallet whose current code is a pre-1.0 build, with guidance. + * Known-vulnerable builds get the upgrade instructions; patched legacy + * builds get the "use the 0.10.20–0.12.x kit" instruction. Accepted hashes + * are never legacy, so an integrator cannot opt into a vulnerable build. + */ + private async assertNotLegacyWallet(contractId: string): Promise { + const wasmHash = await this.contractWasmHash(contractId); + const vulnerable = KNOWN_VULNERABLE_WALLET_WASM_HASHES.includes(wasmHash); + if (!vulnerable && !LEGACY_WALLET_WASM_HASHES.includes(wasmHash)) { + return; + } + throw new LegacyWalletError({ + contractId, + wasmHash, + vulnerable, + upgradeTarget: LEGACY_UPGRADE_TARGET_WASM_HASH, + guideUrl: LEGACY_WALLET_UPGRADE_GUIDE_URL, + }); + } + private async assertWalletWasmHash(contractId: string): Promise { const wasmHash = await this.contractWasmHash(contractId); if (!this.acceptedWasmHashes.includes(wasmHash)) {