Implement client configuration delivery and reissue lifecycle
This commit is contained in:
+1879
-9
File diff suppressed because it is too large
Load Diff
+807
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¤t) {
|
||||
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(¤t) {
|
||||
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(¤t).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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
pub mod app;
|
||||
pub mod backup;
|
||||
pub mod config;
|
||||
pub mod delivery;
|
||||
pub mod domain;
|
||||
pub mod persistence;
|
||||
pub mod runtime;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user