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
2 changes: 1 addition & 1 deletion docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 2>/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":["<hex1>","<hex2>"]}`; `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 2>/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":["<hex1>","<hex2>"]}`; `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

Expand Down
8 changes: 3 additions & 5 deletions keep-cli/src/commands/frost_network/attestation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
28 changes: 7 additions & 21 deletions keep-cli/src/commands/frost_network/dkg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading