Implement client configuration delivery and reissue lifecycle

This commit is contained in:
2026-10-21 21:52:24 +03:00
parent 6ec593c783
commit f718f739b4
13 changed files with 4911 additions and 20 deletions
+1879 -9
View File
File diff suppressed because it is too large Load Diff
+807
View File
@@ -0,0 +1,807 @@
//! Deterministic, bounded rendering of explicitly authorized client configurations.
//!
//! Rendering is pure: callers supply the authoritative saved document, the derived
//! server identity, and settings. Plaintext output is held in [`ClientConfigPayload`]
//! and exposed only through its explicit byte accessor.
//!
//! QR encoding is intentionally not a public API. It is only reachable through
//! the application's file-and-QR delivery operation, which publishes the
//! mandatory plaintext file before encoding those same bytes.
//!
//! ```compile_fail
//! use wireguard_admin::delivery::qr::encode_payload;
//! ```
use std::collections::BTreeSet;
use std::fmt;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::str::FromStr;
use ipnet::IpNet;
use thiserror::Error;
use zeroize::Zeroizing;
pub mod files;
pub(crate) mod qr;
pub use qr::{QrCodeOutput, QrError};
use crate::config::{EndpointHost, ServerEndpoint};
use crate::domain::PublicKey;
use crate::vault::{RetrievedClientRecord, WireGuardPrivateKey};
use crate::wireguard::{ConfigDocument, PeerInspection};
const MAX_CONFIG_BYTES: usize = 64 * 1024;
const MAX_DNS_ENTRIES: usize = 32;
const MAX_ROUTE_ENTRIES: usize = 256;
/// Validated connection defaults used for new clients and missing imported values.
///
/// Values are checked again here because public settings structs can be constructed
/// directly by library callers without passing through settings-file validation.
pub struct DeliverySettings<'a> {
pub server_endpoint: &'a ServerEndpoint,
pub dns_addresses: &'a [IpAddr],
}
/// Secret-bearing rendered file bytes. There is no `Display` or serialization API.
pub struct ClientConfigPayload(Zeroizing<Vec<u8>>);
impl ClientConfigPayload {
/// Explicitly expose rendered configuration bytes to a delivery operation.
pub fn as_bytes(&self) -> &[u8] {
self.0.as_slice()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl fmt::Debug for ClientConfigPayload {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientConfigPayload")
.field("length", &self.0.len())
.field("contents", &"[REDACTED]")
.finish()
}
}
/// Secret-free rendering failures. Variants never contain source text or key material.
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum DeliveryError {
#[error("client identity does not match the authorized private key")]
ClientIdentityMismatch,
#[error("client peer is missing from the authoritative saved configuration")]
MissingSavedPeer,
#[error("client identity is ambiguous in the authoritative saved configuration")]
AmbiguousSavedPeer,
#[error("authoritative saved peer data is malformed or unsupported")]
InvalidSavedPeer,
#[error("retained client addresses do not match authoritative saved addresses")]
AddressMismatch,
#[error("retained server identity does not match the derived saved server identity")]
ServerIdentityMismatch,
#[error("authoritative saved server identity is missing, ambiguous, or invalid")]
InvalidSavedServerIdentity,
#[error("client connection settings are invalid or unsupported")]
InvalidConnectionField,
#[error("rendered client configuration exceeds its size limit")]
TooLarge,
}
/// Render a newly created client's committed connection using a full-tunnel template.
pub fn render_new_client(
identity: PublicKey,
private_key: &WireGuardPrivateKey,
saved: &ConfigDocument,
settings: &DeliverySettings<'_>,
) -> Result<ClientConfigPayload, DeliveryError> {
if !private_key.matches_public_key(&identity) {
return Err(DeliveryError::ClientIdentityMismatch);
}
let server_public_key = authoritative_server_public_key(saved)?;
let addresses = authoritative_addresses(saved, identity, true)?;
let endpoint = render_endpoint(settings.server_endpoint)?;
let dns = render_dns(settings.dns_addresses)?;
let private_text = Zeroizing::new(private_key.to_base64());
let mut output = Zeroizing::new(Vec::with_capacity(256));
append(&mut output, "[Interface]\nPrivateKey = ")?;
append(&mut output, private_text.as_str())?;
append(&mut output, "\nAddress = ")?;
append(&mut output, &addresses)?;
append(&mut output, "\nDNS = ")?;
append(&mut output, &dns)?;
append(&mut output, "\n\n[Peer]\nPublicKey = ")?;
append(&mut output, &server_public_key.to_string())?;
append(&mut output, "\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = ")?;
append(&mut output, &endpoint)?;
append(&mut output, "\n")?;
finish(output)
}
/// Render a password-authorized imported record, retaining validated supported fields.
///
/// The record type is only obtainable through fresh vault retrieval, which rejects
/// authenticated `reissue_blocked` records. Its retained fields are immutable to
/// library callers, and the renderer verifies their authenticated provenance and
/// server binding against the authoritative saved configuration.
pub fn render_imported_client(
identity: PublicKey,
record: &RetrievedClientRecord,
saved: &ConfigDocument,
settings: &DeliverySettings<'_>,
) -> Result<ClientConfigPayload, DeliveryError> {
let server_public_key = authoritative_server_public_key(saved)?;
if !record.is_imported() {
return Err(DeliveryError::ServerIdentityMismatch);
}
let private_key = record
.private_key()
.ok_or(DeliveryError::ClientIdentityMismatch)?;
if !private_key.matches_public_key(&identity) {
return Err(DeliveryError::ClientIdentityMismatch);
}
let addresses = authoritative_addresses(saved, identity, false)?;
validate_retained_addresses(record.client_addresses(), &addresses)?;
let expected_server_public_key = server_public_key.to_string();
if record.server_public_key() != Some(expected_server_public_key.as_str()) {
return Err(DeliveryError::ServerIdentityMismatch);
}
let interface_addresses = parse_address_string(&addresses)?;
let dns_value = match record.interface_dns() {
Some(value) => validate_dns(value)?,
None => render_dns(settings.dns_addresses)?,
};
let endpoint = match record.endpoint() {
Some(value) => validate_endpoint(value)?,
None => render_endpoint(settings.server_endpoint)?,
};
let mtu = record.interface_mtu().map(validate_mtu).transpose()?;
let keepalive = record
.persistent_keepalive()
.map(validate_keepalive)
.transpose()?;
let routes = validate_routes(record.allowed_ips())?;
let psk = record
.preshared_key_base64()
.map(validate_secret_key)
.transpose()?;
let private_text = Zeroizing::new(private_key.to_base64());
let mut output = Zeroizing::new(Vec::with_capacity(512));
append(&mut output, "[Interface]\nPrivateKey = ")?;
append(&mut output, private_text.as_str())?;
append(&mut output, "\nAddress = ")?;
append(&mut output, &interface_addresses)?;
append(&mut output, "\nDNS = ")?;
append(&mut output, &dns_value)?;
if let Some(mtu) = mtu {
append(&mut output, "\nMTU = ")?;
append(&mut output, &mtu)?;
}
append(&mut output, "\n\n[Peer]\nPublicKey = ")?;
append(&mut output, &server_public_key.to_string())?;
if let Some(psk) = psk {
append(&mut output, "\nPresharedKey = ")?;
append(&mut output, psk.as_str())?;
}
append(&mut output, "\nAllowedIPs = ")?;
append(&mut output, &routes)?;
append(&mut output, "\nEndpoint = ")?;
append(&mut output, &endpoint)?;
if let Some(keepalive) = keepalive {
append(&mut output, "\nPersistentKeepalive = ")?;
append(&mut output, &keepalive)?;
}
append(&mut output, "\n")?;
finish(output)
}
/// Derive the server identity only from one valid `[Interface] PrivateKey` in
/// the authoritative saved document. The private key and source text remain
/// borrowed/local to this call and never enter an error or owned long-lived value.
fn authoritative_server_public_key(saved: &ConfigDocument) -> Result<PublicKey, DeliveryError> {
use crate::wireguard::SectionKind;
let interfaces = saved
.sections()
.iter()
.filter(|section| section.kind == SectionKind::Interface)
.collect::<Vec<_>>();
let [interface] = interfaces.as_slice() else {
return Err(DeliveryError::InvalidSavedServerIdentity);
};
let section = saved
.source_slice(interface.span)
.ok_or(DeliveryError::InvalidSavedServerIdentity)?;
let mut private_key_value = None;
for line in section.lines() {
let trimmed = line.trim_matches(|ch: char| ch.is_ascii_whitespace());
if trimmed.is_empty() || trimmed.starts_with(['#', ';']) {
continue;
}
let has_private_key_name = trimmed
.split(['=', ' ', '\t'])
.next()
.is_some_and(|name| name.eq_ignore_ascii_case("PrivateKey"));
if !has_private_key_name {
continue;
}
if private_key_value.is_some() {
return Err(DeliveryError::InvalidSavedServerIdentity);
}
let Some((name, value)) = trimmed.split_once('=') else {
return Err(DeliveryError::InvalidSavedServerIdentity);
};
if !name
.trim_matches(|ch: char| ch.is_ascii_whitespace())
.eq_ignore_ascii_case("PrivateKey")
{
return Err(DeliveryError::InvalidSavedServerIdentity);
}
private_key_value = Some(value.trim_matches(|ch: char| ch.is_ascii_whitespace()));
}
let private_key = WireGuardPrivateKey::parse_base64(
private_key_value.ok_or(DeliveryError::InvalidSavedServerIdentity)?,
)
.map_err(|_| DeliveryError::InvalidSavedServerIdentity)?;
Ok(private_key.public_key())
}
fn authoritative_addresses(
saved: &ConfigDocument,
identity: PublicKey,
require_dual_stack: bool,
) -> Result<String, DeliveryError> {
let matching = saved
.peers()
.iter()
.filter(|peer| {
peer.identity == Some(identity) || peer.public_key_candidates.contains(&identity)
})
.collect::<Vec<_>>();
let peer = match matching.as_slice() {
[] => return Err(DeliveryError::MissingSavedPeer),
[peer] => *peer,
_ => return Err(DeliveryError::AmbiguousSavedPeer),
};
validate_peer(peer)?;
let mut v4 = None;
let mut v6 = None;
for network in &peer.allowed_ips {
match network {
IpNet::V4(network)
if network.prefix_len() == 32 && v4.replace(network.addr()).is_none() => {}
IpNet::V6(network)
if network.prefix_len() == 128 && v6.replace(network.addr()).is_none() => {}
_ => return Err(DeliveryError::InvalidSavedPeer),
}
}
let v4 = v4.ok_or(DeliveryError::InvalidSavedPeer)?;
if require_dual_stack && v6.is_none() {
return Err(DeliveryError::InvalidSavedPeer);
}
Ok(format_addresses(v4, v6))
}
fn validate_peer(peer: &PeerInspection) -> Result<(), DeliveryError> {
if peer.identity.is_none()
|| peer.identity != peer.public_key_candidates.first().copied()
|| peer.public_key_candidates.len() != 1
|| !peer.issues.is_empty()
|| peer.allowed_ips.is_empty()
{
return Err(DeliveryError::InvalidSavedPeer);
}
Ok(())
}
fn format_addresses(v4: Ipv4Addr, v6: Option<Ipv6Addr>) -> String {
let mut output = format!("{v4}/32");
if let Some(v6) = v6 {
output.push_str(&format!(", {v6}/128"));
}
output
}
fn parse_address_string(value: &str) -> Result<String, DeliveryError> {
let items = value.split(", ").collect::<Vec<_>>();
if items.is_empty() || items.len() > 2 {
return Err(DeliveryError::InvalidSavedPeer);
}
Ok(items.join(", "))
}
fn validate_retained_addresses(
retained: &[String],
authoritative: &str,
) -> Result<(), DeliveryError> {
if retained.is_empty() || retained.len() > 2 || retained.iter().any(|value| value.len() > 64) {
return Err(DeliveryError::AddressMismatch);
}
let expected = authoritative
.split(", ")
.map(str::to_owned)
.collect::<BTreeSet<_>>();
let mut actual = BTreeSet::new();
for value in retained {
if value.chars().any(char::is_control) {
return Err(DeliveryError::InvalidConnectionField);
}
let network = IpNet::from_str(value).map_err(|_| DeliveryError::InvalidConnectionField)?;
match network {
IpNet::V4(net) if net.prefix_len() == 32 => {}
IpNet::V6(net) if net.prefix_len() == 128 => {}
_ => return Err(DeliveryError::InvalidConnectionField),
}
actual.insert(network.to_string());
}
if actual == expected {
Ok(())
} else {
Err(DeliveryError::AddressMismatch)
}
}
fn render_dns(dns: &[IpAddr]) -> Result<String, DeliveryError> {
if dns.is_empty() || dns.len() > MAX_DNS_ENTRIES {
return Err(DeliveryError::InvalidConnectionField);
}
Ok(dns
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", "))
}
fn validate_dns(value: &str) -> Result<String, DeliveryError> {
if value.len() > 2048 || value.chars().any(char::is_control) {
return Err(DeliveryError::InvalidConnectionField);
}
let items = value.split(',').map(str::trim).collect::<Vec<_>>();
if items.is_empty() || items.len() > MAX_DNS_ENTRIES || items.iter().any(|item| item.is_empty())
{
return Err(DeliveryError::InvalidConnectionField);
}
let addresses = items
.iter()
.map(|item| {
item.parse::<IpAddr>()
.map_err(|_| DeliveryError::InvalidConnectionField)
})
.collect::<Result<Vec<_>, _>>()?;
render_dns(&addresses)
}
fn render_endpoint(endpoint: &ServerEndpoint) -> Result<String, DeliveryError> {
if endpoint.port == 0 {
return Err(DeliveryError::InvalidConnectionField);
}
match &endpoint.host {
EndpointHost::Ip(IpAddr::V6(address)) => Ok(format!("[{address}]:{}", endpoint.port)),
EndpointHost::Ip(address) => Ok(format!("{address}:{}", endpoint.port)),
EndpointHost::Hostname(host) => {
validate_hostname(host)?;
Ok(format!("{host}:{}", endpoint.port))
}
}
}
fn validate_endpoint(value: &str) -> Result<String, DeliveryError> {
if value.is_empty() || value.chars().any(char::is_control) || value.len() > 255 {
return Err(DeliveryError::InvalidConnectionField);
}
let (host, port) = if let Some(rest) = value.strip_prefix('[') {
let (host, suffix) = rest
.split_once(']')
.ok_or(DeliveryError::InvalidConnectionField)?;
let port = suffix
.strip_prefix(':')
.ok_or(DeliveryError::InvalidConnectionField)?;
let address = host
.parse::<Ipv6Addr>()
.map_err(|_| DeliveryError::InvalidConnectionField)?;
(format!("[{address}]"), parse_port(port)?)
} else {
let (host, port) = value
.rsplit_once(':')
.ok_or(DeliveryError::InvalidConnectionField)?;
if host.contains(':') {
return Err(DeliveryError::InvalidConnectionField);
}
let formatted = if let Ok(address) = host.parse::<Ipv4Addr>() {
address.to_string()
} else {
validate_hostname(host)?;
host.to_owned()
};
(formatted, parse_port(port)?)
};
Ok(format!("{host}:{port}"))
}
fn validate_hostname(host: &str) -> Result<(), DeliveryError> {
if host.is_empty()
|| host.len() > 253
|| host.split('.').any(|label| {
label.is_empty()
|| label.len() > 63
|| label.starts_with('-')
|| label.ends_with('-')
|| !label.as_bytes()[0].is_ascii_alphanumeric()
|| !label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
})
{
return Err(DeliveryError::InvalidConnectionField);
}
Ok(())
}
fn parse_port(value: &str) -> Result<u16, DeliveryError> {
let port = value
.parse::<u16>()
.map_err(|_| DeliveryError::InvalidConnectionField)?;
if port == 0 || port.to_string() != value {
return Err(DeliveryError::InvalidConnectionField);
}
Ok(port)
}
fn validate_mtu(value: &str) -> Result<String, DeliveryError> {
let mtu = value
.parse::<u16>()
.map_err(|_| DeliveryError::InvalidConnectionField)?;
if mtu == 0 || mtu.to_string() != value {
return Err(DeliveryError::InvalidConnectionField);
}
Ok(mtu.to_string())
}
fn validate_keepalive(value: &str) -> Result<String, DeliveryError> {
if value.len() > 5 {
return Err(DeliveryError::InvalidConnectionField);
}
let keepalive = value
.parse::<u16>()
.map_err(|_| DeliveryError::InvalidConnectionField)?;
if keepalive.to_string() != value {
return Err(DeliveryError::InvalidConnectionField);
}
Ok(keepalive.to_string())
}
fn validate_routes(routes: &[String]) -> Result<String, DeliveryError> {
if routes.is_empty()
|| routes.len() > MAX_ROUTE_ENTRIES
|| routes.iter().any(|route| route.len() > 128)
{
return Err(DeliveryError::InvalidConnectionField);
}
let mut parsed = Vec::with_capacity(routes.len());
for route in routes {
if route.chars().any(char::is_control) {
return Err(DeliveryError::InvalidConnectionField);
}
let network = IpNet::from_str(route).map_err(|_| DeliveryError::InvalidConnectionField)?;
parsed.push(network.to_string());
}
Ok(parsed.join(", "))
}
fn validate_secret_key(value: &str) -> Result<Zeroizing<String>, DeliveryError> {
let key = WireGuardPrivateKey::parse_base64(value)
.map_err(|_| DeliveryError::InvalidConnectionField)?;
// Parsing confirms canonical 32-byte standard Base64; retain only a zeroizing copy.
Ok(Zeroizing::new(key.to_base64()))
}
fn append(output: &mut Zeroizing<Vec<u8>>, value: &str) -> Result<(), DeliveryError> {
if output.len().saturating_add(value.len()) > MAX_CONFIG_BYTES {
return Err(DeliveryError::TooLarge);
}
output.extend_from_slice(value.as_bytes());
Ok(())
}
fn finish(output: Zeroizing<Vec<u8>>) -> Result<ClientConfigPayload, DeliveryError> {
if output.is_empty() || output.len() > MAX_CONFIG_BYTES {
return Err(DeliveryError::TooLarge);
}
Ok(ClientConfigPayload(output))
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use crate::config::{EndpointHost, ServerEndpoint};
use crate::vault::{
VaultBundle, WireGuardPrivateKey,
import::{parse_client_config, reconcile},
};
use crate::wireguard::ConfigDocument;
use super::{DeliveryError, DeliverySettings, render_imported_client, render_new_client};
const PASSWORD: &str = "synthetic delivery test password";
fn key(byte: u8) -> WireGuardPrivateKey {
WireGuardPrivateKey::parse_base64(
&base64::engine::general_purpose::STANDARD.encode([byte; 32]),
)
.expect("synthetic key parses")
}
fn document(client: &WireGuardPrivateKey, addresses: &str) -> ConfigDocument {
document_with_server(client, addresses, &key(9))
}
fn document_with_server(
client: &WireGuardPrivateKey,
addresses: &str,
server: &WireGuardPrivateKey,
) -> ConfigDocument {
let source = format!(
"[Interface]\nPrivateKey = {}\n\n[Peer]\nPublicKey = {}\nAllowedIPs = {addresses}\n",
server.to_base64(),
client.public_key()
);
ConfigDocument::parse(source).expect("synthetic server document parses")
}
fn imported_record(
client: &WireGuardPrivateKey,
addresses: &str,
server: &WireGuardPrivateKey,
) -> crate::vault::RetrievedClientRecord {
let saved = document_with_server(client, addresses, server);
let source = format!(
"[Interface]\nPrivateKey = {}\nAddress = {addresses}\nDNS = 10.80.0.1, fd80::1\nMTU = 1380\n\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example.test:51821\nAllowedIPs = 10.0.0.0/8, ::/0\nPersistentKeepalive = 25\n",
client.to_base64(),
server.public_key()
);
let report = reconcile(&saved, [("fixture.conf".to_owned(), source.into_bytes())]);
let mut bundle = VaultBundle::create(PASSWORD).expect("synthetic vault creates");
report
.encrypt_candidate(&mut bundle, 0)
.expect("matching imported file encrypts");
bundle
.retrieve_client_record(client.public_key(), PASSWORD)
.expect("imported record retrieves")
}
fn settings() -> (ServerEndpoint, Vec<IpAddr>) {
(
ServerEndpoint {
host: EndpointHost::Hostname("vpn.example.test".to_owned()),
port: 51820,
},
vec![
"10.80.0.1".parse().expect("valid DNS"),
"fd80::1".parse().expect("valid DNS"),
],
)
}
#[test]
fn new_client_uses_saved_dual_stack_addresses_and_full_tunnel_template() {
let client = key(1);
let server = key(9).public_key();
let saved = document(&client, "10.80.0.7/32, fd80::7/128");
let (endpoint, dns) = settings();
let payload = render_new_client(
client.public_key(),
&client,
&saved,
&DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
},
)
.expect("valid synthetic values render");
let parsed = parse_client_config(payload.as_bytes()).expect("rendered file parses");
assert_eq!(parsed.identity, client.public_key());
assert_eq!(parsed.server_public_key, server);
assert_eq!(parsed.addresses.len(), 2);
assert_eq!(parsed.allowed_ips.len(), 2);
assert_eq!(parsed.allowed_ips[0].to_string(), "0.0.0.0/0");
assert_eq!(parsed.allowed_ips[1].to_string(), "::/0");
}
#[test]
fn new_client_rejects_wrong_key_and_missing_or_ambiguous_saved_peer() {
let client = key(1);
let other = key(2);
let (endpoint, dns) = settings();
let opts = DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
};
let saved = document(&client, "10.80.0.7/32, fd80::7/128");
assert_eq!(
render_new_client(other.public_key(), &client, &saved, &opts).err(),
Some(DeliveryError::ClientIdentityMismatch)
);
let empty = ConfigDocument::parse(format!(
"[Interface]\nPrivateKey = {}\n",
key(9).to_base64()
))
.expect("valid document");
assert_eq!(
render_new_client(client.public_key(), &client, &empty, &opts).err(),
Some(DeliveryError::MissingSavedPeer)
);
let duplicate = ConfigDocument::parse(format!(
"[Interface]\nPrivateKey = {}\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n",
key(9).to_base64(),
client.public_key(), client.public_key()
)).expect("duplicate peer document parses");
assert_eq!(
render_new_client(client.public_key(), &client, &duplicate, &opts).err(),
Some(DeliveryError::AmbiguousSavedPeer)
);
}
#[test]
fn new_client_derives_server_identity_and_rejects_invalid_saved_interface_keys() {
let client = key(1);
let (endpoint, dns) = settings();
let opts = DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
};
let valid_peer = format!(
"\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.7/32, fd80::7/128\n",
client.public_key()
);
let invalid_documents = [
valid_peer.clone(),
format!(
"[Interface]\nPrivateKey = {}\n[Interface]\nPrivateKey = {}{}",
key(9).to_base64(),
key(9).to_base64(),
valid_peer
),
format!("[Interface]\nPrivateKey = invalid{valid_peer}"),
format!(
"[Interface]\nPrivateKey = {}\nPrivateKey = {}{}",
key(9).to_base64(),
key(8).to_base64(),
valid_peer
),
format!("[Interface]\nPrivateKey\n{valid_peer}"),
];
for source in invalid_documents {
let saved = ConfigDocument::parse(source).expect("synthetic document parses");
assert_eq!(
render_new_client(client.public_key(), &client, &saved, &opts).err(),
Some(DeliveryError::InvalidSavedServerIdentity)
);
}
let valid = document(&client, "10.80.0.7/32, fd80::7/128");
let rendered = render_new_client(client.public_key(), &client, &valid, &opts)
.expect("server identity derives from valid saved private key");
let parsed = parse_client_config(rendered.as_bytes()).expect("rendered file parses");
assert_eq!(parsed.server_public_key, key(9).public_key());
}
#[test]
fn imported_ipv4_only_record_preserves_supported_fields() {
let client = key(4);
let server_private = key(9);
let record = imported_record(&client, "10.80.0.9/32", &server_private);
let saved = document(&client, "10.80.0.9/32");
let (endpoint, dns) = settings();
let payload = render_imported_client(
client.public_key(),
&record,
&saved,
&DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
},
)
.expect("supported imported values render");
let parsed =
parse_client_config(payload.as_bytes()).expect("rendered imported file parses");
assert_eq!(
parsed.addresses,
vec!["10.80.0.9/32".parse().expect("valid address")]
);
assert_eq!(parsed.allowed_ips.len(), 2);
assert_eq!(parsed.mtu.as_deref(), Some("1380"));
assert_eq!(parsed.endpoint.as_deref(), Some("vpn.example.test:51821"));
assert_eq!(parsed.persistent_keepalive.as_deref(), Some("25"));
}
#[test]
fn imported_rendering_rejects_stale_and_mismatched_bindings() {
let client = key(6);
let server_private = key(9);
let (endpoint, dns) = settings();
let opts = DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
};
let record = imported_record(&client, "10.80.0.9/32", &server_private);
let stale_saved = document(&client, "10.80.0.8/32");
assert_eq!(
render_imported_client(client.public_key(), &record, &stale_saved, &opts).err(),
Some(DeliveryError::AddressMismatch)
);
let changed_server = document_with_server(&client, "10.80.0.9/32", &key(8));
assert_eq!(
render_imported_client(client.public_key(), &record, &changed_server, &opts).err(),
Some(DeliveryError::ServerIdentityMismatch)
);
assert!(record.server_public_key().is_some());
}
#[test]
fn imported_rendering_requires_saved_server_identity() {
let client = key(12);
let mut bundle = VaultBundle::create(PASSWORD).expect("synthetic vault creates");
bundle
.encrypt_record(client.public_key(), &client)
.expect("client key encrypts");
let record = bundle
.retrieve_client_record(client.public_key(), PASSWORD)
.expect("record retrieves");
let saved = ConfigDocument::parse(format!(
"[Interface]\nPrivateKey = malformed\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.80.0.9/32\n",
client.public_key()
))
.expect("synthetic malformed-key document parses structurally");
let (endpoint, dns) = settings();
let opts = DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
};
let valid_saved = document(&client, "10.80.0.9/32");
assert_eq!(
render_imported_client(client.public_key(), &record, &valid_saved, &opts).err(),
Some(DeliveryError::ServerIdentityMismatch)
);
assert_eq!(
render_imported_client(client.public_key(), &record, &saved, &opts).err(),
Some(DeliveryError::InvalidSavedServerIdentity)
);
}
#[test]
fn payload_debug_and_render_errors_do_not_contain_secret_text() {
let client = key(10);
let saved = document(&client, "10.80.0.7/32, fd80::7/128");
let (endpoint, dns) = settings();
let payload = render_new_client(
client.public_key(),
&client,
&saved,
&DeliverySettings {
server_endpoint: &endpoint,
dns_addresses: &dns,
},
)
.expect("valid synthetic values render");
let debug = format!("{payload:?}");
assert!(debug.contains("REDACTED"));
assert!(!debug.contains(&client.to_base64()));
assert!(Ipv4Addr::LOCALHOST.is_loopback());
assert_eq!(
"::1".parse::<Ipv6Addr>().expect("valid IPv6"),
Ipv6Addr::LOCALHOST
);
}
}
+880
View File
@@ -0,0 +1,880 @@
//! Secure publication of explicitly selected plaintext client configurations.
use std::fmt;
use std::fs::{self, File};
use std::io::{self, Write};
use std::os::fd::AsFd;
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use rustix::fs::{self as rfs, Mode, OFlags, RenameFlags, ResolveFlags};
use thiserror::Error;
use zeroize::{Zeroize, Zeroizing};
use super::{ClientConfigPayload, MAX_CONFIG_BYTES};
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
const MAX_OUTPUT_BYTES: usize = MAX_CONFIG_BYTES;
/// Resolved installation files and protected directories supplied by SavedStore.
pub(crate) struct OutputProtection {
protected_files: Vec<PathBuf>,
protected_directories: Vec<PathBuf>,
}
impl OutputProtection {
pub(crate) fn new(files: Vec<PathBuf>, directories: Vec<PathBuf>) -> Self {
Self {
protected_files: files,
protected_directories: directories,
}
}
}
/// A destination prepared without creating files or directories.
///
/// The optional existing-file identity binds overwrite confirmation to the
/// precise regular file observed by [`prepare_destination`].
pub struct PreparedDestination {
path: PathBuf,
parent: PathBuf,
filename: std::ffi::OsString,
existing: Option<FileIdentity>,
overwrite_confirmed: bool,
}
impl PreparedDestination {
pub fn path(&self) -> &Path {
&self.path
}
pub const fn overwrite_confirmation_required(&self) -> bool {
self.existing.is_some() && !self.overwrite_confirmed
}
/// Confirm replacement of the exact regular file observed during preparation.
pub fn confirm_overwrite(mut self) -> Result<Self, FileOutputError> {
if self.existing.is_none() {
return Err(FileOutputError::OverwriteNotRequired);
}
self.overwrite_confirmed = true;
Ok(self)
}
}
impl fmt::Debug for PreparedDestination {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PreparedDestination")
.field("path", &self.path)
.field("existing_file", &self.existing.is_some())
.field("overwrite_confirmed", &self.overwrite_confirmed)
.finish()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct FileIdentity {
device: u64,
inode: u64,
length: u64,
modified_seconds: i64,
modified_nanoseconds: i64,
}
impl From<&fs::Metadata> for FileIdentity {
fn from(metadata: &fs::Metadata) -> Self {
Self {
device: metadata.dev(),
inode: metadata.ino(),
length: metadata.len(),
modified_seconds: metadata.mtime(),
modified_nanoseconds: metadata.mtime_nsec(),
}
}
}
/// Failure categories let the application preserve retry eligibility while
/// distinguishing a failed preparation from a file that may already be visible.
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum FileOutputError {
#[error("selected output path is unsafe or protected")]
UnsafePath,
#[error("selected destination already exists; confirm replacement for this file")]
OverwriteDeclined,
#[error("the selected destination changed after it was prepared")]
DestinationChanged,
#[error("client configuration exceeds the output size limit")]
TooLarge,
#[error("client configuration could not be written before publication")]
BeforePublication,
#[error("a private temporary client configuration may remain and requires manual cleanup")]
ManualCleanupRequired,
#[error("client configuration may have been published, but directory sync failed")]
PublicationUncertain,
#[error("file output failed")]
Io,
#[error("overwrite confirmation is not applicable to a new destination")]
OverwriteNotRequired,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Checkpoint {
Write,
Flush,
FileSync,
Cleanup,
Publish,
Rollback,
ParentSync,
}
/// Validate a caller-selected absolute destination without performing writes.
/// Existing targets are accepted only when they are regular files; they require
/// an explicit later call to [`PreparedDestination::confirm_overwrite`].
pub(crate) fn prepare_destination(
path: impl AsRef<Path>,
protection: &OutputProtection,
) -> Result<PreparedDestination, FileOutputError> {
let path = path.as_ref();
validate_absolute_path(path)?;
reject_protected(path, protection)?;
let parent = path.parent().ok_or(FileOutputError::UnsafePath)?;
validate_existing_ancestors(parent)?;
let filename = path
.file_name()
.ok_or(FileOutputError::UnsafePath)?
.to_os_string();
let existing = match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_file() => {
reject_protected_hardlink(&metadata, protection)?;
Some(FileIdentity::from(&metadata))
}
Ok(_) => return Err(FileOutputError::UnsafePath),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(_) => return Err(FileOutputError::Io),
};
Ok(PreparedDestination {
path: path.to_path_buf(),
parent: parent.to_path_buf(),
filename,
existing,
overwrite_confirmed: false,
})
}
/// Publish the bounded payload using a private same-directory temporary file.
/// New targets use a no-replace rename. Confirmed overwrite uses an atomic
/// exchange and verifies the displaced inode before removing it.
pub fn publish(
destination: PreparedDestination,
payload: &ClientConfigPayload,
) -> Result<(), FileOutputError> {
publish_with_hook(destination, payload, |_| Ok(()))
}
fn publish_with_hook(
destination: PreparedDestination,
payload: &ClientConfigPayload,
mut hook: impl FnMut(Checkpoint) -> io::Result<()>,
) -> Result<(), FileOutputError> {
if payload.len() > MAX_OUTPUT_BYTES {
return Err(FileOutputError::TooLarge);
}
if destination.overwrite_confirmation_required() {
return Err(FileOutputError::OverwriteDeclined);
}
validate_existing_ancestors(&destination.parent)?;
match (
&destination.existing,
fs::symlink_metadata(&destination.path),
) {
(Some(expected), Ok(actual))
if actual.file_type().is_file() && FileIdentity::from(&actual) == *expected => {}
(None, Err(error)) if error.kind() == io::ErrorKind::NotFound => {}
(None, Ok(_)) => return Err(FileOutputError::DestinationChanged),
_ => return Err(FileOutputError::DestinationChanged),
}
create_missing_private_directories(&destination.parent)?;
validate_existing_ancestors(&destination.parent)?;
let parent = open_parent_directory(&destination.parent)?;
let mut temp_name = None;
let mut temp_file = None;
for _ in 0..32 {
let mut random = [0_u8; 16];
getrandom::fill(&mut random).map_err(|_| FileOutputError::Io)?;
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let name = format!(".wg-admin-client-{sequence}-{}.tmp", hex(&random));
match rfs::openat(
&parent,
name.as_str(),
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::from_raw_mode(0o600),
) {
Ok(fd) => {
temp_name = Some(name);
temp_file = Some(File::from(fd));
break;
}
Err(rustix::io::Errno::EXIST) => continue,
Err(_) => return Err(FileOutputError::Io),
}
}
let temp_name = temp_name.ok_or(FileOutputError::Io)?;
let mut file = temp_file.ok_or(FileOutputError::Io)?;
let mut bytes = Zeroizing::new(payload.as_bytes().to_vec());
let write_result = (|| -> io::Result<()> {
hook(Checkpoint::Write)?;
file.write_all(bytes.as_slice())?;
hook(Checkpoint::Flush)?;
file.flush()?;
hook(Checkpoint::FileSync)?;
file.sync_all()?;
Ok(())
})();
bytes.zeroize();
if write_result.is_err() {
drop(file);
return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) {
FileOutputError::BeforePublication
} else {
FileOutputError::ManualCleanupRequired
});
}
drop(file);
let staged_identity = file_identity_at(&parent, &temp_name);
let Some(staged_identity) = staged_identity else {
return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) {
FileOutputError::Io
} else {
FileOutputError::ManualCleanupRequired
});
};
if hook(Checkpoint::Publish).is_err() {
return Err(if cleanup_temporary(&parent, &temp_name, &mut hook) {
FileOutputError::BeforePublication
} else {
FileOutputError::ManualCleanupRequired
});
}
let publication = if destination.existing.is_some() {
rfs::renameat_with(
&parent,
temp_name.as_str(),
&parent,
&destination.filename,
RenameFlags::EXCHANGE,
)
} else {
rfs::renameat_with(
&parent,
temp_name.as_str(),
&parent,
&destination.filename,
RenameFlags::NOREPLACE,
)
};
if publication.is_err() {
let clean = cleanup_temporary(&parent, &temp_name, &mut hook);
return Err(if !clean {
FileOutputError::ManualCleanupRequired
} else if destination.existing.is_some() {
FileOutputError::DestinationChanged
} else {
FileOutputError::BeforePublication
});
}
if let Some(expected) = destination.existing {
let displaced = file_identity_at(&parent, &temp_name);
if displaced != Some(expected) {
// EXCHANGE never discards either entry. Rollback is another
// unconditional exchange, so a concurrent substitution can change
// either name before or during it. Never unlink by name here.
if hook(Checkpoint::Rollback).is_err() {
return Err(FileOutputError::PublicationUncertain);
}
if rfs::renameat_with(
&parent,
temp_name.as_str(),
&parent,
&destination.filename,
RenameFlags::EXCHANGE,
)
.is_err()
{
return Err(FileOutputError::PublicationUncertain);
}
if file_identity_at(&parent, &destination.filename) != displaced
|| file_identity_at(&parent, &temp_name) != Some(staged_identity)
{
return Err(FileOutputError::PublicationUncertain);
}
if hook(Checkpoint::ParentSync).is_err() || rfs::fsync(&parent).is_err() {
return Err(FileOutputError::PublicationUncertain);
}
// Keep the staged entry under its private temporary name. Removing
// it by name would race with another substitution and could destroy
// an unconfirmed entry.
return Err(FileOutputError::DestinationChanged);
}
// The exchanged entry is the exact confirmed file. The atomic exchange
// closes the check/rename race; remove only after that identity check.
if rfs::unlinkat(&parent, temp_name.as_str(), rfs::AtFlags::empty()).is_err() {
return Err(FileOutputError::PublicationUncertain);
}
}
if file_identity_at(&parent, &destination.filename) != Some(staged_identity) {
return Err(FileOutputError::PublicationUncertain);
}
if hook(Checkpoint::ParentSync).is_err() || rfs::fsync(&parent).is_err() {
return Err(FileOutputError::PublicationUncertain);
}
Ok(())
}
fn cleanup_temporary(
parent: &impl AsFd,
name: &str,
hook: &mut impl FnMut(Checkpoint) -> io::Result<()>,
) -> bool {
if hook(Checkpoint::Cleanup).is_err() {
return false;
}
match rfs::unlinkat(parent, name, rfs::AtFlags::empty()) {
Ok(()) | Err(rustix::io::Errno::NOENT) => true,
Err(_) => false,
}
}
fn file_identity_at(parent: &impl AsFd, name: impl AsRef<std::ffi::OsStr>) -> Option<FileIdentity> {
let fd = rfs::openat(
parent,
name.as_ref(),
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.ok()?;
let metadata = File::from(fd).metadata().ok()?;
metadata.is_file().then(|| FileIdentity::from(&metadata))
}
fn validate_absolute_path(path: &Path) -> Result<(), FileOutputError> {
if !path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, Component::RootDir | Component::Normal(_)))
{
return Err(FileOutputError::UnsafePath);
}
Ok(())
}
fn validate_existing_ancestors(path: &Path) -> Result<(), FileOutputError> {
let mut current = PathBuf::from("/");
let components: Vec<_> = path.components().skip(1).collect();
for (index, component) in components.iter().enumerate() {
let Component::Normal(name) = component else {
return Err(FileOutputError::UnsafePath);
};
current.push(name);
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_dir() => {
validate_directory_metadata(&metadata, index + 1 == components.len())?;
}
Ok(_) => return Err(FileOutputError::UnsafePath),
Err(error) if error.kind() == io::ErrorKind::NotFound => break,
Err(_) => return Err(FileOutputError::Io),
}
}
Ok(())
}
fn create_missing_private_directories(path: &Path) -> Result<(), FileOutputError> {
let mut current = PathBuf::from("/");
for component in path.components().skip(1) {
let Component::Normal(name) = component else {
return Err(FileOutputError::UnsafePath);
};
current.push(name);
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_dir() => {}
Ok(_) => return Err(FileOutputError::UnsafePath),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let mut builder = fs::DirBuilder::new();
builder.mode(0o700);
builder.create(&current).map_err(|_| FileOutputError::Io)?;
}
Err(_) => return Err(FileOutputError::Io),
}
}
Ok(())
}
fn open_parent_directory(path: &Path) -> Result<File, FileOutputError> {
let fd = rfs::openat2(
rfs::ABS,
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
ResolveFlags::NO_SYMLINKS,
)
.map_err(|_| FileOutputError::UnsafePath)?;
let directory = File::from(fd);
let metadata = directory.metadata().map_err(|_| FileOutputError::Io)?;
validate_directory_metadata(&metadata, true)?;
Ok(directory)
}
fn validate_directory_metadata(
metadata: &fs::Metadata,
is_output_parent: bool,
) -> Result<(), FileOutputError> {
let uid = rustix::process::geteuid().as_raw();
if is_output_parent && metadata.uid() != 0 && metadata.uid() != uid {
return Err(FileOutputError::UnsafePath);
}
let mode = metadata.mode();
let group_or_other_writable = mode & 0o022 != 0;
let sticky = mode & 0o1000 != 0;
if group_or_other_writable && (is_output_parent || !sticky) {
return Err(FileOutputError::UnsafePath);
}
Ok(())
}
fn hex(bytes: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(char::from(DIGITS[usize::from(byte >> 4)]));
output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
}
output
}
fn reject_protected(path: &Path, protection: &OutputProtection) -> Result<(), FileOutputError> {
for protected in &protection.protected_files {
if lexical_normalize(path)? == lexical_normalize(protected)? {
return Err(FileOutputError::UnsafePath);
}
let target_meta = fs::symlink_metadata(path);
let protected_meta = fs::symlink_metadata(protected);
if let (Ok(target_meta), Ok(protected_meta)) = (target_meta, protected_meta)
&& target_meta.dev() == protected_meta.dev()
&& target_meta.ino() == protected_meta.ino()
{
return Err(FileOutputError::UnsafePath);
}
}
for directory in &protection.protected_directories {
if lexical_normalize(path)?.starts_with(lexical_normalize(directory)?) {
return Err(FileOutputError::UnsafePath);
}
}
Ok(())
}
fn reject_protected_hardlink(
metadata: &fs::Metadata,
protection: &OutputProtection,
) -> Result<(), FileOutputError> {
for protected in &protection.protected_files {
if let Ok(protected_metadata) = fs::symlink_metadata(protected)
&& metadata.dev() == protected_metadata.dev()
&& metadata.ino() == protected_metadata.ino()
{
return Err(FileOutputError::UnsafePath);
}
}
Ok(())
}
fn lexical_normalize(path: &Path) -> Result<PathBuf, FileOutputError> {
validate_absolute_path(path)?;
let mut normalized = PathBuf::from("/");
for component in path.components().skip(1) {
if let Component::Normal(name) = component {
normalized.push(name);
} else {
return Err(FileOutputError::UnsafePath);
}
}
Ok(normalized)
}
#[cfg(test)]
mod tests {
use super::{
Checkpoint, FileOutputError, OutputProtection, PreparedDestination, prepare_destination,
publish, publish_with_hook,
};
use crate::delivery::ClientConfigPayload;
use rustix::fs as rfs;
use std::fs;
use std::io;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::Path;
use zeroize::Zeroizing;
fn payload() -> ClientConfigPayload {
ClientConfigPayload(Zeroizing::new(b"synthetic client configuration\n".to_vec()))
}
fn protection(root: &Path) -> OutputProtection {
OutputProtection::new(
vec![root.join("settings.toml"), root.join("server.conf")],
vec![root.join("Backup/data"), root.join("Backup/archives")],
)
}
#[test]
fn writes_new_file_with_private_mode_and_private_new_directories() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("chosen/nested/client.conf");
let prepared = prepare_destination(&target, &protection(root.path()))
.expect("safe explicit destination");
assert!(!target.exists());
publish(prepared, &payload()).expect("publication succeeds");
assert_eq!(
fs::metadata(&target).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(
fs::metadata(root.path().join("chosen"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
fs::metadata(root.path().join("chosen/nested"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert!(
!fs::read_dir(root.path().join("chosen/nested"))
.unwrap()
.any(|entry| entry
.unwrap()
.file_name()
.to_string_lossy()
.ends_with(".tmp"))
);
}
#[test]
fn existing_file_requires_bound_confirmation_and_decline_preserves_bytes() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("existing.conf");
fs::write(&target, b"old synthetic value").unwrap();
let prepared = prepare_destination(&target, &protection(root.path())).unwrap();
assert!(prepared.overwrite_confirmation_required());
assert_eq!(
publish(prepared, &payload()),
Err(FileOutputError::OverwriteDeclined)
);
assert!(fs::read(&target).unwrap() == b"old synthetic value");
let confirmed = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
publish(confirmed, &payload()).expect("confirmed bounded replacement");
assert!(fs::read(&target).unwrap() == b"synthetic client configuration\n");
assert_eq!(
fs::metadata(target).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn refuses_symlink_special_traversal_and_protected_targets() {
let root = tempfile::tempdir().expect("temp root");
let outside = root.path().join("outside");
fs::write(&outside, b"untouched").unwrap();
let link = root.path().join("link");
symlink(&outside, &link).unwrap();
assert_eq!(
prepare_destination(&link, &protection(root.path())).unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(
prepare_destination(root.path().join("missing/../bad"), &protection(root.path()))
.unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(
prepare_destination(root.path().join("settings.toml"), &protection(root.path()))
.unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(
prepare_destination(
root.path().join("Backup/data/vault.toml"),
&protection(root.path())
)
.unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(fs::read(outside).unwrap(), b"untouched");
let fifo = root.path().join("fifo");
rfs::mkfifoat(rfs::ABS, &fifo, rustix::fs::Mode::from_raw_mode(0o600))
.expect("synthetic special file");
assert_eq!(
prepare_destination(&fifo, &protection(root.path())).unwrap_err(),
FileOutputError::UnsafePath
);
}
#[test]
fn rejects_symlink_ancestors_and_protects_original_source_entries() {
let root = tempfile::tempdir().expect("temp root");
let other = tempfile::tempdir().expect("other temp root");
let link = root.path().join("linked-dir");
symlink(other.path(), &link).unwrap();
assert_eq!(
prepare_destination(link.join("client.conf"), &protection(root.path())).unwrap_err(),
FileOutputError::UnsafePath
);
let source = root.path().join("clients");
fs::create_dir(&source).unwrap();
let original = source.join("original.conf");
fs::write(&original, b"source fixture").unwrap();
let policy = OutputProtection::new(vec![original.clone()], vec![]);
assert_eq!(
prepare_destination(&original, &policy).unwrap_err(),
FileOutputError::UnsafePath
);
}
#[test]
fn rejects_group_or_world_writable_output_parent_before_staging_plaintext() {
let root = tempfile::tempdir().expect("temp root");
let shared = root.path().join("shared");
fs::create_dir(&shared).unwrap();
fs::set_permissions(&shared, fs::Permissions::from_mode(0o777)).unwrap();
let target = shared.join("client.conf");
assert_eq!(
prepare_destination(&target, &protection(root.path())).unwrap_err(),
FileOutputError::UnsafePath
);
assert_eq!(fs::read_dir(&shared).unwrap().count(), 0);
assert!(!target.exists());
}
#[test]
fn injected_write_and_sync_failures_report_publication_state() {
let root = tempfile::tempdir().expect("temp root");
for (index, failed_stage) in [Checkpoint::Write, Checkpoint::Flush, Checkpoint::FileSync]
.into_iter()
.enumerate()
{
let before_target = root.path().join(format!("pre-publish-{index}.conf"));
let before = prepare_destination(&before_target, &protection(root.path())).unwrap();
let failure = publish_with_hook(before, &payload(), |stage| {
if stage == failed_stage {
Err(io::Error::other("synthetic pre-publication fault"))
} else {
Ok(())
}
});
assert_eq!(failure, Err(FileOutputError::BeforePublication));
assert!(!before_target.exists());
}
let uncertain_target = root.path().join("sync-fail.conf");
let after = prepare_destination(&uncertain_target, &protection(root.path())).unwrap();
let failure = publish_with_hook(after, &payload(), |stage| {
if stage == Checkpoint::ParentSync {
Err(io::Error::other("synthetic flush fault"))
} else {
Ok(())
}
});
assert_eq!(failure, Err(FileOutputError::PublicationUncertain));
assert!(uncertain_target.exists());
assert!(fs::read(uncertain_target).unwrap() == b"synthetic client configuration\n");
}
#[test]
fn cleanup_failure_reports_manual_recovery_and_retains_private_temporary_file() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("cleanup-fail.conf");
let prepared = prepare_destination(&target, &protection(root.path())).unwrap();
let failure = publish_with_hook(prepared, &payload(), |stage| match stage {
Checkpoint::Flush | Checkpoint::Cleanup => Err(io::Error::other("synthetic fault")),
_ => Ok(()),
});
assert_eq!(failure, Err(FileOutputError::ManualCleanupRequired));
assert!(!target.exists());
let temporary = fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.unwrap()
.to_string_lossy()
.starts_with(".wg-admin-client-")
})
.expect("temporary plaintext is retained for manual cleanup");
assert_eq!(
fs::metadata(&temporary).unwrap().permissions().mode() & 0o777,
0o600
);
assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n");
}
#[test]
fn no_replace_publication_keeps_a_racing_new_file_untouched() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("racing.conf");
let prepared = prepare_destination(&target, &protection(root.path())).unwrap();
let failure = publish_with_hook(prepared, &payload(), |stage| {
if stage == Checkpoint::Publish {
fs::write(&target, b"racing synthetic content")?;
}
Ok(())
});
assert_eq!(failure, Err(FileOutputError::BeforePublication));
assert!(fs::read(target).unwrap() == b"racing synthetic content");
}
#[test]
fn changed_existing_destination_cannot_reuse_old_confirmation() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("replace.conf");
fs::write(&target, b"original").unwrap();
let prepared = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
fs::write(&target, b"changed after selection").unwrap();
assert_eq!(
publish(prepared, &payload()),
Err(FileOutputError::DestinationChanged)
);
assert!(fs::read(target).unwrap() == b"changed after selection");
}
#[test]
fn substitution_at_publish_checkpoint_is_exchanged_back_without_data_loss() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("substituted.conf");
fs::write(&target, b"confirmed original").unwrap();
let prepared = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
let result = publish_with_hook(prepared, &payload(), |stage| {
if stage == Checkpoint::Publish {
fs::remove_file(&target)?;
fs::write(&target, b"unconfirmed substitute")?;
}
Ok(())
});
assert_eq!(result, Err(FileOutputError::DestinationChanged));
assert!(fs::read(target).unwrap() == b"unconfirmed substitute");
let temporary = fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.unwrap()
.to_string_lossy()
.ends_with(".tmp")
})
.expect("staged output is retained under its private temporary name");
assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n");
}
#[test]
fn substitution_during_rollback_retains_every_entry_and_reports_uncertain() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("substituted-during-rollback.conf");
let parked = root.path().join("parked-published.conf");
fs::write(&target, b"confirmed original").unwrap();
let prepared = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
let result = publish_with_hook(prepared, &payload(), |stage| {
if stage == Checkpoint::Publish {
fs::remove_file(&target)?;
fs::write(&target, b"first unconfirmed substitute")?;
} else if stage == Checkpoint::Rollback {
fs::rename(&target, &parked)?;
fs::write(&target, b"second unconfirmed substitute")?;
}
Ok(())
});
assert_eq!(result, Err(FileOutputError::PublicationUncertain));
assert!(fs::read(&target).unwrap() == b"first unconfirmed substitute");
assert!(fs::read(parked).unwrap() == b"synthetic client configuration\n");
let temporary = fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.unwrap()
.to_string_lossy()
.ends_with(".tmp")
})
.expect("second unconfirmed substitute is retained");
assert!(fs::read(temporary).unwrap() == b"second unconfirmed substitute");
}
#[test]
fn rollback_parent_sync_failure_is_uncertain_and_keeps_both_entries() {
let root = tempfile::tempdir().expect("temp root");
let target = root.path().join("rollback-sync-fail.conf");
fs::write(&target, b"confirmed original").unwrap();
let prepared = prepare_destination(&target, &protection(root.path()))
.unwrap()
.confirm_overwrite()
.unwrap();
let result = publish_with_hook(prepared, &payload(), |stage| {
if stage == Checkpoint::Publish {
fs::remove_file(&target)?;
fs::write(&target, b"unconfirmed substitute")?;
} else if stage == Checkpoint::ParentSync {
return Err(io::Error::other("synthetic rollback directory sync fault"));
}
Ok(())
});
assert_eq!(result, Err(FileOutputError::PublicationUncertain));
assert!(fs::read(target).unwrap() == b"unconfirmed substitute");
let temporary = fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.unwrap()
.to_string_lossy()
.ends_with(".tmp")
})
.expect("published staged output remains retained");
assert!(fs::read(temporary).unwrap() == b"synthetic client configuration\n");
}
#[test]
fn confirmation_is_not_available_for_a_new_file() {
let root = tempfile::tempdir().unwrap();
let target = root.path().join("new.conf");
let prepared: PreparedDestination =
prepare_destination(&target, &protection(root.path())).unwrap();
assert_eq!(
prepared.confirm_overwrite().unwrap_err(),
FileOutputError::OverwriteNotRequired
);
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Bounded, operation-local QR representation for an authorized client payload.
//!
//! The encoder necessarily allocates internal copies of the payload and QR
//! codewords that this crate cannot zeroize. This module clears its owned matrix
//! on drop and never formats or serializes it implicitly.
use std::fmt;
use qrcode::{QrCode, types::Color};
use thiserror::Error;
use zeroize::Zeroizing;
use super::ClientConfigPayload;
const QUIET_ZONE: usize = 4;
const MAX_QR_SIDE: usize = 185; // Version 40 (177 modules) plus quiet zone.
/// Secret-bearing QR modules including the four-module quiet zone.
///
/// The bytes are row-major: `0` is light and `1` is dark. Access is explicit
/// through [`Self::module`]; there is no `Display` or serialization API.
pub struct QrCodeOutput {
side: usize,
modules: Zeroizing<Vec<u8>>,
}
impl QrCodeOutput {
pub fn side(&self) -> usize {
self.side
}
pub fn module(&self, x: usize, y: usize) -> Option<bool> {
if x >= self.side || y >= self.side {
return None;
}
self.modules.get(y * self.side + x).map(|value| *value != 0)
}
}
impl fmt::Debug for QrCodeOutput {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("QrCodeOutput")
.field("side", &self.side)
.field("modules", &"[REDACTED]")
.finish()
}
}
/// Secret-free QR encoding failures.
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum QrError {
#[error("client configuration exceeds QR capacity")]
CapacityExceeded,
#[error("QR encoding failed")]
EncodingFailed,
#[error("encoded QR dimensions exceed the supported bound")]
DimensionsExceeded,
}
/// Encode exactly the supplied delivery payload with a four-module quiet zone.
pub(crate) fn encode_payload(payload: &ClientConfigPayload) -> Result<QrCodeOutput, QrError> {
encode_bytes(payload.as_bytes())
}
fn encode_bytes(bytes: &[u8]) -> Result<QrCodeOutput, QrError> {
let code = QrCode::new(bytes).map_err(|error| match error {
qrcode::types::QrError::DataTooLong => QrError::CapacityExceeded,
_ => QrError::EncodingFailed,
})?;
let inner_side = code.width();
let side = inner_side
.checked_add(QUIET_ZONE * 2)
.filter(|side| *side <= MAX_QR_SIDE)
.ok_or(QrError::DimensionsExceeded)?;
let mut modules = Zeroizing::new(vec![0; side * side]);
for y in 0..inner_side {
for x in 0..inner_side {
if code[(x, y)] == Color::Dark {
modules[(y + QUIET_ZONE) * side + x + QUIET_ZONE] = 1;
}
}
}
Ok(QrCodeOutput { side, modules })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_bounded_matrix_with_quiet_zone_and_redacted_debug() {
let payload = ClientConfigPayload(Zeroizing::new(
b"[Interface]\nPrivateKey = synthetic-secret\n".to_vec(),
));
let matrix = encode_payload(&payload).expect("small payload encodes");
assert!((21 + 8..=MAX_QR_SIDE).contains(&matrix.side()));
assert!((0..QUIET_ZONE).all(|offset| {
(0..matrix.side()).all(|position| {
matrix.module(position, offset) == Some(false)
&& matrix.module(offset, position) == Some(false)
})
}));
let debug = format!("{matrix:?}");
assert!(debug.contains("REDACTED"));
assert!(!debug.contains("synthetic-secret"));
assert_eq!(matrix.module(matrix.side(), 0), None);
}
#[test]
fn reports_payload_capacity_without_including_payload_in_error() {
let payload = ClientConfigPayload(Zeroizing::new(vec![b'x'; 65_536]));
let error = encode_payload(&payload).expect_err("payload exceeds QR capacity");
assert_eq!(error, QrError::CapacityExceeded);
assert_eq!(
error.to_string(),
"client configuration exceeds QR capacity"
);
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod app;
pub mod backup;
pub mod config;
pub mod delivery;
pub mod domain;
pub mod persistence;
pub mod runtime;
+48
View File
@@ -515,6 +515,54 @@ impl SavedStore {
self.vault_path(),
]
}
/// Paths which client plaintext delivery must not replace. This is a
/// read-only view of the resolved installation mapping; source candidates
/// are protected individually so a safe, newly selected sibling output
/// remains available.
pub(crate) fn delivery_output_protection(
&self,
) -> Result<crate::delivery::files::OutputProtection, crate::delivery::files::FileOutputError>
{
let mut protected_files = self.targets().into_iter().collect::<Vec<_>>();
let source_directory = &self.settings.client_source_directory;
match fs::symlink_metadata(source_directory) {
Ok(metadata) if metadata.file_type().is_dir() => {}
Ok(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Ok(crate::delivery::files::OutputProtection::new(
protected_files,
vec![
self.settings.data_directory.clone(),
self.settings.archives_directory.clone(),
],
));
}
Err(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath),
}
match fs::read_dir(source_directory) {
Ok(entries) => {
let mut count = 0usize;
for entry in entries {
let entry = entry.map_err(|_| crate::delivery::files::FileOutputError::Io)?;
count += 1;
if count > crate::vault::source::MAX_DIRECTORY_ENTRIES {
return Err(crate::delivery::files::FileOutputError::UnsafePath);
}
protected_files.push(entry.path());
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(_) => return Err(crate::delivery::files::FileOutputError::UnsafePath),
}
Ok(crate::delivery::files::OutputProtection::new(
protected_files,
vec![
self.settings.data_directory.clone(),
self.settings.archives_directory.clone(),
],
))
}
}
#[derive(Deserialize, Serialize)]
+135 -7
View File
@@ -110,16 +110,25 @@ impl Drop for RecordPayload {
/// Password-authorized, short-lived client configuration data for later delivery.
pub struct RetrievedClientRecord {
private_key: Option<WireGuardPrivateKey>,
pub client_addresses: Vec<String>,
pub interface_dns: Option<String>,
pub interface_mtu: Option<String>,
pub server_public_key: Option<String>,
pub endpoint: Option<String>,
pub allowed_ips: Vec<String>,
pub persistent_keepalive: Option<String>,
provenance: RecordProvenance,
client_addresses: Vec<String>,
interface_dns: Option<String>,
interface_mtu: Option<String>,
server_public_key: Option<String>,
endpoint: Option<String>,
allowed_ips: Vec<String>,
persistent_keepalive: Option<String>,
preshared_key: Option<String>,
}
/// Provenance is assigned only after successful authenticated decryption and
/// validation of the retained field shape.
#[derive(Clone, Copy, Eq, PartialEq)]
pub(crate) enum RecordProvenance {
NewTemplate,
Imported,
}
impl RetrievedClientRecord {
pub fn private_key(&self) -> Option<&WireGuardPrivateKey> {
self.private_key.as_ref()
@@ -130,9 +139,45 @@ impl RetrievedClientRecord {
self.preshared_key.as_deref()
}
pub fn client_addresses(&self) -> &[String] {
&self.client_addresses
}
pub fn interface_dns(&self) -> Option<&str> {
self.interface_dns.as_deref()
}
pub fn interface_mtu(&self) -> Option<&str> {
self.interface_mtu.as_deref()
}
pub fn server_public_key(&self) -> Option<&str> {
self.server_public_key.as_deref()
}
pub fn endpoint(&self) -> Option<&str> {
self.endpoint.as_deref()
}
pub fn allowed_ips(&self) -> &[String] {
&self.allowed_ips
}
pub fn persistent_keepalive(&self) -> Option<&str> {
self.persistent_keepalive.as_deref()
}
pub fn into_private_key(mut self) -> Result<WireGuardPrivateKey, VaultError> {
self.private_key.take().ok_or(VaultError::CorruptRecord)
}
pub(crate) fn is_new_template(&self) -> bool {
self.provenance == RecordProvenance::NewTemplate
}
pub(crate) fn is_imported(&self) -> bool {
self.provenance == RecordProvenance::Imported
}
}
impl fmt::Debug for RetrievedClientRecord {
@@ -151,6 +196,38 @@ impl Drop for RetrievedClientRecord {
}
}
fn validated_record_provenance(payload: &RecordPayload) -> Result<RecordProvenance, VaultError> {
let key_only = payload.client_addresses.is_empty()
&& payload.interface_dns.is_none()
&& payload.interface_mtu.is_none()
&& payload.server_public_key.is_none()
&& payload.endpoint.is_none()
&& payload.allowed_ips.is_empty()
&& payload.persistent_keepalive.is_none()
&& payload.preshared_key.is_none();
if key_only {
return Ok(RecordProvenance::NewTemplate);
}
// Imported records always carry their source address and server identity.
// Missing either is a partial record, not permission to fall back to the
// new-client template.
if payload.client_addresses.is_empty()
|| payload.server_public_key.is_none()
|| payload
.client_addresses
.iter()
.any(|value| value.parse::<ipnet::IpNet>().is_err())
|| payload
.server_public_key
.as_deref()
.is_some_and(|value| value.parse::<PublicKey>().is_err())
{
return Err(VaultError::CorruptRecord);
}
Ok(RecordProvenance::Imported)
}
/// A serializable encrypted vault. New records use the public recipient; retrieval unlocks the
/// password-protected identity only for the duration of that operation.
#[derive(Serialize, Deserialize)]
@@ -258,6 +335,14 @@ impl VaultBundle {
self.records.contains_key(&label.to_string())
}
/// Return the encrypted record bytes for session-local authorization binding.
/// The ciphertext is never exposed through the public vault API.
pub(crate) fn record_context(&self, label: PublicKey) -> Option<&str> {
self.records
.get(&label.to_string())
.map(|record| record.ciphertext.as_str())
}
/// Remove only the record identified by its canonical WireGuard public key.
/// The encrypted identity and every other record remain unchanged.
pub fn remove_record(&mut self, label: PublicKey) -> Result<bool, VaultError> {
@@ -322,6 +407,7 @@ impl VaultBundle {
if payload.reissue_blocked {
return Err(VaultError::ReissueBlocked);
}
let provenance = validated_record_provenance(&payload)?;
let secret = WireGuardPrivateKey::parse_base64(&payload.private_key)
.map_err(|_| VaultError::RecordMismatch)?;
if secret.public_key() != label {
@@ -329,6 +415,7 @@ impl VaultBundle {
}
Ok(RetrievedClientRecord {
private_key: Some(secret),
provenance,
client_addresses: payload.client_addresses.clone(),
interface_dns: payload.interface_dns.clone(),
interface_mtu: payload.interface_mtu.clone(),
@@ -679,6 +766,47 @@ mod tests {
);
}
#[test]
fn authenticated_retained_shape_never_falls_back_from_partial_import_to_new_template() {
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
let key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let identity = key.public_key();
bundle
.encrypt_payload(
identity,
RecordPayload {
version: 1,
label: identity.to_string(),
private_key: key.to_base64(),
client_addresses: vec!["10.0.0.2/32".to_owned()],
interface_dns: None,
interface_mtu: None,
server_public_key: None,
endpoint: None,
allowed_ips: Vec::new(),
persistent_keepalive: None,
preshared_key: None,
reissue_blocked: false,
},
true,
)
.expect("malformed legacy record can be encrypted for regression test");
assert_eq!(
bundle.retrieve_client_record(identity, PASSWORD).err(),
Some(VaultError::CorruptRecord)
);
let clean_key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let mut clean_bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
clean_bundle
.encrypt_record(clean_key.public_key(), &clean_key)
.expect("key-only new-client record encrypts");
let retrieved = clean_bundle
.retrieve_client_record(clean_key.public_key(), PASSWORD)
.expect("fresh unlock validates key-only provenance");
assert!(retrieved.is_new_template());
}
#[test]
fn authenticated_record_with_unsupported_version_has_specific_error() {
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");