diff --git a/easytier-core/src/config/mod.rs b/easytier-core/src/config/mod.rs index d7974cfb..4b3e39eb 100644 --- a/easytier-core/src/config/mod.rs +++ b/easytier-core/src/config/mod.rs @@ -119,13 +119,12 @@ pub fn normalize_secure_mode_config( match config.local_public_key.as_ref() { None => config.local_public_key = Some(generated_public_key), Some(configured_public_key) => { - let public_key = config.public_key()?; - let canonical_public_key = BASE64_STANDARD.encode(public_key.as_bytes()); - if configured_public_key != &canonical_public_key { + config.public_key()?; + if configured_public_key != &generated_public_key { anyhow::bail!( "local public key {} does not match generated public key {}", configured_public_key, - canonical_public_key + generated_public_key ); } } @@ -748,6 +747,24 @@ mod tests { ); } + #[test] + fn secure_mode_normalization_rejects_mismatched_public_key() { + let private_key = StaticSecret::from([7; 32]); + let other_public_key = PublicKey::from(&StaticSecret::from([9; 32])); + let error = normalize_secure_mode_config(common_pb::SecureModeConfig { + enabled: true, + local_private_key: Some(BASE64_STANDARD.encode(private_key.as_bytes())), + local_public_key: Some(BASE64_STANDARD.encode(other_public_key.as_bytes())), + }) + .unwrap_err() + .to_string(); + + assert!( + error.contains("does not match generated public key"), + "{error}" + ); + } + #[test] fn disabled_secure_mode_does_not_validate_keys() { let config = common_pb::SecureModeConfig { diff --git a/easytier-core/src/config/toml.rs b/easytier-core/src/config/toml.rs index 09a791a2..bcc56987 100644 --- a/easytier-core/src/config/toml.rs +++ b/easytier-core/src/config/toml.rs @@ -6,6 +6,7 @@ use std::{ sync::{Arc, Mutex}, }; +use super::normalize_secure_mode_config; pub use super::{EncryptionAlgorithm, gateway::PortForwardConfig}; use anyhow::Context; #[cfg(feature = "rich-config-errors")] @@ -705,6 +706,12 @@ impl TomlConfig { Self::gen_flags(config.flags.clone().unwrap_or_default()) .context("failed to parse flags")?, ); + config.secure_mode = config + .secure_mode + .take() + .map(normalize_secure_mode_config) + .transpose() + .context("failed to normalize [secure_mode] config")?; let has_network_identity = config.network_identity.is_some(); let config = TomlConfig { @@ -1271,7 +1278,7 @@ network_secret = "network-secret" [secure_mode] enabled = true -local_private_key = "noise-private-key" +local_private_key = "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=" [vpn_portal_config] wireguard_listen = "0.0.0.0:51820" @@ -1293,7 +1300,7 @@ group_secret = "group-secret" let dumped = config.dump(); assert!(dumped.contains("network-secret")); - assert!(dumped.contains("noise-private-key")); + assert!(dumped.contains("YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=")); assert!(dumped.contains("wireguard-private-key")); assert!(dumped.contains("group-secret")); assert_eq!( @@ -1305,7 +1312,7 @@ group_secret = "group-secret" let redacted = config.dump_redacted(); assert!(!redacted.contains("network-secret")); - assert!(!redacted.contains("noise-private-key")); + assert!(!redacted.contains("YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=")); assert!(!redacted.contains("wireguard-private-key")); assert!(!redacted.contains("group-secret")); assert_eq!(redacted.matches("").count(), 4); @@ -1396,6 +1403,7 @@ source = "web" #[cfg(test)] mod compatibility_tests { use super::*; + use base64::{Engine as _, prelude::BASE64_STANDARD}; #[cfg(feature = "config-write")] #[test] @@ -1649,6 +1657,112 @@ enabled = true assert!(identity.network_secret_digest.is_some()); } + #[test] + fn test_toml_secure_mode_generates_keypair_when_keys_missing() { + let config = TomlConfigLoader::new_from_str( + r#" +[secure_mode] +enabled = true +"#, + ) + .unwrap(); + + let secure_mode = config.get_secure_mode().unwrap(); + let private_key = secure_mode.private_key().unwrap(); + let public_key = secure_mode.public_key().unwrap(); + assert_eq!( + x25519_dalek::PublicKey::from(&private_key).as_bytes(), + public_key.as_bytes() + ); + } + + #[test] + fn test_toml_secure_mode_derives_public_key_from_private_key() { + let private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng); + let config = TomlConfigLoader::new_from_str(&format!( + r#" +[secure_mode] +enabled = true +local_private_key = "{}" +"#, + BASE64_STANDARD.encode(private.as_bytes()) + )) + .unwrap(); + + let secure_mode = config.get_secure_mode().unwrap(); + let private_key = secure_mode.private_key().unwrap(); + assert_eq!(private_key.as_bytes(), private.as_bytes()); + assert_eq!( + secure_mode.public_key().unwrap().as_bytes(), + x25519_dalek::PublicKey::from(&private).as_bytes() + ); + } + + #[test] + fn test_toml_secure_mode_rejects_mismatched_keypair() { + let private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng); + let other_public = x25519_dalek::PublicKey::from( + &x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng), + ); + let error = TomlConfigLoader::new_from_str(&format!( + r#" +[secure_mode] +enabled = true +local_private_key = "{}" +local_public_key = "{}" +"#, + BASE64_STANDARD.encode(private.as_bytes()), + BASE64_STANDARD.encode(other_public.as_bytes()) + )) + .unwrap_err(); + let error = format!("{error:#}"); + + assert!( + error.contains("failed to normalize [secure_mode] config"), + "{error}" + ); + assert!( + error.contains("does not match generated public key"), + "{error}" + ); + } + + #[test] + fn test_toml_secure_mode_disabled_keeps_keys_unset() { + let config = TomlConfigLoader::new_from_str( + r#" +[secure_mode] +enabled = false +"#, + ) + .unwrap(); + + let secure_mode = config.get_secure_mode().unwrap(); + assert!(!secure_mode.enabled); + assert_eq!(secure_mode.local_private_key, None); + assert_eq!(secure_mode.local_public_key, None); + } + + #[cfg(feature = "config-write")] + #[test] + fn test_toml_secure_mode_keypair_survives_roundtrip() { + let config = TomlConfigLoader::new_from_str( + r#" +[secure_mode] +enabled = true +"#, + ) + .unwrap(); + + let dumped = config.dump(); + let restored = TomlConfigLoader::new_from_str(&dumped).unwrap(); + + assert_eq!( + config.get_secure_mode().unwrap(), + restored.get_secure_mode().unwrap() + ); + } + #[test] fn test_acl_toml_rule_uses_defaults_for_omitted_fields() { use crate::proto::acl::{Action, ChainType, Protocol}; diff --git a/easytier/src/core.rs b/easytier/src/core.rs index 7190227e..1d05721d 100644 --- a/easytier/src/core.rs +++ b/easytier/src/core.rs @@ -1204,10 +1204,21 @@ impl NetworkOptions { } else if let Some(secure_mode) = self.secure_mode && secure_mode { + // CLI key options replace the file's [secure_mode] keypair as a unit; + // without them the keys already loaded from the config file win. + let cli_private_key = self.local_private_key.clone().filter(|k| !k.is_empty()); + let cli_public_key = self.local_public_key.clone().filter(|k| !k.is_empty()); + let (local_private_key, local_public_key) = + if cli_private_key.is_some() || cli_public_key.is_some() { + (cli_private_key, cli_public_key) + } else { + cfg.get_secure_mode() + .map_or((None, None), |c| (c.local_private_key, c.local_public_key)) + }; let c = SecureModeConfig { enabled: secure_mode, - local_private_key: self.local_private_key.clone(), - local_public_key: self.local_public_key.clone(), + local_private_key, + local_public_key, }; cfg.set_secure_mode(Some(normalize_secure_mode_config(c)?)); } @@ -1919,6 +1930,66 @@ enabled = true assert_eq!(cfg.get_hostname(), "override-host"); } + #[test] + fn secure_mode_cli_flag_preserves_config_file_keypair() { + use base64::{Engine as _, prelude::BASE64_STANDARD}; + let private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng); + let cfg = TomlConfigLoader::new_from_str(&format!( + r#" +[secure_mode] +enabled = true +local_private_key = "{}" +"#, + BASE64_STANDARD.encode(private.as_bytes()) + )) + .unwrap(); + let file_keypair = cfg.get_secure_mode().unwrap(); + + NetworkOptions { + secure_mode: Some(true), + ..Default::default() + } + .merge_into(&cfg) + .unwrap(); + + let merged = cfg.get_secure_mode().unwrap(); + assert!(merged.enabled); + assert_eq!(merged.local_private_key, file_keypair.local_private_key); + assert_eq!(merged.local_public_key, file_keypair.local_public_key); + assert_eq!(merged.private_key().unwrap().as_bytes(), private.as_bytes()); + } + + #[test] + fn secure_mode_cli_key_replaces_config_file_keypair() { + use base64::{Engine as _, prelude::BASE64_STANDARD}; + let cfg = TomlConfigLoader::new_from_str( + r#" +[secure_mode] +enabled = true +"#, + ) + .unwrap(); + let cli_private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng); + + NetworkOptions { + secure_mode: Some(true), + local_private_key: Some(BASE64_STANDARD.encode(cli_private.as_bytes())), + ..Default::default() + } + .merge_into(&cfg) + .unwrap(); + + let merged = cfg.get_secure_mode().unwrap(); + assert_eq!( + merged.private_key().unwrap().as_bytes(), + cli_private.as_bytes() + ); + assert_eq!( + merged.public_key().unwrap().as_bytes(), + x25519_dalek::PublicKey::from(&cli_private).as_bytes() + ); + } + #[test] fn empty_stun_server_options_clear_existing_config() { let cfg = TomlConfigLoader::new_from_str(