Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/wg-appliance-core/src/keys.rs

//! WireGuard keys: base64 ⇄ 32 raw bytes, x25519 derivation, and **keygen**.
//!
//! Every WireGuard tool spells a key as 44 characters of padded base64 and
//! every WireGuard *protocol* operation takes 32 raw bytes, so this is the seam
//! both consumers were writing separately.
//!
//! Three things are load-bearing here rather than incidental:
//!
//! 1. **Length is checked after decoding, never by counting characters.** A
//!    44-character string that decodes to 33 bytes is a thing base64 can
//!    express, and a wrong-length key reaches the crypto as a panic or as a
//!    tunnel that silently never handshakes.
//! 2. **The decoder is constant-time** ([`base64ct`]). A private key decoded by
//!    a lookup table leaks its bytes through the cache; this is the one string
//!    in the suite where that matters.
//! 3. **Keygen reads `getrandom(2)`, not `/dev/urandom`.** An appliance mints
//!    its identity before `/dev` is necessarily populated, and a keygen that
//!    fell back to a weak or empty source would give a whole fleet one
//!    identity — the failure that looks like a working tunnel to the wrong box.

use base64ct::Encoding as _;
use boringtun::x25519::{PublicKey, StaticSecret};
use zeroize::Zeroizing;

/// A WireGuard key is 32 bytes, however it was spelled.
pub const KEY_LEN: usize = 32;

/// What a masked secret renders as, everywhere in this crate: `TunnelSpec`'s
/// private key, `PeerSpec`'s preshared key, `ceremony::Identity`'s private
/// half, and an INI `PrivateKey`/`PresharedKey` value. ONE constant so a grep
/// for the word finds every place a secret is deliberately not printed —
/// and `pub` so a consumer that carries key bytes in a type of its own can
/// mask them with the same word rather than a fourth spelling.
pub const REDACTED: &str = "[redacted]";

/// A key that was not valid base64 of exactly [`KEY_LEN`] bytes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyError(pub String);

impl std::fmt::Display for KeyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "invalid WireGuard key: {}", self.0)
    }
}
impl std::error::Error for KeyError {}

/// Decode a base64 WireGuard key into its 32 raw bytes.
///
/// Two things here are about key material rather than about base64 (F41):
///
/// * The decode buffer is [`Zeroizing`]. It holds the caller's PRIVATE key in
///   the clear on the heap, and until 2026-09-05 it was an ordinary `Vec` —
///   freed, not erased, on every config parse and every identity load.
/// * **The error never echoes the input.** It used to be
///   `format!("{b64:?} is not valid base64")`, so a mistyped or truncated
///   private key was printed verbatim into whatever collected the error — a
///   serial console, a `Result` bubbled up to a log line. The length and the
///   underlying reason say everything an operator needs; the bytes say more
///   than they should.
pub fn decode_key(b64: &str) -> Result<[u8; KEY_LEN], KeyError> {
    let trimmed = b64.trim();
    let raw = Zeroizing::new(base64ct::Base64::decode_vec(trimmed).map_err(|e| {
        KeyError(format!(
            "not valid base64 ({} characters): {e}",
            trimmed.chars().count()
        ))
    })?);
    raw.as_slice().try_into().map_err(|_| {
        KeyError(format!(
            "a WireGuard key is {KEY_LEN} bytes; this base64 decoded to {}",
            raw.len()
        ))
    })
}

/// Encode 32 raw bytes the way `wg(8)` prints them: padded standard base64.
pub fn encode_key(key: &[u8; KEY_LEN]) -> String {
    base64ct::Base64::encode_string(key)
}

/// Derive the x25519 public key from a private key, both raw.
///
/// Curve25519 clamping is applied by `StaticSecret::from`, exactly as
/// `wg pubkey` does — so a private key that was never clamped on disk still
/// derives the public key every other WireGuard implementation derives from it.
pub fn public_from_private_bytes(private: &[u8; KEY_LEN]) -> [u8; KEY_LEN] {
    let secret = StaticSecret::from(*private);
    *PublicKey::from(&secret).as_bytes()
}

/// Derive the base64 public key from a base64 private key.
pub fn public_from_private(private_b64: &str) -> Result<String, KeyError> {
    Ok(encode_key(&public_from_private_bytes(&decode_key(
        private_b64,
    )?)))
}

