From f5c829005cb87cd819616f209f763afdf940147c Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 11 Sep 2026 20:06:40 -0400 Subject: [PATCH] fix(serve)!: bind loopback on a .localhost zone, and refuse acme without an owner A default run mints accounts with no attestation claim and holds blobs in memory, and it was listening on every interface, so anybody on the network could spend both. It binds the loopback address under a `.localhost` zone now; a real zone still listens everywhere, and refuses to start under `--tls acme` without an `--owner` every account it mints would name forever. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I779f2039cccabdadd4ed6bd141b9c046c30f74c8 --- crates/didbot-serve/src/bin/didbot-pds.rs | 112 ++++++++++++++++++++-- 1 file changed, 102 insertions(+), 10 deletions(-) diff --git a/crates/didbot-serve/src/bin/didbot-pds.rs b/crates/didbot-serve/src/bin/didbot-pds.rs index 16a0e5e9..98ff5c26 100644 --- a/crates/didbot-serve/src/bin/didbot-pds.rs +++ b/crates/didbot-serve/src/bin/didbot-pds.rs @@ -327,7 +327,9 @@ usage: didbot-pds [options] [capacity] also takes evaluation_log_retention_days: how long a sealed segment of the policy evaluation log survives before it is trimmed (default 30). - --port listen port (default 3000) + --port listen port (default 3000). A .localhost zone listens on + the loopback address; any other zone listens on every + address this host has. --zone zone agents are minted under (default agents.localhost). May be given more than once: the first is the primary -- the server's own hostname, and the only zone accounts are @@ -335,9 +337,9 @@ usage: didbot-pds [options] backend and, under --tls acme, its own certificate pair. --owner the operator DID every account this run mints names as answerable for it (default did:web:owner.invalid, which - resolves to nothing). Outside .localhost, the server polls - this DID's repository for its bot.did.operator claim and - policy records. + resolves to nothing; required under --tls acme). Outside + .localhost, the server polls this DID's repository for its + bot.did.operator claim and policy records. --demo provision n agents on startup, then delete and pin one. Refused unless the primary zone is under .localhost. --names how to name agents. `hostname` (the default) uses the @@ -1294,6 +1296,19 @@ async fn run(mut args: Args) -> Result<(), String> { )); } } + // A run behind a real certificate is a public one, and every account + // it mints names the owner it was started with. `DEFAULT_OWNER` + // resolves to nothing, so every one of them would be an account + // nobody can be asked about — permanently, since the owner is written + // into the account at mint time. Refused here rather than warned + // about below, where a public deployment is exactly the case the + // warning would be scrolling past. + if args.owner == DEFAULT_OWNER { + return Err(format!( + "--tls acme needs --owner: every account this run mints would name \ + {DEFAULT_OWNER}, which resolves to nobody" + )); + } } // A zone this run may actually write needs to know what to write. The @@ -1475,11 +1490,22 @@ async fn run(mut args: Args) -> Result<(), String> { )?, }; - // Bind to both IPv4 and IPv6 loopback. On a systemd-resolved host, - // `*.localhost` resolves to `::1`, so the server must listen on IPv6. - // Binding to `[::]` with dual-stack enabled (IPV6_V6ONLY=0) listens on - // both the IPv6 unspecified address and the IPv4 loopback, accepting - // connections on either `127.0.0.1` or `::1`. + // Which addresses this run answers on. A `.localhost` zone is a + // development run: it mints with no attestation claim (see this file's + // own doc) and, without `--data`, holds every blob in memory, so a + // listener on every interface hands both to anybody sharing the network. + // It binds the IPv6 loopback instead — `*.localhost` resolves to `::1`, + // which is what a browser and this server's own DNS stub both reach. + // + // A real zone is a deployment meant to be reached, and binds the IPv6 + // unspecified address with dual-stack enabled (IPV6_V6ONLY=0), which + // answers on every IPv4 and IPv6 address this host has. + let loopback_only = zones.iter().all(|zone| LoopbackDns::accepts(zone.host())); + let bind_host = if loopback_only { + Ipv6Addr::LOCALHOST + } else { + Ipv6Addr::UNSPECIFIED + }; let socket = socket2::Socket::new(Domain::IPV6, Type::STREAM, Some(Protocol::TCP)) .map_err(|err| format!("could not create socket: {err}"))?; socket @@ -1488,7 +1514,7 @@ async fn run(mut args: Args) -> Result<(), String> { socket .set_reuse_address(true) .map_err(|err| format!("could not set reuse address: {err}"))?; - let addr = SocketAddr::from((Ipv6Addr::UNSPECIFIED, args.port)); + let addr = SocketAddr::from((bind_host, args.port)); socket .bind(&addr.into()) .map_err(|err| format!("could not bind {addr}: {err}"))?; @@ -1501,6 +1527,11 @@ async fn run(mut args: Args) -> Result<(), String> { .map_err(|err| format!("could not set non-blocking: {err}"))?; let listener = TcpListener::from_std(std_listener) .map_err(|err| format!("could not convert to tokio listener: {err}"))?; + info!( + %addr, + loopback_only, + "listening" + ); // A `.localhost` run admits no host: there is no operator poll to find a // vouch (see below), so no node key ever enters the attestation verifier @@ -3231,6 +3262,67 @@ mod config_tests { assert!(err.contains("--demo"), "error: {err}"); } + /// **What a default run exposes.** With no flags the zone is + /// `agents.localhost`, which mints with no attestation claim and, with + /// no `--data`, holds every blob in memory. A listener on every + /// interface hands both to anybody sharing the network, so a + /// `.localhost` zone binds the loopback address and a real zone binds + /// the unspecified one. + #[test] + fn only_a_real_zone_listens_on_every_interface() { + let bind_host_for = |zone: &str| { + let zone = Zone::new(zone).expect("a valid zone"); + if LoopbackDns::accepts(zone.host()) { + Ipv6Addr::LOCALHOST + } else { + Ipv6Addr::UNSPECIFIED + } + }; + assert_eq!(bind_host_for(DEFAULT_ZONE), Ipv6Addr::LOCALHOST); + assert_eq!(bind_host_for("agents.localhost"), Ipv6Addr::LOCALHOST); + assert_eq!(bind_host_for("agents.pds.example"), Ipv6Addr::UNSPECIFIED); + + // And the binary reads it the one way, from the zones it serves — + // read out of the source, because standing the listener up is what + // `run` does after it has taken the data directory's lock. + let source = include_str!("didbot-pds.rs"); + let (binary, _) = source + .split_once("#[cfg(test)]") + .expect("the binary ends where its tests begin"); + assert!( + binary.contains( + "let loopback_only = zones.iter().all(|zone| LoopbackDns::accepts(zone.host()));" + ), + "the listener no longer decides its address from the zones it serves" + ); + } + + /// A run behind a real certificate is a public one, and an account it + /// mints names its owner forever. The default owner resolves to nobody. + #[tokio::test] + async fn acme_without_an_owner_is_refused() { + let args = parse_args( + [ + "--tls", + "acme", + "--data", + "/nonexistent-this-run-never-gets-there", + "--zone", + "agents.pds.example", + "--route53-zone-id", + "Z123", + ] + .into_iter() + .map(str::to_owned), + ) + .unwrap(); + let err = match run(args).await { + Ok(()) => panic!("--tls acme with no --owner should have been refused"), + Err(err) => err, + }; + assert!(err.contains("--owner"), "error: {err}"); + } + /// A `Route53Dns` built and never resynced starts believing the zone is /// empty, and its local bookkeeping is what `withdraw`/`withdraw_txt` /// consult before deciding a record exists — so a fresh process after a -- 2.51.2