diff --git a/docs/BROWSER_WALLET_SIGNING.md b/docs/BROWSER_WALLET_SIGNING.md index d57699d5..cbde7d1c 100644 --- a/docs/BROWSER_WALLET_SIGNING.md +++ b/docs/BROWSER_WALLET_SIGNING.md @@ -1,114 +1,103 @@ -# Signing with browser wallets - -`starforge` normally signs with a key stored locally (encrypted or not). That is -convenient but it means a funded secret has to live on the machine running the -CLI. If your keys are already in Freighter, xBull, Rabet, or another -[Stellar Wallets Kit](https://github.com/Creit-Tech/Stellar-Wallets-Kit) -compatible wallet, you can keep them there and still sign with the CLI. +# Browser wallet signing (`--signer browser`) + +`starforge wallet sign-tx --signer browser` signs a transaction envelope XDR +with a browser wallet (Freighter, xBull, Rabet, …) instead of importing a +secret key into the CLI. The CLI starts a one-time HTTP server on +`127.0.0.1`, opens a page that uses +[Stellar Wallets Kit](https://github.com/Creit-Tech/Stellar-Wallets-Kit), shows +a decoded view of the transaction, and returns the signed XDR to the CLI. + +## Prerequisites + +- A Stellar Wallets Kit compatible browser wallet installed (this checklist + uses **Freighter**). +- The wallet switched to **Testnet** and holding a funded testnet account + (fund it through Friendbot: `starforge wallet fund ` or + ). +- An unsigned, base64-encoded transaction envelope XDR in a file. Any source + works (for example a multi-sig setup payload or a transaction exported from + another tool). +- A machine with a browser that can reach `http://127.0.0.1:` (a local + desktop session; the port is printed to the terminal and the CLI tries to + open the page for you). + +## Command ```bash -starforge wallet sign-tx --transaction unsigned.xdr --signer browser +starforge wallet sign-tx \ + --transaction unsigned.xdr \ + --signer browser \ + --network testnet \ + --timeout 120 \ + --output signed.xdr ``` -## How the localhost handoff works - -`--signer browser` never loads a secret key into the CLI: - -1. The CLI reads the base64 transaction envelope XDR from `--transaction`. -2. It binds a one-time HTTP server to `127.0.0.1` on an ephemeral port, generates - a 32-byte nonce from the OS RNG, and opens - `http://127.0.0.1:/?nonce=` in your browser. -3. The page renders the unsigned envelope, its decoded summary, and its SHA-256 - digest so you can compare it with what your wallet displays. -4. Pressing **Sign with browser wallet** loads the pinned Stellar Wallets Kit - bundle and asks your wallet to sign the envelope against the network - passphrase. -5. The signed XDR is POSTed back to the same server. The server validates that - it is a non-empty base64 envelope, consumes the nonce, and returns the signed - XDR to the CLI. -6. The CLI writes the signed XDR to `--output`, or prints it to stdout. - -The server stops listening as soon as a signature arrives or after `--timeout` -seconds (default `120`). The nonce is single-use: a replay gets HTTP `410`, and -a malformed payload is rejected without burning the nonce. - -### Flags - -| Flag | Meaning | -| --- | --- | -| `--transaction ` | File containing the base64 transaction envelope XDR (required). | -| `--signer ` | `browser` for the handoff, `local` for a stored key (default `browser`). | -| `--wallet ` | Wallet name; required when `--signer local`. | -| `--network ` | Network passphrase to sign against (default `testnet`). | -| `--output ` | Write the signed XDR here instead of stdout. | -| `--timeout ` | How long to wait for the wallet (default `120`). | +- `--transaction` — file containing the base64 transaction envelope XDR. +- `--signer browser` — use the localhost handoff (the default). +- `--signer local --wallet ` — sign with a locally stored secret key. +- `--network` — `testnet` or `mainnet`; the passphrase is resolved from config. +- `--timeout` — seconds to wait for the wallet (default `120`). +- `--output` — file to write the signed XDR to; omit it to print to stdout. ## Manual test checklist (Freighter on testnet) -Run this once against a real wallet before relying on the command. Everything -below uses testnet only. - -- [ ] Install the [Freighter extension](https://www.freighter.app/) and switch it - to **Testnet**. -- [ ] Create or import a funded testnet account in Freighter and copy its public - key. -- [ ] Build an unsigned transaction envelope for that account, for example with - a payment built by your own flow, and save the base64 XDR to - `unsigned.xdr`. -- [ ] Run: - - starforge wallet sign-tx --transaction unsigned.xdr --signer browser --network testnet --timeout 120 - -- [ ] Confirm the CLI prints a `http://127.0.0.1:/?nonce=` URL and - that your browser opens it. -- [ ] Confirm the page shows the network, the envelope type, the byte length, - the SHA-256 digest, the decoded summary, and the raw XDR. -- [ ] Confirm the page's digest and byte length match the local envelope (for - example `base64 -d unsigned.xdr | shasum -a 256`). -- [ ] Click **Sign with browser wallet**, approve in Freighter, and check that - the page reports the signature was returned to the CLI. -- [ ] Confirm the CLI prints the signed XDR (or writes `--output`). -- [ ] Submit the signed XDR to testnet Horizon and confirm it succeeds. -- [ ] Negative checks: - - [ ] Closing the page and waiting produces a timeout error, not a hang. - - [ ] Reloading the page after signing does not allow a second signature - (nonce already consumed). - - [ ] Rejecting in Freighter leaves the CLI waiting until the timeout. - - [ ] Visiting `http://127.0.0.1:/` **without** the nonce returns - `400`; with a wrong nonce it returns `403`. - - [ ] A request with `Host: attacker.example` returns `403`. - - [ ] A POST with a foreign `Origin` header returns `403`. - -## Automated tests - -The handoff is covered without a browser: - -- `src/utils/browser_signer.rs` unit tests cover nonce single-use, CSP contents, - nonce gating, loopback `Host` enforcement, cross-origin rejection, nonce - preservation on malformed payloads, replay (`410`), the mocked signer, the - decoded summary, and a full raw-TCP handshake. -- `tests/browser_wallet_handoff.rs` drives the public API with a mocked - `WalletSigner` (a fake wallet and a declining wallet) and with a raw TCP "mock - browser wallet", including the rebound-host rejection path. - -Run them with: - -```bash -cargo test --test browser_wallet_handoff -cargo test utils::browser_signer -``` +Run the command above, then verify each item. Tick a box only when the checkbox +was observed on a real run. + +- [ ] The CLI prints a `http://127.0.0.1:/?nonce=` URL and opens it + in the default browser (or tells you to open it manually). +- [ ] The page shows the network (`testnet`), the envelope type, the byte + length, and a SHA-256 digest for the unsigned transaction. +- [ ] The "Decoded transaction" panel renders the transaction summary. +- [ ] **Browser check:** open the page with the nonce removed or changed + (`http://127.0.0.1:/` and `...?nonce=deadbeef`); both are rejected + (HTTP 400 / 403) and no signing UI can be used. +- [ ] **CSP check:** the browser devtools console shows no CSP violations when + the page loads, and the network response for `/` carries a + `Content-Security-Policy` header starting with `default-src 'none'`. +- [ ] Clicking **Sign with browser wallet** opens Freighter, which shows the + same transaction and network as the page. +- [ ] Approving in Freighter returns the signed XDR to the CLI; the terminal + exits successfully and, when `--output` is set, the file contains the + signed XDR. +- [ ] Reloading the page after signing and trying to submit again is rejected + (the nonce was consumed, HTTP 410). +- [ ] Running the command again with a fresh invocation prints a **new** nonce + and a **new** port. +- [ ] Letting the command sit without approving until `--timeout` elapses makes + the CLI exit with a timeout error, and the port is no longer listening. +- [ ] Rejecting the request in Freighter leaves the CLI waiting until the + timeout, and no `signed.xdr` is written. +- [ ] `--network mainnet` shows the mainnet passphrase on the page and in + Freighter, and the CLI never submits the transaction itself. + +### Expected results + +- Success: the signed XDR written to `--output` differs from the input XDR and + decodes as base64. Submitting it is a separate, explicit step. +- Timeout: `browser handoff timed out after s; no signature was returned`, + with a non-zero exit status. +- Bad payload: if the wallet returns something that is not base64, the CLI + reports `browser wallet returned an invalid signature: …` and does not write + output. + +## Troubleshooting + +- **The browser did not open.** Copy the printed URL into a browser manually. + The server is only reachable from the same machine. +- **"handoff rejected the page: HTTP 400/403".** The URL lost its `?nonce=…` + parameter, or it is from an expired earlier run. +- **Nothing happens after clicking Sign.** Confirm the wallet extension is + installed, unlocked, and set to Testnet, then check the browser console for + wallet errors. +- **The page fails to load Stellar Wallets Kit.** The page imports a pinned + bundle from `https://cdn.jsdelivr.net`; a restrictive network or ad blocker + can block it. This is the only external resource the page loads. ## Security notes -The nonce, loopback bind, strict CSP, origin/host checks, request size cap, and -short timeout are documented in -[`docs/SECURITY_THREAT_MODEL.md`](SECURITY_THREAT_MODEL.md) under -"Browser-wallet localhost handoff". In short: - -- the page is only reachable from `127.0.0.1` and only with the one-time nonce; -- requests whose `Host` is not the loopback handoff are refused, which closes - the DNS-rebinding window on the ephemeral port; -- cross-origin signature submissions are refused; -- inline script only runs with the per-response CSP nonce; -- the CLI never reads a secret key for `--signer browser` — only the signed XDR - crosses the handoff. +See `docs/SECURITY_THREAT_MODEL.md` → "Browser-wallet localhost handoff" for the +threats and mitigations: loopback-only bind, a single-use 32-byte nonce, a +strict `Content-Security-Policy` scoped to a script nonce, a short timeout, and +base64 validation of the returned envelope XDR. The CLI never reads a secret +key for `--signer browser`. diff --git a/docs/SECURITY_THREAT_MODEL.md b/docs/SECURITY_THREAT_MODEL.md index 7500d9a5..eb4a5eae 100644 --- a/docs/SECURITY_THREAT_MODEL.md +++ b/docs/SECURITY_THREAT_MODEL.md @@ -9,9 +9,8 @@ asset and boundary below in their design or pull request. - Wallet secret keys, encrypted key material, recovery shares, and signing requests. -- OS keychain entries (macOS Keychain, Windows Credential Manager, Linux - Secret Service) and the `keychain:` references in the configuration - that point at them. +- Browser-wallet handoff state: the one-time localhost URL, its nonce, and the + signed envelope XDR it returns. - Contract WASM, deployment parameters, transaction payloads, and RPC results. - Plugin binaries, manifests, requested capabilities, and marketplace metadata. @@ -26,15 +25,14 @@ We defend against a malicious local process or plugin, a compromised or typosquatted marketplace source, a network attacker, a malicious RPC response, and an attacker who obtains a stale cache or backup. Users may also accidentally approve unsafe commands or disclose secrets through prompts. The -browser-wallet handoff adds a hostile-local-page, DNS-rebinding, and nonce-replay -adversary. +browser-wallet handoff adds a hostile-local-page and nonce-replay adversary. ## Trust boundaries and controls | Boundary | Main risk | Existing controls | Remaining gap | | --- | --- | --- | --- | | Wallet files → signer | Key theft or misuse | Encrypted-at-rest options, validation, explicit wallet selection | OS account compromise remains outside the CLI’s control | -| Wallet secret → OS keychain backend | Secret exposure during migration or key loss | Feature-gated OS backend, `keychain:` references in config, idempotent migration, 0600 file fallback | The OS store must be present and unlocked; live-keychain paths are not exercised in CI | +| Browser wallet → localhost handoff | Forged or replayed signature, hostile local page injecting script, secret exposure | Loopback-only (`127.0.0.1`) bind on an ephemeral port, single-use 32-byte cryptographic nonce, strict `Content-Security-Policy` with a script nonce, short timeout, base64 envelope validation before the signed XDR is accepted, no secret ever loaded into the CLI | The page loads a pinned Stellar Wallets Kit bundle from a CDN, and a compromised browser extension can still return a valid-but-wrong signature; users must compare the displayed digest with their wallet | | CLI → Stellar/Horizon/Soroban RPC | Forged results or endpoint substitution | Configured network endpoints, simulation and deployment validation | TLS/DNS trust and endpoint availability must be monitored | | Marketplace → local template | Supply-chain code execution | Source trust classification, checksum verification when supplied, staged installation | Registries should require signed metadata and mandatory digests | | Plugin binary → CLI process | Arbitrary code and capability abuse | Manifest/version checks and trust levels | Plugins are native code and are not a sandbox | @@ -52,10 +50,39 @@ adversary. message; do not silently reinterpret them. 5. Report a trust decision and the source URL for every external plugin or template before it is loaded or copied into a project. -6. Wallet secrets migrated to an OS keychain must be replaced in the - configuration by a `keychain:` reference only; the plaintext must never - be logged, echoed, or left behind, and the file fallback used on hosts - without a keychain must remain permission-restricted. +6. Bind browser-handoff servers to `127.0.0.1` only, authorise every request + with a single-use cryptographic nonce, serve a strict + `Content-Security-Policy`, and shut the server down on a short timeout. +7. Never accept a browser-supplied signature before validating that it is a + non-empty base64 envelope XDR, and never reuse or accept an already-consumed + handoff nonce. + +## Browser-wallet localhost handoff + +`starforge wallet sign-tx --signer browser` keeps keys in the user's browser +wallet by moving the signature request, not the secret, into a one-time page. +The CLI: + +- generates a fresh 32-byte nonce from the OS-backed RNG and binds an ephemeral + port on `127.0.0.1` (never a public interface); +- serves the page under `default-src 'none'` with a `script-src` restricted to + the nonce plus the pinned Stellar Wallets Kit CDN origin, `connect-src 'self'`, + `frame-ancestors 'none'`, `base-uri 'none'`, and `form-action 'none'`; +- exposes the unsigned envelope, its SHA-256 digest, and a decoded summary so + the user can compare them with what their wallet shows before approving; +- consumes the nonce exactly once, validates the returned signed XDR, and stops + listening as soon as a signature arrives or the timeout expires. + +Threats and mitigations: + +| Threat | Mitigation | +| --- | --- | +| Another local process drives the CLI's handoff endpoint | Nonce required on every route; the port is ephemeral and loopback-only for the lifetime of the command | +| Replay of a captured signed payload | Nonce is single-use and consumed only after a well-formed signature is validated | +| Hostile local page injects script into the handoff page | CSP with a script nonce; inline `