diff --git a/CHANGELOG.md b/CHANGELOG.md index e465293..15bc45c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ 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. +## 0.19.1 — 2026-09-17 + +- **Pinned what `signLegacyUpgradeTx` will sign.** The auth entry must root at + the named wallet's own top-level `update_contract_code` or `migrate_signers` + with no sub-invocations, and an `update_contract_code` may only carry the + canonical legacy-line target (override with `expectedTarget`). `0.19.0` + pinned the root to the transaction's own call but not the function name or + the target, so a hostile transaction rooted at another wallet-admin call + could have been signed through this path. +- **Refused a pointless re-upgrade.** `buildLegacyUpgradeTx` now rejects a + wallet already on the legacy-line target. +- **Counted an entry as live through its `liveUntil` ledger inclusive** in + `inspectLegacyWallet`. +- **Pointed `LegacyWalletError` at the kit's own helpers**, not only at the + 0.10.20–0.12.x line. +- **Docs.** README no longer says this release has no legacy migration path; + the guide's application section, restore step (protocol-23 auto-restore), + policy-signer boundary, migrate-decision timing, and a live testnet + verification of both cohorts through the SDK path. + ## 0.19.0 — 2026-09-17 - **Added a safe in-place upgrade target for pre-fix legacy wallets.** New diff --git a/README.md b/README.md index 1d1af5c..0fd792f 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,10 @@ Each method builds an `AssembledTransaction` (`WalletTx`) that wraps one contrac | `remove(signerKey)` | `remove_signer` | Remove a signer. A policy entry must pass its own `policy__` check. | | `upgrade(newWasmHash)` | `upgrade` | Replace the wallet's WASM (`Buffer`/`Uint8Array`, 32 bytes). | | `getSigner(signerKey)` | `get_signer` | Read a signer entry from the ledger (temporary before persistent). Returns `SignerVal \| null`. | +| `inspectLegacyWallet(contractId)` | (reads) | Status, storage cohort, archived entries, and a recommendation for a pre-1.0 wallet this kit cannot connect to. | +| `buildLegacyUpgradeTx(contractId)` | `update_contract_code` | The in-place upgrade to the legacy-line target; restores archived entries first via `restoreSource`. | +| `signLegacyUpgradeTx(tx, contractId, signer?)` | — | Sign that upgrade with an existing passkey or Ed25519 signer without connecting. | +| `buildLegacyMigrateTx(contractId, signerKeys)` | `migrate_signers` | Re-encode pre-`6a27d48` signer entries after the upgrade. | Parameters: @@ -517,7 +521,7 @@ It uses the latest Stellar RPC ledger close timestamp. - **Current code identity is not signer provenance.** The SDK also verifies immutable birth code and address-bound proofs. See the [signer-provenance design](docs/security-signer-provenance-v2.md). - **WebAuthn requires User Presence (UP), not User Verification (UV).** The contract requires the UP flag but not UV (biometric/PIN), so it stays compatible with non-UV authenticators. Enforce UV at the client/relayer layer if you need it. - **Value-moving policies need a cumulative cap or a co-signer.** A `Signature::Policy` carries no secret, so a per-transfer cap alone is trivially drained by repeated capped transfers. See the [contract interface](#contract-interface) and `sample-policy`. -- **Pre-1.1 wallets cannot use this release.** This alpha release has no legacy connection or migration path. +- **Pre-1.1 wallets cannot connect through this release.** `connectWallet` throws `LegacyWalletError` for them. The kit does inspect them and build, sign, and migrate their in-place upgrade (`inspectLegacyWallet`, `buildLegacyUpgradeTx`, `signLegacyUpgradeTx`, `buildLegacyMigrateTx`); see [docs/legacy-wallet-upgrade.md](./docs/legacy-wallet-upgrade.md). ## Contract interface @@ -567,6 +571,7 @@ This tuple remains stable for address compatibility. Signer proofs provide owner | `packages/passkey-kit-sdk` | Generated smart-wallet contract bindings (do not hand-edit — see [releasing](./docs/releasing.md)). | | `packages/sac-sdk` | Generated SEP-41 SAC bindings. | | `contracts/` | Rust Soroban contracts: `smart-wallet`, `smart-wallet-interface`, `sample-policy`, `example-contract`. | +| `contracts-legacy/` | The pre-1.0 wallet line with dual-layout signer reads: the in-place upgrade target for pre-fix wallets. The canonical artifact is committed at `out/smart_wallet.wasm`. | | `relayer-proxy/` | Cloudflare Worker for keyless, fee-sponsored submission. | | `demo/` | Svelte 5 demo exercising the full client API. | diff --git a/contracts-legacy/smart-wallet/src/tests/mod.rs b/contracts-legacy/smart-wallet/src/tests/mod.rs index 9e11095..5407c74 100644 --- a/contracts-legacy/smart-wallet/src/tests/mod.rs +++ b/contracts-legacy/smart-wallet/src/tests/mod.rs @@ -1,10 +1,11 @@ //! 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. +//! The end-to-end tests load the real mainnet WASM of three vulnerable builds +//! (`fixtures/`), create a wallet on each, upgrade it in place to the committed +//! canonical artifact (`out/smart_wallet.wasm`), and check that the wallet +//! keeps working, that the hole is closed, and that `migrate_signers` +//! re-encodes the pre-`6a27d48` entries. `make test` verifies the artifact's +//! hash first. extern crate std; use ed25519_dalek::{Signer as _, SigningKey}; diff --git a/docs/legacy-wallet-upgrade.md b/docs/legacy-wallet-upgrade.md index de821d0..b91747b 100644 --- a/docs/legacy-wallet-upgrade.md +++ b/docs/legacy-wallet-upgrade.md @@ -67,6 +67,17 @@ on every subsequent call. The wallet is bricked and its funds are locked. | Mainnet upload | tx `501bd5d5d06ac131e7da213fb29f59b6338332e1bc8900705017dab396102c72` (2026-09-17) | | Verify / test | `cd contracts-legacy && make verify` / `make test` | +**Live verification (testnet, 2026-09-17).** The full `passkey-kit` path +(`inspectLegacyWallet` → `buildLegacyUpgradeTx` → `signLegacyUpgradeTx` with an +Ed25519 signer → relayer-style submission of `{ func, auth }` → +`buildLegacyMigrateTx` → `get_signer`) was run against wallets deployed from +the real mainnet bytecode of both cohorts: + +| Cohort | Wallet | Upgrade tx | migrate_signers | Result | +|---|---|---|---|---| +| wrapped (`b62f6221…`) | `CBS2KQYXAA44TDANAB6G2DBLCAFPS2IBIMWKGHUJXZH65SEBVWDLLWBQ` | `732e82f4…` | `a1a00b96…` (0 rewritten) | code = target; unauthenticated `update_signer` now fails | +| bare (`0c0a264d…`) | `CA4CYKSPHUYJ7TXFR47D2FHAXJQ6JNNRLUTNF7LQWEYQUOQLZYUPCTOB` | `3a9ba357…` | `3eb34d00…` (1 rewritten, then 0) | code = target; signer readable; unauthenticated `update_signer` now fails | + 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 @@ -91,7 +102,11 @@ funds move. Because the hole is a race, upgrade funded wallets first. 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). + (`Operation.restoreFootprint` plus a simulated footprint). On protocol 23 + and later, archived entries in a transaction's footprint are restored + automatically as part of that transaction when they fit its limits, so a + separate restore is often unnecessary; the current kit handles the + remaining case through `restoreSource`. 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. @@ -135,8 +150,9 @@ 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. +relayer or a funded source. Or use the current SDK (`0.19.0` and later): it +does not connect to these wallets, but it builds and signs this exact call; +see the next section. ## What the current kit does with a legacy wallet @@ -163,7 +179,13 @@ recognizes one and can craft its upgrade. 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`. + `PasskeyServer.getSigners` or `MercuryIndexer.getSigners`. Decide whether + to migrate from the inspection taken *before* the upgrade: once the wallet + runs the target, `inspectLegacyWallet` no longer knows its original layout. +- `signLegacyUpgradeTx` signs only a top-level `update_contract_code` or + `migrate_signers` on the named wallet, with no sub-invocations, and only + when the upgrade target is the canonical hash (override with + `expectedTarget`). It refuses anything else before hashing. ```ts const { inspection, tx } = await kit.buildLegacyUpgradeTx(contractId); @@ -194,7 +216,9 @@ The 0.10.20–0.12.x kit line still works for the same steps if you prefer it. - 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 + `Signature::Policy` variant. The current kit's `PolicySigner` emits + `Signature::Policy`, so it cannot authorize that case; use a passkey or + Ed25519 signer on such a wallet, or a bare-era client. Passkey and Ed25519 signatures encode the same way in both eras and need no special handling. ## Boundaries diff --git a/docs/releasing.md b/docs/releasing.md index 4b249e7..9d12cff 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.19.0` | +| `passkey-kit` | `0.19.1` | > [!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 dbe165f..67d40fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "passkey-kit", - "version": "0.19.0", + "version": "0.19.1", "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/errors.ts b/src/errors.ts index 0119085..0336003 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -206,12 +206,13 @@ export class LegacyWalletError extends PasskeyKitError { 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}` + `Upgrade it in place to ${upgradeTarget}: this kit builds and signs that call ` + + `(kit.buildLegacyUpgradeTx, kit.signLegacyUpgradeTx, then kit.buildLegacyMigrateTx ` + + `for the oldest layout), or use the passkey-kit 0.10.20–0.12.x line; 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}`; + `which this kit version cannot connect to. Operate it with the passkey-kit ` + + `0.10.20–0.12.x line (kit.inspectLegacyWallet describes its state). Guide: ${guideUrl}`; super(message, PasskeyKitErrorCode.WALLET_LEGACY_CODE, { context: { contractId, wasmHash, vulnerable, upgradeTarget, guideUrl }, }); diff --git a/src/kit.test.ts b/src/kit.test.ts index c88159f..c121f3c 100644 --- a/src/kit.test.ts +++ b/src/kit.test.ts @@ -717,7 +717,7 @@ describe("legacy wallet code", () => { 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("buildLegacyUpgradeTx"); 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 }); @@ -764,6 +764,15 @@ describe("legacy wallet code", () => { expect(error).toBeInstanceOf(LegacyWalletError); }); + it("refuses to build an upgrade for a wallet already on the legacy-line target", async () => { + const kit = makeKit(); + vi.spyOn(kit.rpc, "getContractData").mockResolvedValue( + instanceWithWasm(LEGACY_UPGRADE_TARGET_WASM_HASH) as never + ); + vi.spyOn(kit.rpc, "getLedgerEntries").mockResolvedValue({ latestLedger: 10, entries: [] } as never); + await expect(kit.buildLegacyUpgradeTx(birth.contractId)).rejects.toThrow(/already runs the legacy-line target/); + }); + it("still connects a v1 wallet when a legacy sibling shares the passkey", async () => { const kit = makeKit(); stubProvenance(kit); diff --git a/src/kit.ts b/src/kit.ts index fefb380..b1470c0 100644 --- a/src/kit.ts +++ b/src/kit.ts @@ -1004,6 +1004,14 @@ export class PasskeyKit { contractId: string ): Promise<{ inspection: LegacyWalletInspection; tx: AssembledTransaction }> { const inspection = await this.inspectLegacyWallet(contractId); + if (inspection.wasmHash === inspection.upgradeTarget) { + throw new ValidationError( + `Wallet ${contractId} already runs the legacy-line target ${inspection.upgradeTarget.slice(0, 8)}…; ` + + `nothing to upgrade. ${inspection.recommendation}`, + PasskeyKitErrorCode.INVALID_INPUT, + { contractId, wasmHash: inspection.wasmHash, status: inspection.status } + ); + } if (inspection.status !== "vulnerable" && inspection.status !== "legacy") { throw new ValidationError( `Wallet ${contractId} is not on a known pre-1.0 build (${inspection.status}); ` + @@ -1045,7 +1053,7 @@ export class PasskeyKit { tx: AssembledTransaction, contractId: string, signer: Signer = new PasskeySigner("any"), - options?: Omit + options?: Omit & { expectedTarget?: string } ): Promise> { return signLegacyUpgradeTx( { diff --git a/src/kit/legacy-ops.test.ts b/src/kit/legacy-ops.test.ts index ad526f2..e5a0c66 100644 --- a/src/kit/legacy-ops.test.ts +++ b/src/kit/legacy-ops.test.ts @@ -313,3 +313,65 @@ describe("signLegacyUpgradeTx", () => { } }); }); + +describe("signLegacyUpgradeTx target pin", () => { + it("refuses an update_contract_code that carries a different WASM hash", async () => { + const sim = vi.spyOn(Server.prototype, "simulateTransaction").mockImplementation(simulationStub() as never); + try { + const tx = await buildLegacyUpgradeTx( + { rpcUrl: RPC_URL, networkPassphrase: Networks.TESTNET, timeoutInSeconds: 30, spec }, + WALLET, + "ee".repeat(32) + ); + 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 as { code?: number }).code).toBe(PasskeyKitErrorCode.SIGNING_FAILED); + expect((error as Error).message).toContain("accepted upgrade target"); + } finally { + sim.mockRestore(); + } + }); +}); + +describe("signLegacyUpgradeTx function pin", () => { + it("refuses a top-level call that is not update_contract_code or migrate_signers", async () => { + const sim = vi.spyOn(Server.prototype, "simulateTransaction").mockImplementation(simulationStub() as never); + try { + // A wallet-admin call built the same way, rooted at this wallet, no subs. + const { AssembledTransaction } = await import("@stellar/stellar-sdk/contract"); + const tx = await AssembledTransaction.build({ + method: "add_signer", + args: [xdr.ScVal.scvVoid()], + contractId: WALLET, + rpcUrl: RPC_URL, + networkPassphrase: Networks.TESTNET, + timeoutInSeconds: 30, + parseResultXdr: () => null, + }); + 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 as { code?: number }).code).toBe(PasskeyKitErrorCode.SIGNING_FAILED); + expect((error as Error).message).toContain("got add_signer"); + } finally { + sim.mockRestore(); + } + }); +}); diff --git a/src/kit/legacy-ops.ts b/src/kit/legacy-ops.ts index b68475f..1ae0847 100644 --- a/src/kit/legacy-ops.ts +++ b/src/kit/legacy-ops.ts @@ -146,7 +146,8 @@ export async function inspectLegacyWallet( const liveByKey = new Map(); for (const entry of response.entries) { const liveUntil = entry.liveUntilLedgerSeq; - liveByKey.set(entry.key.toXDR("base64"), liveUntil !== undefined && liveUntil > latest); + // An entry is live through its liveUntil ledger inclusive. + liveByKey.set(entry.key.toXDR("base64"), liveUntil !== undefined && liveUntil >= latest); } const isLive = (key: xdr.LedgerKey) => liveByKey.get(key.toXDR("base64")) === true; const archived = { @@ -297,7 +298,14 @@ export async function signLegacyUpgradeTx( deps: SignAuthEntryDeps & { contractId: string }, tx: AssembledTransaction, signer: Signer, - options?: Omit + options?: Omit & { + /** + * The only WASM hash an `update_contract_code` call may carry. Defaults to + * the canonical legacy-line target, so a transaction that would move the + * wallet to any other code is refused before anything is signed. + */ + expectedTarget?: string; + } ): Promise> { const built = (tx as { built?: AssembledTransaction["built"] }).built; const topOp = built?.operations[0]; @@ -323,9 +331,11 @@ export async function signLegacyUpgradeTx( authorizeEntry: async (entry) => { const clone = xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR()); assertRootIsExactlyThisCall(clone, deps.contractId, topFunc); + assertUpgradeTarget(clone, options?.expectedTarget ?? LEGACY_UPGRADE_TARGET_WASM_HASH); assertAdminRootMatchesHostFunction(clone, deps.contractId, topFunc); + const { expectedTarget: _expectedTarget, ...signOptions } = options ?? {}; return signAuthEntry(deps, clone, signer, { - ...options, + ...signOptions, expiration, allowWalletReentry: true, }); @@ -353,18 +363,47 @@ function assertRootIsExactlyThisCall( : undefined; const actual = fn.switch().name === "sorobanAuthorizedFunctionTypeContractFn" ? fn.contractFn() : undefined; + const name = actual?.functionName().toString(); const ok = expected !== undefined && actual !== undefined && root.subInvocations().length === 0 && + (name === "update_contract_code" || name === "migrate_signers") && 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`, + `update_contract_code / migrate_signers call with no sub-invocations` + + (name ? ` (got ${name})` : ""), PasskeyKitErrorCode.SIGNING_FAILED, { contractId } ); } } + +/** + * An `update_contract_code` root may only carry the expected target hash. + * `migrate_signers` and any other call pass through unchanged. + */ +function assertUpgradeTarget(entry: xdr.SorobanAuthorizationEntry, expectedTarget: string): void { + const fn = entry.rootInvocation().function(); + if (fn.switch().name !== "sorobanAuthorizedFunctionTypeContractFn") { + return; + } + const call = fn.contractFn(); + if (call.functionName().toString() !== "update_contract_code") { + return; + } + const arg = call.args()[0]; + const actual = + arg && arg.switch().name === "scvBytes" ? Buffer.from(arg.bytes()).toString("hex") : ""; + if (actual !== expectedTarget.toLowerCase()) { + throw new SigningError( + `Refusing to sign update_contract_code(${actual.slice(0, 8) || "?"}…): the only ` + + `accepted upgrade target is ${expectedTarget.slice(0, 8)}…`, + PasskeyKitErrorCode.SIGNING_FAILED, + { actual, expectedTarget } + ); + } +} diff --git a/src/version.ts b/src/version.ts index 4c352f5..dc2e552 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.19.0"; +export const VERSION = "0.19.1"; export const NAME = "passkey-kit";