Gunnar

nordisk/tunnr-mullvad

src/device.rs

//! **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"
        );
    }
}