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