From 074546a5e101aedafc02af1c8bf3a1897a5c06b3 Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Thu, 22 Oct 2026 16:14:43 -0400 Subject: [PATCH] Enforce the relay pin on the CLI and web sockets --- docs/SECURITY.md | 2 +- .../src/commands/frost_network/attestation.rs | 8 +- keep-cli/src/commands/frost_network/dkg.rs | 28 +- keep-cli/src/commands/frost_network/duress.rs | 98 +++---- .../src/commands/frost_network/hardware.rs | 17 +- keep-cli/src/commands/frost_network/mod.rs | 37 ++- keep-cli/src/commands/secret.rs | 14 +- keep-cli/src/commands/serve.rs | 108 ++++++-- keep-cli/src/commands/wallet.rs | 23 +- keep-cli/src/config.rs | 15 ++ keep-cli/src/main.rs | 3 + keep-cli/src/pinning.rs | 78 ++++++ keep-frost-net/src/lib.rs | 2 + keep-frost-net/src/pin_store.rs | 247 ++++++++++++++++++ keep-frost-net/src/pinned_transport.rs | 6 +- keep-web/src/bunker.rs | 60 ++++- keep-web/src/main.rs | 4 + 17 files changed, 602 insertions(+), 148 deletions(-) create mode 100644 keep-cli/src/pinning.rs create mode 100644 keep-frost-net/src/pin_store.rs diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 02da7148..10d3ed75 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -66,7 +66,7 @@ Keep treats the Nostr relay as untrusted transport, not a trusted party. Coordin - **What a relay (or a TLS man-in-the-middle) can still do** is limited to metadata analysis (which keys talk, and when) and availability attacks (dropping, delaying, or censoring messages). It cannot read or forge coordination. - **For high assurance, run your own relay.** Self-hosting a relay (privkey's [`wisp`](https://github.com/privkeyio/wisp) is designed for this) and pointing your quorum at it removes the third-party relay from the trust and metadata surface entirely; binding it to a private, authenticated network (e.g. a WireGuard mesh) also removes reliance on relay TLS. This is the model the keep-node appliance uses. - **Certificate pinning is available as opt-in defense-in-depth** for the metadata/availability surface when coordinating over an external `wss://` relay (`verify_relay_certificate`, `PinningServerCertVerifier`). It hardens against a relay-TLS man-in-the-middle observing metadata or impersonating the relay endpoint; it is not required for confidentiality or integrity, which the two properties above already provide. -- **Strict pinning closes the first-use window.** By default a relay with no recorded pin is trusted on first connection (TOFU) and its SPKI hash is pinned for subsequent connections, so the very first connection is the exposed one. Enabling strict pinning (the "Strict pinning" toggle under Settings → TLS certificate pinning on desktop, `set_strict_cert_pinning` on mobile) rejects any `wss://` relay that has no recorded pin instead of trusting it. For high-assurance deployments, provision pins out-of-band before enabling strict mode: obtain each relay's SPKI SHA-256 (e.g. `openssl s_client -connect relay.example.com:443 -servername relay.example.com /dev/null | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -hex`) and place it in the pin store (`cert-pins.json` in the Keep data directory on desktop, mapping hostname to one or more lowercase hex SPKI hashes, e.g. `{"relay.example.com":"<64-hex>"}` or `{"relay.example.com":["",""]}`; `stage_certificate_pin` on mobile). On the FROST coordination sockets the pin is enforced inside the TLS handshake of the live connection itself (`PinnedWebSocketTransport`, installed on the relay client), so on those sockets there is no probe-vs-connect gap: a selective attacker presenting one certificate to a pre-flight check and a different one to the data socket is rejected on the data socket. That covers the desktop FROST node, the mobile signing node, the mobile DKG ceremony, the mobile approver's relay socket (`ApproverHandler`, which carries the pairing and approval traffic), and the NIP-46 remote-signing sockets on desktop and mobile in both directions: where Keep serves as the bunker, and where Keep dials out to someone else's bunker for remote signing or device registration. The pre-flight probe still runs ahead of it and is what learns a pin on first use; on mobile it also filters un-pinned relays out of the set under strict mode, while desktop fails the connect outright. The live handshake only ever enforces a pin and never records one, so a connection cannot write trust into the pin store, and a reconnect cannot silently restore a pin you just cleared. Note that with the proxy enabled, a pin learned on first use is observed through the proxy's exit rather than from your own network path, and that pin then also governs later direct connections and strict mode; the probe still applies full WebPKI validation, so the bar remains a CA-valid certificate for the relay hostname, but the first-use trust anchor moves with the proxy. Provision pins out-of-band if that matters to you. Pinning layers on top of standard WebPKI validation and never replaces it, so enabling it can only tighten trust: an expired, mis-chained or wrong-hostname certificate is rejected even when its pinned key matches. Revocation is not checked, matching a typical TLS client. Two limitations remain. First, when relay traffic is routed through the SOCKS proxy or Tor, the stream is built inside the relay stack and the pin cannot be enforced on it: under strict pinning those modes are refused outright, and without strict pinning they fall back to CA-only validation on the data socket even if a pin for that host is recorded, which is a genuine downgrade; it is logged rather than taken quietly, so if you route through Tor or a proxy and want the pin enforced, enable strict pinning so an un-pinnable connection fails instead. The proxy does reach the sockets listed above: the desktop and mobile coordination sockets, the mobile DKG ceremony, the mobile approver and the bunker sockets all carry the configured SOCKS mode, and the pre-flight pin probe dials through the same proxy and hands the relay name to it rather than resolving the name locally, so on those sockets enabling the proxy leaves behind neither a direct connection nor a local DNS lookup. It does not reach `keep-cli` or `keep-web`, which have no proxy setting, as the second limitation below says. Because the proxy resolves the name, the probe's internal-address check does not apply to a proxied connection; what it can reach is whatever the proxy can reach, which is the same exposure the live sockets have. Second, `keep-cli`, `keep-web` and `keep-agent` do not install the pinning transport and do no pin verification at all: none of them has a pin store or a strict toggle, so pinning is not available to them in any form. `keep-cli` and `keep-web` have no proxy setting either, so their relay sockets are always direct; `keep-agent` does honor its proxy setting, including on the bunker it dials out to for remote signing, but that socket is CA-validated only. One behavioral difference between the two pinned bunkers is worth knowing: desktop shares the live pin set with the verifier, so a pin staged or cleared in Settings reaches the bunker's next handshake, and toggling strict pinning reconnects the node and restarts the bunker so the new setting takes effect immediately; on mobile storage is the source of truth and the bunker reads both the pin set and the strict flag once at start, so any change to either while the bunker is running stops it, and it serves nothing until it is started again with the new setting. The mobile approver reads both each time it connects, on start, pairing or a move to the gateway's new relays, and is not stopped by a change, which applies the next time it connects. Outside strict pinning it also connects to relays its probe could not reach, so an approver started offline comes up when the network does; such a relay is checked against the CA roots only, with no pin learned, until a later start probes it. Relays the approver is given must pass the same URL checks as every other relay here, so a gateway's relay on a private, link-local or carrier-grade NAT address is refused, and strict pinning cannot be combined with the proxy on it. For the strongest guarantee, run your own relay on an authenticated private network (above) rather than relying on relay TLS. +- **Strict pinning closes the first-use window.** By default a relay with no recorded pin is trusted on first connection (TOFU) and its SPKI hash is pinned for subsequent connections, so the very first connection is the exposed one. Enabling strict pinning (the "Strict pinning" toggle under Settings → TLS certificate pinning on desktop, `set_strict_cert_pinning` on mobile) rejects any `wss://` relay that has no recorded pin instead of trusting it. For high-assurance deployments, provision pins out-of-band before enabling strict mode: obtain each relay's SPKI SHA-256 (e.g. `openssl s_client -connect relay.example.com:443 -servername relay.example.com /dev/null | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -hex`) and place it in the pin store (`cert-pins.json` in the Keep data directory on desktop, mapping hostname to one or more lowercase hex SPKI hashes, e.g. `{"relay.example.com":"<64-hex>"}` or `{"relay.example.com":["",""]}`; `stage_certificate_pin` on mobile). On the FROST coordination sockets the pin is enforced inside the TLS handshake of the live connection itself (`PinnedWebSocketTransport`, installed on the relay client), so on those sockets there is no probe-vs-connect gap: a selective attacker presenting one certificate to a pre-flight check and a different one to the data socket is rejected on the data socket. That covers the desktop FROST node, the CLI, the web co-signer, the mobile signing node, the mobile DKG ceremony, the mobile approver's relay socket (`ApproverHandler`, which carries the pairing and approval traffic), and the NIP-46 remote-signing sockets on desktop, the CLI, the web co-signer, and mobile in both directions: where Keep serves as the bunker, and where Keep dials out to someone else's bunker for remote signing or device registration. The pre-flight probe still runs ahead of it and is what learns a pin on first use; on mobile it also filters un-pinned relays out of the set under strict mode, while desktop fails the connect outright. The live handshake only ever enforces a pin and never records one, so a connection cannot write trust into the pin store, and a reconnect cannot silently restore a pin you just cleared. Note that with the proxy enabled, a pin learned on first use is observed through the proxy's exit rather than from your own network path, and that pin then also governs later direct connections and strict mode; the probe still applies full WebPKI validation, so the bar remains a CA-valid certificate for the relay hostname, but the first-use trust anchor moves with the proxy. Provision pins out-of-band if that matters to you. Pinning layers on top of standard WebPKI validation and never replaces it, so enabling it can only tighten trust: an expired, mis-chained or wrong-hostname certificate is rejected even when its pinned key matches. Revocation is not checked, matching a typical TLS client. Two limitations remain. First, when relay traffic is routed through the SOCKS proxy or Tor, the stream is built inside the relay stack and the pin cannot be enforced on it: under strict pinning those modes are refused outright, and without strict pinning they fall back to CA-only validation on the data socket even if a pin for that host is recorded, which is a genuine downgrade; it is logged rather than taken quietly, so if you route through Tor or a proxy and want the pin enforced, enable strict pinning so an un-pinnable connection fails instead. The proxy does reach the sockets listed above: the desktop and mobile coordination sockets, the mobile DKG ceremony, the mobile approver and the bunker sockets all carry the configured SOCKS mode, and the pre-flight pin probe dials through the same proxy and hands the relay name to it rather than resolving the name locally, so on those sockets enabling the proxy leaves behind neither a direct connection nor a local DNS lookup. It does not reach `keep-cli` or `keep-web`, which have no proxy setting, as the second limitation below says. Because the proxy resolves the name, the probe's internal-address check does not apply to a proxied connection; what it can reach is whatever the proxy can reach, which is the same exposure the live sockets have. Second, `keep-cli` and `keep-web` install the pinning transport and store `cert-pins.json` in the vault directory. Strict pinning is off unless the CLI config sets `strict_cert_pinning` or the environment variable `KEEP_STRICT_CERT_PINNING` is `1` or `true`; the web co-signer reads only that environment variable. They still have no proxy setting, so their relay sockets are always direct. `keep-agent` does not install the pinning transport and does no pin verification at all: it has no pin store or strict toggle, so pinning is not available to it in any form. It does honor its proxy setting, including on the bunker it dials out to for remote signing, but that socket is CA-validated only. The web co-signer's keep-state replication socket is also unpinned: that relay is an operator-set local or mesh address, and the pinning transport refuses a private address on `wss`, so pinning it would stop replication to the on-box relay. One behavioral difference between the two pinned bunkers is worth knowing: desktop shares the live pin set with the verifier, so a pin staged or cleared in Settings reaches the bunker's next handshake, and toggling strict pinning reconnects the node and restarts the bunker so the new setting takes effect immediately; on mobile storage is the source of truth and the bunker reads both the pin set and the strict flag once at start, so any change to either while the bunker is running stops it, and it serves nothing until it is started again with the new setting. The mobile approver reads both each time it connects, on start, pairing or a move to the gateway's new relays, and is not stopped by a change, which applies the next time it connects. Outside strict pinning it also connects to relays its probe could not reach, so an approver started offline comes up when the network does; such a relay is checked against the CA roots only, with no pin learned, until a later start probes it. Relays the approver is given must pass the same URL checks as every other relay here, so a gateway's relay on a private, link-local or carrier-grade NAT address is refused, and strict pinning cannot be combined with the proxy on it. For the strongest guarantee, run your own relay on an authenticated private network (above) rather than relying on relay TLS. ## Reporting Vulnerabilities diff --git a/keep-cli/src/commands/frost_network/attestation.rs b/keep-cli/src/commands/frost_network/attestation.rs index 79dce412..4c4c115a 100644 --- a/keep-cli/src/commands/frost_network/attestation.rs +++ b/keep-cli/src/commands/frost_network/attestation.rs @@ -559,6 +559,7 @@ fn write_policy_toml(path: &Path, config: &AttestationConfig) -> Result<()> { /// (trust-on-first-use), and write an `--attestation-config` TOML file. pub fn cmd_frost_network_attestation_provision( out: &Output, + path: &Path, group_npub: &str, relay: &str, out_path: &Path, @@ -597,12 +598,9 @@ pub fn cmd_frost_network_attestation_provision( tokio::runtime::Runtime::new().map_err(|e| KeepError::Runtime(format!("tokio: {e}")))?; let config = rt.block_on(async { - let client = Client::new(Keys::generate()); - client - .add_relay(relay) + let client = crate::pinning::connect_client(path, &[relay.to_string()], Keys::generate()) .await - .map_err(|e| err(format!("add relay {relay}: {e}")))?; - client.connect().await; + .map_err(|e| err(e.to_string()))?; // Announces are ephemeral (KFP_EVENT_KIND is in the NIP-16 ephemeral range), so relays // do not store them: a point-in-time fetch (which returns on EOSE) only catches one if a diff --git a/keep-cli/src/commands/frost_network/dkg.rs b/keep-cli/src/commands/frost_network/dkg.rs index 9482a6a4..22914253 100644 --- a/keep-cli/src/commands/frost_network/dkg.rs +++ b/keep-cli/src/commands/frost_network/dkg.rs @@ -203,12 +203,8 @@ fn cmd_frost_network_dkg_hardware( rt.block_on(async { let keys = subkey.clone(); - let client = Client::new(keys.clone()); - client - .add_relay(relay) - .await - .map_err(|e| KeepError::NetworkErr(NetworkError::relay(e.to_string())))?; - client.connect().await; + let client = + crate::pinning::connect_client(vault_path, &[relay.to_string()], keys.clone()).await?; out.info("Connected to relay"); out.field( @@ -623,12 +619,8 @@ fn cmd_frost_network_dkg_software( let outcome = rt.block_on(async { let keys = subkey.clone(); - let client = Client::new(keys.clone()); - client - .add_relay(relay) - .await - .map_err(|e| KeepError::NetworkErr(NetworkError::relay(e.to_string())))?; - client.connect().await; + let client = + crate::pinning::connect_client(vault_path, &[relay.to_string()], keys.clone()).await?; out.info("Connected to relay"); out.field( @@ -874,8 +866,10 @@ fn write_dkg_recovery_stash(path: &std::path::Path, export_json: &str) -> Result /// invite), and nothing is written to a relay. `--publish` opts into the CLI's /// relay-discovery path, which each participant's `dkg` then fetches. #[tracing::instrument(skip(out))] +#[allow(clippy::too_many_arguments)] pub fn cmd_frost_network_group_create( out: &Output, + path: &std::path::Path, name: &str, threshold: u8, participants: u8, @@ -954,15 +948,7 @@ pub fn cmd_frost_network_group_create( rt.block_on(async { let keys = Keys::generate(); - let client = Client::new(keys.clone()); - - for relay in relays { - client - .add_relay(relay) - .await - .map_err(|e| KeepError::NetworkErr(NetworkError::relay(format!("{relay}: {e}"))))?; - } - client.connect().await; + let client = crate::pinning::connect_client(path, relays, keys.clone()).await?; out.info("Connected to relays"); out.field( diff --git a/keep-cli/src/commands/frost_network/duress.rs b/keep-cli/src/commands/frost_network/duress.rs index 109ebf00..0f5661f2 100644 --- a/keep-cli/src/commands/frost_network/duress.rs +++ b/keep-cli/src/commands/frost_network/duress.rs @@ -635,6 +635,7 @@ pub(crate) fn record_recipients( /// password was entered. pub(crate) async fn run_duress_serve( out: &Output, + vault: &Path, beacon: &Keys, group_pubkey: &[u8; 32], recipients: &[PublicKey], @@ -659,59 +660,58 @@ pub(crate) async fn run_duress_serve( // then correlate or silently drop the duress broadcast by that pubkey). The // wraps authenticate via their own seal, not the client key, so a throwaway // AUTH identity loses nothing. - let client = Client::new(Keys::generate()); - if client.add_relay(relay).await.is_ok() { - client.connect().await; - // Broadcast the beacon (one NIP-59 gift wrap per recorded recipient) and - // re-broadcast on an interval with a fresh nonce, not once: a one-shot - // alert would miss any holder briefly offline, reconnecting, or slow to - // subscribe. Holders freeze on the first wrap they verify and short-circuit - // the rest, so repeats are idempotent. The loop keeps the process resident - // (fail-closed: never unlocks, never answers evals). - // - // Delivery is `Output`-driven: connect() returns immediately and a send can - // race the relay's NIP-42 auth (which completes shortly after connect but - // isn't exposed as a status), so we RETRY FAST until a broadcast is actually - // ACCEPTED (the relay appears in `output.success`) , covering the auth race - // and any connection settling , then relax to the re-broadcast cadence. A - // one-shot give-up would leave a coerced holder on a slow box silent. - // RELEASE GATE: the re-broadcast cadence is a relay-observable duress - // signature; traffic-shape indistinguishability is part of the gate. - let mut delivered = false; - loop { - let connected = client - .relays() - .await - .values() - .any(|r| matches!(r.status(), RelayStatus::Connected)); - if !connected { + let keys = Keys::generate(); + let client = loop { + match crate::pinning::connect_client(vault, &[relay.to_string()], keys.clone()).await { + Ok(client) => break client, + Err(e) => { + debug!(error = %e, "relay"); tokio::time::sleep(std::time::Duration::from_secs(1)).await; - continue; } - let broadcast_ok = - match broadcast_beacon(&client, beacon, group_pubkey, recipients).await { - Ok(ok) => ok, - Err(e) => { - debug!(error = %e, "beacon broadcast build failed"); - false - } - }; - if broadcast_ok { - delivered = true; - } - let interval = if delivered { - BEACON_REPUBLISH_INTERVAL_SECS - } else { - BEACON_INITIAL_RETRY_SECS - }; - tokio::time::sleep(std::time::Duration::from_secs(interval)).await; } - } - - // Reached only when the relay could not be added/connected: stay resident so - // the holder appears online but answers no evaluation requests (fail-closed). + }; + // Broadcast the beacon (one NIP-59 gift wrap per recorded recipient) and + // re-broadcast on an interval with a fresh nonce, not once: a one-shot + // alert would miss any holder briefly offline, reconnecting, or slow to + // subscribe. Holders freeze on the first wrap they verify and short-circuit + // the rest, so repeats are idempotent. The loop keeps the process resident + // (fail-closed: never unlocks, never answers evals). + // + // Delivery is `Output`-driven: connect() returns immediately and a send can + // race the relay's NIP-42 auth (which completes shortly after connect but + // isn't exposed as a status), so we RETRY FAST until a broadcast is actually + // ACCEPTED (the relay appears in `output.success`) , covering the auth race + // and any connection settling , then relax to the re-broadcast cadence. A + // one-shot give-up would leave a coerced holder on a slow box silent. + // RELEASE GATE: the re-broadcast cadence is a relay-observable duress + // signature; traffic-shape indistinguishability is part of the gate. + let mut delivered = false; loop { - tokio::time::sleep(std::time::Duration::from_secs(3600)).await; + let connected = client + .relays() + .await + .values() + .any(|r| matches!(r.status(), RelayStatus::Connected)); + if !connected { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + continue; + } + let broadcast_ok = match broadcast_beacon(&client, beacon, group_pubkey, recipients).await { + Ok(ok) => ok, + Err(e) => { + debug!(error = %e, "beacon broadcast build failed"); + false + } + }; + if broadcast_ok { + delivered = true; + } + let interval = if delivered { + BEACON_REPUBLISH_INTERVAL_SECS + } else { + BEACON_INITIAL_RETRY_SECS + }; + tokio::time::sleep(std::time::Duration::from_secs(interval)).await; } } diff --git a/keep-cli/src/commands/frost_network/hardware.rs b/keep-cli/src/commands/frost_network/hardware.rs index b3609fe6..d5f0aad2 100644 --- a/keep-cli/src/commands/frost_network/hardware.rs +++ b/keep-cli/src/commands/frost_network/hardware.rs @@ -100,12 +100,8 @@ pub fn cmd_frost_network_sign_hardware( rt.block_on(async { let keys = Keys::generate(); - let client = Client::new(keys.clone()); - client - .add_relay(relay) - .await - .map_err(|e| KeepError::NetworkErr(NetworkError::relay(e.to_string())))?; - client.connect().await; + let client = + crate::pinning::connect_client(path, &[relay.to_string()], keys.clone()).await?; out.info("Connected to relay"); out.field( @@ -424,13 +420,8 @@ pub fn cmd_frost_network_nonce_precommit( rt.block_on(async { let keys = Keys::generate(); - let client = Client::new(keys.clone()); - - client - .add_relay(relay) - .await - .map_err(|e| KeepError::NetworkErr(NetworkError::relay(e.to_string())))?; - client.connect().await; + let client = + crate::pinning::connect_client(path, &[relay.to_string()], keys.clone()).await?; out.info("Connected to relay"); out.field( diff --git a/keep-cli/src/commands/frost_network/mod.rs b/keep-cli/src/commands/frost_network/mod.rs index beb27794..df5c2453 100644 --- a/keep-cli/src/commands/frost_network/mod.rs +++ b/keep-cli/src/commands/frost_network/mod.rs @@ -232,6 +232,7 @@ pub fn cmd_frost_network_serve( .map_err(|e| KeepError::Runtime(format!("tokio: {e}")))?; return rt.block_on(duress::run_duress_serve( out, + path, &beacon, &group_pubkey, &recipients, @@ -308,9 +309,7 @@ pub fn cmd_frost_network_serve( let unlock_result = rt.block_on(async move { out.info("Starting FROST coordination node..."); - let node = keep_frost_net::KfpNode::new(share, vec![relay.to_string()]) - .await - .map_err(|e| KeepError::Frost(e.to_string()))?; + let node = crate::pinning::connect_node(path, share, vec![relay.to_string()]).await?; let mut node = node.with_descriptor_lookup(Arc::new(descriptor_lookup_for(keep.clone()))); if let Some(policy) = tpm_policy { @@ -804,9 +803,7 @@ pub fn cmd_frost_network_peers( let spinner = out.spinner("Connecting and discovering peers..."); let node = std::sync::Arc::new( - keep_frost_net::KfpNode::new(share, vec![relay.to_string()]) - .await - .map_err(|e| KeepError::Frost(e.to_string()))?, + crate::pinning::connect_node(path, share, vec![relay.to_string()]).await?, ); node.announce() @@ -973,6 +970,7 @@ pub fn cmd_frost_network_sign( tokio::runtime::Runtime::new().map_err(|e| KeepError::Runtime(format!("tokio: {e}")))?; let signature = rt.block_on(frost_network_sign_round( out, + path, share, relay, message_bytes, @@ -1017,15 +1015,14 @@ fn build_unsigned_frost_event( /// requests an aggregated signature, and returns the 64-byte schnorr sig. async fn frost_network_sign_round( out: &Output, + vault: &Path, share: keep_core::frost::SharePackage, relay: &str, message_bytes: Vec, message_type: &str, structured_payload: Option>, ) -> Result<[u8; 64]> { - let mut node = keep_frost_net::KfpNode::new(share, vec![relay.to_string()]) - .await - .map_err(|e| KeepError::Frost(e.to_string()))?; + let mut node = crate::pinning::connect_node(vault, share, vec![relay.to_string()]).await?; out.info("Starting FROST coordination node..."); let pk = node.pubkey(); @@ -1114,15 +1111,14 @@ async fn wait_for_peers( /// signature is obtained before any is returned. pub(crate) async fn key_path_spend_round( out: &Output, + vault: &Path, share: keep_core::frost::SharePackage, relay: &str, timeout: std::time::Duration, spends: Vec, ) -> Result> { let needed = usize::from(share.metadata.threshold.saturating_sub(1)); - let mut node = keep_frost_net::KfpNode::new(share, vec![relay.to_string()]) - .await - .map_err(|e| KeepError::Frost(e.to_string()))?; + let mut node = crate::pinning::connect_node(vault, share, vec![relay.to_string()]).await?; let shutdown_tx = node.take_shutdown_handle(); let node = std::sync::Arc::new(node); let node_clone = node.clone(); @@ -1277,6 +1273,7 @@ pub fn cmd_frost_network_sign_event( tokio::runtime::Runtime::new().map_err(|e| KeepError::Runtime(format!("tokio: {e}")))?; let sig_bytes = rt.block_on(frost_network_sign_round( out, + path, share, relay, event_id.as_bytes().to_vec(), @@ -1360,9 +1357,7 @@ pub fn cmd_frost_network_health_check( rt.block_on(async { let node = std::sync::Arc::new( - keep_frost_net::KfpNode::new(share, vec![relay.to_string()]) - .await - .map_err(|e| KeepError::Frost(e.to_string()))?, + crate::pinning::connect_node(path, share, vec![relay.to_string()]).await?, ); node.announce() @@ -1656,6 +1651,7 @@ pub(crate) fn load_oprf_key_share( #[allow(clippy::too_many_arguments)] pub(crate) async fn run_oprf_request( out: &Output, + vault: &Path, share: keep_core::frost::SharePackage, relay: &str, mut oprf_share: keep_core::oprf::threshold::KeyShare, @@ -1664,9 +1660,7 @@ pub(crate) async fn run_oprf_request( volume_id: &str, epoch: u32, ) -> Result> { - let mut node = keep_frost_net::KfpNode::new(share, vec![relay.to_string()]) - .await - .map_err(|e| KeepError::Frost(e.to_string()))?; + let mut node = crate::pinning::connect_node(vault, share, vec![relay.to_string()]).await?; // Install the OPRF key share BEFORE running so this box can answer its own // quorum's evaluate requests. `set_oprf_key_share` takes a `Copy`, so wipe our // transient parameter copy immediately after; the caller wipes its own. @@ -1717,6 +1711,7 @@ pub(crate) async fn run_oprf_request( /// `--share-file`; errors clearly if either is missing. pub(crate) fn derive_secret_seal_key( out: &Output, + vault: &Path, keep: &Keep, oprf: &crate::cli::OprfSecretArgs, default_relay: &str, @@ -1743,6 +1738,7 @@ pub(crate) fn derive_secret_seal_key( let mut oprf_share = load_oprf_key_share(share_file)?; let result = rt.block_on(run_oprf_request( out, + vault, share, relay, oprf_share, @@ -1829,6 +1825,7 @@ pub fn cmd_frost_network_oprf_unlock( let result = rt.block_on(run_oprf_request( out, + path, share, relay, oprf_share, @@ -1971,9 +1968,7 @@ pub fn cmd_frost_network_oprf_provision( } let dist = rt.block_on(async move { - let mut node = keep_frost_net::KfpNode::new(share, vec![relay.to_string()]) - .await - .map_err(|e| KeepError::Frost(e.to_string()))?; + let mut node = crate::pinning::connect_node(path, share, vec![relay.to_string()]).await?; attestation::set_optional_announce_attestor(out, &mut node, attestor); out.info("Starting FROST coordination node..."); diff --git a/keep-cli/src/commands/secret.rs b/keep-cli/src/commands/secret.rs index f55b9876..2202cc51 100644 --- a/keep-cli/src/commands/secret.rs +++ b/keep-cli/src/commands/secret.rs @@ -151,7 +151,8 @@ pub fn cmd_secret_add( // any other id would seal a value no quorum could reopen. let record = SecretRecord::new(name.to_string(), kind_core, value.to_vec())?; let oprf_id = hex::encode(record.id); - let oprf_key = derive_secret_seal_key(out, &keep, oprf, default_relay, &oprf_id, epoch)?; + let oprf_key = + derive_secret_seal_key(out, path, &keep, oprf, default_relay, &oprf_id, epoch)?; keep.store_sealed_secret(record, &oprf_key, epoch)?; out.success(&format!( "Stored threshold-sealed secret '{name}' ({kind_core:?}); id {oprf_id}. \ @@ -243,8 +244,15 @@ pub fn cmd_secret_get( // leaves no misleading "revealed" entry. let revealed: Zeroizing> = match seal { Some(seal) => { - let oprf_key = - derive_secret_seal_key(out, &keep, oprf, default_relay, &seal.oprf_id, seal.epoch)?; + let oprf_key = derive_secret_seal_key( + out, + path, + &keep, + oprf, + default_relay, + &seal.oprf_id, + seal.epoch, + )?; keep.reveal_sealed_secret(&id, &oprf_key)? } None => keep.reveal_secret(&id)?, diff --git a/keep-cli/src/commands/serve.rs b/keep-cli/src/commands/serve.rs index 645b1679..dd658f95 100644 --- a/keep-cli/src/commands/serve.rs +++ b/keep-cli/src/commands/serve.rs @@ -112,9 +112,8 @@ pub fn cmd_serve( return rt.block_on(async { out.info("Connecting to FROST network..."); - let node = keep_frost_net::KfpNode::new(share, vec![frost_relay.to_string()]) - .await - .map_err(|e| KeepError::Frost(e.to_string()))?; + let node = + crate::pinning::connect_node(path, share, vec![frost_relay.to_string()]).await?; node.announce() .await @@ -137,17 +136,23 @@ pub fn cmd_serve( let transport_key: [u8; 32] = keep_core::crypto::try_random_bytes()?; - let mut server = Server::new_network_frost_with_config( - net_signer, - transport_key, + let bunker = crate::pinning::pin_bunker( + path, &[relay.to_string()], - None, ServerConfig { auto_approve: true, ..ServerConfig::default() }, ) .await?; + let mut server = Server::new_network_frost_with_config( + net_signer, + transport_key, + &[relay.to_string()], + None, + bunker, + ) + .await?; let bunker_url = server.bunker_url(); out.field("Bunker URL", &bunker_url); out.newline(); @@ -234,6 +239,8 @@ pub fn cmd_serve( ..Default::default() }; rt.block_on(async { + let headless_config = + crate::pinning::pin_bunker(path, &[relay.to_string()], headless_config).await?; // Serve a local inspect socket so read-only commands (`keep list`) // can query this daemon instead of failing on the exclusive writer // lock (#533). Owner-only; metadata only. Shares the same keyring. @@ -282,8 +289,18 @@ pub fn cmd_serve( let (bunker_url, npub, keyring_for_tui, transport_key_for_tui) = rt.block_on(async { if let Some(ref frost) = frost_signer { let transport_key: [u8; 32] = keep_core::crypto::try_random_bytes()?; - let server = - Server::new_frost(frost.clone(), transport_key, &[relay.to_string()], None).await?; + let config = + crate::pinning::pin_bunker(path, &[relay.to_string()], ServerConfig::default()) + .await?; + let server = Server::new_with_config( + Arc::new(Mutex::new(Keyring::new())), + Some(frost.clone()), + Some(transport_key), + &[relay.to_string()], + None, + config, + ) + .await?; Ok::<_, KeepError>(( server.bunker_url(), server.pubkey().to_bech32().unwrap_or_default(), @@ -291,7 +308,18 @@ pub fn cmd_serve( Some(transport_key), )) } else { - let server = Server::new(keyring.clone(), &[relay.to_string()], None).await?; + let config = + crate::pinning::pin_bunker(path, &[relay.to_string()], ServerConfig::default()) + .await?; + let server = Server::new_with_config( + keyring.clone(), + None, + None, + &[relay.to_string()], + None, + config, + ) + .await?; Ok::<_, KeepError>(( server.bunker_url(), server.pubkey().to_bech32().unwrap_or_default(), @@ -306,6 +334,7 @@ pub fn cmd_serve( let (mut tui, tui_tx) = crate::tui::Tui::new(bunker_url, npub, relay.to_string()); let tui_tx_clone = tui_tx.clone(); let relay_clone = relay.to_string(); + let vault = path.to_path_buf(); std::thread::spawn(move || { let rt = match tokio::runtime::Runtime::new() { @@ -329,6 +358,21 @@ pub fn cmd_serve( if !global_auto_approve.is_empty() { tui_config.auto_approve_kinds = global_auto_approve; } + let tui_config = match crate::pinning::pin_bunker( + &vault, + std::slice::from_ref(&relay_clone), + tui_config, + ) + .await + { + Ok(config) => config, + Err(e) => { + let _ = tui_tx_clone.send(TuiEvent::Log( + LogEntry::new("system", "server error", false).with_detail(&e.to_string()), + )); + return; + } + }; let mut server = if let (Some(frost), Some(transport_key)) = (frost_signer, transport_key_for_tui) { match Server::new_with_config( @@ -386,12 +430,14 @@ pub fn cmd_serve( } fn spawn_tui_server( + vault: &Path, keyring: Arc>, relay: &str, bunker_url: String, npub: String, global_auto_approve: std::collections::HashSet, ) -> Result<()> { + let vault = vault.to_path_buf(); let (mut tui, tui_tx) = crate::tui::Tui::new(bunker_url, npub, relay.to_string()); let tui_tx_clone = tui_tx.clone(); let relay_clone = relay.to_string(); @@ -418,6 +464,21 @@ fn spawn_tui_server( if !global_auto_approve.is_empty() { tui_config.auto_approve_kinds = global_auto_approve; } + let tui_config = match crate::pinning::pin_bunker( + &vault, + std::slice::from_ref(&relay_clone), + tui_config, + ) + .await + { + Ok(config) => config, + Err(e) => { + let _ = tui_tx_clone.send(TuiEvent::Log( + LogEntry::new("system", "server error", false).with_detail(&e.to_string()), + )); + return; + } + }; let mut server = match Server::new_with_config( keyring, @@ -453,6 +514,7 @@ fn spawn_tui_server( fn run_headless( out: &Output, + vault: &Path, keyring: Arc>, relay: &str, pre_grants: Vec, @@ -475,6 +537,7 @@ fn run_headless( config.auto_approve_kinds = global_auto_approve; } rt.block_on(async { + let config = crate::pinning::pin_bunker(vault, &[relay.to_string()], config).await?; let mut server = Server::new_with_config(keyring, None, None, &[relay.to_string()], None, config) .await?; @@ -488,11 +551,20 @@ fn run_headless( Ok(()) } -fn get_bunker_info(keyring: Arc>, relay: &str) -> Result<(String, String)> { +fn get_bunker_info( + vault: &Path, + keyring: Arc>, + relay: &str, +) -> Result<(String, String)> { let rt = tokio::runtime::Runtime::new().map_err(|e| KeepError::Runtime(format!("tokio: {e}")))?; rt.block_on(async { - let server = Server::new(keyring, &[relay.to_string()], None).await?; + let config = + crate::pinning::pin_bunker(vault, &[relay.to_string()], ServerConfig::default()) + .await?; + let server = + Server::new_with_config(keyring, None, None, &[relay.to_string()], None, config) + .await?; Ok(( server.bunker_url(), server.pubkey().to_bech32().unwrap_or_default(), @@ -541,12 +613,12 @@ fn cmd_serve_outer(out: &Output, path: &Path, relay: &str, headless: bool) -> Re let keyring = Arc::new(Mutex::new(keyring)); if headless { - return run_headless(out, keyring, relay, pre_grants, global_auto_approve); + return run_headless(out, path, keyring, relay, pre_grants, global_auto_approve); } - let (bunker_url, npub) = get_bunker_info(keyring.clone(), relay)?; + let (bunker_url, npub) = get_bunker_info(path, keyring.clone(), relay)?; info!(relay, npub = %npub, "starting TUI"); - spawn_tui_server(keyring, relay, bunker_url, npub, global_auto_approve) + spawn_tui_server(path, keyring, relay, bunker_url, npub, global_auto_approve) } fn cmd_serve_hidden(out: &Output, path: &Path, relay: &str, headless: bool) -> Result<()> { @@ -577,12 +649,12 @@ fn cmd_serve_hidden(out: &Output, path: &Path, relay: &str, headless: bool) -> R let keyring = Arc::new(Mutex::new(keyring)); if headless { - return run_headless(out, keyring, relay, pre_grants, global_auto_approve); + return run_headless(out, path, keyring, relay, pre_grants, global_auto_approve); } - let (bunker_url, npub) = get_bunker_info(keyring.clone(), relay)?; + let (bunker_url, npub) = get_bunker_info(path, keyring.clone(), relay)?; info!(relay, npub = %npub, "starting TUI for hidden volume"); - spawn_tui_server(keyring, relay, bunker_url, npub, global_auto_approve) + spawn_tui_server(path, keyring, relay, bunker_url, npub, global_auto_approve) } /// Translate an already-loaded global `RelayConfig` into the runtime serve diff --git a/keep-cli/src/commands/wallet.rs b/keep-cli/src/commands/wallet.rs index 57f156e9..5751cbbb 100644 --- a/keep-cli/src/commands/wallet.rs +++ b/keep-cli/src/commands/wallet.rs @@ -168,6 +168,7 @@ pub fn cmd_wallet_sign( .map_err(|e| KeepError::Runtime(format!("tokio: {e}")))?; rt.block_on(crate::commands::frost_network::key_path_spend_round( out, + path, share, relay, Duration::from_secs(timeout_secs), @@ -463,7 +464,7 @@ pub fn cmd_wallet_register( let register_outcome = rt.block_on(async { let spinner = out.spinner("Connecting to signer..."); - let client = keep_nip46::Nip46Client::connect_to(device_uri, None, None) + let client = crate::pinning::connect_signer(path, device_uri) .await .map_err(|e| KeepError::Runtime(format!("connect: {e}")))?; spinner.finish(); @@ -1138,9 +1139,7 @@ pub fn cmd_wallet_propose( let keep = Arc::new(Mutex::new(keep)); rt.block_on(async { - let node = keep_frost_net::KfpNode::new(share, vec![relay.to_string()]) - .await - .map_err(|e| KeepError::Frost(e.to_string()))?; + let node = crate::pinning::connect_node(path, share, vec![relay.to_string()]).await?; let node = node.with_descriptor_lookup(Arc::new(descriptor_lookup_for(keep.clone()))); let node = std::sync::Arc::new(node); @@ -1495,9 +1494,7 @@ pub fn cmd_wallet_announce_keys( tokio::runtime::Runtime::new().map_err(|e| KeepError::Runtime(format!("tokio: {e}")))?; rt.block_on(async { - let node = keep_frost_net::KfpNode::new(share, vec![relay.to_string()]) - .await - .map_err(|e| KeepError::Frost(e.to_string()))?; + let node = crate::pinning::connect_node(path, share, vec![relay.to_string()]).await?; let node = node.with_descriptor_lookup(Arc::new(keep_frost_net::KeepDescriptorLookup::new({ let snapshot = descriptor_snapshot.clone(); @@ -1714,9 +1711,7 @@ pub fn cmd_wallet_spend( tokio::runtime::Runtime::new().map_err(|e| KeepError::Runtime(format!("tokio: {e}")))?; rt.block_on(async { - let node = keep_frost_net::KfpNode::new(share, vec![relay.to_string()]) - .await - .map_err(|e| KeepError::Frost(e.to_string()))?; + let node = crate::pinning::connect_node(path, share, vec![relay.to_string()]).await?; let node = node.with_descriptor_lookup(Arc::new(keep_frost_net::KeepDescriptorLookup::new( { let snapshot = descriptor_snapshot.clone(); @@ -1922,6 +1917,7 @@ fn parse_session_id(s: &str) -> Result<[u8; 32]> { #[allow(clippy::too_many_arguments)] async fn approve_psbt_session( out: &Output, + vault: &Path, descriptors: Arc>, node: Arc, session_id: [u8; 32], @@ -2076,7 +2072,7 @@ async fn approve_psbt_session( local_fp )); - let client = keep_nip46::Nip46Client::connect_to(&bunker_uri, None, None) + let client = crate::pinning::connect_signer(vault, &bunker_uri) .await .map_err(|e| KeepError::Frost(format!("NIP-46 connect: {e}")))?; let signed_outcome = async { @@ -2223,9 +2219,7 @@ pub fn cmd_wallet_approve_psbt( registry.insert(fp, format!("signer-{fp}"), uri.clone()); } - let node = keep_frost_net::KfpNode::new(share, vec![relay.to_string()]) - .await - .map_err(|e| KeepError::Frost(e.to_string()))?; + let node = crate::pinning::connect_node(path, share, vec![relay.to_string()]).await?; let node = node.with_descriptor_lookup(Arc::new(keep_frost_net::KeepDescriptorLookup::new( { let snapshot = descriptor_snapshot.clone(); @@ -2293,6 +2287,7 @@ pub fn cmd_wallet_approve_psbt( let bunker = registry_entries[idx].1.clone(); approve_psbt_session( out, + path, descriptor_snapshot.clone(), node.clone(), session_id, diff --git a/keep-cli/src/config.rs b/keep-cli/src/config.rs index 365b7a60..b757b859 100644 --- a/keep-cli/src/config.rs +++ b/keep-cli/src/config.rs @@ -106,6 +106,10 @@ pub struct Config { pub relays: Vec, #[serde(default, deserialize_with = "deserialize_timeout")] pub timeout: Option, + /// Reject a `wss://` relay that has no pin in `cert-pins.json` instead of + /// trusting it on first use. Also enabled by `KEEP_STRICT_CERT_PINNING`. + #[serde(default)] + pub strict_cert_pinning: bool, } impl Config { @@ -198,6 +202,17 @@ timeout = 60 assert_eq!(config.log_level, LogLevel::Debug); assert_eq!(config.relays.len(), 2); assert_eq!(config.timeout, Some(60)); + assert!(!config.strict_cert_pinning); + } + + #[test] + fn strict_cert_pinning_parses_and_defaults_off() { + assert!(!Config::parse("").unwrap().strict_cert_pinning); + assert!( + Config::parse("strict_cert_pinning = true") + .unwrap() + .strict_cert_pinning + ); } #[test] diff --git a/keep-cli/src/main.rs b/keep-cli/src/main.rs index 1b25e669..555e9cc7 100644 --- a/keep-cli/src/main.rs +++ b/keep-cli/src/main.rs @@ -11,6 +11,7 @@ mod output; mod panic; #[cfg(windows)] mod panic_windows; +mod pinning; mod signer; mod tui; #[cfg(feature = "warden")] @@ -529,6 +530,7 @@ fn dispatch_frost_network( let relay = relay.as_deref().unwrap_or(default_relay); commands::frost_network::cmd_frost_network_attestation_provision( out, + path, &group, relay, &out_path, @@ -631,6 +633,7 @@ fn dispatch_frost_network( }; commands::frost_network::cmd_frost_network_group_create( out, + path, &name, threshold, participants, diff --git a/keep-cli/src/pinning.rs b/keep-cli/src/pinning.rs new file mode 100644 index 00000000..2c28c419 --- /dev/null +++ b/keep-cli/src/pinning.rs @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: © 2026 PrivKey LLC +// SPDX-License-Identifier: MIT +use std::path::Path; + +use keep_core::error::{KeepError, Result}; +use keep_frost_net::{KfpNode, RelayPinStore, TlsPinning}; +use keep_nip46::ServerConfig; + +/// Open the vault directory's pin store. Strict mode is on when the CLI +/// config says so or when `KEEP_STRICT_CERT_PINNING` is `1` or `true`. +fn store(vault: &Path) -> Result { + let strict = crate::config::Config::load()?.strict_cert_pinning + || keep_frost_net::env_requests_strict_pinning(); + RelayPinStore::open(vault, strict).map_err(|e| KeepError::Frost(e.to_string())) +} + +async fn probed(vault: &Path, relays: &[String]) -> Result { + let pins = store(vault)?; + pins.probe(relays, None) + .await + .map_err(|e| KeepError::Frost(e.to_string()))?; + Ok(pins.pinning()) +} + +/// Bring up a coordination node whose relay sockets enforce the SPKI pin +/// inside their own handshake. The probe runs first and is what records a +/// trust-on-first-use pin; the handshake only checks it. +pub async fn connect_node( + vault: &Path, + share: keep_core::frost::SharePackage, + relays: Vec, +) -> Result { + let pinning = probed(vault, &relays).await?; + KfpNode::new_pinned(share, relays, None, pinning) + .await + .map_err(|e| KeepError::Frost(e.to_string())) +} + +/// Probe `relays` and attach the resulting pin handle to a bunker config. +pub async fn pin_bunker( + vault: &Path, + relays: &[String], + config: ServerConfig, +) -> Result { + let tls_pinning = Some(probed(vault, relays).await?); + Ok(ServerConfig { + tls_pinning, + ..config + }) +} + +/// A nostr client whose sockets enforce the vault pin. The probe runs first. +pub async fn connect_client( + vault: &Path, + relays: &[String], + keys: nostr_sdk::prelude::Keys, +) -> Result { + let pinning = probed(vault, relays).await?; + let client = keep_frost_net::build_client(&keys, None, Some(pinning)) + .map_err(|e| KeepError::Frost(e.to_string()))?; + for relay in relays { + client + .add_relay(relay) + .await + .map_err(|e| KeepError::Frost(format!("add relay {relay}: {e}")))?; + } + client.connect().await; + Ok(client) +} + +/// Dial a remote bunker. The relays come from the URI, so they are probed +/// before the client exists; passing no pin handle is what this replaces. +pub async fn connect_signer(vault: &Path, uri: &str) -> Result { + let (_, relays, _) = keep_nip46::parse_bunker_url(uri) + .map_err(|e| KeepError::InvalidInput(format!("invalid NIP-46 URI: {e}")))?; + let pinning = probed(vault, &relays).await?; + keep_nip46::Nip46Client::connect_to(uri, None, Some(pinning)).await +} diff --git a/keep-frost-net/src/lib.rs b/keep-frost-net/src/lib.rs index 11510940..fa9f2a38 100644 --- a/keep-frost-net/src/lib.rs +++ b/keep-frost-net/src/lib.rs @@ -74,6 +74,7 @@ mod nonce_pool; mod nonce_store; mod oprf_session; mod peer; +mod pin_store; mod pinned_transport; mod pinning; pub mod proof; @@ -135,6 +136,7 @@ pub use oprf_session::{ OprfUnlockSessionState, MAX_OPRF_EVALS_PER_WINDOW, OPRF_EVAL_WINDOW, }; pub use peer::{AttestationStatus, Peer, PeerManager, PeerStatus}; +pub use pin_store::{env_requests_strict_pinning, RelayPinStore}; pub use pinned_transport::{ build_client, wait_for_any_relay, with_proxy, PinnedWebSocketTransport, TlsPinning, }; diff --git a/keep-frost-net/src/pin_store.rs b/keep-frost-net/src/pin_store.rs new file mode 100644 index 00000000..d7f74b6a --- /dev/null +++ b/keep-frost-net/src/pin_store.rs @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: © 2026 PrivKey LLC +// SPDX-License-Identifier: MIT +use std::fs::OpenOptions; +use std::io::Write; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use crate::cert_pin::{verify_relay_certificate, CertificatePinSet}; +use crate::error::{FrostNetError, Result}; +use crate::pinned_transport::TlsPinning; + +const FILE_NAME: &str = "cert-pins.json"; + +/// On-disk SPKI pin set for a process that has a data directory and no +/// settings UI. The live handshake reads this set and never writes it; the +/// probe below is what records a trust-on-first-use pin. +pub struct RelayPinStore { + path: PathBuf, + pins: Arc>, + require_pinned: bool, +} + +impl RelayPinStore { + /// Load `cert-pins.json` from `dir`. A missing file is a first run. A + /// file that cannot be read, or that contains a pin that cannot be + /// decoded, is refused: dropping it would let the next connection pin + /// whatever certificate is presented. + pub fn open(dir: &Path, require_pinned: bool) -> Result { + let path = dir.join(FILE_NAME); + let pins = match std::fs::read(&path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => CertificatePinSet::new(), + Err(e) => { + return Err(FrostNetError::Transport(format!( + "certificate pin store {}: {e}", + path.display() + ))) + } + Ok(bytes) => { + let (pins, malformed) = + CertificatePinSet::from_json_bytes(&bytes).map_err(|e| { + FrostNetError::Transport(format!( + "certificate pin store {}: {e}", + path.display() + )) + })?; + if !malformed.is_empty() { + return Err(FrostNetError::Transport(format!( + "certificate pin store {} is corrupt ({})", + path.display(), + malformed.join("; ") + ))); + } + pins + } + }; + Ok(Self { + path, + pins: Arc::new(Mutex::new(pins)), + require_pinned, + }) + } + + /// The handle the live relay socket enforces. It shares this set, so a + /// pin the probe just recorded is the one the handshake checks. + pub fn pinning(&self) -> TlsPinning { + TlsPinning::new(self.pins.clone(), self.require_pinned) + } + + pub fn is_pinned(&self, hostname: &str) -> Result { + Ok(self.lock()?.is_pinned(hostname)) + } + + /// Learn or enforce a pin for every `wss://` relay, and persist any pin + /// learned for the first time. A cleartext relay is skipped unless strict + /// pinning is on, in which case it is refused before anything is dialed. + pub async fn probe(&self, relays: &[String], proxy: Option) -> Result<()> { + crate::install_default_crypto_provider(); + for relay in relays { + if !relay.starts_with("wss://") { + if self.require_pinned { + return Err(FrostNetError::Transport(format!( + "strict pinning refuses non-wss relay {relay}" + ))); + } + continue; + } + let snapshot = self.lock()?.clone(); + let (_hash, learned) = + verify_relay_certificate(relay, &snapshot, self.require_pinned, proxy).await?; + if let Some((hostname, hash)) = learned { + let mut pins = self.lock()?; + if !pins.is_pinned(&hostname) { + pins.add_pin(hostname.clone(), hash); + drop(pins); + self.persist()?; + tracing::info!(hostname = %hostname, "recorded relay certificate pin"); + } + } + } + Ok(()) + } + + fn lock(&self) -> Result> { + self.pins + .lock() + .map_err(|_| FrostNetError::Transport("certificate pin lock poisoned".into())) + } + + fn persist(&self) -> Result<()> { + if let Some(reasons) = corruption(&self.path)? { + return Err(FrostNetError::Transport(format!( + "refusing to overwrite corrupt certificate pin store ({})", + reasons.join("; ") + ))); + } + let json = serde_json::to_string_pretty(&self.lock()?.to_hex_map()) + .map_err(|e| FrostNetError::Transport(format!("serialize certificate pins: {e}")))?; + write_private(&self.path, json.as_bytes()) + } +} + +/// `KEEP_STRICT_CERT_PINNING=1` or `true` turns strict pinning on for a +/// process that has no settings screen. +pub fn env_requests_strict_pinning() -> bool { + value_requests_strict(std::env::var("KEEP_STRICT_CERT_PINNING").ok().as_deref()) +} + +fn value_requests_strict(value: Option<&str>) -> bool { + matches!(value, Some("1") | Some("true") | Some("TRUE")) +} + +fn corruption(path: &Path) -> Result>> { + match std::fs::read(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(FrostNetError::Transport(format!( + "certificate pin store {}: {e}", + path.display() + ))), + Ok(bytes) => match CertificatePinSet::from_json_bytes(&bytes) { + Ok((_, malformed)) if malformed.is_empty() => Ok(None), + Ok((_, malformed)) => Ok(Some(malformed)), + Err(e) => Ok(Some(vec![format!("parse error: {e}")])), + }, + } +} + +fn write_private(path: &Path, data: &[u8]) -> Result<()> { + let tmp = path.with_file_name(".cert-pins.json.tmp"); + let mut opts = OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut file = opts + .open(&tmp) + .map_err(|e| FrostNetError::Transport(format!("write {}: {e}", tmp.display())))?; + file.write_all(data) + .map_err(|e| FrostNetError::Transport(format!("write {}: {e}", tmp.display())))?; + file.sync_all() + .map_err(|e| FrostNetError::Transport(format!("sync {}: {e}", tmp.display())))?; + std::fs::rename(&tmp, path) + .map_err(|e| FrostNetError::Transport(format!("replace {}: {e}", path.display())))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dir() -> tempfile::TempDir { + tempfile::tempdir().unwrap() + } + + #[test] + fn a_missing_store_opens_empty() { + let dir = dir(); + let store = RelayPinStore::open(dir.path(), false).unwrap(); + assert!(!store.is_pinned("relay.example.com").unwrap()); + assert!(!store.pinning().require_pinned()); + } + + #[test] + fn a_recorded_pin_survives_reopen_and_strict_mode_is_on_the_handle() { + let dir = dir(); + let store = RelayPinStore::open(dir.path(), false).unwrap(); + { + let mut pins = store.lock().unwrap(); + pins.add_pin("relay.example.com".into(), [9u8; 32]); + } + store.persist().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.path().join(FILE_NAME)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + let again = RelayPinStore::open(dir.path(), true).unwrap(); + assert!(again.is_pinned("relay.example.com").unwrap()); + assert!(again.pinning().require_pinned()); + } + + #[test] + fn a_corrupt_store_refuses_to_open() { + let dir = dir(); + std::fs::write(dir.path().join(FILE_NAME), b"not json").unwrap(); + assert!(RelayPinStore::open(dir.path(), false).is_err()); + } + + #[test] + fn a_malformed_pin_refuses_to_open() { + let dir = dir(); + std::fs::write(dir.path().join(FILE_NAME), br#"{"relay.example.com":"zz"}"#).unwrap(); + let Err(err) = RelayPinStore::open(dir.path(), false) else { + panic!("corrupt pin store opened"); + }; + assert!(err.to_string().contains("corrupt"), "{err}"); + } + + #[tokio::test] + async fn strict_pinning_refuses_a_cleartext_relay_before_dialing() { + let dir = dir(); + let store = RelayPinStore::open(dir.path(), true).unwrap(); + let err = store + .probe(&["ws://127.0.0.1:1".into()], None) + .await + .unwrap_err(); + assert!(err.to_string().contains("non-wss"), "{err}"); + assert!(!dir.path().join(FILE_NAME).exists()); + } + + #[test] + fn env_strict_accepts_only_one_and_true() { + assert!(value_requests_strict(Some("1"))); + assert!(value_requests_strict(Some("true"))); + assert!(value_requests_strict(Some("TRUE"))); + assert!(!value_requests_strict(Some("yes"))); + assert!(!value_requests_strict(Some(""))); + assert!(!value_requests_strict(None)); + } +} diff --git a/keep-frost-net/src/pinned_transport.rs b/keep-frost-net/src/pinned_transport.rs index 10958566..3aa2a9ca 100644 --- a/keep-frost-net/src/pinned_transport.rs +++ b/keep-frost-net/src/pinned_transport.rs @@ -275,8 +275,10 @@ impl PinnedWebSocketTransport { // Screen the addresses we are about to dial, matching the probe's // guard. DNS resolution happens here, after any URL-level check, so // this is what actually stops a rebind onto a loopback or - // link-local address. - let addrs: Vec = if ALLOW_INTERNAL_HOSTS { + // link-local address. A cleartext relay has no certificate to pin, + // and strict mode already refused it above; screening it too would + // reject the local relays a `ws://` client dialed before pinning. + let addrs: Vec = if ALLOW_INTERNAL_HOSTS || url.scheme() != "wss" { addrs } else { addrs diff --git a/keep-web/src/bunker.rs b/keep-web/src/bunker.rs index 0d109c8f..cdf63a41 100644 --- a/keep-web/src/bunker.rs +++ b/keep-web/src/bunker.rs @@ -245,6 +245,10 @@ pub struct NetworkConfig { /// the share was stored without it, so the caller can store it in the vault. pub learned_verifying_shares: tokio::sync::mpsc::UnboundedSender<(u16, std::collections::BTreeMap)>, + /// Vault directory that holds `cert-pins.json`. + pub pin_dir: std::path::PathBuf, + /// Refuse a relay that has no recorded pin, and refuse a non-`wss` relay. + pub require_pinned: bool, } /// What `spawn_network_frost` reports back once the co-signer is up. @@ -253,6 +257,28 @@ pub struct NetworkHandle { pub node: Arc, } +/// Probe every relay, record a first-use pin, and return the handle the live +/// handshake enforces. A corrupt pin file, or a cleartext relay while strict +/// pinning is on, refuses to connect. +async fn relay_pinning( + pin_dir: &std::path::Path, + require_pinned: bool, + frost_relays: &[String], + bunker_relays: &[String], +) -> Result { + let store = keep_frost_net::RelayPinStore::open(pin_dir, require_pinned) + .map_err(|e| format!("certificate pins: {e}"))?; + store + .probe(frost_relays, None) + .await + .map_err(|e| format!("certificate pins: {e}"))?; + store + .probe(bunker_relays, None) + .await + .map_err(|e| format!("certificate pins: {e}"))?; + Ok(store.pinning()) +} + /// Spawns the always-on network-FROST co-signer: a long-lived `KfpNode` that /// holds one share and coordinates with peers over the FROST relays, fronted by /// a NIP-46 bunker on the bunker relays. Runs on a dedicated thread+runtime @@ -276,7 +302,28 @@ pub fn spawn_network_frost( let threshold = cfg.share.metadata.threshold; let total = cfg.share.metadata.total_shares; - let node = match KfpNode::new(cfg.share, cfg.frost_relays.clone()).await { + let pinning = match relay_pinning( + &cfg.pin_dir, + cfg.require_pinned, + &cfg.frost_relays, + &cfg.bunker_relays, + ) + .await + { + Ok(pinning) => pinning, + Err(e) => { + let _ = info_tx.send(Err(e)); + return; + } + }; + let node = match KfpNode::new_pinned( + cfg.share, + cfg.frost_relays.clone(), + None, + pinning.clone(), + ) + .await + { Ok(n) => Arc::new(n), Err(e) => { let _ = info_tx.send(Err(format!("frost node: {e}"))); @@ -343,6 +390,7 @@ pub fn spawn_network_frost( &cfg.bunker_relays, Some(callbacks), ServerConfig { + tls_pinning: Some(pinning), // Least privilege for the co-signer: connect handshake + // signing only, not NIP-04/44 encrypt/decrypt. connect_grant: Permission::GET_PUBLIC_KEY | Permission::SIGN_EVENT, @@ -396,6 +444,8 @@ pub fn spawn_single_key( bunker_secret: String, events: broadcast::Sender, approvals: Arc>>>, + pin_dir: std::path::PathBuf, + require_pinned: bool, ) -> Result { let (info_tx, info_rx) = channel::>(); @@ -413,6 +463,13 @@ pub fn spawn_single_key( approvals, signing_enabled: None, }); + let pinning = match relay_pinning(&pin_dir, require_pinned, &[], &relays).await { + Ok(pinning) => pinning, + Err(e) => { + let _ = info_tx.send(Err(e)); + return; + } + }; let mut server = match Server::new_with_config( keyring, None, @@ -420,6 +477,7 @@ pub fn spawn_single_key( &relays, Some(callbacks), ServerConfig { + tls_pinning: Some(pinning), // Least privilege for the single-key fallback: connect + // signing only. connect_grant: Permission::GET_PUBLIC_KEY | Permission::SIGN_EVENT, diff --git a/keep-web/src/main.rs b/keep-web/src/main.rs index 85ea13a4..719a570d 100644 --- a/keep-web/src/main.rs +++ b/keep-web/src/main.rs @@ -392,6 +392,8 @@ async fn main() -> Result<(), Box> { bunker_secret: state::load_or_create_bunker_secret(&vault_path)?, transport_key: state::load_or_create_transport_key(&vault_path)?, learned_verifying_shares: learned_tx, + pin_dir: vault_path.clone(), + require_pinned: keep_frost_net::env_requests_strict_pinning(), }, events.clone(), approvals.clone(), @@ -420,6 +422,8 @@ async fn main() -> Result<(), Box> { bunker_secret, events.clone(), approvals.clone(), + vault_path.clone(), + keep_frost_net::env_requests_strict_pinning(), ) .map_err(|e| format!("failed to start bunker: {e}"))? } else {