summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-22 20:37:34 +0200
committertv <tv@krebsco.de>2026-10-22 20:52:07 +0200
commit1dcf40f06c27f4deff73df204dc05fbf999234f5 (patch)
tree3da4c7adc7fa46823bef7d5541b686b7b36e804e /src
parent98ca55ee593c1d073241a295c9aba513e8e96e99 (diff)
introduce pre-shared swarm key
Diffstat (limited to 'src')
-rw-r--r--src/main.rs17
-rw-r--r--src/state.rs9
-rw-r--r--src/swarm/auth.rs193
-rw-r--r--src/swarm/mod.rs1
-rw-r--r--src/swarm/peer.rs85
-rw-r--r--src/transport/mod.rs4
-rw-r--r--src/transport/mycelium.rs8
7 files changed, 316 insertions, 1 deletions
diff --git a/src/main.rs b/src/main.rs
index ef9bf54..96758ec 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15,7 +15,7 @@ use anyhow::{Context, Result};
use automerge::AutoCommit;
use clap::Parser;
use tokio::sync::{broadcast, mpsc, watch, RwLock};
-use tracing::{debug, error, info, trace};
+use tracing::{debug, error, info, trace, warn};
use url::Url;
mod api;
@@ -75,6 +75,11 @@ struct Args {
#[arg(long, value_name = "PATH")]
htpasswd: String,
+ /// Pre-shared swarm key: only peers that know it may join (raw bytes, at least 16;
+ /// e.g. `head -c 32 /dev/urandom`). Without it, anyone who knows the swarm ID can.
+ #[arg(long, value_name = "PATH")]
+ swarm_key_file: Option<PathBuf>,
+
/// Keepalive interval; dormancy, resync and probe timings derive from it.
#[arg(long, value_name = "SECONDS", default_value_t = 60)]
keepalive_secs: u64,
@@ -91,6 +96,15 @@ async fn main() -> Result<()> {
let args = Args::parse();
+ // Read before anything starts, so a bad key file fails right away.
+ let swarm_key = match &args.swarm_key_file {
+ Some(path) => Some(Arc::new(swarm::auth::SwarmKey::from_file(path)?)),
+ None => {
+ warn!("No swarm key: any peer that learns the swarm ID can join");
+ None
+ }
+ };
+
// First thing: until a handler is installed, SIGINT and SIGTERM kill the process on
// the spot, and startup (spawning Mycelium, bootstrap) can take minutes.
let stop = watch_for_shutdown().context("Installing signal handlers")?;
@@ -163,6 +177,7 @@ async fn main() -> Result<()> {
change_events,
timing: Timing::from_keepalive(Duration::from_secs(args.keepalive_secs)),
remake: Arc::new(remake::RemakeState::default()),
+ swarm_key,
stale: Default::default(),
};
diff --git a/src/state.rs b/src/state.rs
index 736f55b..49f75dc 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -102,6 +102,10 @@ pub(crate) struct PeerState {
/// Consecutive sync exchanges that changed nothing (livelock detection).
pub(crate) stalled_exchanges: u32,
+ /// The peer proved the swarm key in its HELLO or HELLO_REPLY (always false and
+ /// irrelevant without a swarm key).
+ pub(crate) authenticated: bool,
+
/// The answer we expect to our last sync message(s), if any.
pub(crate) sync_reply: Option<SyncReplyWait>,
/// How long to wait for it; doubles while answers stay out.
@@ -141,6 +145,7 @@ impl Default for PeerState {
last_sync_rx: None,
last_resync_at: None,
stalled_exchanges: 0,
+ authenticated: false,
sync_reply: None,
sync_reply_timeout: SYNC_REPLY_TIMEOUT,
epoch: None,
@@ -364,6 +369,10 @@ pub(crate) struct AppState {
/// Per-node remake state (prebuilt snapshots, switch status, archive).
pub(crate) remake: Arc<RemakeState>,
+ /// Pre-shared key every member must prove in the handshake; None admits anyone who
+ /// knows the swarm ID.
+ pub(crate) swarm_key: Option<Arc<crate::swarm::auth::SwarmKey>>,
+
/// While rejoining a newer epoch, the previous document, served read-only (and
/// marked stale) instead of answering 503.
pub(crate) stale: Arc<std::sync::RwLock<Option<StaleView>>>,
diff --git a/src/swarm/auth.rs b/src/swarm/auth.rs
new file mode 100644
index 0000000..8b1352c
--- /dev/null
+++ b/src/swarm/auth.rs
@@ -0,0 +1,193 @@
+//! Swarm membership by pre-shared key.
+//!
+//! Mycelium authenticates the sender of every message (`src_pk` can't be forged), so we
+//! always know *who* talks to us. With a swarm key configured, only peers that also know
+//! the key may take part: HELLO and HELLO_REPLY carry a tag proving it, and until a peer
+//! has sent a valid one, everything else it sends is ignored.
+//!
+//! The tag is `HMAC-SHA256(key, label ‖ swarm_id ‖ from_pk ‖ to_pk)`. It needs no
+//! challenge: it is bound to both keys, and only `from_pk` can send as `from_pk`, so a
+//! captured tag is useless to anyone else. Different labels for HELLO and HELLO_REPLY
+//! keep one from being passed off as the other. A HELLO has no swarm ID yet (a new node
+//! doesn't know it), so its tag binds an empty one.
+//!
+//! Limits of a shared key: every member holds the same secret, so a single member can't
+//! be revoked (forgetting it lasts until it says HELLO again), and changing the key
+//! means restarting every node. A member list in the document is the proper successor.
+
+use std::path::Path;
+use std::sync::Mutex;
+use std::time::{Duration, Instant};
+
+use anyhow::{bail, Context, Result};
+use sha2::{Digest, Sha256};
+
+const HELLO_LABEL: &[u8] = b"continuum/hello/1";
+const HELLO_REPLY_LABEL: &[u8] = b"continuum/hello-reply/1";
+
+/// The swarm's pre-shared key.
+pub(crate) struct SwarmKey {
+ key: Vec<u8>,
+ rejections: Mutex<Rejections>,
+}
+
+/// For rate limiting the log of rejected handshakes.
+#[derive(Default)]
+struct Rejections {
+ last_logged: Option<Instant>,
+ suppressed: u64,
+}
+
+/// At most one rejection log line per this interval.
+const REJECTION_LOG_INTERVAL: Duration = Duration::from_secs(10);
+
+impl SwarmKey {
+ /// Reads the key from a file; its bytes are the key, as they are. Generate one with
+ /// e.g. `head -c 32 /dev/urandom > swarm.key`.
+ pub(crate) fn from_file(path: &Path) -> Result<Self> {
+ let key = std::fs::read(path)
+ .with_context(|| format!("Reading the swarm key from {}", path.display()))?;
+ if key.len() < 16 {
+ bail!(
+ "The swarm key in {} has {} bytes; use at least 16 (32 random bytes recommended)",
+ path.display(),
+ key.len()
+ );
+ }
+ Ok(Self::new(key))
+ }
+
+ fn new(key: Vec<u8>) -> Self {
+ Self {
+ key,
+ rejections: Mutex::default(),
+ }
+ }
+
+ /// Records a rejected handshake. Returns how many went unlogged since the last log
+ /// line if this one should be logged, None if it should be suppressed.
+ pub(crate) fn note_rejection(&self) -> Option<u64> {
+ let mut rejections = self
+ .rejections
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ let due = rejections
+ .last_logged
+ .is_none_or(|last| last.elapsed() >= REJECTION_LOG_INTERVAL);
+ if !due {
+ rejections.suppressed += 1;
+ return None;
+ }
+ rejections.last_logged = Some(Instant::now());
+ Some(std::mem::take(&mut rejections.suppressed))
+ }
+
+ /// Tag for a HELLO from `from` to `to`.
+ pub(crate) fn hello_tag(&self, from: &str, to: &str) -> Vec<u8> {
+ self.tag(HELLO_LABEL, "", from, to).to_vec()
+ }
+
+ /// Tag for a HELLO_REPLY from `from` to `to`, naming the swarm.
+ pub(crate) fn hello_reply_tag(&self, swarm_id: &str, from: &str, to: &str) -> Vec<u8> {
+ self.tag(HELLO_REPLY_LABEL, swarm_id, from, to).to_vec()
+ }
+
+ pub(crate) fn verify_hello(&self, tag: &[u8], from: &str, to: &str) -> bool {
+ constant_time_eq(tag, &self.tag(HELLO_LABEL, "", from, to))
+ }
+
+ pub(crate) fn verify_hello_reply(
+ &self,
+ tag: &[u8],
+ swarm_id: &str,
+ from: &str,
+ to: &str,
+ ) -> bool {
+ constant_time_eq(tag, &self.tag(HELLO_REPLY_LABEL, swarm_id, from, to))
+ }
+
+ fn tag(&self, label: &[u8], swarm_id: &str, from: &str, to: &str) -> [u8; 32] {
+ // Length-prefixed fields, so no two different inputs encode the same.
+ let mut message = Vec::new();
+ for field in [label, swarm_id.as_bytes(), from.as_bytes(), to.as_bytes()] {
+ message.extend_from_slice(&(field.len() as u32).to_be_bytes());
+ message.extend_from_slice(field);
+ }
+ hmac_sha256(&self.key, &message)
+ }
+}
+
+/// HMAC-SHA256 (RFC 2104), on top of the `sha2` crate we already use.
+fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
+ const BLOCK: usize = 64;
+
+ let mut block_key = [0u8; BLOCK];
+ if key.len() > BLOCK {
+ block_key[..32].copy_from_slice(&Sha256::digest(key));
+ } else {
+ block_key[..key.len()].copy_from_slice(key);
+ }
+
+ let mut inner = Sha256::new();
+ inner.update(block_key.map(|byte| byte ^ 0x36));
+ inner.update(message);
+ let inner = inner.finalize();
+
+ let mut outer = Sha256::new();
+ outer.update(block_key.map(|byte| byte ^ 0x5c));
+ outer.update(inner);
+
+ let mut tag = [0u8; 32];
+ tag.copy_from_slice(&outer.finalize());
+ tag
+}
+
+fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
+ a.len() == b.len() && a.iter().zip(b).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn hex(bytes: &[u8]) -> String {
+ bytes.iter().map(|byte| format!("{byte:02x}")).collect()
+ }
+
+ #[test]
+ fn hmac_matches_rfc_4231() {
+ // Test case 2.
+ assert_eq!(
+ hex(&hmac_sha256(b"Jefe", b"what do ya want for nothing?")),
+ "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
+ );
+ // Test case 6: a key longer than the block size.
+ assert_eq!(
+ hex(&hmac_sha256(
+ &[0xaa; 131],
+ b"Test Using Larger Than Block-Size Key - Hash Key First"
+ )),
+ "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
+ );
+ }
+
+ #[test]
+ fn tags_bind_key_peers_and_kind() {
+ let key = SwarmKey::new(vec![1; 32]);
+ let other = SwarmKey::new(vec![2; 32]);
+
+ let hello = key.hello_tag("A", "B");
+ assert!(key.verify_hello(&hello, "A", "B"));
+ assert!(!other.verify_hello(&hello, "A", "B"));
+ assert!(!key.verify_hello(&hello, "B", "A"));
+ assert!(!key.verify_hello(&hello, "A", "C"));
+ assert!(!key.verify_hello(&[], "A", "B"));
+
+ let reply = key.hello_reply_tag("swarm", "A", "B");
+ assert!(key.verify_hello_reply(&reply, "swarm", "A", "B"));
+ assert!(!key.verify_hello_reply(&reply, "other-swarm", "A", "B"));
+ // A HELLO tag is no HELLO_REPLY tag, and the other way round.
+ assert!(!key.verify_hello_reply(&hello, "", "A", "B"));
+ assert!(!key.verify_hello(&key.hello_reply_tag("", "A", "B"), "A", "B"));
+ }
+}
diff --git a/src/swarm/mod.rs b/src/swarm/mod.rs
index 7316146..a4277c7 100644
--- a/src/swarm/mod.rs
+++ b/src/swarm/mod.rs
@@ -1,6 +1,7 @@
//! The swarm of peers: bootstrap, handshake, keepalive and dormancy (`peer`), and
//! Automerge synchronization with every member (`sync`).
+pub(crate) mod auth;
pub(crate) mod bootstrap;
pub(crate) mod peer;
pub(crate) mod sync;
diff --git a/src/swarm/peer.rs b/src/swarm/peer.rs
index 53a4731..d801b0f 100644
--- a/src/swarm/peer.rs
+++ b/src/swarm/peer.rs
@@ -237,6 +237,11 @@ async fn transmit_hello(state: &AppState, peer: &str, delay: Duration) -> Result
peer,
&ParsedMessage::Hello(transport::HelloRequest {
epoch: state.local_epoch(),
+ auth: state
+ .swarm_key
+ .as_ref()
+ .map(|key| key.hello_tag(state.local_id(), peer))
+ .unwrap_or_default(),
}),
)
.await;
@@ -321,10 +326,16 @@ async fn send_hello_reply(state: &AppState, peer: &str) -> Result<()> {
}
}
+ let auth = state
+ .swarm_key
+ .as_ref()
+ .map(|key| key.hello_reply_tag(&swarm_id, state.local_id(), peer))
+ .unwrap_or_default();
let reply = transport::HelloReply {
swarm_id,
peers,
epoch: state.local_epoch(),
+ auth,
};
let ready = state.is_ready().await;
@@ -563,6 +574,76 @@ async fn observe_peer_epoch(state: &AppState, peer: &str, remote: Epoch) -> bool
relation == EpochRelation::Same
}
+// --- swarm key -----------------------------------------------------------------
+
+/// With a swarm key, only peers that proved it get past this point. A HELLO or
+/// HELLO_REPLY must carry a valid tag, and it admits its sender; anything else is only
+/// accepted from a peer admitted that way. Everything else is ignored without a reply,
+/// so strangers learn nothing, not even whether we know the key.
+async fn admit(state: &AppState, peer: &str, message: &ParsedMessage) -> bool {
+ let Some(key) = &state.swarm_key else {
+ return true;
+ };
+ let local = state.local_id();
+
+ let proof = match message {
+ ParsedMessage::Hello(hello) => Some(key.verify_hello(&hello.auth, peer, local)),
+ ParsedMessage::HelloReply(reply) => {
+ Some(key.verify_hello_reply(&reply.auth, &reply.swarm_id, peer, local))
+ }
+ _ => None,
+ };
+
+ match proof {
+ Some(true) => {
+ remember_peer(state, peer).await;
+ if let Some(peer_state) = state.get_peer_state(peer).await {
+ let mut peer_state = peer_state.lock().await;
+ if !peer_state.authenticated {
+ peer_state.authenticated = true;
+ debug!(peer = %peer, "Peer proved the swarm key");
+ }
+ }
+ true
+ }
+ Some(false) => {
+ // A known peer with a wrong key (e.g. one that was given another key) loses
+ // its admission until it proves the right one, and with it its membership:
+ // we stop syncing our document to it, not just listening to it.
+ if let Some(peer_state) = state.get_peer_state(peer).await {
+ let mut peer_state = peer_state.lock().await;
+ if peer_state.authenticated {
+ warn!(peer = %peer, "Peer no longer proves the swarm key; dropping its membership");
+ }
+ peer_state.authenticated = false;
+ peer_state.membership = PeerMembership::Unknown;
+ peer_state.hello_state = HelloState::NotSent;
+ peer_state.reset_sync();
+ }
+ // The one place an operator can see why a node can't join: rate limited,
+ // since anyone can send us handshakes.
+ if let Some(suppressed) = key.note_rejection() {
+ info!(
+ peer = %peer,
+ suppressed,
+ "Rejected handshake without a valid swarm key proof"
+ );
+ }
+ false
+ }
+ None => {
+ let admitted = match state.get_peer_state(peer).await {
+ Some(peer_state) => peer_state.lock().await.authenticated,
+ None => false,
+ };
+ if !admitted {
+ trace!(peer = %peer, "Ignoring message from a peer that hasn't proven the swarm key");
+ }
+ admitted
+ }
+ }
+}
+
// --- inbound dispatch --------------------------------------------------------
pub(crate) async fn handle_message(
@@ -575,6 +656,10 @@ pub(crate) async fn handle_message(
return Ok(());
}
+ if !admit(state, &peer, &message).await {
+ return Ok(());
+ }
+
remember_peer(state, &peer).await;
// Any inbound message proves liveness.
diff --git a/src/transport/mod.rs b/src/transport/mod.rs
index 0afb5ee..58db611 100644
--- a/src/transport/mod.rs
+++ b/src/transport/mod.rs
@@ -43,6 +43,8 @@ impl ParsedMessage {
#[derive(Debug, Clone)]
pub struct HelloRequest {
pub epoch: Epoch,
+ /// Proof of the swarm key (`swarm::auth`); empty without one.
+ pub auth: Vec<u8>,
}
#[derive(Debug, Clone)]
@@ -50,6 +52,8 @@ pub struct HelloReply {
pub swarm_id: String,
pub peers: Vec<String>,
pub epoch: Epoch,
+ /// Proof of the swarm key (`swarm::auth`); empty without one.
+ pub auth: Vec<u8>,
}
#[derive(Debug, Clone)]
diff --git a/src/transport/mycelium.rs b/src/transport/mycelium.rs
index 194f998..057916f 100644
--- a/src/transport/mycelium.rs
+++ b/src/transport/mycelium.rs
@@ -851,6 +851,8 @@ fn bytes_to_keepalive(bytes: &[u8]) -> Result<Keepalive, TransportError> {
pub struct HelloRequestShadow {
#[serde(default)]
epoch: EpochShadow,
+ #[serde(default)]
+ auth: ByteBuf,
}
#[derive(Serialize, Deserialize)]
@@ -861,6 +863,8 @@ pub struct HelloReplyShadow<'a> {
pub peers: Vec<String>,
#[serde(default)]
epoch: EpochShadow,
+ #[serde(default)]
+ auth: ByteBuf,
}
#[derive(Serialize, Deserialize)]
@@ -905,12 +909,14 @@ mod automerge_msg_codec {
pub fn hello_request_to_bytes(msg: &HelloRequest) -> Result<Vec<u8>, rmp_serde::encode::Error> {
rmp_serde::to_vec(&HelloRequestShadow {
epoch: epoch_to_shadow(&msg.epoch),
+ auth: ByteBuf::from(msg.auth.clone()),
})
}
pub fn bytes_to_hello_request(bytes: &[u8]) -> Result<HelloRequest, TransportError> {
let shadow: HelloRequestShadow = rmp_serde::from_slice(bytes)?;
Ok(HelloRequest {
epoch: epoch_from_shadow(shadow.epoch)?,
+ auth: shadow.auth.into_vec(),
})
}
@@ -920,6 +926,7 @@ pub fn hello_reply_to_bytes(msg: &HelloReply) -> Result<Vec<u8>, rmp_serde::enco
swarm_id: Cow::Borrowed(&msg.swarm_id),
peers: msg.peers.clone(), // Vec must be cloned for serialization wrapper matching
epoch: epoch_to_shadow(&msg.epoch),
+ auth: ByteBuf::from(msg.auth.clone()),
};
rmp_serde::to_vec(&shadow)
}
@@ -929,6 +936,7 @@ pub fn bytes_to_hello_reply(bytes: &[u8]) -> Result<HelloReply, TransportError>
swarm_id: shadow.swarm_id.into_owned(),
peers: shadow.peers,
epoch: epoch_from_shadow(shadow.epoch)?,
+ auth: shadow.auth.into_vec(),
})
}