From cf3078ab22f9745c54d93acdeaa0999f013e9a87 Mon Sep 17 00:00:00 2001 From: kalepail Date: Thu, 17 Sep 2026 16:02:52 -0400 Subject: [PATCH] feat: craft the legacy wallet upgrade from the wallet's actual state; 0.19.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kit cannot connect to a pre-1.0 wallet, but it can now build the one transaction such a wallet needs. New kit methods: - inspectLegacyWallet(id): code hash, status (vulnerable / legacy / current / unknown), storage cohort, which of the instance / current-code / target-code entries are archived, and a plain recommendation. Read-only. - buildLegacyUpgradeTx(id): update_contract_code(), refused for wallets not on a pre-1.0 build; restores archived entries first when restoreSource is configured, else throws RESTORE_REQUIRED with guidance. - buildLegacyMigrateTx(id, keys): migrate_signers for the bare cohort. - signLegacyUpgradeTx(tx, id, signer?): signs the wallet's auth entry with an existing passkey or Ed25519 signer without a connected wallet; the entry must root at exactly the transaction's own call on this wallet with no sub-invocations. Records the mainnet upload of the legacy-line target (tx 501bd5d5…) in the docs. Cuts CHANGELOG 0.19.0 and bumps the package version. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 16 +- SECURITY.md | 4 +- docs/legacy-wallet-upgrade.md | 48 ++++- docs/releasing.md | 2 +- package.json | 2 +- src/index.ts | 8 + src/kit.ts | 151 ++++++++++++++ src/kit/legacy-ops.test.ts | 315 +++++++++++++++++++++++++++++ src/kit/legacy-ops.ts | 370 ++++++++++++++++++++++++++++++++++ src/version.ts | 2 +- 10 files changed, 903 insertions(+), 15 deletions(-) create mode 100644 src/kit/legacy-ops.test.ts create mode 100644 src/kit/legacy-ops.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 82b3607..e465293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to `passkey-kit` are recorded here. The `0.13.0` entry covers the ground-up **v1 overhaul** of the contract, SDK, bindings, and services; `0.13.1` wires live signer discovery onto Mercury's hosted indexer. -## Unreleased +## 0.19.0 — 2026-09-17 - **Added a safe in-place upgrade target for pre-fix legacy wallets.** New `contracts-legacy/` workspace builds the last pre-1.0 wallet (`e45c42b9…`) @@ -25,6 +25,20 @@ All notable changes to `passkey-kit` are recorded here. The `0.13.0` entry cover New exports: `LegacyWalletError`, `KNOWN_VULNERABLE_WALLET_WASM_HASHES`, `LEGACY_WALLET_WASM_HASHES`, `LEGACY_UPGRADE_TARGET_WASM_HASH`, `LEGACY_WALLET_UPGRADE_GUIDE_URL`. +- **Crafted the legacy upgrade from the wallet's actual state.** New kit + methods for wallets this kit cannot connect to: `inspectLegacyWallet(id)` + reads the code hash and the liveness of the instance, current-code, and + target-code entries and returns a status (`vulnerable` / `legacy` / + `current` / `unknown`), the storage cohort, what is archived, and a plain + recommendation; `buildLegacyUpgradeTx(id)` builds + `update_contract_code()` (restoring archived entries first when + `restoreSource` is configured); `buildLegacyMigrateTx(id, keys)` builds + `migrate_signers`; `signLegacyUpgradeTx(tx, id, signer?)` signs the + wallet's auth entry with an existing passkey or Ed25519 signer without a + connected wallet, root-pinned like every admin write. Submit through + `PasskeyServer.send`. Also exported: `classifyWasmHash`, + `BARE_LAYOUT_WALLET_WASM_HASHES`, `LegacyWalletInspection`, + `LegacyCodeStatus`. - **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/SECURITY.md b/SECURITY.md index bf903f6..fdb36b1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,11 +27,11 @@ Wallets deployed from the following mainnet WASM hashes run code in which `updat 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. +Upgrade affected wallets in place to the legacy-line build `c079d3a4136eb6ca68eb724acd3d8af11b0be4a0ed82605925a6dfd4dd83a97c`, built from [`contracts-legacy/`](./contracts-legacy) and live on mainnet (upload tx `501bd5d5…`, 2026-09-17). 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. +The current SDK refuses to deploy from these hashes, throws `LegacyWalletError` with the upgrade guidance when `connectWallet` meets a wallet running one of them, and builds the upgrade itself through `inspectLegacyWallet` / `buildLegacyUpgradeTx` / `signLegacyUpgradeTx`. ## Cargo advisory status diff --git a/docs/legacy-wallet-upgrade.md b/docs/legacy-wallet-upgrade.md index ec87d32..de821d0 100644 --- a/docs/legacy-wallet-upgrade.md +++ b/docs/legacy-wallet-upgrade.md @@ -64,7 +64,7 @@ on every subsequent call. The wallet is bricked and its funds are locked. | 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 | +| Mainnet upload | tx `501bd5d5d06ac131e7da213fb29f59b6338332e1bc8900705017dab396102c72` (2026-09-17) | | Verify / test | `cd contracts-legacy && make verify` / `make test` | The tests load the real mainnet `0c0a264d…`, `b62f6221…`, and `c5509dfa…` @@ -111,7 +111,7 @@ 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. +# once per network: upload the code. It is live on testnet and mainnet as of 2026-09-17. stellar contract upload --wasm contracts-legacy/out/smart_wallet.wasm \ --source --network mainnet @@ -141,13 +141,43 @@ 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. +recognizes one and can craft its upgrade. + +- `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. +- `inspectLegacyWallet(contractId)` reads the wallet's code hash and the + liveness of its instance, current-code, and target-code entries, and returns + the status, the storage cohort, which entries are archived, and a + recommendation. Read-only. +- `buildLegacyUpgradeTx(contractId)` builds `update_contract_code()` + for a wallet on a pre-1.0 build. When `restoreSource` is configured it + restores archived entries first; otherwise it throws `RESTORE_REQUIRED` + with guidance. +- `signLegacyUpgradeTx(tx, contractId, signer?)` signs the wallet's auth + entry with one of its existing signers, without connecting. The default is + a discoverable passkey prompt; pass `new PasskeySigner(keyId)` for a + specific credential or an `Ed25519Signer`. +- `buildLegacyMigrateTx(contractId, signerKeys)` builds `migrate_signers` + for the bare cohort after the upgrade. Get the keys from + `PasskeyServer.getSigners` or `MercuryIndexer.getSigners`. + +```ts +const { inspection, tx } = await kit.buildLegacyUpgradeTx(contractId); +if (inspection.upgradeRequired) { + const signed = await kit.signLegacyUpgradeTx(tx, contractId, new PasskeySigner(keyId)); + await server.send(signed); + if (inspection.migrateRequired) { + const keys = (await server.getSigners(contractId)).map((s) => s.key); + await server.send(await kit.buildLegacyMigrateTx(contractId, keys)); + } +} +``` + +The 0.10.20–0.12.x kit line still works for the same steps if you prefer it. ## Client compatibility after the upgrade diff --git a/docs/releasing.md b/docs/releasing.md index d27ca21..4b249e7 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -17,7 +17,7 @@ The versions checked into each `package.json` are authoritative; `npm view` is t |---|---| | `passkey-kit-sdk` | `0.9.0` | | `sac-sdk` | `0.4.4` | -| `passkey-kit` | `0.18.3` (publish-tool guard — see [CHANGELOG](../CHANGELOG.md)) | +| `passkey-kit` | `0.19.0` | > [!IMPORTANT] > Publishing is an **outward-facing, user-gated** step. Bump the versions intentionally, and have the person with npm access run the publish commands (they hold the credentials and the OTP device). diff --git a/package.json b/package.json index e98f14f..dbe165f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "passkey-kit", - "version": "0.18.3", + "version": "0.19.0", "description": "A helper library for creating and using smart wallet accounts on the Stellar blockchain.", "author": "Tyler van der Hoeven ", "license": "MIT", diff --git a/src/index.ts b/src/index.ts index ba21aac..72be6b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,14 @@ export { wrapError, } from "./errors.js"; +// Legacy (pre-1.0) wallet upgrade helpers +export { + classifyWasmHash, + BARE_LAYOUT_WALLET_WASM_HASHES, + type LegacyCodeStatus, + type LegacyWalletInspection, +} from "./kit/legacy-ops.js"; + // Legacy (pre-1.0) wallet code identities export { KNOWN_VULNERABLE_WALLET_WASM_HASHES, diff --git a/src/kit.ts b/src/kit.ts index 5b01351..fefb380 100644 --- a/src/kit.ts +++ b/src/kit.ts @@ -43,6 +43,7 @@ import { import { ConfigurationError, PasskeyKitError, + ValidationError, PasskeyKitErrorCode, WalletNotConnectedError, WalletOwnershipError, @@ -69,6 +70,14 @@ import { SubmissionManager, } from "./managers/index.js"; import { resolveDeployer } from "./kit/deploy-ops.js"; +import { + buildLegacyMigrateTx, + buildLegacyUpgradeTx, + inspectLegacyWallet, + signLegacyUpgradeTx, + type LegacyWalletInspection, +} from "./kit/legacy-ops.js"; +import { restoreFootprint } from "./kit/tx-ops.js"; import { buildSecp256r1Signer, type PolicySignerTxOptions, @@ -173,6 +182,8 @@ export class PasskeyKit { readonly acceptedWasmHashes: readonly string[]; /** Accepted immutable birth code identities, lowercase hex. Never empty. */ readonly acceptedBirthWasmHashes: readonly string[]; + /** Funded key used only for footprint restores, when configured. */ + private readonly restoreKeypair?: Keypair; /** Full-history source for immutable wallet-birth verification. */ readonly history?: Horizon.Server; readonly rpId?: string; @@ -302,6 +313,8 @@ export class PasskeyKit { calculateExpiration({ rpc: this.rpc, timeoutInSeconds: this.timeoutInSeconds }), }); + this.restoreKeypair = restoreKeypair; + this.submissionManager = new SubmissionManager({ rpc: this.rpc, rpcUrl: config.rpcUrl, @@ -911,6 +924,144 @@ export class PasskeyKit { } } + // -- Legacy (pre-1.0) wallet upgrade ------------------------------------------ + + /** + * Inspect a wallet this kit cannot connect to and say what it needs: + * its code status (vulnerable / legacy / current / unknown), its storage + * cohort, which ledger entries are archived, and a plain recommendation. + * Read-only. + */ + inspectLegacyWallet(contractId: string): Promise { + return inspectLegacyWallet( + { + rpc: this.rpc, + acceptedWasmHashes: this.acceptedWasmHashes, + contractWasmHash: (id) => this.contractWasmHash(id), + }, + contractId + ); + } + + private legacyTxDeps(contractId: string) { + return { + rpcUrl: this.rpcUrl, + networkPassphrase: this.networkPassphrase, + timeoutInSeconds: this.timeoutInSeconds, + spec: new PasskeyClient({ + contractId, + rpcUrl: this.rpcUrl, + networkPassphrase: this.networkPassphrase, + }).spec, + }; + } + + /** + * Restore archived entries a simulated transaction needs, using the + * configured `restoreSource`, and rebuild. Throws with guidance when no + * restore source is configured. + */ + private async withRestore( + build: () => Promise>, + contractId: string + ): Promise> { + let tx = await build(); + const simulation = tx.simulation; + if (simulation && Api.isSimulationRestore(simulation)) { + if (!this.restoreKeypair) { + throw new PasskeyKitError( + `Wallet ${contractId} has archived ledger entries that must be restored before this ` + + `call can run. Configure \`restoreSource\` (a funded key) on the kit, or submit a ` + + `RestoreFootprint operation yourself, then retry.`, + PasskeyKitErrorCode.RESTORE_REQUIRED, + { context: { contractId } } + ); + } + await restoreFootprint( + { + rpc: this.rpc, + networkPassphrase: this.networkPassphrase, + sourceKeypair: this.restoreKeypair, + timeoutInSeconds: this.timeoutInSeconds, + }, + simulation.restorePreamble + ); + tx = await build(); + } + return tx; + } + + /** + * Build the in-place upgrade for a pre-1.0 wallet: + * `update_contract_code()`, authorized by the wallet. + * Refuses wallets that are not on a known pre-1.0 build. Restores archived + * entries first when `restoreSource` is configured. + * + * Sign it with {@link signLegacyUpgradeTx} and submit through + * `PasskeyServer.send` (or your own funded source). + */ + async buildLegacyUpgradeTx( + contractId: string + ): Promise<{ inspection: LegacyWalletInspection; tx: AssembledTransaction }> { + const inspection = await this.inspectLegacyWallet(contractId); + if (inspection.status !== "vulnerable" && inspection.status !== "legacy") { + throw new ValidationError( + `Wallet ${contractId} is not on a known pre-1.0 build (${inspection.status}); ` + + `there is no legacy upgrade to build. ${inspection.recommendation}`, + PasskeyKitErrorCode.INVALID_INPUT, + { contractId, wasmHash: inspection.wasmHash, status: inspection.status } + ); + } + const tx = await this.withRestore( + () => buildLegacyUpgradeTx(this.legacyTxDeps(contractId), contractId, inspection.upgradeTarget), + contractId + ); + return { inspection, tx }; + } + + /** + * Build `migrate_signers(keys)` for a wallet already upgraded to the + * legacy-line target. Needs no wallet authorization; any funded source can + * submit it. Get the keys from `PasskeyServer.getSigners` / + * `MercuryIndexer.getSigners`. + */ + buildLegacyMigrateTx( + contractId: string, + signerKeys: readonly SignerKey[] + ): Promise> { + return this.withRestore( + () => buildLegacyMigrateTx(this.legacyTxDeps(contractId), contractId, signerKeys), + contractId + ); + } + + /** + * Sign a legacy wallet's upgrade transaction with one of its existing + * signers, without connecting. Defaults to a discoverable passkey prompt; + * pass `new PasskeySigner(keyId)` to require a specific credential, or an + * `Ed25519Signer`. + */ + signLegacyUpgradeTx( + tx: AssembledTransaction, + contractId: string, + signer: Signer = new PasskeySigner("any"), + options?: Omit + ): Promise> { + return signLegacyUpgradeTx( + { + networkPassphrase: this.networkPassphrase, + spec: this.legacyTxDeps(contractId).spec, + signerContext: this.signerContext(), + calculateExpiration: () => + calculateExpiration({ rpc: this.rpc, timeoutInSeconds: this.timeoutInSeconds }), + contractId, + }, + tx, + signer, + options + ); + } + // -- Signing ----------------------------------------------------------------- /** Sign a single auth entry (defaults to the connected passkey signer). */ diff --git a/src/kit/legacy-ops.test.ts b/src/kit/legacy-ops.test.ts new file mode 100644 index 0000000..ad526f2 --- /dev/null +++ b/src/kit/legacy-ops.test.ts @@ -0,0 +1,315 @@ +/** + * Legacy-wallet upgrade helpers: classification, inspection against mocked + * ledger state, transaction shape, and signing without a connected wallet. + */ + +import { describe, expect, it, vi } from "vitest"; +import { Address, Keypair, Networks, Operation, xdr } from "@stellar/stellar-sdk"; +import { Server } from "@stellar/stellar-sdk/rpc"; +import { Client as PasskeyClient } from "passkey-kit-sdk"; + +import { + KNOWN_VULNERABLE_WALLET_WASM_HASHES, + LEGACY_UPGRADE_TARGET_WASM_HASH, + LEGACY_WALLET_WASM_HASHES, +} from "../constants.js"; +import { PasskeyKitErrorCode, ValidationError } from "../errors.js"; +import { Ed25519Signer } from "../signers.js"; +import base64url from "../base64url.js"; +import { + BARE_LAYOUT_WALLET_WASM_HASHES, + buildLegacyMigrateTx, + buildLegacyUpgradeTx, + classifyWasmHash, + inspectLegacyWallet, + signLegacyUpgradeTx, +} from "./legacy-ops.js"; + +const RPC_URL = "https://rpc.example"; +const WALLET = "CA2W527X3BNSF5NOQ5GOCM2TFFYYTC6U3I2JDDIBA6PKAWVEBOJFSUC3"; +const CURRENT = "ab".repeat(32); +const VULN_BARE = BARE_LAYOUT_WALLET_WASM_HASHES[0]!; +const VULN_WRAPPED = KNOWN_VULNERABLE_WALLET_WASM_HASHES.find( + (h) => !BARE_LAYOUT_WALLET_WASM_HASHES.includes(h) +)!; +const PATCHED = LEGACY_WALLET_WASM_HASHES[0]!; + +const spec = new PasskeyClient({ + contractId: WALLET, + rpcUrl: RPC_URL, + networkPassphrase: Networks.TESTNET, +}).spec; + +describe("classifyWasmHash", () => { + it("recognizes every known class, case-insensitively", () => { + expect(classifyWasmHash(CURRENT, [CURRENT])).toEqual({ status: "current", cohort: null }); + expect(classifyWasmHash(VULN_BARE.toUpperCase(), [CURRENT])).toEqual({ + status: "vulnerable", + cohort: "bare", + }); + expect(classifyWasmHash(VULN_WRAPPED, [CURRENT])).toEqual({ + status: "vulnerable", + cohort: "wrapped", + }); + expect(classifyWasmHash(PATCHED, [CURRENT])).toEqual({ status: "legacy", cohort: "wrapped" }); + expect(classifyWasmHash("00".repeat(32), [CURRENT])).toEqual({ + status: "unknown", + cohort: null, + }); + }); + + it("treats an accepted hash as current even if it is a legacy build", () => { + expect(classifyWasmHash(PATCHED, [PATCHED]).status).toBe("current"); + }); +}); + +/** A getLedgerEntries stub: `live` keys get a future TTL, the rest are archived. */ +function ledgerEntriesStub(latestLedger: number, liveKeys: xdr.LedgerKey[]) { + const live = new Set(liveKeys.map((k) => k.toXDR("base64"))); + return vi.fn(async (...keys: xdr.LedgerKey[]) => ({ + latestLedger, + entries: keys.map((key) => ({ + key, + val: xdr.LedgerEntryData.contractCode( + new xdr.ContractCodeEntry({ + ext: new xdr.ContractCodeEntryExt(0), + hash: Buffer.alloc(32), + code: Buffer.alloc(0), + }) + ), + lastModifiedLedgerSeq: 1, + liveUntilLedgerSeq: live.has(key.toXDR("base64")) ? latestLedger + 1000 : 0, + })), + })); +} + +function codeKey(hash: string) { + return xdr.LedgerKey.contractCode( + new xdr.LedgerKeyContractCode({ hash: Buffer.from(hash, "hex") }) + ); +} + +describe("inspectLegacyWallet", () => { + it("describes a bare-cohort vulnerable wallet with archived entries", async () => { + const rpc = { + getLedgerEntries: ledgerEntriesStub(100, [codeKey(LEGACY_UPGRADE_TARGET_WASM_HASH)]), + } as unknown as Server; + const inspection = await inspectLegacyWallet( + { rpc, acceptedWasmHashes: [CURRENT], contractWasmHash: async () => VULN_BARE }, + WALLET + ); + expect(inspection).toMatchObject({ + status: "vulnerable", + cohort: "bare", + upgradeRequired: true, + migrateRequired: true, + upgradeTarget: LEGACY_UPGRADE_TARGET_WASM_HASH, + archived: { instance: true, code: true, target: false }, + }); + expect(inspection.recommendation).toContain("restore the archived entries (instance, current code)"); + expect(inspection.recommendation).toContain("migrate_signers"); + }); + + it("describes a wrapped-cohort vulnerable wallet with everything live", async () => { + const target = codeKey(LEGACY_UPGRADE_TARGET_WASM_HASH); + const current = codeKey(VULN_WRAPPED); + const instance = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: Address.fromString(WALLET).toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + }) + ); + const rpc = { + getLedgerEntries: ledgerEntriesStub(100, [target, current, instance]), + } as unknown as Server; + const inspection = await inspectLegacyWallet( + { rpc, acceptedWasmHashes: [CURRENT], contractWasmHash: async () => VULN_WRAPPED }, + WALLET + ); + expect(inspection).toMatchObject({ + status: "vulnerable", + cohort: "wrapped", + upgradeRequired: true, + migrateRequired: false, + archived: { instance: false, code: false, target: false }, + }); + expect(inspection.recommendation).not.toContain("restore"); + expect(inspection.recommendation).not.toContain("migrate_signers"); + }); + + it("says nothing is needed for current code, and points patched legacy at the old kit", async () => { + const rpc = { getLedgerEntries: ledgerEntriesStub(100, []) } as unknown as Server; + const current = await inspectLegacyWallet( + { rpc, acceptedWasmHashes: [CURRENT], contractWasmHash: async () => CURRENT }, + WALLET + ); + expect(current.status).toBe("current"); + expect(current.upgradeRequired).toBe(false); + const legacy = await inspectLegacyWallet( + { rpc, acceptedWasmHashes: [CURRENT], contractWasmHash: async () => PATCHED }, + WALLET + ); + expect(legacy.status).toBe("legacy"); + expect(legacy.upgradeRequired).toBe(false); + expect(legacy.recommendation).toContain("0.10.20"); + }); +}); + +/** A simulation stub that returns one wallet auth entry rooted at the invoked call. */ +function simulationStub() { + return vi.fn(async (tx: { operations: Operation[] }) => { + const op = tx.operations[0] as Operation.InvokeHostFunction; + const invoke = op.func.invokeContract(); + const entry = new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsAddress( + new xdr.SorobanAddressCredentials({ + address: invoke.contractAddress(), + nonce: xdr.Int64.fromString("7"), + signatureExpirationLedger: 0, + signature: xdr.ScVal.scvVoid(), + }) + ), + rootInvocation: new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(invoke), + subInvocations: [], + }), + }); + return { + id: "1", + latestLedger: 100, + minResourceFee: "100", + transactionData: new (await import("@stellar/stellar-sdk")).SorobanDataBuilder(), + events: [], + result: { auth: [entry], retval: xdr.ScVal.scvU32(1) }, + _parsed: true, + }; + }); +} + +describe("buildLegacyUpgradeTx / buildLegacyMigrateTx", () => { + const deps = { rpcUrl: RPC_URL, networkPassphrase: Networks.TESTNET, timeoutInSeconds: 30, spec }; + + it("invokes update_contract_code(target) on the wallet", async () => { + const sim = vi.spyOn(Server.prototype, "simulateTransaction").mockImplementation(simulationStub() as never); + try { + const tx = await buildLegacyUpgradeTx(deps, WALLET); + const op = tx.built!.operations[0] as Operation.InvokeHostFunction; + const invoke = op.func.invokeContract(); + expect(Address.fromScAddress(invoke.contractAddress()).toString()).toBe(WALLET); + expect(invoke.functionName().toString()).toBe("update_contract_code"); + expect(Buffer.from(invoke.args()[0]!.bytes()).toString("hex")).toBe(LEGACY_UPGRADE_TARGET_WASM_HASH); + expect(op.auth).toHaveLength(1); + } finally { + sim.mockRestore(); + } + }); + + it("rejects a malformed target", () => { + expect(() => buildLegacyUpgradeTx(deps, WALLET, "nope")).toThrow(ValidationError); + }); + + it("invokes migrate_signers with encoded signer keys and parses the count", async () => { + const sim = vi.spyOn(Server.prototype, "simulateTransaction").mockImplementation(simulationStub() as never); + try { + const cred = Buffer.alloc(16, 0xab); + const tx = await buildLegacyMigrateTx(deps, WALLET, [ + { key: "Secp256r1", value: base64url(cred) }, + { key: "Ed25519", value: Keypair.random().publicKey() }, + ]); + const invoke = (tx.built!.operations[0] as Operation.InvokeHostFunction).func.invokeContract(); + expect(invoke.functionName().toString()).toBe("migrate_signers"); + const keys = invoke.args()[0]!.vec()!; + expect(keys).toHaveLength(2); + expect(keys[0]!.vec()![0]!.sym().toString()).toBe("Secp256r1"); + expect(Buffer.from(keys[0]!.vec()![1]!.bytes()).toString("hex")).toBe(cred.toString("hex")); + expect(keys[1]!.vec()![0]!.sym().toString()).toBe("Ed25519"); + expect(tx.result).toBe(1); + } finally { + sim.mockRestore(); + } + }); + + it("refuses an empty key list", () => { + expect(() => buildLegacyMigrateTx(deps, WALLET, [])).toThrow(ValidationError); + }); +}); + +describe("signLegacyUpgradeTx", () => { + it("signs the wallet's auth entry with an Ed25519 signer, address-bound, without a connected wallet", async () => { + const sim = vi.spyOn(Server.prototype, "simulateTransaction").mockImplementation(simulationStub() as never); + try { + const kp = Keypair.random(); + const tx = await buildLegacyUpgradeTx( + { rpcUrl: RPC_URL, networkPassphrase: Networks.TESTNET, timeoutInSeconds: 30, spec }, + WALLET + ); + const signed = await signLegacyUpgradeTx( + { + networkPassphrase: Networks.TESTNET, + spec, + signerContext: { webAuthn: { startAuthentication: vi.fn() } as never } as never, + calculateExpiration: async () => 150, + contractId: WALLET, + }, + tx, + new Ed25519Signer(kp) + ); + const op = signed.built!.operations[0] as Operation.InvokeHostFunction; + const entry = op.auth[0]!; + expect(entry.credentials().switch().name).toBe("sorobanCredentialsAddressV2"); + const creds = entry.credentials().addressV2(); + expect(creds.signatureExpirationLedger()).toBe(150); + const sigMap = creds.signature().vec()![0]!.map()!; + expect(sigMap).toHaveLength(1); + expect(sigMap[0]!.key().vec()![0]!.sym().toString()).toBe("Ed25519"); + expect(sigMap[0]!.val().vec()![0]!.sym().toString()).toBe("Ed25519"); + } finally { + sim.mockRestore(); + } + }); + + it("refuses to sign an entry whose root is not the transaction's own call", async () => { + const other = "CBKMUZNFQIAL775XBB2W2GP5CNHBM5YGH6C3XB7AY6SUVO2IBU3VYK2V"; + const sim = vi.spyOn(Server.prototype, "simulateTransaction").mockImplementation((async (tx: { operations: Operation[] }) => { + const base = await simulationStub()(tx); + // Root the auth entry at a different contract's call. + const entry = base.result.auth[0]!; + const rooted = new xdr.SorobanAuthorizationEntry({ + credentials: entry.credentials(), + rootInvocation: new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: Address.fromString(other).toScAddress(), + functionName: "update_contract_code", + args: [], + }) + ), + subInvocations: [], + }), + }); + return { ...base, result: { ...base.result, auth: [rooted] } }; + }) as never); + try { + const tx = await buildLegacyUpgradeTx( + { rpcUrl: RPC_URL, networkPassphrase: Networks.TESTNET, timeoutInSeconds: 30, spec }, + WALLET + ); + const error = await signLegacyUpgradeTx( + { + networkPassphrase: Networks.TESTNET, + spec, + signerContext: {} as never, + calculateExpiration: async () => 150, + contractId: WALLET, + }, + tx, + new Ed25519Signer(Keypair.random()) + ).catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect((error as { code?: number }).code).toBe(PasskeyKitErrorCode.SIGNING_FAILED); + } finally { + sim.mockRestore(); + } + }); +}); diff --git a/src/kit/legacy-ops.ts b/src/kit/legacy-ops.ts new file mode 100644 index 0000000..b68475f --- /dev/null +++ b/src/kit/legacy-ops.ts @@ -0,0 +1,370 @@ +/** + * Legacy (pre-1.0) wallet upgrade helpers. + * + * The kit cannot connect to a pre-1.0 wallet (`connectWallet` throws + * {@link LegacyWalletError}), but it can still craft the one transaction such + * a wallet needs: an in-place `update_contract_code` to the legacy-line build + * that reads both pre-1.0 storage layouts, followed for the bare-layout cohort + * by a `migrate_signers` call. This module builds those transactions from the + * wallet's actual on-chain state, so an application only has to sign and + * submit. See docs/legacy-wallet-upgrade.md for the full procedure. + * + * @packageDocumentation + */ + +import { Address, xdr } from "@stellar/stellar-sdk"; +import { AssembledTransaction, type Spec as ContractSpec } from "@stellar/stellar-sdk/contract"; +import type { Server } from "@stellar/stellar-sdk/rpc"; +import type { Operation } from "@stellar/stellar-sdk"; + +import { + KNOWN_VULNERABLE_WALLET_WASM_HASHES, + LEGACY_UPGRADE_TARGET_WASM_HASH, + LEGACY_WALLET_UPGRADE_GUIDE_URL, + LEGACY_WALLET_WASM_HASHES, +} from "../constants.js"; +import { PasskeyKitErrorCode, SigningError, ValidationError } from "../errors.js"; +import type { SignerKey } from "../types.js"; +import type { Signer } from "../signers.js"; +import { signerKeyToScVal } from "./auth-payload.js"; +import { + assertAdminRootMatchesHostFunction, + signAuthEntry, + type SignAuthEntryDeps, + type SignOptions, +} from "./tx-ops.js"; +import { toContractSignerKey } from "./wallet-ops.js"; + +/** + * Pre-1.0 wallets that store signers in the pre-`6a27d48` (2024-12-13) + * layout. Every later build fails to decode them, which is why only the + * legacy-line target is a safe upgrade for them and why `migrate_signers` + * exists. + */ +export const BARE_LAYOUT_WALLET_WASM_HASHES: readonly string[] = [ + "0c0a264d4cc0b3e79b8533e2a2e1f0ed21501a5a3f9f2455d2f18c232940b865", + "19868df3653d427cafa1c30bdb6cec1ca5c8c815eeabab8a8bae6d83efb1fedd", +]; + +/** How a wallet's current code relates to this kit. */ +export type LegacyCodeStatus = + /** One of the known-vulnerable pre-fix builds: upgrade or drain now. */ + | "vulnerable" + /** A patched pre-1.0 build: safe, but only legacy tooling operates it. */ + | "legacy" + /** One of this kit's accepted hashes: nothing to do. */ + | "current" + /** Not a hash this kit knows. */ + | "unknown"; + +/** What {@link inspectLegacyWallet} learned about a wallet. */ +export interface LegacyWalletInspection { + contractId: string; + /** Current code hash (hex). */ + wasmHash: string; + status: LegacyCodeStatus; + /** Storage layout cohort, when the code is a known pre-1.0 build. */ + cohort: "bare" | "wrapped" | null; + /** The in-place upgrade target for every pre-1.0 wallet. */ + upgradeTarget: string; + /** True when the wallet should call `update_contract_code(upgradeTarget)`. */ + upgradeRequired: boolean; + /** True when `migrate_signers` should follow the upgrade (bare cohort). */ + migrateRequired: boolean; + /** + * Ledger entries that are archived and must be restored before anything on + * the wallet can run. `instance` is the wallet instance, `code` its current + * code, `target` the upgrade target's code. + */ + archived: { instance: boolean; code: boolean; target: boolean }; + /** Operator guide. */ + guideUrl: string; + /** One-paragraph instruction for this wallet. */ + recommendation: string; +} + +/** Classify a code hash without touching the network. */ +export function classifyWasmHash( + wasmHash: string, + acceptedWasmHashes: readonly string[] +): { status: LegacyCodeStatus; cohort: "bare" | "wrapped" | null } { + const hash = wasmHash.toLowerCase(); + if (acceptedWasmHashes.includes(hash)) { + return { status: "current", cohort: null }; + } + if (KNOWN_VULNERABLE_WALLET_WASM_HASHES.includes(hash)) { + return { + status: "vulnerable", + cohort: BARE_LAYOUT_WALLET_WASM_HASHES.includes(hash) ? "bare" : "wrapped", + }; + } + if (LEGACY_WALLET_WASM_HASHES.includes(hash)) { + return { status: "legacy", cohort: "wrapped" }; + } + return { status: "unknown", cohort: null }; +} + +function instanceLedgerKey(contractId: string): xdr.LedgerKey { + return xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: Address.fromString(contractId).toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + }) + ); +} + +function codeLedgerKey(wasmHash: string): xdr.LedgerKey { + return xdr.LedgerKey.contractCode( + new xdr.LedgerKeyContractCode({ hash: Buffer.from(wasmHash, "hex") }) + ); +} + +/** Deps for {@link inspectLegacyWallet}. */ +export interface InspectDeps { + rpc: Server; + acceptedWasmHashes: readonly string[]; + /** Read the wallet's current code hash (hex). */ + contractWasmHash: (contractId: string) => Promise; +} + +/** + * Read a wallet's code hash and the liveness of the entries an upgrade + * touches, and say what to do. + */ +export async function inspectLegacyWallet( + deps: InspectDeps, + contractId: string +): Promise { + const wasmHash = (await deps.contractWasmHash(contractId)).toLowerCase(); + const { status, cohort } = classifyWasmHash(wasmHash, deps.acceptedWasmHashes); + const upgradeTarget = LEGACY_UPGRADE_TARGET_WASM_HASH; + + const keys = [instanceLedgerKey(contractId), codeLedgerKey(wasmHash), codeLedgerKey(upgradeTarget)]; + const response = await deps.rpc.getLedgerEntries(...keys); + const latest = response.latestLedger; + const liveByKey = new Map(); + for (const entry of response.entries) { + const liveUntil = entry.liveUntilLedgerSeq; + liveByKey.set(entry.key.toXDR("base64"), liveUntil !== undefined && liveUntil > latest); + } + const isLive = (key: xdr.LedgerKey) => liveByKey.get(key.toXDR("base64")) === true; + const archived = { + instance: !isLive(keys[0]!), + code: !isLive(keys[1]!), + target: !isLive(keys[2]!), + }; + + const upgradeRequired = status === "vulnerable"; + const migrateRequired = cohort === "bare"; + + let recommendation: string; + switch (status) { + case "vulnerable": + recommendation = + `This wallet runs known-vulnerable code ${wasmHash.slice(0, 8)}…; anyone can overwrite ` + + `its signers. Upgrade it in place now: ` + + (archived.instance || archived.code || archived.target + ? `restore the archived entries (${[ + archived.instance && "instance", + archived.code && "current code", + archived.target && "target code", + ] + .filter(Boolean) + .join(", ")}), then ` + : "") + + `call update_contract_code(${upgradeTarget.slice(0, 8)}…) authorized by an existing signer` + + (migrateRequired + ? `, then call migrate_signers with every signer key (this wallet stores the pre-6a27d48 layout)` + : "") + + `. Or move its funds out. Guide: ${LEGACY_WALLET_UPGRADE_GUIDE_URL}`; + break; + case "legacy": + recommendation = + `This wallet runs patched pre-1.0 code ${wasmHash.slice(0, 8)}…. It is not vulnerable; ` + + `operate it with the passkey-kit 0.10.20–0.12.x line. Guide: ${LEGACY_WALLET_UPGRADE_GUIDE_URL}`; + break; + case "current": + recommendation = "This wallet runs accepted current code. No upgrade is needed."; + break; + default: + recommendation = + `This wallet runs code ${wasmHash.slice(0, 8)}… that this kit does not recognize. ` + + `Verify its provenance before acting.`; + } + + return { + contractId, + wasmHash, + status, + cohort, + upgradeTarget, + upgradeRequired, + migrateRequired, + archived, + guideUrl: LEGACY_WALLET_UPGRADE_GUIDE_URL, + recommendation, + }; +} + +/** Deps for the transaction builders. */ +export interface LegacyTxDeps { + rpcUrl: string; + networkPassphrase: string; + timeoutInSeconds: number; + /** The wallet contract spec, used to encode `SignerKey` arguments. */ + spec: ContractSpec; +} + +/** + * Build `wallet.update_contract_code(target)` for a legacy wallet. + * + * The transaction's single auth entry is the wallet's own, to be signed by an + * existing signer through {@link signLegacyUpgradeTx}. The envelope source is + * the SDK's placeholder account, so submit through `PasskeyServer.send` (a + * relayer supplies the source and fees) or rebuild with your own funded source. + */ +export function buildLegacyUpgradeTx( + deps: LegacyTxDeps, + contractId: string, + target: string = LEGACY_UPGRADE_TARGET_WASM_HASH +): Promise> { + if (!/^[0-9a-f]{64}$/i.test(target)) { + throw new ValidationError( + "upgrade target must be a 32-byte hex WASM hash", + PasskeyKitErrorCode.INVALID_INPUT, + { target } + ); + } + return AssembledTransaction.build({ + method: "update_contract_code", + args: [xdr.ScVal.scvBytes(Buffer.from(target, "hex"))], + contractId, + rpcUrl: deps.rpcUrl, + networkPassphrase: deps.networkPassphrase, + timeoutInSeconds: deps.timeoutInSeconds, + parseResultXdr: () => null, + }); +} + +/** + * Build `wallet.migrate_signers(keys)` for a wallet already on the legacy-line + * target. The call needs no wallet authorization (it is a value-preserving + * re-encoding), so any funded source can submit it. Returns the number of + * entries rewritten when simulated or executed. + * + * Soroban has no storage iteration, so the caller supplies the keys: the + * hosted passkey indexer (`MercuryIndexer.getSigners` / + * `PasskeyServer.getSigners`) lists every signer a wallet has ever held. + */ +export function buildLegacyMigrateTx( + deps: LegacyTxDeps, + contractId: string, + signerKeys: readonly SignerKey[] +): Promise> { + if (signerKeys.length === 0) { + throw new ValidationError( + "migrate_signers needs at least one signer key", + PasskeyKitErrorCode.INVALID_INPUT, + { contractId } + ); + } + const keys = signerKeys.map((key) => + signerKeyToScVal(deps.spec, toContractSignerKey(key)) + ); + return AssembledTransaction.build({ + method: "migrate_signers", + args: [xdr.ScVal.scvVec(keys)], + contractId, + rpcUrl: deps.rpcUrl, + networkPassphrase: deps.networkPassphrase, + timeoutInSeconds: deps.timeoutInSeconds, + parseResultXdr: (value) => value.u32(), + }); +} + +/** + * Sign a legacy wallet's `update_contract_code` auth entry with one of its + * existing signers, without a connected wallet. + * + * Passkey and Ed25519 signature entries encode identically on every pre-1.0 + * build, and the signature covers the V2 address-bound payload the host + * presents to any custom account, so the kit's normal signers work here. The + * entry root is pinned to the transaction's own host function, as for every + * wallet-admin write. + */ +export async function signLegacyUpgradeTx( + deps: SignAuthEntryDeps & { contractId: string }, + tx: AssembledTransaction, + signer: Signer, + options?: Omit +): Promise> { + const built = (tx as { built?: AssembledTransaction["built"] }).built; + const topOp = built?.operations[0]; + const topFunc = + topOp?.type === "invokeHostFunction" + ? (topOp as Operation.InvokeHostFunction).func + : undefined; + if (!topFunc) { + throw new ValidationError( + "the transaction has no invoke-host-function operation to sign", + PasskeyKitErrorCode.INVALID_INPUT, + { contractId: deps.contractId } + ); + } + + // Resolve the expiration once here (the kit's own ledger-based default) so + // the SDK does not fetch it, and pass the same value to every entry. + const expiration = options?.expiration ?? (await deps.calculateExpiration()); + + await tx.signAuthEntries({ + address: deps.contractId, + expiration, + authorizeEntry: async (entry) => { + const clone = xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR()); + assertRootIsExactlyThisCall(clone, deps.contractId, topFunc); + assertAdminRootMatchesHostFunction(clone, deps.contractId, topFunc); + return signAuthEntry(deps, clone, signer, { + ...options, + expiration, + allowWalletReentry: true, + }); + }, + }); + + return tx; +} + +/** + * The legacy upgrade signs exactly one thing: this wallet's own + * `update_contract_code` (or `migrate_signers`) as the transaction's + * top-level call, with no sub-invocations. Anything else is refused before + * hashing, so a hostile transaction cannot borrow this signing path. + */ +function assertRootIsExactlyThisCall( + entry: xdr.SorobanAuthorizationEntry, + contractId: string, + topFunc: xdr.HostFunction +): void { + const root = entry.rootInvocation(); + const fn = root.function(); + const expected = topFunc.switch().name === "hostFunctionTypeInvokeContract" + ? topFunc.invokeContract() + : undefined; + const actual = + fn.switch().name === "sorobanAuthorizedFunctionTypeContractFn" ? fn.contractFn() : undefined; + const ok = + expected !== undefined && + actual !== undefined && + root.subInvocations().length === 0 && + Address.fromScAddress(actual.contractAddress()).toString() === contractId && + actual.toXDR("base64") === expected.toXDR("base64"); + if (!ok) { + throw new SigningError( + `Refusing to sign: the auth entry must root at ${contractId}'s own top-level ` + + `update_contract_code / migrate_signers call with no sub-invocations`, + PasskeyKitErrorCode.SIGNING_FAILED, + { contractId } + ); + } +} diff --git a/src/version.ts b/src/version.ts index 34e5a45..4c352f5 100644 --- a/src/version.ts +++ b/src/version.ts @@ -6,5 +6,5 @@ // This file is auto-generated. Do not edit manually. // Run `pnpm run sync-version` (or `pnpm build`) to update. -export const VERSION = "0.18.3"; +export const VERSION = "0.19.0"; export const NAME = "passkey-kit";