Handle client keys, encrypted vault
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod domain;
|
||||
pub mod startup;
|
||||
pub mod vault;
|
||||
pub mod wireguard;
|
||||
|
||||
+723
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user