//! **Device registration**, as shapes — and pointedly *not* as a client.
//!
//! # Why there is no HTTP in this module
//!
//! Registration is the one exchange that turns "this device's public key" into
//! "here is your tunnel address". It needs an account number, which is a
//! credential, and it needs TLS, which on today's Rust means linking C
//! (`aws-lc-sys` or `ring`) into whatever holds it.
//!
//! Neither belongs in the appliance:
//!
//! * The appliance's private key is minted **on the device** and never leaves
//! it. Only the public half is ever sent — see [`RegisterDevice`], which is
//! a struct with exactly one field for that reason. Nothing in this crate
//! can leak a private key, because no type in it has a place to put one.
//! The **account number** is likewise not here: it is an authorization
//! header the caller supplies, so this crate never holds a credential it
//! could log, serialise or `Debug`-print.
//! * The transport is the caller's. A host-side provisioning tool already has
//! an HTTP client; a distroless PID 1 must not grow one.
//!
//! So this module owns the **request and response shapes and their
//! invariants**, and the caller owns the socket. That split is what lets the
//! shapes be tested with no account and no network.
//!
//! # What could not be verified from this host, said out loud
//!
//! We hold no Mullvad account, so no test here has ever seen a real
//! registration response. The shapes are written from the field names and
//! forms reported by the previous lane's measurement (2026-09-05: the response
//! returns `ipv4_address` **already in CIDR form**, e.g. `10.67.114.172/32`)
//! and every test that asserts them says `shape_only` in its name. That is the
//! honest grade, and it is why nothing in this module drives a refusal on its
//! own — [`crate::audit`] warns about an unexpected address rather than
//! rejecting it.
use std::net::{IpAddr, Ipv4Addr};
use serde::{Deserialize, Serialize};
use crate::facts;
/// The registration request. **One field.**
///
/// That is the invariant worth encoding in a type: a device registration sends
/// the public key and nothing else. A struct that had somewhere to put the
/// private key would eventually have the private key put in it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RegisterDevice {
/// The device's WireGuard **public** key, base64. The private half stays on
/// the device that minted it.
pub pubkey: String,
}
impl RegisterDevice {
/// A registration for a public key.
pub fn new(public_key: impl Into<String>) -> RegisterDevice {
RegisterDevice {
pubkey: public_key.into(),
}
}
}
/// What a registration answers with, as far as this workspace needs it.
///
/// Lenient about fields it does not use (`#[serde(default)]`, no
/// `deny_unknown_fields`) for the same reason [`crate::relays`] is: their
/// product gaining a field must not become our outage.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct RegisteredDevice {
/// Mullvad's opaque device id.
#[serde(default)]
pub id: String,
/// The human name Mullvad generated for the device ("cuddly-lemming").
#[serde(default)]
pub name: String,
/// The public key, echoed back. Worth checking against what was sent — see
/// [`RegisteredDevice::echoes`].
#[serde(default)]
pub pubkey: String,
/// The assigned tunnel address, **already in CIDR form** (`10.67.114.172/32`).
/// This is the `[Interface] Address` of the resulting config.
pub ipv4_address: String,
/// The assigned IPv6 tunnel address, also in CIDR form.
#[serde(default)]
pub ipv6_address: String,
}
/// Why a registration response could not be turned into a config.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistrationError {
/// `ipv4_address` was not `addr/prefix`.
AddressNotCidr(String),
/// The address part did not parse as an IPv4 address.
AddressNotIpv4(String),
/// The prefix was absent, unparseable, or greater than 32.
PrefixInvalid(String),
/// The response echoed a different public key than the one sent. **This is
/// an error and not a warning**: a config built on it would carry an
/// address routed to somebody else's key, and the tunnel would hand-shake
/// and then be silently dropped by the relay.
KeyMismatch {
/// The public key that was registered.
sent: String,
/// The public key the response echoed back.
got: String,
},
}
impl std::fmt::Display for RegistrationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RegistrationError::AddressNotCidr(s) => write!(
f,
"ipv4_address {s:?} is not `addr/prefix` — Mullvad returns the assigned \
address already in CIDR form, so a bare address means the response is not \
the one this parser was written for"
),
RegistrationError::AddressNotIpv4(s) => {
write!(f, "ipv4_address {s:?} does not parse as an IPv4 address")
}
RegistrationError::PrefixInvalid(s) => {
write!(f, "ipv4_address {s:?} has no usable /prefix")
}
RegistrationError::KeyMismatch { sent, got } => write!(
f,
"the registration echoed public key {got:?} but {sent:?} was sent — the \
assigned address belongs to a different device"
),
}
}
}
impl RegisteredDevice {
/// Does this response echo the key that was registered?
pub fn echoes(&self, request: &RegisterDevice) -> bool {
self.pubkey == request.pubkey
}
/// The assigned address and prefix, parsed out of the CIDR string.
pub fn assigned_ipv4(&self) -> Result<(Ipv4Addr, u8), RegistrationError> {
let (addr, prefix) = self
.ipv4_address
.split_once('/')
.ok_or_else(|| RegistrationError::AddressNotCidr(self.ipv4_address.clone()))?;
let addr: Ipv4Addr = addr
.trim()
.parse()
.map_err(|_| RegistrationError::AddressNotIpv4(self.ipv4_address.clone()))?;
let prefix: u8 = prefix
.trim()
.parse()
.ok()
.filter(|p| *p <= 32)
.ok_or_else(|| RegistrationError::PrefixInvalid(self.ipv4_address.clone()))?;
Ok((addr, prefix))
}
/// The assigned address, checked against the key that was sent.
///
/// The key check is here rather than left to the caller because it is the
/// one that fails silently: a mismatched address produces a tunnel that
/// completes its handshake and then carries nothing.
pub fn assigned_ipv4_for(
&self,
request: &RegisterDevice,
) -> Result<(Ipv4Addr, u8), RegistrationError> {
if !self.echoes(request) {
return Err(RegistrationError::KeyMismatch {
sent: request.pubkey.clone(),
got: self.pubkey.clone(),
});
}
self.assigned_ipv4()
}
/// Whether the assigned address falls in the range Mullvad has been
/// observed to assign from.
///
/// **INFERRED** (see [`facts::TUNNEL_NET_INFERRED`]) — a `false` is worth
/// saying and is never worth refusing over.
pub fn address_is_plausible(&self) -> bool {
self.assigned_ipv4()
.map(|(a, _)| facts::is_plausible_assigned_address(IpAddr::V4(a)))
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The response shape, asserted against a JSON body written from the
/// reported field names and forms. **`shape_only`**: this host holds no
/// Mullvad account, so no test in this module has ever seen a real
/// registration response, and the name says so rather than letting a
/// fixture read as a live proof.
#[test]
fn shape_only_a_registration_response_yields_the_assigned_address() {
let body = r#"{
"id": "5b9f4dc2-3f2b-4f4a-9c6f-1f0f0f0f0f0f",
"name": "cuddly-lemming",
"pubkey": "xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=",
"ipv4_address": "10.67.114.172/32",
"ipv6_address": "fc00:bbbb:bbbb:bb01::5:72ac/128"
}"#;
let dev: RegisteredDevice = serde_json::from_str(body).expect("parses");
let req = RegisterDevice::new("xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=");
assert!(dev.echoes(&req));
assert_eq!(
dev.assigned_ipv4_for(&req),
Ok(("10.67.114.172".parse().unwrap(), 32))
);
assert!(
dev.address_is_plausible(),
"10.67.114.172 sits inside the inferred assignment range"
);
}
/// RED-WHEN-BROKEN — the failure that is invisible on the wire. An echoed
/// key that is not ours means the address belongs to another device, and
/// the resulting tunnel hand-shakes and then carries nothing.
#[test]
fn shape_only_a_mismatched_echoed_key_is_an_error_not_a_warning() {
let dev = RegisteredDevice {
id: "x".into(),
name: "y".into(),
pubkey: "SOMEBODY+ELSES+KEY+aaaaaaaaaaaaaaaaaaaaaaa=".into(),
ipv4_address: "10.67.114.172/32".into(),
ipv6_address: String::new(),
};
let req = RegisterDevice::new("xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=");
assert!(!dev.echoes(&req));
assert!(matches!(
dev.assigned_ipv4_for(&req),
Err(RegistrationError::KeyMismatch { .. })
));
// …and the address itself is perfectly parseable, which is exactly why
// the key check cannot be left to the caller to remember.
assert!(dev.assigned_ipv4().is_ok());
}
/// RED-WHEN-BROKEN — a bare address (no `/prefix`) must be an error. The
/// measured fact is that Mullvad returns CIDR form; a response without it
/// is not the response this parser was written against, and guessing `/32`
/// would hide a schema change.
#[test]
fn shape_only_a_malformed_assigned_address_is_refused_by_shape() {
for (bad, want_variant) in [
("10.67.114.172", "cidr"),
("10.67.114.172/", "prefix"),
("10.67.114.172/33", "prefix"),
("10.67.114.172/nope", "prefix"),
("not-an-address/32", "ipv4"),
("", "cidr"),
] {
let dev = RegisteredDevice {
id: String::new(),
name: String::new(),
pubkey: String::new(),
ipv4_address: bad.into(),
ipv6_address: String::new(),
};
let err = dev.assigned_ipv4().expect_err(&format!("{bad:?} must fail"));
let matched = match (&err, want_variant) {
(RegistrationError::AddressNotCidr(_), "cidr") => true,
(RegistrationError::PrefixInvalid(_), "prefix") => true,
(RegistrationError::AddressNotIpv4(_), "ipv4") => true,
_ => false,
};
assert!(matched, "{bad:?} gave {err:?}, wanted a {want_variant} error");
assert!(!dev.address_is_plausible());
}
// The identity guard: the well-formed case still succeeds, or every
// assertion above is satisfied by a function that always errors.
let ok = RegisteredDevice {
id: String::new(),
name: String::new(),
pubkey: String::new(),
ipv4_address: "10.67.114.172/32".into(),
ipv6_address: String::new(),
};
assert!(ok.assigned_ipv4().is_ok());
}
/// The request type has exactly one field, and this test is what keeps it
/// that way: it serialises to a one-key object, so adding a place to put a
/// private key or an account number breaks a test rather than shipping.
#[test]
fn a_registration_request_carries_the_public_key_and_nothing_else() {
let req = RegisterDevice::new("xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=");
let json = serde_json::to_string(&req).expect("serialises");
assert_eq!(
json,
r#"{"pubkey":"xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg="}"#
);
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
value.as_object().map(|o| o.len()),
Some(1),
"one field, so there is nowhere for a secret to be added"
);
}
}