/// Mint a fresh keypair as raw `(private, public)`.
///
/// This is what PROVISIONING needs and what `wg genkey` is otherwise shelled
/// out for. The 32 secret bytes come from `getrandom(2)` via `OsRng`; clamping
/// is applied on the way to the public half.
pub fn generate_keypair() -> ([u8; KEY_LEN], [u8; KEY_LEN]) {
    let secret = StaticSecret::random_from_rng(rand_core::OsRng);
    let public = PublicKey::from(&secret);
    (secret.to_bytes(), *public.as_bytes())
}

/// [`generate_keypair`], spelled the way an operator writes it into a config.
pub fn generate_keypair_b64() -> (String, String) {
    let (private, public) = generate_keypair();
    (encode_key(&private), encode_key(&public))
}

/// Test-only: the ONE way this crate's tests prove that a rendering masks a
/// secret. `dataplane`, `ceremony` and `ini` each render their secret-bearing
/// type through it rather than each growing a needle list of its own.
#[cfg(test)]
pub(crate) mod probe {
    use super::{encode_key, KEY_LEN, REDACTED};

    /// Whitespace-squashed and lowercased. `{:#?}` puts every byte of a derived
    /// `[u8; 32]` on its own line at a nesting-dependent indent, so a needle
    /// written for `{:?}` (`[171, 171, …`) would be BLIND to a pretty-printed
    /// leak; and the INI tokenizer lowercases a KEY, so a `PrivateKey gEi6…=`
    /// line (a lost `=`) carries the secret case-folded in `Item::Pair::key`.
    fn squash(s: &str) -> String {
        s.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_ascii_lowercase()
    }

