use std::io::Read;
use std::net::IpAddr;
use zeroize::Zeroizing;
use crate::config::name::ProfileName;
use crate::config::profile::{Profile, Transport};
use crate::config::value::WireGuardPublicKey;
use crate::error::Error;
use crate::keys::device_key::DeviceKey;
use crate::keys::preshared_key::PresharedKey;
use crate::keys::store::{DeviceSecrets, KeyStore};
use super::prompt;
pub fn peer(store: &crate::config::store::Store, name: &ProfileName) -> Result<i32, Error> {
let profile = store.load_profile(name)?;
let keys = KeyStore::from_env(store.dir().clone())?;
let Some(secrets) = keys.load(name)? else {
return Err(Error::msg(format!(
"profile `{name}` has no device key. Create one with `gitaway key rotate {name}`"
)));
};
eprintln!("Add this peer to the WireGuard server for profile `{name}`:\n");
print_peer_block(name, &profile, &secrets.key.public_key());
if secrets.preshared_key.is_some() {
eprintln!(
"\nThis profile also uses a preshared key: the server's entry for this device \
needs the same one."
);
}
Ok(0)
}
pub fn rotate(
store: &crate::config::store::Store,
name: &ProfileName,
yes: bool,
) -> Result<i32, Error> {
let profile = store.load_profile(name)?;
let keys = KeyStore::from_env(store.dir().clone())?;
let existing = keys.load(name)?;
let replacing = existing.is_some();
if replacing && !yes {
if !prompt::interactive() {
return Err(Error::msg(
"pass --yes to replace a device key when not running in a terminal",
));
}
let question = format!(
"Replace the device key for `{name}`? The tunnel won't connect until the server has the new public key"
);
if !prompt::confirm(&question)? {
eprintln!("Nothing changed.");
return Ok(0);
}
}
let key = DeviceKey::generate();
let public_key = key.public_key();
let preshared_key = existing.and_then(|old| old.preshared_key);
let keeps_preshared = preshared_key.is_some();
keys.save(name, &DeviceSecrets { key, preshared_key })?;
warn_if_fallback(&keys, name);
let action = if replacing { "Replaced" } else { "Created" };
eprintln!(
"{action} the device key for `{name}`, stored in {}.\n\
Give the server this peer, replacing any old one for this device:\n",
keys.location(name)
);
print_peer_block(name, &profile, &public_key);
if keeps_preshared {
eprintln!("\nThe profile's preshared key was kept: the server's entry needs it too.");
}
Ok(0)
}
pub(crate) const IMPORT_LIMIT: u64 = 64 * 1024;
pub fn import(
store: &crate::config::store::Store,
name: &ProfileName,
yes: bool,
) -> Result<i32, Error> {
let profile = store.load_profile(name)?;
let keys = KeyStore::from_env(store.dir().clone())?;
let existing = keys.load(name)?;
let replacing = existing.is_some();
if replacing && !yes && !prompt::interactive() {
return Err(Error::msg(format!(
"profile `{name}` already has a device key. Pass --yes to replace it \
when the key comes from a pipe"
)));
}
let text = read_private_key_text()?;
let key = parse_private_key(&text).map_err(Error::msg)?;
let preshared_key = if is_config(&text) {
parse_preshared_key(&text).map_err(Error::msg)?
} else {
existing.and_then(|old| old.preshared_key)
};
let Transport::WireGuard(wg) = &profile.transport;
if key.public_key() == wg.server_public_key {
return Err(Error::msg(
"that's the VPN server's own private key, which must never leave the server. \
Use this device's key: the PrivateKey under [Interface] in its client config",
));
}
if replacing && !yes {
let question = format!(
"Replace the device key for `{name}`? The tunnel will only connect if the server has the new public key"
);
if !prompt::confirm(&question)? {
eprintln!("Nothing changed.");
return Ok(0);
}
}
let public_key = key.public_key();
let has_preshared = preshared_key.is_some();
keys.save(name, &DeviceSecrets { key, preshared_key })?;
warn_if_fallback(&keys, name);
if has_preshared {
eprintln!("The profile uses a preshared key too, stored with the device key.");
}
eprintln!(
"Imported the device key for `{name}`, stored in {}.\n\
The server needs this peer. If another device or app uses the same key, don't run\n\
them at the same time: the server keeps one session per key.\n",
keys.location(name)
);
print_peer_block(name, &profile, &public_key);
Ok(0)
}
fn read_private_key_text() -> Result<Zeroizing<String>, Error> {
if prompt::interactive() {
return prompt::secret("Private key (input hidden)");
}
let mut text = Zeroizing::new(String::new());
std::io::stdin()
.take(IMPORT_LIMIT + 1)
.read_to_string(&mut text)
.map_err(|e| Error::msg(format!("couldn't read the key from stdin: {e}")))?;
if text.len() as u64 > IMPORT_LIMIT {
return Err(Error::msg(
"stdin is too long to be a key or a WireGuard config",
));
}
Ok(text)
}
pub(crate) fn parse_private_key(text: &str) -> Result<DeviceKey, String> {
let mut found = text.lines().filter_map(private_key_line);
let candidate = match (found.next(), found.next()) {
(Some(value), None) => value,
(Some(_), Some(_)) => {
return Err(
"found more than one PrivateKey line; give the config of just this device"
.to_owned(),
);
}
(None, _) => text.trim(),
};
if candidate.is_empty() {
return Err("no key given".to_owned());
}
DeviceKey::from_base64(candidate).map_err(|_| {
"that isn't a WireGuard private key (44 characters of base64, as `wg genkey` prints)"
.to_owned()
})
}
fn is_config(text: &str) -> bool {
text.lines().any(|line| private_key_line(line).is_some())
}
fn parse_preshared_key(text: &str) -> Result<Option<PresharedKey>, String> {
let mut found = text
.lines()
.filter_map(|line| setting(line, "PresharedKey"));
match (found.next(), found.next()) {
(None, _) => Ok(None),
(Some(_), Some(_)) => Err("found more than one PresharedKey line".to_owned()),
(Some(value), None) => PresharedKey::from_base64(value)
.map(Some)
.map_err(|_| "the PresharedKey isn't a WireGuard preshared key".to_owned()),
}
}
fn private_key_line(line: &str) -> Option<&str> {
setting(line, "PrivateKey")
}
fn setting<'a>(line: &'a str, name: &str) -> Option<&'a str> {
let (key, value) = line.split_once('=')?;
key.trim().eq_ignore_ascii_case(name).then(|| value.trim())
}
pub(crate) fn warn_if_fallback(keys: &KeyStore, name: &ProfileName) {
if keys.fell_back_to_file() {
eprintln!(
"gitaway: warning: no OS keychain found, so the device key is in {} (readable only by you).",
keys.location(name)
);
}
}
pub(crate) fn print_peer_block(
name: &ProfileName,
profile: &Profile,
public_key: &WireGuardPublicKey,
) {
let Transport::WireGuard(wg) = &profile.transport;
println!("[Peer]");
println!("# gitaway profile `{name}`");
println!("PublicKey = {public_key}");
match &wg.address {
Some(address) => println!("AllowedIPs = {}", single_host(address.ip)),
None => {
println!("AllowedIPs = <a free tunnel address for this device>/32");
eprintln!(
"\nThis profile has no tunnel address yet. Pick a free one on the server, put it in\n\
AllowedIPs, then save it here with\n \
gitaway profile set {name} transport.wireguard.address <address>/32"
);
}
}
}
fn single_host(ip: IpAddr) -> String {
match ip {
IpAddr::V4(ip) => format!("{ip}/32"),
IpAddr::V6(ip) => format!("{ip}/128"),
}
}
#[cfg(test)]
mod tests {
use super::*;
const PRIVATE: &str = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
const PUBLIC: &str = "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=";
fn public_of(text: &str) -> Result<String, String> {
parse_private_key(text).map(|key| key.public_key().to_string())
}
#[test]
fn a_bare_key_is_read_with_or_without_whitespace() {
assert_eq!(public_of(PRIVATE).unwrap(), PUBLIC);
assert_eq!(public_of(&format!(" {PRIVATE}\n")).unwrap(), PUBLIC);
}
#[test]
fn the_private_key_is_found_in_a_wireguard_config() {
let config = format!(
"[Interface]\nprivatekey={PRIVATE}\nAddress = 10.0.0.2/32\n\n\
[Peer]\nPublicKey = {PUBLIC}\nEndpoint = vpn.example.net:51820\n"
);
assert_eq!(public_of(&config).unwrap(), PUBLIC);
}
#[test]
fn ambiguous_empty_or_damaged_input_is_refused_without_echoing_it() {
let two = format!("PrivateKey = {PRIVATE}\nPrivateKey = {PRIVATE}\n");
let damaged = format!("[Interface]\nPrivateKey = {}\n", &PRIVATE[..40]);
for bad in [two.as_str(), "", " \n", "not a key", damaged.as_str()] {
let error = public_of(bad).unwrap_err();
assert!(!error.contains(&PRIVATE[..20]), "secret echoed: {error}");
}
}
#[test]
fn a_configs_preshared_key_is_found_and_bad_ones_are_refused_quietly() {
let psk = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=";
let config = format!("[Interface]\nPrivateKey = {PRIVATE}\n[Peer]\npresharedkey={psk}\n");
assert!(is_config(&config));
assert!(!is_config(PRIVATE));
let found = parse_preshared_key(&config).unwrap().unwrap();
assert_eq!(found.to_base64().as_str(), psk);
assert!(parse_preshared_key(PRIVATE).unwrap().is_none());
let twice = format!("{config}PresharedKey = {psk}\n");
assert!(parse_preshared_key(&twice).is_err());
let damaged = config.replace(psk, &psk[..40]);
let error = parse_preshared_key(&damaged).err().unwrap();
assert!(!error.contains(&psk[..20]), "secret echoed: {error}");
}
#[test]
fn allowed_ips_is_a_single_host() {
assert_eq!(single_host("10.0.0.2".parse().unwrap()), "10.0.0.2/32");
assert_eq!(single_host("fd00::2".parse().unwrap()), "fd00::2/128");
}
}