Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 93 additions & 104 deletions docs/BROWSER_WALLET_SIGNING.md
Original file line number Diff line number Diff line change
@@ -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 <name>` or
<https://friendbot.stellar.org>).
- 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:<port>` (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:<port>/?nonce=<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 <PATH>` | File containing the base64 transaction envelope XDR (required). |
| `--signer <browser\|local>` | `browser` for the handoff, `local` for a stored key (default `browser`). |
| `--wallet <NAME>` | Wallet name; required when `--signer local`. |
| `--network <testnet\|mainnet>` | Network passphrase to sign against (default `testnet`). |
| `--output <PATH>` | Write the signed XDR here instead of stdout. |
| `--timeout <SECONDS>` | 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 <name>` — 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:<port>/?nonce=<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:<port>/` **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:<port>/?nonce=<hex>` 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:<port>/` 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 <n>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`.
48 changes: 37 additions & 11 deletions docs/SECURITY_THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<key>` 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.
Expand All @@ -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 |
Expand All @@ -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:<key>` 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 `<script>` blocks without the nonce do not execute |
| Oversized or malformed request exhausts memory or crashes the CLI | `Content-Length` capped by `MAX_REQUEST_BYTES`; malformed bodies get a `400` without consuming the nonce |
| Stolen secrets | The CLI never reads a secret for `--signer browser`; only the signed XDR crosses the handoff |
| Malicious wallet or CDN returns a wrong signature | Out of the CLI's control; the page shows the envelope digest so the user can reject it, and the CLI never submits from this command |

## Review cadence and gap tracking

Expand All @@ -64,4 +91,3 @@ wallet handling, plugin loading, marketplace fetching, AI integrations, or RPC
code. New gaps should become issues tagged `security` and link back here;
closing a gap should update the corresponding boundary row and add a regression
test where feasible.

52 changes: 37 additions & 15 deletions src/commands/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,24 +323,29 @@ pub enum WalletCommands {
},
/// Derive all 10 Stellar addresses (m/44'/148'/0..9') from a BIP39 recovery phrase
Derive,
/// Move wallet secrets into an OS-native secret backend
///
/// Only `--to keychain` is supported. Each plaintext wallet secret is
/// written to the macOS Keychain, the Windows Credential Manager, or the
/// Linux Secret Service, and the configuration is rewritten to keep only a
/// `keychain:<key>` reference instead of the secret.
///
/// On hosts without a usable OS keychain (for example a headless CI
/// runner), a permission-restricted `secrets.json` fallback next to the
/// config is used instead, and the command prints a notice. Migration is
/// idempotent and never drops a key.
/// Sign a base64 transaction envelope XDR with a browser wallet or a local key
///
/// Example:
/// starforge wallet migrate --to keychain
Migrate {
/// Target secret backend. Only `keychain` is supported.
/// starforge wallet sign-tx --transaction unsigned.xdr --signer browser
SignTx {
/// Path to a file containing the base64 transaction envelope XDR
#[arg(long)]
to: String,
transaction: PathBuf,
/// Signer backend: `browser` uses a one-time localhost handoff
#[arg(long, value_enum, default_value = "browser")]
signer: TxSignerKind,
/// Wallet name to use when `--signer local`
#[arg(long)]
wallet: Option<String>,
/// Network for the signing passphrase
#[arg(long, default_value = "testnet", value_parser = ["testnet", "mainnet"])]
network: String,
/// Where to write the signed XDR (defaults to stdout)
#[arg(long)]
output: Option<PathBuf>,
/// Seconds to wait for the browser wallet before aborting
#[arg(long, default_value_t = crate::utils::browser_signer::DEFAULT_HANDOFF_TIMEOUT_SECS)]
timeout: u64,
},
/// Multi-signature account management
#[command(subcommand)]
Expand Down Expand Up @@ -378,6 +383,15 @@ pub enum TxSignerKind {
Local,
}

/// Backend used by `starforge wallet sign-tx`.
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum TxSignerKind {
/// Sign with a browser wallet via a one-time localhost handoff
Browser,
/// Sign with a locally stored secret key
Local,
}

#[derive(Subcommand)]
pub enum MultisigCommands {
/// Create a multi-sig config for an existing wallet
Expand Down Expand Up @@ -587,6 +601,14 @@ pub async fn handle(cmd: WalletCommands) -> Result<()> {
parallelism,
use_global,
} => tune_wallet_kdf(&name, mem, iterations, parallelism, use_global),
WalletCommands::SignTx {
transaction,
signer,
wallet,
network,
output,
timeout,
} => sign_transaction_file(transaction, signer, wallet, network, output, timeout),
WalletCommands::Multisig(cmd) => handle_multisig(cmd).await,
WalletCommands::Tx { args } => crate::commands::tx::handle(args).await,
WalletCommands::Auth { args } => crate::commands::sep::handle(args).await,
Expand Down
Loading