From 862b44386795c47a9adf027367dba39225d9ff42 Mon Sep 17 00:00:00 2001 From: Henry <162936729+Henrypave@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:56:56 +0100 Subject: [PATCH] feat(wallet): sign transactions with a browser wallet via a one-time localhost handoff --- docs/BROWSER_WALLET_SIGNING.md | 103 +++ docs/SECURITY_THREAT_MODEL.md | 40 +- src/commands/wallet.rs | 117 +++- src/utils/browser_signer.rs | 1106 ++++++++++++++++++++++++++++++++ src/utils/mod.rs | 1 + 5 files changed, 1364 insertions(+), 3 deletions(-) create mode 100644 docs/BROWSER_WALLET_SIGNING.md create mode 100644 src/utils/browser_signer.rs diff --git a/docs/BROWSER_WALLET_SIGNING.md b/docs/BROWSER_WALLET_SIGNING.md new file mode 100644 index 00000000..cbde7d1c --- /dev/null +++ b/docs/BROWSER_WALLET_SIGNING.md @@ -0,0 +1,103 @@ +# 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 \ + --network testnet \ + --timeout 120 \ + --output signed.xdr +``` + +- `--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 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 + +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 cd10aeca..eb4a5eae 100644 --- a/docs/SECURITY_THREAT_MODEL.md +++ b/docs/SECURITY_THREAT_MODEL.md @@ -9,6 +9,8 @@ asset and boundary below in their design or pull request. - Wallet secret keys, encrypted key material, recovery shares, and signing requests. +- 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. @@ -22,13 +24,15 @@ asset and boundary below in their design or pull request. 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. +accidentally approve unsafe commands or disclose secrets through prompts. The +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 | +| 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 | @@ -46,6 +50,39 @@ accidentally approve unsafe commands or disclose secrets through prompts. 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. 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 ` + + +"#; + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine::general_purpose, Engine as _}; + + fn sample_request() -> BrowserSignRequest { + BrowserSignRequest::new( + general_purpose::STANDARD.encode(b"unsigned-envelope-bytes"), + "testnet", + ) + } + + fn request(method: &str, path: &str, query: &str, body: Vec) -> HttpRequest { + HttpRequest { + method: method.to_string(), + path: path.to_string(), + query: query.to_string(), + body, + } + } + + #[test] + fn nonce_is_single_use() { + let mut guard = NonceGuard::generate(); + assert!(!guard.is_consumed()); + assert_eq!(guard.value().len(), NONCE_BYTES * 2); + + let nonce = guard.value().to_string(); + guard.verify_and_consume(&nonce).unwrap(); + assert!(guard.is_consumed()); + assert!(matches!( + guard.verify_and_consume(&nonce), + Err(HandoffError::NonceAlreadyUsed) + )); + } + + #[test] + fn wrong_nonce_is_rejected_without_consuming() { + let mut guard = NonceGuard::generate(); + assert!(matches!( + guard.verify("deadbeef"), + Err(HandoffError::InvalidNonce) + )); + assert!(matches!(guard.verify(""), Err(HandoffError::MissingNonce))); + assert!(!guard.is_consumed()); + + let nonce = guard.value().to_string(); + guard.verify_and_consume(&nonce).unwrap(); + assert!(guard.is_consumed()); + } + + #[test] + fn csp_is_strict_and_scoped_to_the_nonce() { + let csp = content_security_policy("abc123"); + assert!(csp.contains("default-src 'none'")); + assert!(csp.contains("script-src 'nonce-abc123' https://cdn.jsdelivr.net")); + assert!(csp.contains("connect-src 'self'")); + assert!(csp.contains("object-src 'none'")); + assert!(csp.contains("base-uri 'none'")); + assert!(csp.contains("frame-ancestors 'none'")); + assert!(!csp.contains("'unsafe-eval'")); + } + + #[test] + fn page_requires_the_nonce_and_sets_csp() { + let sign_request = sample_request(); + let mut guard = NonceGuard::from_nonce("abc123"); + + let (missing, _) = handle_request( + &request("GET", "/", "", Vec::new()), + &mut guard, + &sign_request, + "abc123", + ); + assert_eq!(missing.status, 400); + + let (wrong, _) = handle_request( + &request("GET", "/", "nonce=nope", Vec::new()), + &mut guard, + &sign_request, + "abc123", + ); + assert_eq!(wrong.status, 403); + + let (page, signed) = handle_request( + &request("GET", "/", "nonce=abc123", Vec::new()), + &mut guard, + &sign_request, + "abc123", + ); + assert_eq!(page.status, 200); + assert!(signed.is_none()); + assert_eq!(page.content_type, "text/html; charset=utf-8"); + let csp = page + .headers + .iter() + .find(|(name, _)| *name == "Content-Security-Policy") + .map(|(_, value)| value.as_str()) + .expect("page must carry a CSP header"); + assert!(csp.contains("nonce-abc123")); + let html = String::from_utf8(page.body).unwrap(); + assert!(html.contains("nonce=\"abc123\"")); + assert!(html.contains("@creit.tech/stellar-wallets-kit")); + assert!(!html.contains("__NONCE__")); + assert!(!html.contains("__KIT_VERSION__")); + } + + #[test] + fn invalid_signature_does_not_burn_the_nonce() { + let sign_request = sample_request(); + let mut guard = NonceGuard::from_nonce("abc123"); + let body = + serde_json::json!({ "nonce": "abc123", "signedXdr": "not base64!!" }).to_string(); + + let (response, signed) = handle_request( + &request( + "POST", + "/api/sign-result", + "nonce=abc123", + body.into_bytes(), + ), + &mut guard, + &sign_request, + "abc123", + ); + assert_eq!(response.status, 400); + assert!(signed.is_none()); + assert!( + !guard.is_consumed(), + "a malformed payload must not burn the nonce" + ); + } + + #[test] + fn response_is_rejected_once_the_nonce_is_used() { + let sign_request = sample_request(); + let signed = general_purpose::STANDARD.encode(b"signed-envelope-bytes"); + let mut guard = NonceGuard::from_nonce("abc123"); + + let body = serde_json::json!({ "nonce": "abc123", "signedXdr": &signed }).to_string(); + let (first, returned) = handle_request( + &request( + "POST", + "/api/sign-result", + "nonce=abc123", + body.clone().into_bytes(), + ), + &mut guard, + &sign_request, + "abc123", + ); + assert_eq!(first.status, 200); + assert_eq!(returned.as_deref(), Some(signed.as_str())); + + let (second, _) = handle_request( + &request( + "POST", + "/api/sign-result", + "nonce=abc123", + body.into_bytes(), + ), + &mut guard, + &sign_request, + "abc123", + ); + assert_eq!(second.status, 410); + } + + #[test] + fn http_handshake_returns_the_signed_xdr() { + let sign_request = sample_request(); + let mut server = + HandoffServer::bind(sign_request, Duration::from_secs(5)).expect("bind handoff"); + let port = server.port(); + let nonce = server.nonce().to_string(); + let signed = general_purpose::STANDARD.encode(b"signed-envelope-bytes"); + + let handle = std::thread::spawn(move || server.wait()); + + let mut get = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap(); + write!( + get, + "GET /api/transaction?nonce={} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", + nonce + ) + .unwrap(); + let mut get_response = String::new(); + get.read_to_string(&mut get_response).unwrap(); + assert!(get_response.contains("200 OK"), "{}", get_response); + assert!(get_response.contains("transactionXdr"), "{}", get_response); + + let payload = serde_json::json!({ "nonce": &nonce, "signedXdr": &signed }).to_string(); + let mut post = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap(); + write!( + post, + "POST /api/sign-result?nonce={} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ + Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + nonce, + payload.len(), + payload + ) + .unwrap(); + let mut post_response = String::new(); + post.read_to_string(&mut post_response).unwrap(); + assert!(post_response.contains("200 OK"), "{}", post_response); + + let returned = handle.join().unwrap().unwrap(); + assert_eq!(returned, signed); + } + + #[test] + fn handoff_times_out_without_a_wallet() { + let mut server = HandoffServer::bind(sample_request(), Duration::from_millis(150)).unwrap(); + let start = Instant::now(); + let err = server.wait().unwrap_err(); + assert!(matches!(err, HandoffError::Timeout(_))); + assert!(start.elapsed() >= Duration::from_millis(100)); + } + + #[test] + fn mock_signer_round_trips_and_propagates_failures() { + let sign_request = sample_request(); + let valid = general_purpose::STANDARD.encode(b"mock-signed"); + assert_eq!( + sign_with(&MockWalletSigner::returning(valid.clone()), &sign_request).unwrap(), + valid + ); + + assert!(matches!( + sign_with(&MockWalletSigner::timing_out(), &sign_request), + Err(HandoffError::Timeout(_)) + )); + assert!(matches!( + sign_with(&MockWalletSigner::rejected(), &sign_request), + Err(HandoffError::WalletRejected(_)) + )); + } + + #[test] + fn decoded_summary_flags_invalid_base64() { + let bad = BrowserSignRequest { + transaction_xdr: "!!not-base64!!".to_string(), + network: "testnet".to_string(), + network_passphrase: "Test SDF Network ; September 2015".to_string(), + }; + let decoded = bad.decoded_summary(); + assert!(!decoded.base64_valid); + assert_eq!(decoded.byte_length, 0); + assert_eq!(decoded.envelope_type, "unknown"); + + let good = BrowserSignRequest::new( + general_purpose::STANDARD.encode([0u8, 0, 0, 2, 1, 2, 3]), + "testnet", + ); + let decoded = good.decoded_summary(); + assert!(decoded.base64_valid); + assert_eq!(decoded.byte_length, 7); + assert_eq!(decoded.envelope_type, "ENVELOPE_TYPE_TX"); + assert_eq!(decoded.sha256.len(), 64); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 496f6ccb..76bee3ac 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -49,6 +49,7 @@ pub mod batch_forecast; pub mod benchmarking; pub mod bindings; pub mod bridge; +pub mod browser_signer; pub mod call_graph; pub mod cargo_lock; pub mod cargo_metadata;