From 837e795f702e6ddfb99f9af91c63a93679447522 Mon Sep 17 00:00:00 2001 From: Yaroslav Bolyukin Date: Mon, 01 May 2023 23:39:09 +0000 Subject: [PATCH] feat: reencrypt secret on remote server --- --- a/cmds/fleet/src/cmds/info.rs +++ b/cmds/fleet/src/cmds/info.rs @@ -1,15 +1,8 @@ -use std::{collections::BTreeSet, time::Duration}; +use std::collections::BTreeSet; -use crate::{command::CommandExt, host::Config}; -use anyhow::{bail, ensure, Result}; +use crate::host::Config; +use anyhow::{ensure, Result}; use clap::Parser; -use nixlike::format_nix; -use serde_json::{json, Value}; -use tokio::{ - fs::{self, File}, - io::AsyncWriteExt, - process::Command, -}; #[derive(Parser)] pub struct Info { --- a/cmds/fleet/src/cmds/secrets/mod.rs +++ b/cmds/fleet/src/cmds/secrets/mod.rs @@ -12,6 +12,7 @@ iter, path::PathBuf, }; +use tokio::fs::read_to_string; use tracing::{info, warn}; #[derive(Parser)] @@ -50,19 +51,30 @@ Read { name: String, machine: String, + #[clap(long)] + plaintext: bool, }, UpdateShared { name: String, + #[clap(long)] machines: Option>, + #[clap(long)] add_machines: Vec, + #[clap(long)] remove_machines: Vec, /// Which host should we use to decrypt + #[clap(long)] + prefer_identities: Vec, + }, + Regenerate { + /// Which host should we use to decrypt, in case if reencryption is required, without + /// regeneration + #[clap(long)] prefer_identities: Vec, }, - Regenerate, } impl Secrets { @@ -110,11 +122,10 @@ } }; - let mut data = config.data_mut(); - if data.shared_secrets.contains_key(&name) && !force { + if config.has_shared(&name) && !force { bail!("secret already defined"); } - data.shared_secrets.insert( + config.replace_shared( name, FleetSharedSecret { owners: machines, @@ -123,7 +134,7 @@ secret, public: match (public, public_file) { (Some(v), None) => Some(v), - (None, Some(v)) => Some(std::fs::read_to_string(v)?), + (None, Some(v)) => Some(read_to_string(v).await?), (Some(_), Some(_)) => { bail!("only public or public_file should be set") } @@ -159,12 +170,11 @@ encrypted }; - let mut data = config.data_mut(); - let host_secrets = data.host_secrets.entry(machine).or_default(); - if host_secrets.contains_key(&name) && !force { + if config.has_secret(&machine, &name) && !force { bail!("secret already defined"); } - host_secrets.insert( + config.insert_secret( + &machine, name, FleetSecret { expire_at: None, @@ -180,34 +190,22 @@ } // TODO: Instead of using sudo, decode secret on remote machine #[allow(clippy::await_holding_refcell_ref)] - Secrets::Read { name, machine } => { - let data = config.data(); - - let Some(host_secrets) = data.host_secrets.get(&machine) else { - bail!("no secrets for machine {machine}"); - }; - let Some(secret) = host_secrets.get(&name) else { - bail!("machine {machine} has no secret {name}"); - }; + Secrets::Read { + name, + machine, + plaintext, + } => { + let secret = config.host_secret(&machine, &name)?; if secret.secret.is_empty() { bail!("no secret {name}"); } - let identity = config.identity(&machine).await?; - let decryptor = Decryptor::new(Cursor::new(&secret.secret))?; - let decryptor = match decryptor { - Decryptor::Recipients(r) => r, - Decryptor::Passphrase(_) => bail!("should be recipients"), - }; - let mut decryptor = decryptor - .decrypt(iter::once(&identity as &dyn age::Identity)) - .context("failed to decrypt, wrong key?")?; - - let mut decrypted = Vec::new(); - decryptor - .read_to_end(&mut decrypted) - .context("failed to decrypt")?; - // secret.secret - std::io::stdout().lock().write_all(&decrypted)?; + let data = config.decrypt_on_host(&machine, secret.secret).await?; + if plaintext { + let s = String::from_utf8(data).context("output is not utf8")?; + print!("{s}"); + } else { + println!("{}", z85::encode(&data)); + } } Secrets::UpdateShared { name, @@ -216,20 +214,18 @@ mut remove_machines, prefer_identities, } => { - let mut data = config.data_mut(); if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() { bail!("no operation"); } - let Some(mut secret) = data.shared_secrets.get_mut(&name) else { - bail!("no shared secret {name}"); - }; + let mut secret = config.shared_secret(&name)?; if secret.secret.secret.is_empty() { bail!("no secret"); } let initial_machines = secret.owners.clone(); let mut target_machines = secret.owners.clone(); + info!("Currently encrypted for {initial_machines:?}"); // ensure!(machines.is_some() || !add_machines.is_empty() || ) if let Some(machines) = machines { @@ -254,23 +250,31 @@ removed = true; } if !removed { - bail!("secret is not enabled for {machine}"); + warn!("secret is not enabled for {machine}"); } } for machine in &add_machines { if target_machines.iter().any(|m| m == machine) { warn!("secret is already added to {machine}"); + } else { + target_machines.push(machine.to_owned()); } } - if remove_machines.is_empty() { + if !remove_machines.is_empty() { warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"); } + if target_machines.is_empty() { info!("no machines left for secret, removing it"); - data.shared_secrets.remove(&name); + config.remove_shared(&name); return Ok(()); } + if target_machines == initial_machines { + warn!("secret owners are already correct"); + return Ok(()); + } + let identity_holder = if !prefer_identities.is_empty() { prefer_identities .iter() @@ -282,51 +286,34 @@ bail!("no available holder found"); }; let target_recipients = futures::stream::iter(&target_machines) - .flat_map(|m| futures::stream::once(config.recipient(m))) + .then(|m| async { config.key(m).await }) .collect::>() - .await - .into_iter() - .map(|v| v.map(|v| Box::new(v) as Box)) - .collect::>>()?; + .await; + let target_recipients = + target_recipients.into_iter().collect::>>()?; - let identity = config.identity(identity_holder).await?; - let decryptor = Decryptor::new(Cursor::new(&secret.secret.secret))?; - let decryptor = match decryptor { - Decryptor::Recipients(r) => r, - Decryptor::Passphrase(_) => bail!("should be recipients"), - }; - let mut decryptor = decryptor - .decrypt(iter::once(&identity as &dyn age::Identity)) - .context("failed to decrypt, wrong key?")?; + let encrypted = config + .reencrypt_on_host(&identity_holder, secret.secret.secret, target_recipients) + .await?; - let mut decrypted = Vec::new(); - decryptor - .read_to_end(&mut decrypted) - .context("failed to decrypt")?; - - let mut encrypted = vec![]; - let mut encryptor = Encryptor::with_recipients(target_recipients) - .expect("recipients provided") - .wrap_output(&mut encrypted)?; - io::copy(&mut Cursor::new(decrypted), &mut encryptor)?; - encryptor.finish()?; - + secret.owners = target_machines; secret.secret.secret = encrypted; + config.replace_shared(name, secret); } - Secrets::Regenerate => { - // config.data_mut().shared_secrets + Secrets::Regenerate { prefer_identities } => { { let expected_shared_set = config.shared_config_attr_names("sharedSecrets").await?; let expected_shared_set = expected_shared_set.iter().collect::>(); - let shared_set = config.data(); - let shared_set = shared_set.shared_secrets.keys().collect::>(); + let shared_set = config.list_shared(); + let shared_set = shared_set.iter().collect::>(); for removed in expected_shared_set.difference(&shared_set) { warn!("secret needs to be generated: {removed}") } } let mut to_remove = Vec::new(); - for (name, data) in &config.data().shared_secrets { + for name in &config.list_shared() { + let mut data = config.shared_secret(name)?; let expected_owners: Vec = config .shared_config_attr(&format!("sharedSecrets.\"{name}\".expectedOwners")) .await?; @@ -337,12 +324,54 @@ } let set = data.owners.iter().collect::>(); let expected_set = expected_owners.iter().collect::>(); + let should_remove = set.difference(&expected_set).next().is_some(); if set != expected_set { warn!("reconfiguring owners for {name}"); + let generator: Option = config + .shared_config_attr(&format!("sharedSecrets.\"{name}\".generator")) + .await?; + // TODO: if !.owner_dependent + if let Some(str) = generator { + todo!("regenerate") + } else { + if should_remove { + warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"); + } + + let identity_holder = if !prefer_identities.is_empty() { + prefer_identities + .iter() + .find(|i| data.owners.iter().any(|s| s == *i)) + } else { + data.owners.first() + }; + let Some(identity_holder) = identity_holder else { + bail!("no available holder found"); + }; + + let target_recipients = futures::stream::iter(&expected_owners) + .then(|m| async { config.key(m).await }) + .collect::>() + .await; + let target_recipients = + target_recipients.into_iter().collect::>>()?; + + let encrypted = config + .reencrypt_on_host( + &identity_holder, + data.secret.secret, + target_recipients, + ) + .await?; + + data.secret.secret = encrypted; + data.owners = expected_owners; + config.replace_shared(name.to_owned(), data); + } } } for k in to_remove { - config.data_mut().shared_secrets.remove(&k); + config.remove_shared(&k); } } } --- a/cmds/fleet/src/fleetdata.rs +++ b/cmds/fleet/src/fleetdata.rs @@ -1,8 +1,7 @@ -use anyhow::{bail, Result}; +use anyhow::Result; use chrono::{DateTime, Utc}; use nixlike::format_nix; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use serde_json::{json, Value}; use std::collections::BTreeMap; use tempfile::TempDir; use tokio::{ @@ -11,8 +10,6 @@ process::Command, }; -use crate::command::CommandExt; - #[derive(Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct HostData { @@ -34,16 +31,18 @@ pub host_secrets: BTreeMap>, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] +#[must_use] pub struct FleetSharedSecret { pub owners: Vec, #[serde(flatten)] pub secret: FleetSecret, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] +#[must_use] pub struct FleetSecret { #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -75,6 +74,8 @@ .and_then(|string| z85::decode(string).map_err(|err| Error::custom(err.to_string()))) } +/// Isn't used yet +#[allow(dead_code)] pub async fn dummy_flake() -> Result { let data_str = fs::read_to_string("fleet.nix").await?; --- a/cmds/fleet/src/host.rs +++ b/cmds/fleet/src/host.rs @@ -8,15 +8,15 @@ sync::Arc, }; -use anyhow::Result; +use anyhow::{Result, bail, Context}; use clap::{ArgGroup, Parser}; use serde::de::DeserializeOwned; -use tempfile::{NamedTempFile, TempDir}; +use tempfile::NamedTempFile; use tokio::process::Command; use crate::{ command::CommandExt, - fleetdata::{dummy_flake, FleetData}, + fleetdata::{FleetData, FleetSecret, FleetSharedSecret}, }; pub struct FleetConfigInternals { @@ -125,13 +125,93 @@ .await } - pub fn data(&self) -> Ref { + pub(super) fn data(&self) -> Ref { self.data.borrow() } - pub fn data_mut(&self) -> RefMut { + pub(super) fn data_mut(&self) -> RefMut { self.data.borrow_mut() } + pub fn list_shared(&self) -> Vec { + let data = self.data(); + data.shared_secrets.keys().cloned().collect() + } + pub fn has_shared(&self, name: &str) -> bool { + let data = self.data(); + data.shared_secrets.contains_key(name) + } + pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) { + let mut data = self.data_mut(); + data.shared_secrets.insert(name.to_owned(), shared); + } + pub fn remove_shared(&self, secret: &str) { + let mut data = self.data_mut(); + data.shared_secrets.remove(secret); + } + + pub fn list_secrets(&self, host: &str) -> Vec { + let data = self.data(); + let Some(host_secrets) = data.host_secrets.get(host) else { + return Vec::new(); + }; + host_secrets.keys().cloned().collect() + } + pub fn has_secret(&self, host: &str, secret: &str) -> bool { + let data = self.data(); + let Some(host_secrets) = data.host_secrets.get(host) else { + return false; + }; + host_secrets.contains_key(secret) + } + pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) { + let mut data = self.data_mut(); + let host_secrets = data.host_secrets.entry(host.to_owned()).or_default(); + host_secrets.insert(secret, value); + } + + pub async fn decrypt_on_host(&self, host: &str, data: Vec) -> Result>{ + let data = z85::encode(&data); + let encoded = self.command_on(host, "fleet-install-secrets", true) + .arg("decrypt") + .arg("--secret") + .arg(data).run_string().await.context("failed to call remote host for decrypt")?.trim().to_owned(); + Ok(z85::decode(encoded).context("bad encoded data? outdated host?")?) + } + pub async fn reencrypt_on_host(&self, host: &str, data: Vec, targets: Vec) -> Result>{ + let data = z85::encode(&data); + let mut recmd = self.command_on(host, "fleet-install-secrets", true); + recmd + .arg("reencrypt") + .arg("--secret") + .arg(format!("\"{}\"", data.replace('$', "\\$"))); + for target in targets { + recmd.arg("--targets"); + recmd.arg(format!("\"{target}\"")); + } + let encoded = recmd.run_string().await.context("failed to call remote host for decrypt")?.trim().to_owned(); + Ok(z85::decode(encoded).context("bad encoded data? outdated host?")?) + } + + #[must_use] + pub fn host_secret(&self, host: &str, secret: &str) -> Result { + let data = self.data(); + let Some(host_secrets) = data.host_secrets.get(host) else { + bail!("no secrets for machine {host}"); + }; + let Some(secret) = host_secrets.get(secret) else { + bail!("machine {host} has no secret {secret}"); + }; + Ok(secret.clone()) + } + #[must_use] + pub fn shared_secret(&self, secret: &str) -> Result { + let data = self.data(); + let Some(secret) = data.shared_secrets.get(secret) else { + bail!("no shared secret {secret}"); + }; + Ok(secret.clone()) + } + pub fn save(&self) -> Result<()> { let mut tempfile = NamedTempFile::new_in(self.directory.clone())?; let data = nixlike::serialize(&self.data() as &FleetData)?; --- a/cmds/fleet/src/keys.rs +++ b/cmds/fleet/src/keys.rs @@ -36,15 +36,6 @@ } } /// Insecure, requires root - pub async fn identity(&self, host: &str) -> anyhow::Result { - warn!("Loading private key for {host}"); - let key = self - .command_on(host, "cat", true) - .arg("/etc/ssh/ssh_host_ed25519_key") - .run_string() - .await?; - Ok(age::ssh::Identity::from_buffer(key.as_bytes(), None)?) - } pub async fn recipient(&self, host: &str) -> anyhow::Result { let key = self.key(host).await?; age::ssh::Recipient::from_str(&key).map_err(|e| anyhow!("parse recipient error: {:?}", e)) --- a/cmds/install-secrets/src/main.rs +++ b/cmds/install-secrets/src/main.rs @@ -1,21 +1,66 @@ -use age::Decryptor; +use age::{ssh::Identity as SshIdentity, ssh::Recipient as SshRecipient, Decryptor}; +use age::{Encryptor, Identity, Recipient}; use anyhow::{anyhow, bail, Context, Result}; use clap::Parser; use log::{error, info, warn}; use nix::sys::stat::Mode; use nix::unistd::{chown, Group, User}; use serde::{Deserialize, Deserializer}; +use std::fmt::{self, Display}; use std::fs::{self, File}; use std::io::{self, Cursor, Read, Write}; use std::iter; use std::os::unix::prelude::PermissionsExt; -use std::str::from_utf8; +use std::path::Path; +use std::str::{from_utf8, FromStr}; use std::{collections::HashMap, path::PathBuf}; +#[derive(Clone, Debug)] +struct SecretWrapper(Vec); +impl Display for SecretWrapper { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let encoded = z85::encode(&self.0); + write!(f, "{encoded}") + } +} +impl FromStr for SecretWrapper { + type Err = z85::DecodeError; + + fn from_str(s: &str) -> Result { + z85::decode(s).map(Self) + } +} +impl<'de> Deserialize<'de> for SecretWrapper { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let v = String::deserialize(deserializer)?; + let de = z85::decode(v).map_err(|err| serde::de::Error::custom(err.to_string()))?; + Ok(Self(de)) + } +} + #[derive(Parser)] #[clap(author)] -struct Opts { - data: PathBuf, +enum Opts { + /// Install secrets from json specification + Install { data: PathBuf }, + /// Reencrypt secret using host key, outputting in z85 encoded string + Reencrypt { + #[clap(long)] + secret: SecretWrapper, + #[clap(long)] + targets: Vec, + }, + /// Decrypt secret using host key, outputting in z85 encoded string + Decrypt { + #[clap(long)] + secret: SecretWrapper, + /// Shoult decoded output be printed as plaintext, instead of z85? + #[clap(long)] + plaintext: bool, + }, } #[derive(Deserialize)] @@ -25,8 +70,7 @@ mode: String, owner: String, - #[serde(deserialize_with = "from_z85")] - secret: Option>, + secret: Option, public: Option, public_path: PathBuf, @@ -36,21 +80,45 @@ stable_secret_path: PathBuf, } -fn from_z85<'de, D>(deserializer: D) -> Result>, D::Error> -where - D: Deserializer<'de>, -{ - use serde::de::Error; - if let Some(v) = >::deserialize(deserializer)? { - Ok(Some( - z85::decode(v).map_err(|err| Error::custom(err.to_string()))?, - )) - } else { - Ok(None) - } +type Data = HashMap; + +fn decrypt(input: &SecretWrapper, identity: &dyn Identity) -> Result> { + let mut input = Cursor::new(&input.0); + let decryptor = Decryptor::new(&mut input).context("failed to init decryptor")?; + let decryptor = match decryptor { + Decryptor::Recipients(r) => r, + Decryptor::Passphrase(_) => bail!("should be recipients"), + }; + let mut decryptor = decryptor + .decrypt(iter::once(identity as &dyn age::Identity)) + .context("failed to decrypt, wrong key?")?; + + let mut decrypted = Vec::new(); + decryptor + .read_to_end(&mut decrypted) + .context("failed to decrypt")?; + Ok(decrypted) +} +fn encrypt(input: &[u8], targets: Vec) -> Result { + let recipients = targets + .into_iter() + .map(|t| { + SshRecipient::from_str(&t).map_err(|e| anyhow!("failed to parse recipient: {e:?}")) + }) + .collect::>>()?; + let recipients = recipients + .into_iter() + .map(|v| Box::new(v) as Box) + .collect::>(); + let mut encrypted = vec![]; + let mut encryptor = Encryptor::with_recipients(recipients) + .expect("recipients provided") + .wrap_output(&mut encrypted) + .expect("constructor should not fail"); + io::copy(&mut Cursor::new(input), &mut encryptor).expect("copy should not fail"); + encryptor.finish().context("failed to finish encryption")?; + Ok(SecretWrapper(encrypted)) } - -type Data = HashMap; fn init_secret(identity: &age::ssh::Identity, value: DataItem) -> Result<()> { if let Some(public) = &value.public { @@ -93,23 +161,7 @@ let mut hashed = File::create(&value.secret_path)?; // File is owned by root, and only root can modify it - let decrypted = { - let mut input = Cursor::new(&secret); - let decryptor = Decryptor::new(&mut input).context("failed to init decryptor")?; - let decryptor = match decryptor { - Decryptor::Recipients(r) => r, - Decryptor::Passphrase(_) => bail!("should be recipients"), - }; - let mut decryptor = decryptor - .decrypt(iter::once(identity as &dyn age::Identity)) - .context("failed to decrypt, wrong key?")?; - - let mut decrypted = Vec::new(); - decryptor - .read_to_end(&mut decrypted) - .context("failed to decrypt")?; - decrypted - }; + let decrypted = decrypt(&secret, identity)?; if decrypted.is_empty() { warn!("secret is decoded as empty, something is broken?"); } @@ -132,13 +184,19 @@ Ok(()) } -fn main() -> anyhow::Result<()> { - env_logger::Builder::new() - .filter_level(log::LevelFilter::Info) - .init(); +fn host_identity() -> anyhow::Result { + let identity = SshIdentity::from_buffer( + &mut Cursor::new( + fs::read("/etc/ssh/ssh_host_ed25519_key").context("failed to read host private key")?, + ), + None, + ) + .context("failed to parse identity")?; + Ok(identity) +} - let opts = Opts::parse(); - let data = fs::read(&opts.data).context("failed to read secrets data")?; +fn install(data: &Path) -> anyhow::Result<()> { + let data = fs::read(data).context("failed to read secrets data")?; let data_str = from_utf8(&data).context("failed to read data to string")?; let data: Data = serde_json::from_str(data_str).context("failed to parse data")?; @@ -149,13 +207,7 @@ fs::create_dir("/run/secrets").context("failed to create secrets directory")?; } - let identity = age::ssh::Identity::from_buffer( - &mut Cursor::new( - fs::read("/etc/ssh/ssh_host_ed25519_key").context("failed to read host private key")?, - ), - None, - ) - .context("failed to parse identity")?; + let identity = host_identity()?; let mut failed = false; for (name, value) in data { @@ -174,3 +226,35 @@ Ok(()) } + +fn main() -> anyhow::Result<()> { + env_logger::Builder::new() + .filter_level(log::LevelFilter::Info) + .init(); + + let opts = Opts::parse(); + + match opts { + Opts::Install { data } => install(&data), + Opts::Reencrypt { secret, targets } => { + let identity = host_identity()?; + let decrypted = decrypt(&secret, &identity).context("during decryption")?; + let encrypted = encrypt(&decrypted, targets).context("during re-encryption")?; + + println!("{encrypted}"); + Ok(()) + } + Opts::Decrypt { secret, plaintext } => { + let identity = host_identity()?; + let decrypted = decrypt(&secret, &identity).context("during decryption")?; + + if plaintext { + let s = String::from_utf8(decrypted).context("output is not utf8")?; + print!("{}", s); + } else { + println!("{}", SecretWrapper(decrypted)); + } + Ok(()) + } + } +} --- a/modules/fleet/secrets.nix +++ b/modules/fleet/secrets.nix @@ -20,9 +20,18 @@ ''; default = [ ]; }; + ownerDependent = mkOption { + type = bool; + description = "Is this secret owner-dependent, and needs to be regenerated on ownership set change, or it may be just reencrypted"; + }; generator = mkOption { - type = package; - description = "Derivation to execute for secret generation"; + type = nullOr package; + description = '' + Derivation to execute for secret generation + + If null - may only be created manually + ''; + default = null; }; expireIn = mkOption { type = nullOr int; --- a/nixos/secrets.nix +++ b/nixos/secrets.nix @@ -89,9 +89,10 @@ }; }; config = { + environment.systemPackages = with pkgs; [pkgs.fleet-install-secrets]; system.activationScripts.decryptSecrets = stringAfter [ "users" "groups" "specialfs" ] '' 1>&2 echo "setting up secrets" - ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets ${secretsFile} + ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile} ''; }; } -- gitstuff