Handle client keys, encrypted vault

This commit is contained in:
2026-10-19 23:11:29 +03:00
parent 766059e6d1
commit c879a8101b
11 changed files with 4267 additions and 21 deletions
+1
View File
@@ -1,4 +1,5 @@
pub mod config;
pub mod domain;
pub mod startup;
pub mod vault;
pub mod wireguard;
+723
View File
@@ -0,0 +1,723 @@
//! Secret-safe primitives used by the encrypted client vault.
pub mod import;
pub mod source;
use std::collections::BTreeMap;
use std::fmt;
use std::io::Write;
use std::str::FromStr;
use age::Encryptor;
use age::secrecy::{ExposeSecret, SecretString};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
use zeroize::{Zeroize, Zeroizing};
use crate::domain::PublicKey;
/// A WireGuard private key held in memory and erased when dropped.
///
/// Private key bytes are neither formatted nor serialized implicitly. Use
/// [`Self::to_base64`] only at an explicit WireGuard configuration boundary.
pub struct WireGuardPrivateKey(StaticSecret);
impl WireGuardPrivateKey {
/// Generate key material from the operating system's random source.
pub fn generate() -> Result<Self, KeyError> {
let mut bytes = Zeroizing::new([0_u8; 32]);
getrandom::fill(bytes.as_mut()).map_err(|_| KeyError::RandomUnavailable)?;
Ok(Self(StaticSecret::from(*bytes)))
}
/// Parse canonical standard Base64 encoding of exactly 32 private key bytes.
pub fn parse_base64(value: &str) -> Result<Self, KeyError> {
if value.len() != 44 {
return Err(KeyError::InvalidPrivateKey);
}
let mut decoded = Zeroizing::new(Vec::with_capacity(32));
STANDARD
.decode_vec(value, &mut decoded)
.map_err(|_| KeyError::InvalidPrivateKey)?;
let bytes: Zeroizing<[u8; 32]> = Zeroizing::new(
decoded
.as_slice()
.try_into()
.map_err(|_| KeyError::InvalidPrivateKey)?,
);
let canonical = Zeroizing::new(STANDARD.encode(*bytes));
if canonical.as_str() != value {
return Err(KeyError::InvalidPrivateKey);
}
Ok(Self(StaticSecret::from(*bytes)))
}
/// Explicitly encode this private key for a WireGuard configuration.
///
/// The returned `String` contains plaintext key material and must be kept
/// short-lived by the caller.
pub fn to_base64(&self) -> String {
STANDARD.encode(self.0.as_bytes())
}
/// Derive the corresponding WireGuard public key using X25519.
pub fn public_key(&self) -> PublicKey {
let public = X25519PublicKey::from(&self.0);
PublicKey::from_bytes(public.to_bytes())
}
/// Check correspondence with an expected public identity.
pub fn matches_public_key(&self, expected: &PublicKey) -> bool {
self.public_key() == *expected
}
}
const BUNDLE_VERSION: u32 = 1;
const MAX_BUNDLE_BYTES: usize = 16 * 1024 * 1024;
const MAX_RECORDS: usize = 4096;
const MAX_RECORD_CIPHERTEXT_BYTES: usize = 64 * 1024;
const MAX_IDENTITY_CIPHERTEXT_BYTES: usize = 16 * 1024;
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RecordPayload {
version: u32,
label: String,
private_key: String,
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>,
reissue_blocked: bool,
}
impl Drop for RecordPayload {
fn drop(&mut self) {
self.private_key.zeroize();
if let Some(value) = &mut self.preshared_key {
value.zeroize();
}
}
}
/// 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>,
preshared_key: Option<String>,
}
impl RetrievedClientRecord {
pub fn private_key(&self) -> Option<&WireGuardPrivateKey> {
self.private_key.as_ref()
}
/// Explicitly expose the optional preshared key for a delivery boundary.
pub fn preshared_key_base64(&self) -> Option<&str> {
self.preshared_key.as_deref()
}
pub fn into_private_key(mut self) -> Result<WireGuardPrivateKey, VaultError> {
self.private_key.take().ok_or(VaultError::CorruptRecord)
}
}
impl fmt::Debug for RetrievedClientRecord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RetrievedClientRecord")
.field("imported_fields", &"[REDACTED]")
.finish()
}
}
impl Drop for RetrievedClientRecord {
fn drop(&mut self) {
if let Some(value) = &mut self.preshared_key {
value.zeroize();
}
}
}
/// 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)]
#[serde(deny_unknown_fields)]
pub struct VaultBundle {
version: u32,
recipient: String,
identity_ciphertext: String,
records: BTreeMap<String, VaultRecord>,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct VaultRecord {
ciphertext: String,
reissue_blocked: bool,
}
impl fmt::Debug for VaultBundle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VaultBundle")
.field("version", &self.version)
.field("record_count", &self.records.len())
.field("encrypted_material", &"[REDACTED]")
.finish()
}
}
impl VaultBundle {
/// Create a separate age x25519 identity protected by age's standard passphrase format.
pub fn create(password: &str) -> Result<Self, VaultError> {
if password.is_empty() {
return Err(VaultError::InvalidInput);
}
let identity = age::x25519::Identity::generate();
let recipient = identity.to_public().to_string();
let identity_text = identity.to_string();
let encryptor =
Encryptor::with_user_passphrase(SecretString::new(password.to_owned().into()));
let mut ciphertext = Vec::new();
let mut writer = encryptor
.wrap_output(&mut ciphertext)
.map_err(|_| VaultError::Crypto)?;
writer
.write_all(identity_text.expose_secret().as_bytes())
.map_err(|_| VaultError::Crypto)?;
writer.finish().map_err(|_| VaultError::Crypto)?;
if ciphertext.len() > MAX_IDENTITY_CIPHERTEXT_BYTES {
return Err(VaultError::TooLarge);
}
Ok(Self {
version: BUNDLE_VERSION,
recipient,
identity_ciphertext: STANDARD.encode(ciphertext),
records: BTreeMap::new(),
})
}
/// Encrypt a client private key with the public recipient only; no password is required.
pub fn encrypt_record(
&mut self,
label: PublicKey,
secret: &WireGuardPrivateKey,
) -> Result<(), VaultError> {
self.encrypt_record_with_status(label, secret, false)
}
/// Encrypt a key and bind its conflict status inside the authenticated payload.
pub(crate) fn encrypt_record_with_status(
&mut self,
label: PublicKey,
secret: &WireGuardPrivateKey,
reissue_blocked: bool,
) -> Result<(), VaultError> {
self.validate_shape()?;
if secret.public_key() != label {
return Err(VaultError::RecordMismatch);
}
let payload = RecordPayload {
version: 1,
label: label.to_string(),
private_key: secret.to_base64(),
client_addresses: Vec::new(),
interface_dns: None,
interface_mtu: None,
server_public_key: None,
endpoint: None,
allowed_ips: Vec::new(),
persistent_keepalive: None,
preshared_key: None,
reissue_blocked,
};
self.encrypt_payload(label, payload, true)
}
/// Return the unauthenticated display snapshot; retrieval checks encrypted status instead.
pub fn reissue_blocked(&self, label: PublicKey) -> Result<bool, VaultError> {
self.records
.get(&label.to_string())
.map(|record| record.reissue_blocked)
.ok_or(VaultError::MissingRecord)
}
pub fn contains_record(&self, label: PublicKey) -> bool {
self.records.contains_key(&label.to_string())
}
/// Retrieve a single record, freshly unlocking and verifying the identity on every call.
pub fn retrieve_record(
&self,
label: PublicKey,
password: &str,
) -> Result<WireGuardPrivateKey, VaultError> {
self.retrieve_client_record(label, password)?
.into_private_key()
}
/// Retrieve all retained connection parameters with fresh password authorization.
pub fn retrieve_client_record(
&self,
label: PublicKey,
password: &str,
) -> Result<RetrievedClientRecord, VaultError> {
self.validate_shape()?;
if password.is_empty() {
return Err(VaultError::InvalidInput);
}
let encoded_identity = STANDARD
.decode(&self.identity_ciphertext)
.map_err(|_| VaultError::CorruptVault)?;
let password_identity =
age::scrypt::Identity::new(SecretString::new(password.to_owned().into()));
let identity_bytes = Zeroizing::new(
age::decrypt(&password_identity, &encoded_identity)
.map_err(|_| VaultError::UnlockFailed)?,
);
let identity_text =
std::str::from_utf8(&identity_bytes).map_err(|_| VaultError::CorruptVault)?;
let identity =
age::x25519::Identity::from_str(identity_text).map_err(|_| VaultError::CorruptVault)?;
if identity.to_public().to_string() != self.recipient {
return Err(VaultError::RecipientMismatch);
}
let record = self
.records
.get(&label.to_string())
.ok_or(VaultError::MissingRecord)?;
let ciphertext = STANDARD
.decode(&record.ciphertext)
.map_err(|_| VaultError::CorruptRecord)?;
let plaintext =
age::decrypt(&identity, &ciphertext).map_err(|_| VaultError::CorruptRecord)?;
let plaintext = Zeroizing::new(plaintext);
let text = std::str::from_utf8(&plaintext).map_err(|_| VaultError::CorruptRecord)?;
let payload: RecordPayload = toml::from_str(text).map_err(|_| VaultError::CorruptRecord)?;
if payload.version != 1 {
return Err(VaultError::UnsupportedVersion);
}
if payload.label != label.to_string() {
return Err(VaultError::RecordMismatch);
}
if payload.reissue_blocked {
return Err(VaultError::ReissueBlocked);
}
let secret = WireGuardPrivateKey::parse_base64(&payload.private_key)
.map_err(|_| VaultError::RecordMismatch)?;
if secret.public_key() != label {
return Err(VaultError::RecordMismatch);
}
Ok(RetrievedClientRecord {
private_key: Some(secret),
client_addresses: payload.client_addresses.clone(),
interface_dns: payload.interface_dns.clone(),
interface_mtu: payload.interface_mtu.clone(),
server_public_key: payload.server_public_key.clone(),
endpoint: payload.endpoint.clone(),
allowed_ips: payload.allowed_ips.clone(),
persistent_keepalive: payload.persistent_keepalive.clone(),
preshared_key: payload.preshared_key.clone(),
})
}
/// Serialize encrypted bundle contents for persistence or backup without unlocking.
pub fn to_toml(&self) -> Result<String, VaultError> {
self.validate_shape()?;
let encoded = toml::to_string(self).map_err(|_| VaultError::Serialization)?;
if encoded.len() > MAX_BUNDLE_BYTES {
return Err(VaultError::TooLarge);
}
Ok(encoded)
}
/// Load bounded serialized contents without requesting the password.
pub fn from_toml(encoded: &str) -> Result<Self, VaultError> {
if encoded.len() > MAX_BUNDLE_BYTES {
return Err(VaultError::TooLarge);
}
let bundle: Self = toml::from_str(encoded).map_err(|_| VaultError::CorruptVault)?;
if bundle.version != BUNDLE_VERSION {
return Err(VaultError::UnsupportedVersion);
}
bundle.validate_shape()?;
Ok(bundle)
}
pub fn recipient(&self) -> &str {
&self.recipient
}
pub fn record_count(&self) -> usize {
self.records.len()
}
fn encrypt_payload(
&mut self,
label: PublicKey,
payload: RecordPayload,
reject_duplicate: bool,
) -> Result<(), VaultError> {
self.validate_shape()?;
let key = label.to_string();
if reject_duplicate && self.records.contains_key(&key) {
return Err(VaultError::DuplicateRecord);
}
if !self.records.contains_key(&key) && self.records.len() >= MAX_RECORDS {
return Err(VaultError::TooManyRecords);
}
let plaintext =
Zeroizing::new(toml::to_string(&payload).map_err(|_| VaultError::Serialization)?);
let recipient = age::x25519::Recipient::from_str(&self.recipient)
.map_err(|_| VaultError::CorruptVault)?;
let ciphertext =
age::encrypt(&recipient, plaintext.as_bytes()).map_err(|_| VaultError::Crypto)?;
if ciphertext.len() > MAX_RECORD_CIPHERTEXT_BYTES {
return Err(VaultError::TooLarge);
}
self.records.insert(
key,
VaultRecord {
ciphertext: STANDARD.encode(ciphertext),
// Advisory only. Retrieval trusts the authenticated encrypted payload.
reissue_blocked: payload.reissue_blocked,
},
);
Ok(())
}
fn validate_shape(&self) -> Result<(), VaultError> {
if self.version != BUNDLE_VERSION {
return Err(VaultError::UnsupportedVersion);
}
if self.records.len() > MAX_RECORDS
|| age::x25519::Recipient::from_str(&self.recipient).is_err()
{
return Err(VaultError::CorruptVault);
}
let identity_ciphertext = STANDARD
.decode(&self.identity_ciphertext)
.map_err(|_| VaultError::CorruptVault)?;
if identity_ciphertext.len() > MAX_IDENTITY_CIPHERTEXT_BYTES
|| STANDARD.encode(&identity_ciphertext) != self.identity_ciphertext
|| age::Decryptor::new(identity_ciphertext.as_slice()).is_err()
{
return Err(VaultError::CorruptVault);
}
for (label, record) in &self.records {
let parsed = label
.parse::<PublicKey>()
.map_err(|_| VaultError::CorruptVault)?;
let ciphertext = STANDARD
.decode(&record.ciphertext)
.map_err(|_| VaultError::CorruptRecord)?;
if parsed.to_string() != *label
|| ciphertext.len() > MAX_RECORD_CIPHERTEXT_BYTES
|| STANDARD.encode(&ciphertext) != record.ciphertext
|| age::Decryptor::new(ciphertext.as_slice()).is_err()
{
return Err(VaultError::CorruptRecord);
}
}
Ok(())
}
}
/// Secret-safe errors for vault operations.
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum VaultError {
#[error("vault input is invalid")]
InvalidInput,
#[error("cryptographic operation failed")]
Crypto,
#[error("vault serialization failed")]
Serialization,
#[error("vault data is corrupt or malformed")]
CorruptVault,
#[error("vault record is corrupt or malformed")]
CorruptRecord,
#[error("vault version is not supported")]
UnsupportedVersion,
#[error("vault data exceeds a size limit")]
TooLarge,
#[error("vault contains too many records")]
TooManyRecords,
#[error("vault identity could not be unlocked")]
UnlockFailed,
#[error("vault recipient does not match its protected identity")]
RecipientMismatch,
#[error("vault record does not match its public-key label")]
RecordMismatch,
#[error("vault record is missing")]
MissingRecord,
#[error("vault already contains a record for this public key")]
DuplicateRecord,
#[error("client reissue is blocked pending reconciliation")]
ReissueBlocked,
}
/// Recoverable errors from private-key generation and parsing.
#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum KeyError {
#[error("operating system random source is unavailable")]
RandomUnavailable,
#[error("private key must be canonical standard Base64 encoding of exactly 32 bytes")]
InvalidPrivateKey,
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use super::{KeyError, RecordPayload, VaultBundle, VaultError, WireGuardPrivateKey};
use crate::domain::PublicKey;
const PASSWORD: &str = "synthetic vault passphrase for tests";
fn prepared() -> (VaultBundle, WireGuardPrivateKey) {
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
let key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
bundle
.encrypt_record(key.public_key(), &key)
.expect("recipient encryption must not need a password");
(bundle, key)
}
#[test]
fn generated_keys_derive_matching_public_identities() {
let first = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let second = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let first_public = first.public_key();
let second_public = second.public_key();
assert!(first.matches_public_key(&first_public));
assert!(!first.matches_public_key(&second_public));
assert_ne!(first_public, second_public);
}
#[test]
fn explicit_base64_round_trip_preserves_key_identity() {
let original = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let public = original.public_key();
let encoded = original.to_base64();
assert_eq!(encoded.len(), 44);
let imported = WireGuardPrivateKey::parse_base64(&encoded)
.expect("generated key encoding should parse canonically");
assert!(imported.matches_public_key(&public));
}
#[test]
fn malformed_private_key_encodings_are_rejected() {
let short = STANDARD.encode([0_u8; 31]);
let full = STANDARD.encode([0_u8; 32]);
let without_padding = full.trim_end_matches('=').to_owned();
let invalid_after_decoded_prefix = format!("{}!", &full[..43]);
assert_eq!(
WireGuardPrivateKey::parse_base64(&short).err(),
Some(KeyError::InvalidPrivateKey)
);
assert_eq!(
WireGuardPrivateKey::parse_base64(&without_padding).err(),
Some(KeyError::InvalidPrivateKey)
);
assert_eq!(
WireGuardPrivateKey::parse_base64("not base64").err(),
Some(KeyError::InvalidPrivateKey)
);
assert_eq!(
WireGuardPrivateKey::parse_base64(&invalid_after_decoded_prefix).err(),
Some(KeyError::InvalidPrivateKey)
);
}
#[test]
fn encrypted_bundle_round_trips_without_unlocking_and_retrieves_with_password() {
let (bundle, key) = prepared();
let serialized = bundle.to_toml().expect("bundle should serialize");
assert!(!serialized.contains(&key.to_base64()));
assert!(!serialized.contains(PASSWORD));
let loaded = VaultBundle::from_toml(&serialized).expect("bundle loads without password");
assert_eq!(loaded.record_count(), 1);
assert_eq!(loaded.recipient(), bundle.recipient());
let retrieved = loaded
.retrieve_record(key.public_key(), PASSWORD)
.expect("correct password should retrieve matching secret");
assert!(retrieved.matches_public_key(&key.public_key()));
}
#[test]
fn every_retrieval_requires_the_correct_password() {
let (bundle, key) = prepared();
assert_eq!(
bundle
.retrieve_record(key.public_key(), "wrong password")
.err(),
Some(VaultError::UnlockFailed)
);
assert_eq!(
bundle.retrieve_record(key.public_key(), "").err(),
Some(VaultError::InvalidInput)
);
assert!(
bundle
.retrieve_record(key.public_key(), PASSWORD)
.expect("fresh correct authorization should work")
.matches_public_key(&key.public_key())
);
}
#[test]
fn malformed_truncated_and_unknown_version_data_are_rejected() {
let (bundle, key) = prepared();
let serialized = bundle.to_toml().expect("bundle should serialize");
assert_eq!(
VaultBundle::from_toml(&serialized[..serialized.len() / 2]).err(),
Some(VaultError::CorruptVault)
);
assert_eq!(
VaultBundle::from_toml(&serialized.replace("version = 1", "version = 9")).err(),
Some(VaultError::UnsupportedVersion)
);
let mut corrupt_record = bundle;
let record = corrupt_record
.records
.get_mut(&key.public_key().to_string())
.expect("record exists");
let mut bytes = STANDARD
.decode(&record.ciphertext)
.expect("ciphertext is base64");
bytes.truncate(bytes.len() / 2);
record.ciphertext = STANDARD.encode(bytes);
assert_eq!(
corrupt_record
.retrieve_record(key.public_key(), PASSWORD)
.err(),
Some(VaultError::CorruptRecord)
);
}
#[test]
fn protected_identity_detects_corruption_and_recipient_replacement() {
let (mut bundle, key) = prepared();
let mut identity_ciphertext = STANDARD
.decode(&bundle.identity_ciphertext)
.expect("identity ciphertext is base64");
identity_ciphertext.truncate(identity_ciphertext.len() / 2);
bundle.identity_ciphertext = STANDARD.encode(identity_ciphertext);
assert!(matches!(
bundle.retrieve_record(key.public_key(), PASSWORD),
Err(VaultError::UnlockFailed | VaultError::CorruptVault)
));
let (mut bundle, key) = prepared();
let different_identity = age::x25519::Identity::generate();
bundle.recipient = different_identity.to_public().to_string();
assert_eq!(
bundle.retrieve_record(key.public_key(), PASSWORD).err(),
Some(VaultError::RecipientMismatch)
);
}
#[test]
fn relabelled_record_is_rejected_and_authenticated_conflict_blocks_reissue() {
let (mut bundle, key) = prepared();
let other_label = WireGuardPrivateKey::generate()
.expect("OS randomness should be available")
.public_key();
let record = bundle
.records
.remove(&key.public_key().to_string())
.expect("record exists");
bundle.records.insert(other_label.to_string(), record);
assert_eq!(
bundle.retrieve_record(other_label, PASSWORD).err(),
Some(VaultError::RecordMismatch)
);
let key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
bundle
.encrypt_record_with_status(key.public_key(), &key, true)
.expect("blocked record should encrypt");
assert_eq!(
bundle.retrieve_record(key.public_key(), PASSWORD).err(),
Some(VaultError::ReissueBlocked)
);
let serialized = bundle
.to_toml()
.expect("blocked record should serialize")
.replace("reissue_blocked = true", "reissue_blocked = false");
let changed = VaultBundle::from_toml(&serialized).expect("public status is advisory");
assert_eq!(changed.reissue_blocked(key.public_key()), Ok(false));
assert_eq!(
changed.retrieve_record(key.public_key(), PASSWORD).err(),
Some(VaultError::ReissueBlocked)
);
}
#[test]
fn authenticated_record_with_unsupported_version_has_specific_error() {
let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed");
let key = WireGuardPrivateKey::generate().expect("OS randomness should be available");
let label = key.public_key();
bundle
.encrypt_payload(
label,
RecordPayload {
version: 2,
label: label.to_string(),
private_key: key.to_base64(),
client_addresses: Vec::new(),
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("test record should be encrypted with the normal age path");
assert_eq!(
bundle.retrieve_record(label, PASSWORD).err(),
Some(VaultError::UnsupportedVersion)
);
}
#[test]
fn vault_debug_and_errors_do_not_include_secret_material() {
let (bundle, key) = prepared();
let debug = format!("{bundle:?}");
assert!(!debug.contains(PASSWORD));
assert!(!debug.contains(&key.to_base64()));
let error = VaultError::UnlockFailed.to_string();
assert!(!error.contains(PASSWORD));
assert!(!error.contains(&key.to_base64()));
let unknown = PublicKey::from_bytes([0xA5; 32]);
assert_eq!(
bundle.retrieve_record(unknown, PASSWORD).err(),
Some(VaultError::MissingRecord)
);
}
}
+815
View File
@@ -0,0 +1,815 @@
//! Caller-buffer parsing and reconciliation for existing WireGuard client files.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
use ipnet::IpNet;
use zeroize::Zeroizing;
use crate::domain::PublicKey;
use crate::wireguard::{ConfigDocument, MetadataAttachment, PeerIssue, SectionKind};
use super::{RecordPayload, VaultBundle, VaultError, WireGuardPrivateKey};
/// Secret-free outcome of parsing a supplied client configuration.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClientFileIssue {
InvalidEncoding,
InvalidStructure,
MissingPrivateKey,
AmbiguousPrivateKey,
InvalidPrivateKey,
MissingAddress,
InvalidAddress,
AmbiguousAddress,
MissingServerKey,
AmbiguousServerKey,
InvalidServerKey,
AmbiguousPresharedKey,
InvalidPresharedKey,
}
/// Parsed client parameters. Secret-bearing fields are intentionally not formatted.
pub struct ParsedClientConfig {
private_key: WireGuardPrivateKey,
preshared_key: Option<Zeroizing<String>>,
pub identity: PublicKey,
pub addresses: Vec<IpNet>,
address_text: Vec<String>,
pub dns: Option<String>,
pub mtu: Option<String>,
pub server_public_key: PublicKey,
pub endpoint: Option<String>,
pub allowed_ips: Vec<IpNet>,
allowed_ips_text: Vec<String>,
pub persistent_keepalive: Option<String>,
pub unsupported_address_shape: bool,
}
impl fmt::Debug for ParsedClientConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ParsedClientConfig")
.field("identity", &self.identity)
.field("addresses", &self.addresses)
.field("server_public_key", &self.server_public_key)
.field("unsupported_address_shape", &self.unsupported_address_shape)
.field("secret_parameters", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, Copy)]
enum ClientSection {
Interface,
Peer,
Other,
}
/// Parse a caller-supplied client configuration without reading files or exposing its source.
pub fn parse_client_config(input: &[u8]) -> Result<ParsedClientConfig, ClientFileIssue> {
let text = std::str::from_utf8(input).map_err(|_| ClientFileIssue::InvalidEncoding)?;
let mut section: Option<ClientSection> = None;
let mut interface_count = 0;
let mut peer_count = 0;
let mut private_keys = Vec::<Zeroizing<String>>::new();
let mut psk = Vec::<Zeroizing<String>>::new();
let (mut addresses, mut dns, mut mtu) = (Vec::new(), Vec::new(), Vec::new());
let (mut server_keys, mut endpoints, mut allowed, mut keepalive) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
for raw_line in text.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') || line.ends_with(']') {
let Some(name) = line.strip_prefix('[').and_then(|n| n.strip_suffix(']')) else {
return Err(ClientFileIssue::InvalidStructure);
};
let name = name.trim();
section = if name.eq_ignore_ascii_case("interface") {
interface_count += 1;
Some(ClientSection::Interface)
} else if name.eq_ignore_ascii_case("peer") {
peer_count += 1;
Some(ClientSection::Peer)
} else {
Some(ClientSection::Other)
};
continue;
}
let Some((key, value)) = line.split_once('=') else {
return Err(ClientFileIssue::InvalidStructure);
};
let key = key.trim();
let value = value.trim();
match section {
Some(ClientSection::Interface) => {
if key.eq_ignore_ascii_case("privatekey") {
private_keys.push(Zeroizing::new(value.to_owned()));
} else if key.eq_ignore_ascii_case("address") {
addresses.push(value.to_owned());
} else if key.eq_ignore_ascii_case("dns") {
dns.push(value.to_owned());
} else if key.eq_ignore_ascii_case("mtu") {
mtu.push(value.to_owned());
} // Hooks and unknown fields are never interpreted or executed.
}
Some(ClientSection::Peer) => {
if key.eq_ignore_ascii_case("publickey") {
server_keys.push(value.to_owned());
} else if key.eq_ignore_ascii_case("presharedkey") {
psk.push(Zeroizing::new(value.to_owned()));
} else if key.eq_ignore_ascii_case("endpoint") {
endpoints.push(value.to_owned());
} else if key.eq_ignore_ascii_case("allowedips") {
allowed.push(value.to_owned());
} else if key.eq_ignore_ascii_case("persistentkeepalive") {
keepalive.push(value.to_owned());
}
}
_ => return Err(ClientFileIssue::InvalidStructure),
}
}
if interface_count != 1 || peer_count != 1 {
return Err(ClientFileIssue::InvalidStructure);
}
let private_text = match private_keys.as_slice() {
[] => return Err(ClientFileIssue::MissingPrivateKey),
[value] => value,
_ => return Err(ClientFileIssue::AmbiguousPrivateKey),
};
let private_key = WireGuardPrivateKey::parse_base64(private_text)
.map_err(|_| ClientFileIssue::InvalidPrivateKey)?;
let address_text = match addresses.as_slice() {
[] => return Err(ClientFileIssue::MissingAddress),
[value] => value,
_ => return Err(ClientFileIssue::AmbiguousAddress),
};
let address_items = split_network_list(address_text).ok_or(ClientFileIssue::InvalidAddress)?;
let parsed_addresses = address_items
.iter()
.map(|item| IpNet::from_str(item).map_err(|_| ClientFileIssue::InvalidAddress))
.collect::<Result<Vec<_>, _>>()?;
let dns = single_optional(dns).ok_or(ClientFileIssue::InvalidStructure)?;
let mtu = single_optional(mtu).ok_or(ClientFileIssue::InvalidStructure)?;
let server_key_text = match server_keys.as_slice() {
[] => return Err(ClientFileIssue::MissingServerKey),
[value] => value,
_ => return Err(ClientFileIssue::AmbiguousServerKey),
};
let server_public_key =
PublicKey::from_str(server_key_text).map_err(|_| ClientFileIssue::InvalidServerKey)?;
let preshared_key = match psk.as_slice() {
[] => None,
[value] => {
WireGuardPrivateKey::parse_base64(value)
.map_err(|_| ClientFileIssue::InvalidPresharedKey)?;
Some(value.clone())
}
_ => return Err(ClientFileIssue::AmbiguousPresharedKey),
};
let endpoint = single_optional(endpoints).ok_or(ClientFileIssue::InvalidStructure)?;
let mut allowed_ips_text = Vec::new();
for value in &allowed {
allowed_ips_text.extend(split_network_list(value).ok_or(ClientFileIssue::InvalidAddress)?);
}
let allowed_ips = allowed_ips_text
.iter()
.map(|item| IpNet::from_str(item).map_err(|_| ClientFileIssue::InvalidAddress))
.collect::<Result<Vec<_>, _>>()?;
let persistent_keepalive =
single_optional(keepalive).ok_or(ClientFileIssue::InvalidStructure)?;
let unsupported_address_shape = parsed_addresses.iter().any(|net| match net {
IpNet::V4(v4) => v4.prefix_len() != 32,
IpNet::V6(v6) => v6.prefix_len() != 128,
});
Ok(ParsedClientConfig {
identity: private_key.public_key(),
private_key,
preshared_key,
addresses: parsed_addresses,
address_text: address_items,
dns,
mtu,
server_public_key,
endpoint,
allowed_ips,
allowed_ips_text,
persistent_keepalive,
unsupported_address_shape,
})
}
fn single_optional(values: Vec<String>) -> Option<Option<String>> {
match values.as_slice() {
[] => Some(None),
[value] => Some(Some(value.clone())),
_ => None,
}
}
fn split_network_list(value: &str) -> Option<Vec<String>> {
let items = value.split(',').map(str::trim).collect::<Vec<_>>();
if items.is_empty() || items.iter().any(|item| item.is_empty()) {
return None;
}
items
.into_iter()
.map(|item| {
IpNet::from_str(item).ok()?;
Some(item.to_owned())
})
.collect()
}
/// Reconciliation status for one supplied candidate; it contains no source text or secrets.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CandidateStatus {
Invalid(ClientFileIssue),
DuplicateFileIdentity,
AmbiguousServerPeer,
FileOnly,
Matched {
address_discrepancy: bool,
server_identity: ServerIdentityComparison,
missing_metadata: bool,
invalid_metadata: bool,
unsupported_projection: bool,
},
}
/// Whether the client file's peer key could be checked against the authoritative server identity.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ServerIdentityComparison {
Matches,
Mismatch,
Unresolved,
}
struct Candidate {
display_name: String,
parsed: Result<ParsedClientConfig, ClientFileIssue>,
status: CandidateStatus,
}
/// Secret-free reconciliation summary. The display name is never used as identity.
pub struct ImportReport {
candidates: Vec<Candidate>,
server_only: Vec<PublicKey>,
}
impl fmt::Debug for ImportReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ImportReport")
.field("candidate_count", &self.candidates.len())
.field("server_only", &self.server_only)
.field("details", &"[REDACTED SOURCE DETAILS]")
.finish()
}
}
impl ImportReport {
pub fn candidate_summaries(&self) -> Vec<(&str, CandidateStatus)> {
self.candidates
.iter()
.map(|item| (item.display_name.as_str(), item.status.clone()))
.collect()
}
pub fn server_only(&self) -> &[PublicKey] {
&self.server_only
}
/// Encrypt one valid candidate. Duplicate vault records are rejected without replacement.
pub fn encrypt_candidate(
&self,
bundle: &mut VaultBundle,
candidate_index: usize,
) -> Result<(), VaultError> {
let candidate = self
.candidates
.get(candidate_index)
.ok_or(VaultError::InvalidInput)?;
let parsed = candidate
.parsed
.as_ref()
.map_err(|_| VaultError::InvalidInput)?;
if matches!(
candidate.status,
CandidateStatus::DuplicateFileIdentity | CandidateStatus::Invalid(_)
) {
return Err(VaultError::InvalidInput);
}
let blocked = match candidate.status {
CandidateStatus::Matched {
address_discrepancy,
server_identity,
unsupported_projection,
..
} => {
address_discrepancy
|| server_identity != ServerIdentityComparison::Matches
|| unsupported_projection
}
CandidateStatus::AmbiguousServerPeer | CandidateStatus::FileOnly => true,
CandidateStatus::Invalid(_) | CandidateStatus::DuplicateFileIdentity => true,
};
let payload = RecordPayload {
version: 1,
label: parsed.identity.to_string(),
private_key: parsed.private_key.to_base64(),
client_addresses: parsed.address_text.clone(),
interface_dns: parsed.dns.clone(),
interface_mtu: parsed.mtu.clone(),
server_public_key: Some(parsed.server_public_key.to_string()),
endpoint: parsed.endpoint.clone(),
allowed_ips: parsed.allowed_ips_text.clone(),
persistent_keepalive: parsed.persistent_keepalive.clone(),
preshared_key: parsed.preshared_key.as_ref().map(|value| value.to_string()),
reissue_blocked: blocked,
};
bundle.encrypt_payload(parsed.identity, payload, true)
}
}
/// Reconcile supplied caller-buffer files with the authoritative server document.
pub fn reconcile(
server: &ConfigDocument,
files: impl IntoIterator<Item = (String, Vec<u8>)>,
) -> ImportReport {
reconcile_zeroizing(
server,
files
.into_iter()
.map(|(display_name, bytes)| (display_name, Zeroizing::new(bytes))),
)
}
pub(crate) fn reconcile_zeroizing(
server: &ConfigDocument,
files: impl IntoIterator<Item = (String, Zeroizing<Vec<u8>>)>,
) -> ImportReport {
let mut candidates = files
.into_iter()
.map(|(display_name, bytes)| Candidate {
display_name,
parsed: parse_client_config(&bytes),
status: CandidateStatus::FileOnly,
})
.collect::<Vec<_>>();
let mut identity_counts = BTreeMap::<PublicKey, usize>::new();
for candidate in &candidates {
if let Ok(parsed) = &candidate.parsed {
*identity_counts.entry(parsed.identity).or_default() += 1;
}
}
let server_key = interface_public_key(server);
let mut represented = BTreeSet::new();
for candidate in &mut candidates {
let parsed = match &candidate.parsed {
Ok(parsed) => parsed,
Err(issue) => {
candidate.status = CandidateStatus::Invalid(*issue);
continue;
}
};
represented.insert(parsed.identity);
if identity_counts
.get(&parsed.identity)
.copied()
.unwrap_or_default()
> 1
{
candidate.status = CandidateStatus::DuplicateFileIdentity;
continue;
}
let peers = server
.peers()
.iter()
.filter(|peer| peer.public_key_candidates.contains(&parsed.identity))
.collect::<Vec<_>>();
if peers.is_empty() {
candidate.status = CandidateStatus::FileOnly;
continue;
}
if peers.len() != 1
|| peers[0].identity != Some(parsed.identity)
|| peers[0].issues.iter().any(|issue| {
matches!(
issue,
PeerIssue::DuplicatePeerIdentity { .. }
| PeerIssue::DuplicatePublicKeyField { .. }
| PeerIssue::MalformedPublicKey { .. }
)
})
{
candidate.status = CandidateStatus::AmbiguousServerPeer;
continue;
}
let peer = peers[0];
let file_addresses = parsed
.addresses
.iter()
.map(|net| net.addr())
.collect::<BTreeSet<_>>();
let server_addresses = peer
.allowed_ips
.iter()
.map(|net| net.addr())
.collect::<BTreeSet<_>>();
let address_discrepancy = file_addresses != server_addresses;
let server_identity = match server_key {
Some(server_key) if server_key == parsed.server_public_key => {
ServerIdentityComparison::Matches
}
Some(_) => ServerIdentityComparison::Mismatch,
None => ServerIdentityComparison::Unresolved,
};
let missing_metadata = matches!(peer.metadata, MetadataAttachment::Absent);
let invalid_metadata = matches!(peer.metadata, MetadataAttachment::Invalid(_));
let server_unsupported = peer.issues.iter().any(|issue| {
matches!(
issue,
PeerIssue::MalformedAllowedIp { .. }
| PeerIssue::UnsupportedAllowedIpShape { .. }
| PeerIssue::MissingIpv4HostRoute
)
});
candidate.status = CandidateStatus::Matched {
address_discrepancy,
server_identity,
missing_metadata,
invalid_metadata,
unsupported_projection: parsed.unsupported_address_shape || server_unsupported,
};
}
let server_only = server
.peers()
.iter()
.filter_map(|peer| peer.identity)
.filter(|identity| !represented.contains(identity))
.collect();
ImportReport {
candidates,
server_only,
}
}
fn interface_public_key(document: &ConfigDocument) -> Option<PublicKey> {
let interfaces = document
.sections()
.iter()
.filter(|section| section.kind == SectionKind::Interface)
.collect::<Vec<_>>();
if interfaces.len() != 1 {
return None;
}
let text = document.source_slice(interfaces[0].span)?;
let keys = text
.lines()
.filter_map(|line| {
let (key, value) = line.split_once('=')?;
key.trim()
.eq_ignore_ascii_case("PrivateKey")
.then(|| value.trim())
})
.collect::<Vec<_>>();
let [private] = keys.as_slice() else {
return None;
};
WireGuardPrivateKey::parse_base64(private)
.ok()
.map(|key| key.public_key())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::{VaultBundle, VaultError};
const PASSWORD: &str = "synthetic passphrase";
fn generated() -> (String, String, String) {
let client = WireGuardPrivateKey::generate().unwrap();
let server = WireGuardPrivateKey::generate().unwrap();
let psk = WireGuardPrivateKey::generate().unwrap();
(client.to_base64(), server.to_base64(), psk.to_base64())
}
fn client_config(client: &str, server: &str, psk: &str, address: &str) -> String {
let server_public = WireGuardPrivateKey::parse_base64(server)
.unwrap()
.public_key();
format!(
"[Interface]\nPrivateKey = {client}\nAddress = {address}\nDNS = 1.1.1.1\nMTU = 1380\n[Peer]\nPublicKey = {server_public}\nPresharedKey = {psk}\nEndpoint = example.invalid:51820\nAllowedIPs = 0.0.0.0/0, ::/0\nPersistentKeepalive = 25\n"
)
}
fn server_config(server: &str, client_public: &PublicKey, address: &str) -> String {
format!(
"[Interface]\nPrivateKey = {server}\n[Peer]\nPublicKey = {client_public}\nAllowedIPs = {address}\n"
)
}
#[test]
fn client_interface_private_key_is_identity_and_peer_public_key_is_server() {
let (client, server, psk) = generated();
let parsed =
parse_client_config(client_config(&client, &server, &psk, "10.0.0.2/32").as_bytes())
.unwrap();
let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap();
let server_key = WireGuardPrivateKey::parse_base64(&server).unwrap();
assert_eq!(parsed.identity, client_key.public_key());
assert_ne!(parsed.identity, server_key.public_key());
assert_eq!(parsed.server_public_key, server_key.public_key());
}
#[test]
fn import_preserves_secrets_and_parameters_only_inside_encrypted_record() {
let (client, server, psk) = generated();
let private = WireGuardPrivateKey::parse_base64(&client).unwrap();
let server_private = WireGuardPrivateKey::parse_base64(&server).unwrap();
let source = client_config(&client, &server, &psk, "10.0.0.2/32, fd00::2/128");
let parsed = parse_client_config(source.as_bytes()).unwrap();
assert!(parsed.preshared_key.as_ref().map(|value| value.as_str()) == Some(psk.as_str()));
let server_doc = ConfigDocument::parse(server_config(
&server,
&private.public_key(),
"10.0.0.2/32, fd00::2/128",
))
.unwrap();
let report = reconcile(
&server_doc,
[("alias-looking-name.conf".into(), source.as_bytes().to_vec())],
);
let summaries = report.candidate_summaries();
assert_eq!(
summaries[0].1,
CandidateStatus::Matched {
address_discrepancy: false,
server_identity: ServerIdentityComparison::Matches,
missing_metadata: true,
invalid_metadata: false,
unsupported_projection: false,
}
);
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
report.encrypt_candidate(&mut bundle, 0).unwrap();
let serialized = bundle.to_toml().unwrap();
assert!(!serialized.contains(&client));
assert!(!serialized.contains(&psk));
assert!(!serialized.contains("example.invalid"));
let retrieved = bundle
.retrieve_client_record(parsed.identity, PASSWORD)
.unwrap();
assert!(retrieved.preshared_key_base64() == Some(psk.as_str()));
assert_eq!(retrieved.client_addresses, ["10.0.0.2/32", "fd00::2/128"]);
assert_eq!(retrieved.endpoint.as_deref(), Some("example.invalid:51820"));
assert_eq!(retrieved.interface_dns.as_deref(), Some("1.1.1.1"));
assert_eq!(retrieved.interface_mtu.as_deref(), Some("1380"));
assert_eq!(retrieved.allowed_ips, ["0.0.0.0/0", "::/0"]);
let debug = format!("{retrieved:?}");
for raw_value in [
psk.as_str(),
"10.0.0.2/32",
"1.1.1.1",
"1380",
"example.invalid:51820",
"0.0.0.0/0",
"25",
] {
assert!(!debug.contains(raw_value));
}
assert!(debug.contains("[REDACTED]"));
let _ = server_private;
}
#[test]
fn conflicts_invalid_duplicates_and_server_only_are_reported_and_blocked() {
let (client, server, psk) = generated();
let key = WireGuardPrivateKey::parse_base64(&client).unwrap();
let server_private = WireGuardPrivateKey::parse_base64(&server).unwrap();
let file = client_config(&client, &server, &psk, "10.0.0.9/32");
let doc = ConfigDocument::parse(server_config(&server, &key.public_key(), "10.0.0.2/32"))
.unwrap();
let report = reconcile(
&doc,
[
("one.conf".into(), file.as_bytes().to_vec()),
("two.conf".into(), file.as_bytes().to_vec()),
(
"bad.conf".into(),
b"[Interface]\nPrivateKey = nope\n".to_vec(),
),
],
);
let statuses = report.candidate_summaries();
assert_eq!(statuses[0].1, CandidateStatus::DuplicateFileIdentity);
assert_eq!(statuses[1].1, CandidateStatus::DuplicateFileIdentity);
assert!(matches!(statuses[2].1, CandidateStatus::Invalid(_)));
assert!(report.server_only().is_empty());
let single = reconcile(&doc, [("file.conf".into(), file.as_bytes().to_vec())]);
assert_eq!(
single.candidate_summaries()[0].1,
CandidateStatus::Matched {
address_discrepancy: true,
server_identity: ServerIdentityComparison::Matches,
missing_metadata: true,
invalid_metadata: false,
unsupported_projection: false,
}
);
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
single.encrypt_candidate(&mut bundle, 0).unwrap();
assert_eq!(bundle.reissue_blocked(key.public_key()), Ok(true));
assert_eq!(
bundle.retrieve_record(key.public_key(), PASSWORD).err(),
Some(VaultError::ReissueBlocked)
);
let empty_doc =
ConfigDocument::parse(format!("[Interface]\nPrivateKey = {server}\n")).unwrap();
let file_only = reconcile(&empty_doc, [("file.conf".into(), file.as_bytes().to_vec())]);
assert_eq!(
file_only.candidate_summaries()[0].1,
CandidateStatus::FileOnly
);
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
file_only.encrypt_candidate(&mut bundle, 0).unwrap();
assert!(bundle.reissue_blocked(key.public_key()).unwrap());
let unused_key = server_private.public_key();
assert!(!report.server_only().contains(&unused_key));
}
#[test]
fn mutable_public_gate_cannot_authorize_reissue_and_duplicates_never_overwrite() {
let (client, server, psk) = generated();
let key = WireGuardPrivateKey::parse_base64(&client).unwrap();
let doc = ConfigDocument::parse(server_config(&server, &key.public_key(), "10.0.0.2/32"))
.unwrap();
let report = reconcile(
&doc,
[(
"file.conf".into(),
client_config(&client, &server, &psk, "10.0.0.3/32").into_bytes(),
)],
);
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
report.encrypt_candidate(&mut bundle, 0).unwrap();
let before = bundle.to_toml().unwrap();
assert_eq!(
bundle.encrypt_record(key.public_key(), &key),
Err(VaultError::DuplicateRecord)
);
let mut changed = before.replace("reissue_blocked = true", "reissue_blocked = false");
let loaded = VaultBundle::from_toml(&changed).unwrap();
assert_eq!(loaded.reissue_blocked(key.public_key()), Ok(false));
assert_eq!(
loaded.retrieve_record(key.public_key(), PASSWORD).err(),
Some(VaultError::ReissueBlocked)
);
changed.clear();
}
#[test]
fn parse_and_report_debug_are_redacted() {
let (client, server, psk) = generated();
let source = format!(
"{}PrivateKey = SYNTHETIC-MISPLACED-PRIVATE-VALUE\n",
client_config(&client, &server, &psk, "10.0.0.2/32")
);
let parsed = parse_client_config(source.as_bytes()).unwrap();
let debug = format!("{parsed:?}");
assert!(!debug.contains(&client));
assert!(!debug.contains(&psk));
assert!(!debug.contains("SYNTHETIC-MISPLACED-PRIVATE-VALUE"));
let malformed =
format!("{source}[Unknown]\nCredential = SYNTHETIC-REJECTED-SECRET-VALUE\n");
let error = parse_client_config(malformed.as_bytes()).unwrap_err();
assert_eq!(error, ClientFileIssue::InvalidStructure);
assert!(!format!("{error:?}").contains("SYNTHETIC-REJECTED-SECRET-VALUE"));
let invalid_psk = client_config(
&client,
&server,
"SYNTHETIC-INVALID-PSK-VALUE",
"10.0.0.2/32",
);
let error = parse_client_config(invalid_psk.as_bytes()).unwrap_err();
assert_eq!(error, ClientFileIssue::InvalidPresharedKey);
assert!(!format!("{error:?}").contains("SYNTHETIC-INVALID-PSK-VALUE"));
let report_debug = format!(
"{:?}",
reconcile(
&ConfigDocument::parse("[Interface]\n").unwrap(),
[("file".into(), malformed.into_bytes())]
)
);
assert!(!report_debug.contains("SYNTHETIC-MISPLACED-PRIVATE-VALUE"));
assert!(!report_debug.contains("SYNTHETIC-REJECTED-SECRET-VALUE"));
}
#[test]
fn report_separates_server_only_binding_and_projection_anomalies() {
let (client, server, psk) = generated();
let (_, other_server, _) = generated();
let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap();
let unmatched = WireGuardPrivateKey::generate().unwrap().public_key();
let config = format!(
"[Interface]\nPrivateKey = {server}\n# wg-admin:begin\ninvalid metadata\n# wg-admin:end\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.0.0.2/32\n[Peer]\nPublicKey = {unmatched}\nAllowedIPs = 10.9.0.1/24\n",
client_key.public_key()
);
let client_file = client_config(&client, &other_server, &psk, "10.0.0.2/24");
let report = reconcile(
&ConfigDocument::parse(config).unwrap(),
[("candidate.conf".into(), client_file.into_bytes())],
);
assert_eq!(report.server_only(), &[unmatched]);
assert_eq!(
report.candidate_summaries()[0].1,
CandidateStatus::Matched {
address_discrepancy: false,
server_identity: ServerIdentityComparison::Mismatch,
missing_metadata: false,
invalid_metadata: true,
unsupported_projection: true,
}
);
}
#[test]
fn unresolved_server_identity_is_distinct_and_blocks_reissue() {
let (client, server, psk) = generated();
let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap();
let server_doc = ConfigDocument::parse(format!(
"[Interface]\nAddress = 10.0.0.1/24\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.0.0.2/32\n",
client_key.public_key()
))
.unwrap();
let file = client_config(&client, &server, &psk, "10.0.0.2/32");
let report = reconcile(&server_doc, [("client.conf".into(), file.into_bytes())]);
assert_eq!(
report.candidate_summaries()[0].1,
CandidateStatus::Matched {
address_discrepancy: false,
server_identity: ServerIdentityComparison::Unresolved,
missing_metadata: true,
invalid_metadata: false,
unsupported_projection: false,
}
);
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
report.encrypt_candidate(&mut bundle, 0).unwrap();
assert_eq!(bundle.reissue_blocked(client_key.public_key()), Ok(true));
assert_eq!(
bundle
.retrieve_record(client_key.public_key(), PASSWORD)
.err(),
Some(VaultError::ReissueBlocked)
);
}
#[test]
fn server_key_mismatch_alone_allows_encryption_but_blocks_reissue() {
let (client, server, psk) = generated();
let (_, other_server, _) = generated();
let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap();
let server_doc = ConfigDocument::parse(server_config(
&server,
&client_key.public_key(),
"10.0.0.2/32",
))
.unwrap();
let file = client_config(&client, &other_server, &psk, "10.0.0.2/32");
let report = reconcile(&server_doc, [("client.conf".into(), file.into_bytes())]);
assert_eq!(
report.candidate_summaries()[0].1,
CandidateStatus::Matched {
address_discrepancy: false,
server_identity: ServerIdentityComparison::Mismatch,
missing_metadata: true,
invalid_metadata: false,
unsupported_projection: false,
}
);
let mut bundle = VaultBundle::create(PASSWORD).unwrap();
assert!(report.encrypt_candidate(&mut bundle, 0).is_ok());
assert_eq!(bundle.record_count(), 1);
assert_eq!(bundle.reissue_blocked(client_key.public_key()), Ok(true));
assert_eq!(
bundle
.retrieve_record(client_key.public_key(), PASSWORD)
.err(),
Some(VaultError::ReissueBlocked)
);
}
}
+511
View File
@@ -0,0 +1,511 @@
//! Bounded, read-only access to configured client source files.
use std::ffi::OsString;
use std::fs::File;
use std::io::{Read, Take};
use std::path::{Path, PathBuf};
use rustix::fd::OwnedFd;
use rustix::fs::{self as rfs, FileType, Mode, OFlags, ResolveFlags};
use thiserror::Error;
use crate::config::ResolvedSettings;
use crate::wireguard::ConfigDocument;
use super::import::{ImportReport, reconcile, reconcile_zeroizing};
use super::{VaultBundle, VaultError};
use zeroize::Zeroizing;
/// Only immediate `*.conf` entries are candidates. Matching is ASCII-case-insensitive.
pub const MAX_CANDIDATE_BYTES: usize = 1024 * 1024;
pub const MAX_SCAN_BYTES: usize = 32 * 1024 * 1024;
pub const MAX_CANDIDATES: usize = 1024;
pub const MAX_DIRECTORY_ENTRIES: usize = 4096;
/// A source failure safe to show in a UI or log. It never contains I/O error text.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SourceFailureKind {
DirectoryUnavailable,
EnumerationFailed,
TooManyEntries,
FileUnavailable,
NotRegularFile,
FileTooLarge,
ScanTooLarge,
ReadFailed,
EncryptionFailed,
DuplicateRecordEqualityUnverified,
}
/// Per-file issue with a display-only filename and a redacted failure category.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SourceIssue {
pub file_name: Option<String>,
pub kind: SourceFailureKind,
}
/// Directory scan and reconciliation result. `scan_complete` is false whenever
/// enumeration or a configured limit prevented a complete inventory.
pub struct SourceImportReport {
pub scan_complete: bool,
pub issues: Vec<SourceIssue>,
pub reconciliation: ImportReport,
}
impl std::fmt::Debug for SourceImportReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SourceImportReport")
.field("scan_complete", &self.scan_complete)
.field("issues", &self.issues)
.field("reconciliation", &self.reconciliation)
.finish()
}
}
/// Bundle prepared for a later explicit persistence/Commit operation.
pub struct PreparedImportSetup {
pub bundle: VaultBundle,
pub report: SourceImportReport,
}
#[derive(Debug, Error, Eq, PartialEq)]
pub enum SourceImportError {
#[error("vault setup failed")]
Vault(#[from] VaultError),
#[error("source directory is invalid")]
InvalidDirectory,
}
/// Create a new password-protected vault and encrypt valid candidates from the
/// configured source directory. This only returns prepared in-memory data.
pub fn prepare_initial_setup(
settings: &ResolvedSettings,
server: &ConfigDocument,
vault_password: &str,
) -> Result<PreparedImportSetup, SourceImportError> {
let mut bundle = VaultBundle::create(vault_password)?;
let report = import_directory(&mut bundle, settings, server)?;
Ok(PreparedImportSetup { bundle, report })
}
/// Explicitly import configured candidates into an existing prepared bundle.
/// It uses the bundle's public recipient and never unlocks or replaces its identity.
pub fn import_configured_directory(
bundle: &mut VaultBundle,
settings: &ResolvedSettings,
server: &ConfigDocument,
) -> Result<SourceImportReport, SourceImportError> {
import_directory(bundle, settings, server)
}
fn import_directory(
bundle: &mut VaultBundle,
settings: &ResolvedSettings,
server: &ConfigDocument,
) -> Result<SourceImportReport, SourceImportError> {
let source = &settings.client_source_directory;
if !source.is_absolute() {
return Err(SourceImportError::InvalidDirectory);
}
let directory = match open_source_directory(source) {
Ok(fd) => fd,
Err(_) => {
return Ok(SourceImportReport {
scan_complete: false,
issues: vec![SourceIssue {
file_name: None,
kind: SourceFailureKind::DirectoryUnavailable,
}],
reconciliation: reconcile(server, []),
});
}
};
let entries = match rfs::Dir::read_from(&directory) {
Ok(entries) => entries,
Err(_) => {
return Ok(SourceImportReport {
scan_complete: false,
issues: vec![SourceIssue {
file_name: None,
kind: SourceFailureKind::EnumerationFailed,
}],
reconciliation: reconcile(server, []),
});
}
};
Ok(import_open_directory(bundle, server, &directory, entries))
}
fn import_open_directory(
bundle: &mut VaultBundle,
server: &ConfigDocument,
directory: &OwnedFd,
mut entries: rfs::Dir,
) -> SourceImportReport {
let entries = std::iter::from_fn(move || {
entries.read().map(|entry| {
entry.map_err(std::io::Error::from).map(|entry| {
use std::os::unix::ffi::OsStrExt;
OsString::from(std::ffi::OsStr::from_bytes(entry.file_name().to_bytes()))
})
})
});
import_entries(bundle, server, directory, entries)
}
fn import_entries(
bundle: &mut VaultBundle,
server: &ConfigDocument,
directory: &OwnedFd,
entries: impl IntoIterator<Item = std::io::Result<OsString>>,
) -> SourceImportReport {
import_entries_with_limits(
bundle,
server,
directory,
entries,
MAX_SCAN_BYTES,
MAX_DIRECTORY_ENTRIES,
)
}
fn import_entries_with_limits(
bundle: &mut VaultBundle,
server: &ConfigDocument,
directory: &OwnedFd,
entries: impl IntoIterator<Item = std::io::Result<OsString>>,
max_scan_bytes: usize,
max_directory_entries: usize,
) -> SourceImportReport {
let mut complete = true;
let mut issues = Vec::new();
let mut candidates = Vec::new();
let mut aggregate_bytes = 0usize;
let mut candidate_count = 0usize;
for (examined_entries, entry) in entries.into_iter().enumerate() {
if examined_entries >= max_directory_entries {
complete = false;
issues.push(SourceIssue {
file_name: None,
kind: SourceFailureKind::TooManyEntries,
});
break;
}
let entry = match entry {
Ok(entry) => entry,
Err(_) => {
complete = false;
issues.push(SourceIssue {
file_name: None,
kind: SourceFailureKind::EnumerationFailed,
});
continue;
}
};
let name = entry;
if !is_candidate_name(&name) {
continue;
}
if candidate_count >= MAX_CANDIDATES {
complete = false;
issues.push(SourceIssue {
file_name: None,
kind: SourceFailureKind::TooManyEntries,
});
break;
}
candidate_count += 1;
let display_name = safe_file_name(&name);
match read_candidate(directory, &name, aggregate_bytes, max_scan_bytes) {
Ok(bytes) => {
aggregate_bytes += bytes.len();
candidates.push((display_name, bytes));
}
Err(kind) => {
complete = false;
issues.push(SourceIssue {
file_name: Some(display_name),
kind,
});
}
}
}
let reconciliation = reconcile_zeroizing(server, candidates);
let summaries = reconciliation.candidate_summaries();
for (index, (name, status)) in summaries.iter().enumerate() {
if matches!(
status,
super::import::CandidateStatus::Invalid(_)
| super::import::CandidateStatus::DuplicateFileIdentity
) {
continue;
}
if let Err(error) = reconciliation.encrypt_candidate(bundle, index) {
complete = false;
issues.push(SourceIssue {
file_name: Some((*name).to_owned()),
kind: match error {
VaultError::DuplicateRecord => {
SourceFailureKind::DuplicateRecordEqualityUnverified
}
_ => SourceFailureKind::EncryptionFailed,
},
});
}
}
SourceImportReport {
scan_complete: complete,
issues,
reconciliation,
}
}
#[cfg(target_os = "linux")]
fn open_source_directory(path: &Path) -> std::io::Result<OwnedFd> {
rfs::openat2(
rfs::CWD,
path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
)
.map_err(std::io::Error::from)
}
#[cfg(not(target_os = "linux"))]
fn open_source_directory(_path: &Path) -> std::io::Result<OwnedFd> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"secure directory opening is unavailable",
))
}
fn read_candidate(
directory: &OwnedFd,
name: &std::ffi::OsStr,
aggregate_bytes: usize,
max_scan_bytes: usize,
) -> Result<Zeroizing<Vec<u8>>, SourceFailureKind> {
let file = open_source_file(directory, name).map_err(|_| SourceFailureKind::FileUnavailable)?;
let stat = rfs::fstat(&file).map_err(|_| SourceFailureKind::FileUnavailable)?;
if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile {
return Err(SourceFailureKind::NotRegularFile);
}
let size = usize::try_from(stat.st_size).map_err(|_| SourceFailureKind::FileTooLarge)?;
if size > MAX_CANDIDATE_BYTES {
return Err(SourceFailureKind::FileTooLarge);
}
let remaining_scan_bytes = max_scan_bytes.saturating_sub(aggregate_bytes);
if size > remaining_scan_bytes {
return Err(SourceFailureKind::ScanTooLarge);
}
let mut file = File::from(file);
let mut bytes = Zeroizing::new(Vec::with_capacity(size.min(MAX_CANDIDATE_BYTES)));
let read_limit = MAX_CANDIDATE_BYTES.min(remaining_scan_bytes);
let mut reader: Take<&mut File> = (&mut file).take((read_limit + 1) as u64);
reader
.read_to_end(&mut bytes)
.map_err(|_| SourceFailureKind::ReadFailed)?;
if bytes.len() > MAX_CANDIDATE_BYTES {
return Err(SourceFailureKind::FileTooLarge);
}
if bytes.len() > remaining_scan_bytes {
return Err(SourceFailureKind::ScanTooLarge);
}
Ok(bytes)
}
#[cfg(target_os = "linux")]
fn open_source_file(directory: &OwnedFd, name: &std::ffi::OsStr) -> std::io::Result<OwnedFd> {
rfs::openat2(
directory,
PathBuf::from(name),
OFlags::RDONLY | OFlags::NONBLOCK | OFlags::CLOEXEC,
Mode::empty(),
ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
)
.map_err(std::io::Error::from)
}
#[cfg(not(target_os = "linux"))]
fn open_source_file(_directory: &OwnedFd, _name: &std::ffi::OsStr) -> std::io::Result<OwnedFd> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"secure file opening is unavailable",
))
}
fn is_candidate_name(name: &std::ffi::OsStr) -> bool {
Path::new(name)
.extension()
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|extension| extension.eq_ignore_ascii_case("conf"))
}
fn safe_file_name(name: &std::ffi::OsStr) -> String {
let raw = name.to_string_lossy();
raw.chars()
.map(|character| {
if character.is_control() {
'\u{fffd}'
} else {
character
}
})
.take(255)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::WireGuardPrivateKey;
use std::fs;
#[test]
fn entry_enumeration_error_is_visible_and_other_files_still_import() {
let directory = tempfile::tempdir().unwrap();
let server_key = WireGuardPrivateKey::generate().unwrap();
let client_key = WireGuardPrivateKey::generate().unwrap();
let server = ConfigDocument::parse(
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n",
server_key.to_base64(),
client_key.public_key()
)
.as_bytes(),
)
.unwrap();
let client_bytes = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\n",
client_key.to_base64(),
server_key.public_key()
);
fs::write(directory.path().join("valid.conf"), &client_bytes).unwrap();
let dirfd = open_source_directory(directory.path()).unwrap();
let entries = [
Ok(OsString::from("valid.conf")),
Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
];
let mut bundle = VaultBundle::create("password").unwrap();
let report = import_entries(&mut bundle, &server, &dirfd, entries);
assert!(!report.scan_complete);
assert_eq!(bundle.record_count(), 1);
assert!(
report
.issues
.iter()
.any(|issue| issue.kind == SourceFailureKind::EnumerationFailed)
);
assert!(fs::read(directory.path().join("valid.conf")).unwrap() == client_bytes.as_bytes());
}
#[test]
fn enumeration_and_reads_stay_pinned_after_source_path_is_replaced() {
let parent = tempfile::tempdir().unwrap();
let source = parent.path().join("source");
let pinned_path = parent.path().join("pinned");
fs::create_dir(&source).unwrap();
let server_key = WireGuardPrivateKey::generate().unwrap();
let client_key = WireGuardPrivateKey::generate().unwrap();
let decoy_key = WireGuardPrivateKey::generate().unwrap();
let server = ConfigDocument::parse(format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n",
server_key.to_base64(), client_key.public_key()
))
.unwrap();
let bytes = format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\n",
client_key.to_base64(),
server_key.public_key()
);
fs::write(source.join("client.conf"), &bytes).unwrap();
let directory = open_source_directory(&source).unwrap();
fs::rename(&source, &pinned_path).unwrap();
fs::create_dir(&source).unwrap();
fs::write(
source.join("replacement.conf"),
format!(
"[Interface]\nPrivateKey = {}\nAddress = 10.8.0.3/32\n\n[Peer]\nPublicKey = {}\n",
decoy_key.to_base64(),
server_key.public_key()
),
)
.unwrap();
let entries = rfs::Dir::read_from(&directory).unwrap();
let mut bundle = VaultBundle::create("password").unwrap();
let report = import_open_directory(&mut bundle, &server, &directory, entries);
assert!(report.scan_complete);
assert_eq!(bundle.record_count(), 1);
assert!(report.reconciliation.server_only().is_empty());
assert_eq!(
report.reconciliation.candidate_summaries()[0].1,
super::super::import::CandidateStatus::Matched {
address_discrepancy: false,
server_identity: super::super::import::ServerIdentityComparison::Matches,
missing_metadata: true,
invalid_metadata: false,
unsupported_projection: false,
}
);
assert!(fs::read(pinned_path.join("client.conf")).unwrap() == bytes.as_bytes());
assert!(fs::read_dir(&source).unwrap().next().is_some());
}
#[test]
fn aggregate_scan_byte_limit_is_enforced_after_successful_reads() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("one.conf"), b"one").unwrap();
fs::write(directory.path().join("two.conf"), b"two").unwrap();
let server = ConfigDocument::parse("[Interface]\n").unwrap();
let dirfd = open_source_directory(directory.path()).unwrap();
let entries = [
Ok(OsString::from("one.conf")),
Ok(OsString::from("two.conf")),
];
let mut bundle = VaultBundle::create("password").unwrap();
let report = import_entries_with_limits(&mut bundle, &server, &dirfd, entries, 4, 10);
assert!(!report.scan_complete);
assert_eq!(
report.issues,
[SourceIssue {
file_name: Some("two.conf".to_owned()),
kind: SourceFailureKind::ScanTooLarge,
}]
);
}
#[test]
fn directory_entry_limit_marks_inventory_incomplete() {
let directory = tempfile::tempdir().unwrap();
let dirfd = open_source_directory(directory.path()).unwrap();
let server = ConfigDocument::parse("[Interface]\n").unwrap();
let entries = [
Ok(OsString::from("one.txt")),
Ok(OsString::from("two.txt")),
Ok(OsString::from("three.txt")),
];
let mut bundle = VaultBundle::create("password").unwrap();
let report = import_entries_with_limits(&mut bundle, &server, &dirfd, entries, 4, 2);
assert!(!report.scan_complete);
assert_eq!(
report.issues,
[SourceIssue {
file_name: None,
kind: SourceFailureKind::TooManyEntries,
}]
);
}
}