sync/src/invite.rs
//! **A WireGuard downstream's invite** (lane HOL-WGDS, owner 2026-10-27).
//!
//! A downstream declared `via: Wireguard(key: ..)` on its upstream enrols ONCE, through
//! an invite the upstream signs with its sync key — the key the downstream already
//! trusts for its manifests (`sync.upstreams`). The invite says where the front's
//! downstream interface is (its WireGuard key and endpoint), the downstream's own
//! address on it, and the one door it may reach there (the sync door). The downstream
//! verifies it under its trust list and keeps it.
//!
//! **The link's pre-shared key** (lane WG-PSK, 2026-10-30): an invite of schema
//! [`INVITE_SCHEMA_PSK`] also carries the WireGuard `PresharedKey` of the downstream's link
//! ([`WgPsk`], 32 random bytes the upstream minted for this enrolment). WireGuard's handshake
//! is X25519 alone; with the PSK mixed in, a recording of the tunnel cannot be opened later by
//! a quantum computer. That makes the invite a SECRET: it travels as the console hands it out
//! (over its post-quantum TLS) or on a USB stick, it is kept 0600, and no `Debug` prints the key.
//!
//! ```text
//! upstream (main) downstream
//! `downstreams: [(name: "lab-2", via: Wireguard(key: K))]`
//! invite(lab-2) ── Signed{kind: invite} ──USB/console──► open_invite(bytes, sync.upstreams)
//! → the tunnel to `endpoint`, as `address`
//! → the mirror's door: `door`
//! ```
use serde::{Deserialize, Serialize};
use crate::{Kind, Signed, SyncError, SyncKey, TrustedPeer};
/// The invite's schema (no pre-shared key: an enrolment from before lane WG-PSK).
pub const INVITE_SCHEMA: &str = "holger.downstream-invite/v1";
/// The schema of an invite that carries the link's pre-shared key ([`DownstreamInvite::psk`]).
/// A downstream that does not know the key refuses it by its schema instead of raising a
/// tunnel the front would never answer.
pub const INVITE_SCHEMA_PSK: &str = "holger.downstream-invite/v2";
/// **A WireGuard pre-shared key** (base64 of 32 bytes), as the invite, main's enrolment record
/// and the front's relay list carry it. Its `Debug` never prints it.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct WgPsk(pub String);
impl std::fmt::Debug for WgPsk {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("WgPsk(..)")
}
}
impl WgPsk {
/// A new key: 32 bytes from the OS generator, never derived from another key.
pub fn mint() -> Result<WgPsk, SyncError> {
let mut raw = [0u8; 32];
getrandom::fill(&mut raw).map_err(|e| {
SyncError::Io(format!("no randomness for a WireGuard pre-shared key: {e}"))
})?;
use base64::Engine as _;
Ok(WgPsk(base64::engine::general_purpose::STANDARD.encode(raw)))
}
/// The key's 32 bytes; refused by name when it is not one (blank, not base64, not 32 bytes).
pub fn bytes(&self) -> Result<[u8; 32], SyncError> {
use base64::Engine as _;
let raw = base64::engine::general_purpose::STANDARD
.decode(self.0.trim())
.map_err(|_| {
SyncError::Malformed("the WireGuard pre-shared key is not base64".into())
})?;
raw.try_into().map_err(|r: Vec<u8>| {
SyncError::Malformed(format!(
"the WireGuard pre-shared key is {} bytes, not 32",
r.len()
))
})
}
}
/// **The invite**: what a WireGuard downstream needs to reach its upstream's sync door.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DownstreamInvite {
pub schema: String,
/// The upstream's sync name (the envelope's signer).
pub upstream: String,
/// The downstream it is for.
pub downstream: String,
/// The downstream's WireGuard public key the upstream declared (base64): the invite
/// is for THAT key, and the downstream refuses it under another.
pub downstream_key: String,
/// The front's downstream interface: its WireGuard public key (base64) ...
pub hub_key: String,
/// ... and its UDP endpoint, `ip:port`.
pub endpoint: String,
/// The downstream's own address on that interface.
pub address: String,
/// The front's address on it (the one peer the downstream routes to).
pub hub_address: String,
/// The one door the downstream reaches: `http://<hub_address>:<port>`.
pub door: String,
pub issued_at: i64,
/// Not taken after this (unix seconds).
pub expires_at: i64,
/// The link's WireGuard pre-shared key (schema [`INVITE_SCHEMA_PSK`]); `None` in a
/// [`INVITE_SCHEMA`] invite. A SECRET: see the module's head.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub psk: Option<WgPsk>,
}
impl DownstreamInvite {
/// **A direct invite** (lane HOL-PRODUCTS-7): a plain server's invite for a downstream that
/// reaches it with no tunnel (`via: Front`). The same format: schema v1 (no pre-shared key,
/// as there is no WireGuard link), signed by the upstream; `downstream_key` is the
/// downstream's SYNC key (the one its acks are signed with), the WireGuard fields are
/// empty, and `door` is the upstream's sync door the downstream pulls from.
pub fn direct(
upstream: &str,
downstream: &str,
downstream_key: &str,
door: &str,
issued_at: i64,
expires_at: i64,
) -> DownstreamInvite {
DownstreamInvite {
schema: INVITE_SCHEMA.into(),
upstream: upstream.into(),
downstream: downstream.into(),
downstream_key: downstream_key.trim().into(),
hub_key: String::new(),
endpoint: String::new(),
address: String::new(),
hub_address: String::new(),
door: door.trim().into(),
issued_at,
expires_at,
psk: None,
}
}
/// Is this a [`direct`](Self::direct) invite (no WireGuard link to bring up)?
pub fn is_direct(&self) -> bool {
self.hub_key.is_empty() && self.endpoint.is_empty() && self.psk.is_none()
}
/// The schema an invite with (or without) a pre-shared key is written under.
pub fn schema_for(psk: Option<&WgPsk>) -> &'static str {
if psk.is_some() {
INVITE_SCHEMA_PSK
} else {
INVITE_SCHEMA
}
}
}
impl SyncKey {
/// Sign `invite` as `invite.upstream`.
pub fn sign_invite(&self, invite: &DownstreamInvite) -> Result<Signed, SyncError> {
Signed::seal(Kind::Invite, &invite.upstream, self, invite)
}
}
/// ★ **Open an invite** under `trusted` (the downstream's `sync.upstreams`): the signer
/// trusted with its key, the kind an invite, the schema this one, the invite naming its
/// signer and `downstream`, signed for `own_key`, and not expired at `now`.
pub fn open_invite(
bytes: &[u8],
trusted: &[TrustedPeer],
downstream: &str,
own_key: &str,
now: i64,
) -> Result<DownstreamInvite, SyncError> {
let s = Signed::from_bytes(bytes)?;
let i: DownstreamInvite = s.open(Kind::Invite, trusted)?;
match (i.schema.as_str(), &i.psk) {
(INVITE_SCHEMA, None) => {}
(INVITE_SCHEMA_PSK, Some(p)) => {
p.bytes()?;
}
(INVITE_SCHEMA, Some(_)) => {
return Err(SyncError::Malformed(format!(
"a '{INVITE_SCHEMA}' invite carries no pre-shared key; this one does"
)))
}
(INVITE_SCHEMA_PSK, None) => {
return Err(SyncError::Malformed(format!(
"a '{INVITE_SCHEMA_PSK}' invite carries the link's pre-shared key; this one has none"
)))
}
(other, _) => {
return Err(SyncError::Malformed(format!(
"invite schema '{other}', expected '{INVITE_SCHEMA_PSK}' or '{INVITE_SCHEMA}'"
)))
}
}
if i.upstream != s.signer {
return Err(SyncError::Malformed(format!(
"the invite names upstream '{}' but '{}' signed it",
i.upstream, s.signer
)));
}
if i.downstream != downstream {
return Err(SyncError::Malformed(format!(
"the invite is for downstream '{}', not '{downstream}'",
i.downstream
)));
}
if i.downstream_key.trim() != own_key.trim() {
return Err(SyncError::Malformed(format!(
"the invite is for the WireGuard key {}…, not this downstream's {}…",
head(&i.downstream_key),
head(own_key)
)));
}
if now > i.expires_at {
return Err(SyncError::Malformed(format!(
"the invite expired at {} (now {now}): ask the upstream for a new one",
i.expires_at
)));
}
Ok(i)
}
fn head(s: &str) -> String {
s.trim().chars().take(8).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn invite(now: i64) -> DownstreamInvite {
DownstreamInvite {
schema: INVITE_SCHEMA.into(),
upstream: "cloud".into(),
downstream: "lab-2".into(),
downstream_key: "DSKEY".into(),
hub_key: "HUBKEY".into(),
endpoint: "203.0.113.7:51830".into(),
address: "10.46.0.2".into(),
hub_address: "10.46.0.1".into(),
door: "http://10.46.0.1:50070".into(),
issued_at: now,
expires_at: now + 3600,
psk: None,
}
}
const PSK: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
/// ★ **An invite carries the link's pre-shared key under schema v2** (lane WG-PSK): it
/// opens with the key, which reads as its 32 bytes; a v1 invite (an enrolment from before
/// the lane) still opens with none. A v2 invite without a key, a v1 invite with one and a
/// key that is not 32 bytes are refused by name; no `Debug` prints the key.
#[test]
fn an_invite_carries_the_links_preshared_key_under_schema_v2_and_never_prints_it() {
let up = SyncKey::from_seed([7; 32]);
let trusted = vec![TrustedPeer {
name: "cloud".into(),
key: up.public_hex(),
}];
let psk = WgPsk(PSK.into());
assert_eq!(DownstreamInvite::schema_for(Some(&psk)), INVITE_SCHEMA_PSK);
assert_eq!(DownstreamInvite::schema_for(None), INVITE_SCHEMA);
let with = DownstreamInvite {
schema: INVITE_SCHEMA_PSK.into(),
psk: Some(psk.clone()),
..invite(100)
};
let i = open_invite(
&up.sign_invite(&with).unwrap().to_bytes(),
&trusted,
"lab-2",
"DSKEY",
200,
)
.unwrap();
assert_eq!(
i.psk.as_ref().map(|p| p.bytes().unwrap()),
Some(core::array::from_fn(|i| i as u8))
);
assert!(!format!("{i:?}").contains(PSK), "{i:?}");
assert!(
open_invite(
&up.sign_invite(&invite(100)).unwrap().to_bytes(),
&trusted,
"lab-2",
"DSKEY",
200
)
.unwrap()
.psk
.is_none(),
"a v1 invite opens with no key"
);
let no_key = DownstreamInvite {
schema: INVITE_SCHEMA_PSK.into(),
..invite(100)
};
assert!(open_invite(
&up.sign_invite(&no_key).unwrap().to_bytes(),
&trusted,
"lab-2",
"DSKEY",
200
)
.unwrap_err()
.to_string()
.contains("pre-shared key"));
let v1_key = DownstreamInvite {
psk: Some(psk),
..invite(100)
};
assert!(open_invite(
&up.sign_invite(&v1_key).unwrap().to_bytes(),
&trusted,
"lab-2",
"DSKEY",
200
)
.unwrap_err()
.to_string()
.contains("pre-shared key"));
let short = DownstreamInvite {
schema: INVITE_SCHEMA_PSK.into(),
psk: Some(WgPsk("c2hvcnQ=".into())),
..invite(100)
};
assert!(open_invite(
&up.sign_invite(&short).unwrap().to_bytes(),
&trusted,
"lab-2",
"DSKEY",
200
)
.unwrap_err()
.to_string()
.contains("pre-shared key"));
}
/// ★ **A minted key is 32 fresh bytes** each time, from the OS generator.
#[test]
fn a_minted_preshared_key_is_32_fresh_bytes() {
let (a, b) = (WgPsk::mint().unwrap(), WgPsk::mint().unwrap());
assert_eq!(a.bytes().unwrap().len(), 32);
assert_ne!(
a.bytes().unwrap(),
b.bytes().unwrap(),
"two mints, two keys"
);
assert_ne!(a.bytes().unwrap(), [0u8; 32]);
assert_eq!(format!("{a:?}"), "WgPsk(..)");
}
/// ★ An invite opens under the upstream's trusted key, for its downstream and key,
/// before it expires; anything else is refused by name.
#[test]
fn an_invite_opens_only_signed_by_the_trusted_upstream_for_this_downstream() {
let up = SyncKey::from_seed([7; 32]);
let trusted = vec![TrustedPeer {
name: "cloud".into(),
key: up.public_hex(),
}];
let bytes = up.sign_invite(&invite(100)).unwrap().to_bytes();
let i = open_invite(&bytes, &trusted, "lab-2", "DSKEY", 200).unwrap();
assert_eq!(
(i.address.as_str(), i.door.as_str()),
("10.46.0.2", "http://10.46.0.1:50070")
);
assert!(open_invite(&bytes, &trusted, "lab-3", "DSKEY", 200)
.unwrap_err()
.to_string()
.contains("not 'lab-3'"));
assert!(open_invite(&bytes, &trusted, "lab-2", "OTHER", 200)
.unwrap_err()
.to_string()
.contains("WireGuard key"));
assert!(open_invite(&bytes, &trusted, "lab-2", "DSKEY", 100 + 3601)
.unwrap_err()
.to_string()
.contains("expired"));
let stranger = vec![TrustedPeer {
name: "cloud".into(),
key: SyncKey::from_seed([8; 32]).public_hex(),
}];
assert!(matches!(
open_invite(&bytes, &stranger, "lab-2", "DSKEY", 200),
Err(SyncError::UntrustedSigner { .. })
));
// A manifest is never an invite, the kind is in the signed bytes.
let m = crate::SyncManifest {
schema: crate::MANIFEST_SCHEMA.into(),
upstream: "cloud".into(),
generation: 1,
issued_at: 1,
carrier: "pull".into(),
added: vec![],
invalidations: vec![],
};
let mb = up.sign_manifest(&m).unwrap().to_bytes();
assert!(open_invite(&mb, &trusted, "lab-2", "DSKEY", 200)
.unwrap_err()
.to_string()
.contains("'manifest' document where a 'invite'"));
// A tampered invite does not verify.
let mut s = Signed::from_bytes(&bytes).unwrap();
let mut body = invite(100);
body.address = "10.46.0.9".into();
use base64::Engine as _;
s.payload =
base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&body).unwrap());
assert!(matches!(
open_invite(&s.to_bytes(), &trusted, "lab-2", "DSKEY", 200),
Err(SyncError::BadSignature(_))
));
}
}