diff --git a/crates/torad/src/vpn/config.rs b/crates/torad/src/vpn/config.rs index 4f3736a..b1b53b3 100644 --- a/crates/torad/src/vpn/config.rs +++ b/crates/torad/src/vpn/config.rs @@ -1,6 +1,241 @@ -use std::fmt; +//! Parser for `wg-quick`-style WireGuard configuration files. +//! +//! Everything here is validated at parse time and stored as a typed value, not +//! a string. The values flow into `ip addr add`, route setup, and the +//! `wireguard-go` UAPI socket, none of which give a usable diagnostic when +//! handed garbage — a mistyped address surfaces there as a bare `EINVAL` or a +//! UAPI `errno` with no indication of which config line caused it. Catching it +//! here means the operator gets a file, a line number, and the offending value. -use anyhow::Context; +use std::fmt; +use std::net::{IpAddr, Ipv6Addr}; +use std::str::FromStr; + +use anyhow::{Context, Result, bail}; + +/// A WireGuard key: exactly 32 bytes, written in the config as 44 base64 +/// characters. +/// +/// The original base64 text is retained so the key can be echoed back in the +/// form the operator wrote it, and the decoded bytes so Wave 3 can render the +/// hex encoding the UAPI protocol expects without re-parsing. +#[derive(Clone, PartialEq, Eq)] +pub struct Key { + bytes: [u8; 32], + text: String, +} + +impl Key { + /// Decode a 44-character base64 WireGuard key. + /// + /// Errors deliberately describe only the *shape* of the problem, never the + /// input: this runs on private keys, and the error text ends up in logs. + pub fn from_base64(s: &str) -> Result { + let bytes = s.as_bytes(); + if bytes.len() != 44 { + bail!( + "expected 44 base64 characters (a 32-byte key), got {}", + bytes.len() + ); + } + if bytes[43] != b'=' { + bail!("expected the 44th character to be '=' padding"); + } + + let mut out = [0u8; 32]; + let mut acc: u32 = 0; + let mut acc_bits = 0u32; + let mut written = 0usize; + for (i, &c) in bytes[..43].iter().enumerate() { + let sextet = base64_value(c) + .with_context(|| format!("invalid base64 character at position {}", i + 1))?; + acc = (acc << 6) | u32::from(sextet); + acc_bits += 6; + if acc_bits >= 8 { + acc_bits -= 8; + out[written] = ((acc >> acc_bits) & 0xff) as u8; + written += 1; + } + } + debug_assert_eq!(written, 32); + + Ok(Self { + bytes: out, + text: s.to_owned(), + }) + } + + /// The key as base64, exactly as it appeared in the config. + /// + /// Calling this on a private key puts it somewhere; that is the point of + /// making it an explicit call rather than a `Display` impl. + pub fn as_base64(&self) -> &str { + &self.text + } + + /// Lowercase hex, the encoding the WireGuard UAPI protocol expects. + pub fn to_hex(&self) -> String { + use fmt::Write; + self.bytes + .iter() + .fold(String::with_capacity(64), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) + } +} + +/// Redacted unconditionally: `Key` is used for both private and public keys, +/// and a `Debug` that printed the bytes would leak the private one through any +/// `{:?}` on a containing struct. Public keys are surfaced deliberately by +/// [`PeerSection`]'s `Debug`, which calls [`Key::as_base64`]. +impl fmt::Debug for Key { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Key()") + } +} + +fn base64_value(c: u8) -> Result { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + ALPHABET + .iter() + .position(|&b| b == c) + .map(|p| p as u8) + .context("not a base64 character") +} + +/// An IP address with a prefix length, e.g. `10.2.0.2/32` or `::/0`. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct IpCidr { + pub addr: IpAddr, + pub prefix_len: u8, +} + +impl FromStr for IpCidr { + type Err = anyhow::Error; + + /// A bare address with no `/` is accepted and takes the full prefix length + /// (`/32` or `/128`), matching what `wg` itself does with `AllowedIPs`. + fn from_str(s: &str) -> Result { + let (addr_part, prefix_part) = match s.split_once('/') { + Some((a, p)) => (a.trim(), Some(p.trim())), + None => (s.trim(), None), + }; + + let addr: IpAddr = addr_part + .parse() + .with_context(|| format!("invalid IP address {addr_part:?}"))?; + let max_prefix = if addr.is_ipv4() { 32 } else { 128 }; + + let prefix_len = match prefix_part { + Some(p) => { + let parsed: u8 = p + .parse() + .with_context(|| format!("invalid prefix length {p:?}"))?; + if parsed > max_prefix { + bail!("prefix length /{parsed} exceeds the maximum /{max_prefix} for {addr}"); + } + parsed + } + None => max_prefix, + }; + + Ok(Self { addr, prefix_len }) + } +} + +impl fmt::Display for IpCidr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.addr, self.prefix_len) + } +} + +/// Renders as the config wrote it. The derived form +/// (`IpCidr { addr: 10.2.0.2, prefix_len: 32 }`) makes a list of them unreadable +/// in a log line, which is where these are most often seen. +impl fmt::Debug for IpCidr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self}") + } +} + +/// A peer endpoint: a host (IP literal or DNS name) and a port. +/// +/// The host is not resolved here. WireGuard resolves endpoint names at +/// handshake time and re-resolves on roaming, so pinning an address at parse +/// time would be wrong; this only rejects shapes that can never resolve. +#[derive(Clone, PartialEq, Eq)] +pub struct Endpoint { + pub host: String, + pub port: u16, +} + +impl FromStr for Endpoint { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let s = s.trim(); + + let (host, port_part) = if let Some(rest) = s.strip_prefix('[') { + let (host, after) = rest + .split_once(']') + .context("unterminated IPv6 literal; expected [address]:port")?; + host.parse::() + .with_context(|| format!("invalid IPv6 address {host:?}"))?; + let port = after + .strip_prefix(':') + .context("missing ':port' after the IPv6 literal")?; + (host, port) + } else { + let (host, port) = s + .rsplit_once(':') + .with_context(|| format!("missing ':port' in endpoint {s:?}"))?; + if host.contains(':') { + bail!("a bare IPv6 address must be bracketed, e.g. [{host}]:{port}"); + } + (host, port) + }; + + if host.is_empty() { + bail!("endpoint host must not be empty"); + } + if host.parse::().is_err() + && host.contains(|c: char| c.is_whitespace() || c == '/' || c == '@') + { + bail!("invalid endpoint host {host:?}"); + } + + let port: u16 = port_part + .parse() + .with_context(|| format!("invalid port {port_part:?}"))?; + if port == 0 { + bail!("endpoint port must not be 0"); + } + + Ok(Self { + host: host.to_owned(), + port, + }) + } +} + +impl fmt::Display for Endpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.host.parse::().is_ok() { + write!(f, "[{}]:{}", self.host, self.port) + } else { + write!(f, "{}:{}", self.host, self.port) + } + } +} + +/// See [`IpCidr`]'s `Debug`: same reasoning, and it keeps the IPv6 bracketing +/// visible so a malformed endpoint reads the way `wg` would print it. +impl fmt::Debug for Endpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self}") + } +} #[derive(Debug)] pub struct WireguardConfig { @@ -9,33 +244,50 @@ pub struct WireguardConfig { } pub struct InterfaceSection { - pub private_key: String, - pub address: String, - pub dns: Option, + pub private_key: Key, + /// One or more addresses to assign to the tunnel interface. `wg-quick` + /// allows a comma-separated list, and dual-stack providers use it. + pub addresses: Vec, + /// Resolvers to write into the namespace's `resolv.conf`. Empty when the + /// config has no `DNS` line. + pub dns: Vec, } /// Hand-written so the private key can never reach a log through a `{:?}` -/// format on this struct or anything containing it. The derived impl would -/// print it verbatim, and this value flows through error contexts during -/// interface setup. +/// format on this struct or anything containing it. [`Key`]'s own `Debug` is +/// already redacted; this is the second layer, and it also keeps the field +/// visibly marked as a secret at the point where it is most likely to be read. impl fmt::Debug for InterfaceSection { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("InterfaceSection") .field("private_key", &"") - .field("address", &self.address) + .field("addresses", &self.addresses) .field("dns", &self.dns) .finish() } } -#[derive(Debug)] pub struct PeerSection { - pub public_key: String, - pub allowed_ips: Vec, - pub endpoint: String, + pub public_key: Key, + pub allowed_ips: Vec, + pub endpoint: Endpoint, pub persistent_keepalive: Option, } +/// Hand-written only to undo [`Key`]'s blanket redaction for the public key, +/// which is not a secret and is the first thing worth seeing when a handshake +/// fails against the wrong peer. +impl fmt::Debug for PeerSection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PeerSection") + .field("public_key", &self.public_key.as_base64()) + .field("allowed_ips", &self.allowed_ips) + .field("endpoint", &self.endpoint) + .field("persistent_keepalive", &self.persistent_keepalive) + .finish() + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Section { None, @@ -43,99 +295,200 @@ enum Section { Peer, } -pub fn parse(text: &str) -> anyhow::Result { +pub fn parse(text: &str) -> Result { let mut section = Section::None; + let mut seen_interface = false; + let mut seen_peer = false; + let mut private_key = None; - let mut address = None; + let mut addresses = None; let mut dns = None; let mut public_key = None; let mut allowed_ips = None; let mut endpoint = None; let mut persistent_keepalive = None; - for raw_line in text.lines() { + for (index, raw_line) in text.lines().enumerate() { + let lineno = index + 1; let line = strip_comment(raw_line).trim(); if line.is_empty() { continue; } - if line == "[Interface]" { - section = Section::Interface; - continue; - } - if line == "[Peer]" { - section = Section::Peer; + if let Some(rest) = line.strip_prefix('[') { + let name = rest + .strip_suffix(']') + .with_context(|| format!("line {lineno}: unterminated section header {line:?}"))?; + section = match name.trim() { + "Interface" => { + if seen_interface { + bail!("line {lineno}: duplicate [Interface] section"); + } + seen_interface = true; + Section::Interface + } + "Peer" => { + if seen_peer { + // A second [Peer] previously overwrote the first in + // silence. torad brings up one tunnel with one peer; + // saying so beats picking a peer at random. + bail!( + "line {lineno}: multiple [Peer] sections are not supported \ + (torad configures a single tunnel with one peer)" + ); + } + seen_peer = true; + Section::Peer + } + other => bail!("line {lineno}: unknown section [{other}]"), + }; continue; } let Some((key, value)) = line.split_once('=') else { - continue; + bail!("line {lineno}: expected `Key = Value`, got {line:?}"); }; let key = key.trim(); let value = value.trim(); match section { Section::Interface => match key { - "PrivateKey" => private_key = Some(value.to_owned()), - "Address" => address = Some(value.to_owned()), - "DNS" => dns = Some(value.to_owned()), + "PrivateKey" => set_once( + &mut private_key, + // No value in the context: this is the secret. + Key::from_base64(value) + .with_context(|| format!("line {lineno}: invalid PrivateKey"))?, + key, + lineno, + )?, + "Address" => set_once( + &mut addresses, + parse_cidr_list(value) + .with_context(|| format!("line {lineno}: invalid Address {value:?}"))?, + key, + lineno, + )?, + "DNS" => set_once( + &mut dns, + parse_dns_list(value) + .with_context(|| format!("line {lineno}: invalid DNS {value:?}"))?, + key, + lineno, + )?, _ => {} }, Section::Peer => match key { - "PublicKey" => public_key = Some(value.to_owned()), - "AllowedIPs" => allowed_ips = Some(value), - "Endpoint" => endpoint = Some(value.to_owned()), - "PersistentKeepalive" => { - let v: u16 = value - .parse() - .with_context(|| format!("invalid PersistentKeepalive value: {value}"))?; - persistent_keepalive = Some(v); - } + "PublicKey" => set_once( + &mut public_key, + Key::from_base64(value) + .with_context(|| format!("line {lineno}: invalid PublicKey {value:?}"))?, + key, + lineno, + )?, + "AllowedIPs" => set_once( + &mut allowed_ips, + parse_cidr_list(value) + .with_context(|| format!("line {lineno}: invalid AllowedIPs {value:?}"))?, + key, + lineno, + )?, + "Endpoint" => set_once( + &mut endpoint, + value + .parse::() + .with_context(|| format!("line {lineno}: invalid Endpoint {value:?}"))?, + key, + lineno, + )?, + "PersistentKeepalive" => set_once( + &mut persistent_keepalive, + value.parse::().with_context(|| { + format!("line {lineno}: invalid PersistentKeepalive {value:?}") + })?, + key, + lineno, + )?, _ => {} }, - Section::None => {} + Section::None => { + bail!("line {lineno}: key {key:?} appears before any section header") + } } } - let interface = InterfaceSection { - private_key: private_key.ok_or_else(|| { - anyhow::anyhow!("missing required key 'PrivateKey' in [Interface] section") - })?, - address: address.ok_or_else(|| { - anyhow::anyhow!("missing required key 'Address' in [Interface] section") - })?, - dns, - }; - - // Peer section must have been entered. - if public_key.is_none() && allowed_ips.is_none() && endpoint.is_none() { - anyhow::bail!("missing [Peer] section"); + if !seen_interface { + bail!("missing [Interface] section"); + } + if !seen_peer { + bail!("missing [Peer] section"); } + let interface = InterfaceSection { + private_key: private_key + .context("missing required key 'PrivateKey' in [Interface] section")?, + addresses: addresses.context("missing required key 'Address' in [Interface] section")?, + dns: dns.unwrap_or_default(), + }; + let peer = PeerSection { - public_key: public_key - .ok_or_else(|| anyhow::anyhow!("missing required key 'PublicKey' in [Peer] section"))?, - allowed_ips: allowed_ips - .ok_or_else(|| anyhow::anyhow!("missing required key 'AllowedIPs' in [Peer] section"))? - .split(',') - .map(|s| s.trim().to_owned()) - .collect(), - endpoint: endpoint - .ok_or_else(|| anyhow::anyhow!("missing required key 'Endpoint' in [Peer] section"))?, + public_key: public_key.context("missing required key 'PublicKey' in [Peer] section")?, + allowed_ips: allowed_ips.context("missing required key 'AllowedIPs' in [Peer] section")?, + endpoint: endpoint.context("missing required key 'Endpoint' in [Peer] section")?, persistent_keepalive, }; Ok(WireguardConfig { interface, peer }) } +/// Assign to a slot that must not already be occupied. +/// +/// A repeated key used to overwrite silently, so a config carrying two +/// `Endpoint` lines would connect to whichever came last with no hint that the +/// other existed. +fn set_once(slot: &mut Option, value: T, key: &str, lineno: usize) -> Result<()> { + if slot.is_some() { + bail!("line {lineno}: duplicate key {key:?}"); + } + *slot = Some(value); + Ok(()) +} + +fn parse_cidr_list(value: &str) -> Result> { + value + .split(',') + .map(|part| { + let part = part.trim(); + if part.is_empty() { + bail!("empty entry in comma-separated list"); + } + part.parse::() + }) + .collect() +} + +fn parse_dns_list(value: &str) -> Result> { + value + .split(',') + .map(|part| { + let part = part.trim(); + part.parse::().with_context(|| { + format!( + "{part:?} is not an IP address \ + (search-domain entries in DNS are not supported)" + ) + }) + }) + .collect() +} + /// Strip inline comments (# ...) but preserve the rest. /// Only strips when `#` is preceded by whitespace or is at column 0. fn strip_comment(line: &str) -> &str { - if let Some(pos) = line.find('#') { - if pos == 0 || line.as_bytes()[pos - 1] == b' ' || line.as_bytes()[pos - 1] == b'\t' { - return &line[..pos]; - } + if let Some(pos) = line.find('#') + && (pos == 0 || line.as_bytes()[pos - 1] == b' ' || line.as_bytes()[pos - 1] == b'\t') + { + return &line[..pos]; } line } @@ -144,159 +497,301 @@ fn strip_comment(line: &str) -> &str { mod tests { use super::*; - const PROTONVPN_SAMPLE: &str = r#"[Interface] -# Key for nixarr -# Bouncing = 1 -# NetShield = 1 -# Moderate NAT = off -# NAT-PMP (Port Forwarding) = on -# VPN Accelerator = on -PrivateKey = aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq= -Address = 10.2.0.2/32 -DNS = 10.2.0.1 + /// Valid 32-byte keys. The previous fixtures (`aaaa`, and a 41-character + /// private key) are exactly what parse-time validation now rejects. + const PRIVATE_KEY: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="; + const PUBLIC_KEY: &str = "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8="; -[Peer] -# UA#44 -PublicKey = eqjhoqO6K1nLiej026+RkpSTHloVrOHLlMQaB0Tl5GM= -AllowedIPs = 0.0.0.0/0, ::/0 -Endpoint = 156.146.50.5:51820 -PersistentKeepalive = 25 -"#; + fn minimal() -> String { + format!( + "[Interface]\n\ + PrivateKey = {PRIVATE_KEY}\n\ + Address = 10.0.0.2/32\n\ + \n\ + [Peer]\n\ + PublicKey = {PUBLIC_KEY}\n\ + AllowedIPs = 0.0.0.0/0\n\ + Endpoint = 1.2.3.4:51820\n" + ) + } + + fn v4(a: u8, b: u8, c: u8, d: u8, prefix_len: u8) -> IpCidr { + IpCidr { + addr: IpAddr::from([a, b, c, d]), + prefix_len, + } + } + + fn protonvpn_sample() -> String { + format!( + "[Interface]\n\ + # Key for nixarr\n\ + # Bouncing = 1\n\ + # NAT-PMP (Port Forwarding) = on\n\ + PrivateKey = {PRIVATE_KEY}\n\ + Address = 10.2.0.2/32\n\ + DNS = 10.2.0.1\n\ + \n\ + [Peer]\n\ + # UA#44\n\ + PublicKey = {PUBLIC_KEY}\n\ + AllowedIPs = 0.0.0.0/0, ::/0\n\ + Endpoint = 156.146.50.5:51820\n\ + PersistentKeepalive = 25\n" + ) + } #[test] fn parses_protonvpn_sample() { - let cfg = parse(PROTONVPN_SAMPLE).unwrap(); - assert_eq!( - cfg.interface.private_key, - "aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq=" - ); - assert_eq!(cfg.interface.address, "10.2.0.2/32"); - assert_eq!(cfg.interface.dns.as_deref(), Some("10.2.0.1")); - assert_eq!( - cfg.peer.public_key, - "eqjhoqO6K1nLiej026+RkpSTHloVrOHLlMQaB0Tl5GM=" - ); - assert_eq!(cfg.peer.allowed_ips, vec!["0.0.0.0/0", "::/0"]); - assert_eq!(cfg.peer.endpoint, "156.146.50.5:51820"); + let cfg = parse(&protonvpn_sample()).unwrap(); + assert_eq!(cfg.interface.private_key.as_base64(), PRIVATE_KEY); + assert_eq!(cfg.interface.addresses, vec![v4(10, 2, 0, 2, 32)]); + assert_eq!(cfg.interface.dns, vec![IpAddr::from([10, 2, 0, 1])]); + assert_eq!(cfg.peer.public_key.as_base64(), PUBLIC_KEY); + assert_eq!(cfg.peer.allowed_ips.len(), 2); + assert_eq!(cfg.peer.allowed_ips[0], v4(0, 0, 0, 0, 0)); + assert_eq!(cfg.peer.allowed_ips[1].to_string(), "::/0"); + assert_eq!(cfg.peer.endpoint.host, "156.146.50.5"); + assert_eq!(cfg.peer.endpoint.port, 51820); assert_eq!(cfg.peer.persistent_keepalive, Some(25)); } #[test] - fn parses_config_without_dns() { - let text = r#"[Interface] -PrivateKey = aaaa -Address = 10.0.0.2/32 - -[Peer] -PublicKey = bbbb -AllowedIPs = 0.0.0.0/0 -Endpoint = 1.2.3.4:51820 -"#; - let cfg = parse(text).unwrap(); - assert!(cfg.interface.dns.is_none()); - } - - #[test] - fn parses_config_without_keepalive() { - let text = r#"[Interface] -PrivateKey = aaaa -Address = 10.0.0.2/32 - -[Peer] -PublicKey = bbbb -AllowedIPs = 0.0.0.0/0 -Endpoint = 1.2.3.4:51820 -"#; - let cfg = parse(text).unwrap(); + fn parses_config_without_dns_or_keepalive() { + let cfg = parse(&minimal()).unwrap(); + assert!(cfg.interface.dns.is_empty()); assert!(cfg.peer.persistent_keepalive.is_none()); } #[test] - fn ignores_comments_only_lines() { - let text = r#"[Interface] -# This is a comment -# Another = comment -PrivateKey = aaaa -Address = 10.0.0.2/32 + fn parses_multiple_addresses_and_allowed_ips() { + let text = minimal() + .replace( + "Address = 10.0.0.2/32", + "Address = 10.0.0.2/32, fd00::2/128", + ) + .replace( + "AllowedIPs = 0.0.0.0/0", + "AllowedIPs = 0.0.0.0/0, ::/0, 192.168.0.0/16", + ); + let cfg = parse(&text).unwrap(); + assert_eq!(cfg.interface.addresses.len(), 2); + assert_eq!(cfg.interface.addresses[1].to_string(), "fd00::2/128"); + assert_eq!(cfg.peer.allowed_ips.len(), 3); + assert_eq!(cfg.peer.allowed_ips[2], v4(192, 168, 0, 0, 16)); + } -[Peer] -# Peer comment -PublicKey = bbbb -AllowedIPs = 0.0.0.0/0 -Endpoint = 1.2.3.4:51820 -"#; - assert!(parse(text).is_ok()); + /// `wg` treats a prefixless `AllowedIPs` entry as a host route; so do we. + #[test] + fn bare_address_takes_the_full_prefix() { + let text = minimal().replace("AllowedIPs = 0.0.0.0/0", "AllowedIPs = 8.8.8.8, fd00::1"); + let cfg = parse(&text).unwrap(); + assert_eq!(cfg.peer.allowed_ips[0], v4(8, 8, 8, 8, 32)); + assert_eq!(cfg.peer.allowed_ips[1].prefix_len, 128); } #[test] - fn rejects_missing_private_key() { - let text = r#"[Interface] -Address = 10.0.0.2/32 - -[Peer] -PublicKey = bbbb -AllowedIPs = 0.0.0.0/0 -Endpoint = 1.2.3.4:51820 -"#; - let err = parse(text).unwrap_err().to_string(); - assert!(err.contains("PrivateKey")); + fn parses_bracketed_ipv6_endpoint() { + let text = minimal().replace("Endpoint = 1.2.3.4:51820", "Endpoint = [2001:db8::1]:51820"); + let cfg = parse(&text).unwrap(); + assert_eq!(cfg.peer.endpoint.host, "2001:db8::1"); + assert_eq!(cfg.peer.endpoint.port, 51820); + assert_eq!(cfg.peer.endpoint.to_string(), "[2001:db8::1]:51820"); } #[test] - fn rejects_missing_peer_section() { - let text = r#"[Interface] -PrivateKey = aaaa -Address = 10.0.0.2/32 -"#; - let err = parse(text).unwrap_err().to_string(); - assert!(err.contains("[Peer]")); - } - - #[test] - fn parses_multiple_allowed_ips() { - let text = r#"[Interface] -PrivateKey = aaaa -Address = 10.0.0.2/32 - -[Peer] -PublicKey = bbbb -AllowedIPs = 0.0.0.0/0, ::/0, 192.168.0.0/16 -Endpoint = 1.2.3.4:51820 -"#; - let cfg = parse(text).unwrap(); + fn parses_hostname_endpoint() { + let text = minimal().replace( + "Endpoint = 1.2.3.4:51820", + "Endpoint = node-ua-44.protonvpn.net:51820", + ); + let cfg = parse(&text).unwrap(); + assert_eq!(cfg.peer.endpoint.host, "node-ua-44.protonvpn.net"); assert_eq!( - cfg.peer.allowed_ips, - vec!["0.0.0.0/0", "::/0", "192.168.0.0/16"] + cfg.peer.endpoint.to_string(), + "node-ua-44.protonvpn.net:51820" ); } #[test] - fn debug_redacts_private_key() { - let cfg = parse(PROTONVPN_SAMPLE).unwrap(); + fn ignores_comment_lines_and_strips_inline_comments() { + let text = minimal().replace( + "Address = 10.0.0.2/32", + "# a comment\nAddress = 10.0.0.2/32 # the VPN address", + ); + let cfg = parse(&text).unwrap(); + assert_eq!(cfg.interface.addresses, vec![v4(10, 0, 0, 2, 32)]); + } + + fn parse_err(text: &str) -> String { + // `{:#}` renders the whole anyhow chain, so assertions can match on the + // inner cause as well as the line-number context wrapped around it. + format!("{:#}", parse(text).unwrap_err()) + } + + #[test] + fn rejects_missing_required_keys() { + let no_private = minimal().replace(&format!("PrivateKey = {PRIVATE_KEY}\n"), ""); + assert!(parse_err(&no_private).contains("PrivateKey")); + + let no_peer_section = + format!("[Interface]\nPrivateKey = {PRIVATE_KEY}\nAddress = 10.0.0.2/32\n"); + assert!(parse_err(&no_peer_section).contains("[Peer]")); + + let no_endpoint = minimal().replace("Endpoint = 1.2.3.4:51820\n", ""); + assert!(parse_err(&no_endpoint).contains("Endpoint")); + } + + #[test] + fn rejects_a_line_that_is_not_key_equals_value() { + let text = minimal().replace("Address = 10.0.0.2/32", "Address 10.0.0.2/32"); + let err = parse_err(&text); + assert!(err.contains("line 3"), "want the line number, got: {err}"); + assert!(err.contains("Key = Value"), "got: {err}"); + } + + #[test] + fn rejects_a_second_peer_section() { + let text = format!("{}\n[Peer]\nPublicKey = {PUBLIC_KEY}\n", minimal()); + let err = parse_err(&text); + assert!(err.contains("line 10"), "want the line number, got: {err}"); + assert!(err.contains("multiple [Peer]"), "got: {err}"); + } + + #[test] + fn rejects_a_second_interface_section() { + let text = format!("{}\n[Interface]\nAddress = 10.0.0.3/32\n", minimal()); + assert!(parse_err(&text).contains("duplicate [Interface]")); + } + + #[test] + fn rejects_a_duplicate_key() { + let text = minimal().replace( + "Endpoint = 1.2.3.4:51820", + "Endpoint = 1.2.3.4:51820\nEndpoint = 5.6.7.8:51820", + ); + let err = parse_err(&text); + assert!(err.contains("duplicate key"), "got: {err}"); + assert!(err.contains("Endpoint"), "got: {err}"); + } + + #[test] + fn rejects_an_unknown_section() { + let text = minimal().replace("[Peer]", "[Peers]"); + assert!(parse_err(&text).contains("unknown section [Peers]")); + } + + #[test] + fn rejects_a_key_before_any_section() { + let text = format!("Address = 10.0.0.2/32\n{}", minimal()); + assert!(parse_err(&text).contains("before any section header")); + } + + #[test] + fn rejects_invalid_addresses() { + for bad in [ + "10.0.0.256/32", // octet out of range + "10.0.0.2/33", // prefix too long for v4 + "fd00::1/129", // prefix too long for v6 + "10.0.0.2/", // empty prefix + "not-an-ip/32", + "10.0.0.2, ", // empty trailing entry + ] { + let text = minimal().replace("Address = 10.0.0.2/32", &format!("Address = {bad}")); + assert!( + parse(&text).is_err(), + "expected {bad:?} to be rejected as an Address" + ); + } + } + + #[test] + fn rejects_invalid_endpoints() { + for bad in [ + "1.2.3.4", // no port + "1.2.3.4:0", // reserved port + "1.2.3.4:70000", // port out of range + "1.2.3.4:http", // non-numeric port + "2001:db8::1:51820", // unbracketed IPv6 + "[2001:db8::1]", // bracketed, no port + "[2001:db8::zz]:51820", // bad IPv6 literal + ":51820", // empty host + ] { + let text = minimal().replace("Endpoint = 1.2.3.4:51820", &format!("Endpoint = {bad}")); + assert!( + parse(&text).is_err(), + "expected {bad:?} to be rejected as an Endpoint" + ); + } + } + + #[test] + fn rejects_a_non_ip_dns_entry() { + let text = minimal().replace( + "Address = 10.0.0.2/32", + "Address = 10.0.0.2/32\nDNS = example.com", + ); + assert!(parse_err(&text).contains("search-domain")); + } + + #[test] + fn rejects_malformed_keys() { + // A 41-character key: long enough to look right at a glance, which is + // precisely why it needs to fail here rather than at the UAPI socket. + let short = "aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq="; + assert_eq!(short.len(), 41); + for bad in [short, "aaaa", "", &"A".repeat(44)] { + let text = minimal().replace( + &format!("PublicKey = {PUBLIC_KEY}"), + &format!("PublicKey = {bad}"), + ); + assert!(parse(&text).is_err(), "expected key {bad:?} to be rejected"); + } + } + + #[test] + fn private_key_never_appears_in_an_error() { + let text = minimal().replace( + &format!("PrivateKey = {PRIVATE_KEY}"), + "PrivateKey = SECRETSECRETSECRETSECRETSECRETSECRETSECRET=", + ); + let err = parse_err(&text); + assert!(!err.contains("SECRET"), "private key leaked: {err}"); + assert!(err.contains("PrivateKey"), "got: {err}"); + } + + #[test] + fn debug_redacts_the_private_key_but_not_the_public_one() { + let cfg = parse(&protonvpn_sample()).unwrap(); let rendered = format!("{cfg:?}"); assert!( - !rendered.contains("aFzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq1Vzq="), + !rendered.contains(PRIVATE_KEY), "private key leaked into Debug output: {rendered}" ); assert!(rendered.contains(""), "got: {rendered}"); // The rest of the config must still be inspectable. + assert!(rendered.contains(PUBLIC_KEY), "got: {rendered}"); assert!(rendered.contains("10.2.0.2/32"), "got: {rendered}"); } + /// A bare `Key` must stay redacted wherever it is printed, since the same + /// type carries private keys. #[test] - fn strips_inline_comments() { - let text = r#"[Interface] -PrivateKey = aaaa -Address = 10.2.0.2/32 # the VPN address -DNS = 1.1.1.1 + fn key_debug_is_redacted() { + let key = Key::from_base64(PRIVATE_KEY).unwrap(); + assert_eq!(format!("{key:?}"), "Key()"); + } -[Peer] -PublicKey = bbbb -AllowedIPs = 0.0.0.0/0 -Endpoint = 1.2.3.4:51820 -"#; - let cfg = parse(text).unwrap(); - assert_eq!(cfg.interface.address, "10.2.0.2/32"); - assert_eq!(cfg.interface.dns.as_deref(), Some("1.1.1.1")); + /// Wave 3 sends keys to the `wireguard-go` UAPI socket as hex. + #[test] + fn key_round_trips_base64_to_hex() { + let key = Key::from_base64(PRIVATE_KEY).unwrap(); + // PRIVATE_KEY is base64 of the bytes 0x00..=0x1f. + let expected: String = (0u8..32).map(|b| format!("{b:02x}")).collect(); + assert_eq!(key.to_hex(), expected); + assert_eq!(key.to_hex().len(), 64); + + let all_ones = Key::from_base64("//////////////////////////////////////////8=").unwrap(); + assert_eq!(all_ones.to_hex(), "ff".repeat(32)); } }