    /// Every `rendered` `(name, text)` must contain NONE of the three spellings
    /// of each secret — decimal byte list, base64 (padding dropped, so an
    /// unpadded copy is caught too), hex — and MUST contain [`REDACTED`] and
    /// every `must_show` string, so a mask that prints nothing at all is caught
    /// too. Each needle is first found in a control rendering of the raw
    /// bytes: a needle that never matches anything cannot pass.
    pub(crate) fn assert_masks(
        rendered: &[(&str, String)],
        secrets: &[(&str, [u8; KEY_LEN])],
        must_show: &[&str],
    ) {
        for (what, secret) in secrets {
            let hex: String = secret.iter().map(|b| format!("{b:02x}")).collect();
            let b64 = encode_key(secret);
            let needles = [
                ("as a byte list", squash(&format!("{secret:?}"))),
                ("as base64", b64.trim_end_matches('=').to_ascii_lowercase()),
                ("as hex", hex.clone()),
            ];
            let control = squash(&format!("{secret:?} {b64} {hex}"));
            for (spelling, needle) in &needles {
                assert!(
                    control.contains(needle.as_str()),
                    "the test's own needle for {what} {spelling} is wrong: {needle}"
                );
                for (name, text) in rendered {
                    assert!(
                        !squash(text).contains(needle.as_str()),
                        "{name} leaked {what} {spelling}:\n{text}"
                    );
                }
            }
        }
        for (name, text) in rendered {
            assert!(text.contains(REDACTED), "{name} must show the mask:\n{text}");
            for shown in must_show {
                assert!(text.contains(shown), "{name} must still show {shown:?}:\n{text}");
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A real x25519 pair. `gEi6…` is the private key gunnar's config fixtures
    /// use and `R/1R…` is what `wg pubkey` prints for it — recorded from the
    /// tool rather than computed here, so the assertion has an independent
    /// source. This ONE vector is what proves the curve math is really running:
    /// it is the same equality the deleted kernel lane could only get by asking
    /// a live kernel to derive the key for it.
    const PRIVATE: &str = "gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=";
    const ITS_PUBLIC: &str = "R/1RsV4yjvIeEUApgRu3u584R5/WCKgKaPqHieam4TU=";

    /// The wg-quick(8) man-page pair, a second independent vector from a
    /// different source document.
    const MANPAGE_PRIVATE: &str = "yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=";
    const MANPAGE_PUBLIC: &str = "HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw=";

    #[test]
    fn derives_the_public_keys_wg_pubkey_prints() {
        assert_eq!(public_from_private(PRIVATE).unwrap(), ITS_PUBLIC);
        assert_eq!(public_from_private(MANPAGE_PRIVATE).unwrap(), MANPAGE_PUBLIC);
    }

    #[test]
    fn a_key_round_trips_through_base64() {
        let key = [0x22u8; KEY_LEN];
        let text = encode_key(&key);
        assert_eq!(text.len(), 44, "a 32-byte key is 44 padded base64 characters");
        assert_eq!(decode_key(&text).unwrap(), key);
    }

    /// LAW 2 — the decoder must REFUSE, or "decoded" means nothing. Length is
    /// checked after decoding: `c2hvcnQ=` is legal base64 of five bytes.
    #[test]
    fn wrong_length_and_non_base64_are_refused_not_padded() {
        assert!(decode_key("c2hvcnQ=").is_err(), "5 bytes is not a key");
        assert!(decode_key("").is_err(), "0 bytes is not a key");
        assert!(decode_key("****").is_err(), "not base64 at all");
        // 33 bytes: 44 characters of base64 would be the right LENGTH and the
        // wrong key. This is the case a character count cannot see.
        let thirty_three = base64ct::Base64::encode_string(&[7u8; 33]);
        assert!(
            decode_key(&thirty_three).is_err(),
            "a 33-byte decode must be refused: {thirty_three}"
        );
    }

    /// **A decode failure must not print the thing that failed to decode.**
    ///
    /// The error read `format!("{b64:?} is not valid base64")`, so a private
    /// key with one character wrong — the exact case an operator hits, and the
    /// exact case that produces an error somebody pastes into a ticket — was
    /// echoed in full into whatever collected it. The length and the reason say
    /// everything the operator needs.
    ///
    /// The needle is proven real against a control first: without that, this
    /// test would pass just as happily if `decode_key` returned `Ok`.
    #[test]
    fn a_decode_error_never_echoes_the_key_it_could_not_decode() {
        // A real private key with one illegal character appended — long enough
        // that its prefix cannot appear by accident.
        let nearly = format!("{PRIVATE}!");
        let err = decode_key(&nearly).expect_err("a '!' is not base64");
        let text = err.to_string();
        assert!(
            nearly.contains(PRIVATE),
            "the control: the input really does carry the key"
        );
        assert!(
            !text.contains(PRIVATE),
            "the error echoed the private key it refused:\n{text}"
        );
        assert!(
            !text.contains(&PRIVATE[..16]),
            "not even a prefix of it:\n{text}"
        );
        // And it is still a USEFUL error: it says how much it was given.
        assert!(text.contains("45"), "the length is what diagnoses a typo: {text}");
    }

    #[test]
    fn surrounding_whitespace_survives_a_key_read_off_a_file() {
        assert_eq!(
            decode_key(&format!("  {PRIVATE}\n")).unwrap(),
            decode_key(PRIVATE).unwrap()
        );
    }

    /// RED-WHEN-BROKEN: a minted key must be real, usable and **unique**. A
    /// keygen that returned a constant would give a whole fleet one identity,
    /// and every tunnel would come up.
    #[test]
    fn keygen_mints_a_unique_usable_key() {
        let (p1, pub1) = generate_keypair_b64();
        let (p2, _) = generate_keypair_b64();
        assert_eq!(decode_key(&p1).unwrap().len(), KEY_LEN);
        assert_eq!(
            public_from_private(&p1).unwrap(),
            pub1,
            "the pair the generator returns must be self-consistent"
        );
        assert_ne!(p1, p2, "keygen must not hand a fleet one identity");
        assert_ne!(p1, PRIVATE, "never a key that is written down somewhere");
        assert_ne!(p1, MANPAGE_PRIVATE);
    }

    /// The clamping rule, which is why a key on disk and the key in use are the
    /// same identity: curve25519 ignores the low three bits of byte 0 and the
    /// top two of byte 31, so a key and its clamped form MUST derive one public
    /// key. `wg genkey` clamps on the way out and `wg` clamps again on the way
    /// in; a decoder that "helpfully" normalised differently would silently
    /// change a fleet's identity.
    #[test]
    fn a_key_and_its_clamped_form_are_one_identity() {
        let raw = [0xffu8; KEY_LEN];
        let mut clamped = raw;
        clamped[0] &= 248;
        clamped[31] &= 127;
        clamped[31] |= 64;
        assert_ne!(clamped, raw, "these two byte strings really do differ");
        assert_eq!(
            public_from_private_bytes(&raw),
            public_from_private_bytes(&clamped),
            "clamped-away bits must not change the derived identity"
        );
        // And the bits that are NOT clamped away must: this is the control that
        // keeps the assertion above from passing on a constant.
        let mut other = clamped;
        other[5] ^= 0x01;
        assert_ne!(
            public_from_private_bytes(&other),
            public_from_private_bytes(&clamped),
            "a real bit flip must change the identity, or the derivation is dead"
        );
    }
}