difftreelog
feat reencrypt secret on remote server
in: trunk
8 files changed
cmds/fleet/src/cmds/info.rsdiffbeforeafterboth--- 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 {
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth1use crate::{2 fleetdata::{FleetSecret, FleetSharedSecret},3 host::Config,4};5use age::{Decryptor, Encryptor};6use anyhow::{bail, ensure, Context, Result};7use clap::Parser;8use futures::{StreamExt, TryStreamExt};9use std::{10 collections::HashSet,11 io::{self, Cursor, Read, Write},12 iter,13 path::PathBuf,14};15use tracing::{info, warn};1617#[derive(Parser)]18pub enum Secrets {19 /// Force load keys for all defined hosts20 ForceKeys,21 /// Add secret, data should be provided in stdin22 AddShared {23 /// Secret name24 name: String,25 /// Secret owners26 machines: Vec<String>,27 /// Override secret if already present28 #[clap(long)]29 force: bool,30 #[clap(long)]31 public: Option<String>,32 #[clap(long)]33 public_file: Option<PathBuf>,34 },35 /// Add secret, data should be provided in stdin36 Add {37 /// Secret name38 name: String,39 /// Secret owners40 machine: String,41 /// Override secret if already present42 #[clap(long)]43 force: bool,44 #[clap(long)]45 public: Option<String>,46 #[clap(long)]47 public_file: Option<PathBuf>,48 },49 /// Read secret from remote host, requires sudo on said host50 Read {51 name: String,52 machine: String,53 },54 UpdateShared {55 name: String,5657 machines: Option<Vec<String>>,5859 add_machines: Vec<String>,60 remove_machines: Vec<String>,6162 /// Which host should we use to decrypt63 prefer_identities: Vec<String>,64 },65 Regenerate,66}6768impl Secrets {69 pub async fn run(self, config: &Config) -> Result<()> {70 match self {71 Secrets::ForceKeys => {72 for host in config.list_hosts().await? {73 if config.should_skip(&host) {74 continue;75 }76 config.key(&host).await?;77 }78 }79 Secrets::AddShared {80 machines,81 name,82 force,83 public,84 public_file,85 } => {86 let recipients = futures::stream::iter(machines.iter())87 .then(|m| config.recipient(m))88 .try_collect::<Vec<_>>()89 .await?;9091 let secret = {92 let mut input = vec![];93 io::stdin().read_to_end(&mut input)?;9495 if input.is_empty() {96 input97 } else {98 let mut encrypted = vec![];99 let recipients = recipients100 .iter()101 .cloned()102 .map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)103 .collect();104 let mut encryptor = age::Encryptor::with_recipients(recipients)105 .expect("recipients provided")106 .wrap_output(&mut encrypted)?;107 io::copy(&mut Cursor::new(input), &mut encryptor)?;108 encryptor.finish()?;109 encrypted110 }111 };112113 let mut data = config.data_mut();114 if data.shared_secrets.contains_key(&name) && !force {115 bail!("secret already defined");116 }117 data.shared_secrets.insert(118 name,119 FleetSharedSecret {120 owners: machines,121 secret: FleetSecret {122 expire_at: None,123 secret,124 public: match (public, public_file) {125 (Some(v), None) => Some(v),126 (None, Some(v)) => Some(std::fs::read_to_string(v)?),127 (Some(_), Some(_)) => {128 bail!("only public or public_file should be set")129 }130 (None, None) => None,131 },132 },133 },134 );135 }136 Secrets::Add {137 machine,138 name,139 force,140 public,141 public_file,142 } => {143 let recipient = config.recipient(&machine).await?;144145 let secret = {146 let mut input = vec![];147 io::stdin().read_to_end(&mut input)?;148 if input.is_empty() {149 bail!("no data provided")150 }151152 let mut encrypted = vec![];153 let recipient = Box::new(recipient) as Box<dyn age::Recipient + Send>;154 let mut encryptor = age::Encryptor::with_recipients(vec![recipient])155 .expect("recipients provided")156 .wrap_output(&mut encrypted)?;157 io::copy(&mut Cursor::new(input), &mut encryptor)?;158 encryptor.finish()?;159 encrypted160 };161162 let mut data = config.data_mut();163 let host_secrets = data.host_secrets.entry(machine).or_default();164 if host_secrets.contains_key(&name) && !force {165 bail!("secret already defined");166 }167 host_secrets.insert(168 name,169 FleetSecret {170 expire_at: None,171 secret,172 public: match (public, public_file) {173 (Some(v), None) => Some(v),174 (None, Some(v)) => Some(std::fs::read_to_string(v)?),175 (Some(_), Some(_)) => bail!("only public or public_file should be set"),176 (None, None) => None,177 },178 },179 );180 }181 // TODO: Instead of using sudo, decode secret on remote machine182 #[allow(clippy::await_holding_refcell_ref)]183 Secrets::Read { name, machine } => {184 let data = config.data();185186 let Some(host_secrets) = data.host_secrets.get(&machine) else {187 bail!("no secrets for machine {machine}");188 };189 let Some(secret) = host_secrets.get(&name) else {190 bail!("machine {machine} has no secret {name}");191 };192 if secret.secret.is_empty() {193 bail!("no secret {name}");194 }195 let identity = config.identity(&machine).await?;196 let decryptor = Decryptor::new(Cursor::new(&secret.secret))?;197 let decryptor = match decryptor {198 Decryptor::Recipients(r) => r,199 Decryptor::Passphrase(_) => bail!("should be recipients"),200 };201 let mut decryptor = decryptor202 .decrypt(iter::once(&identity as &dyn age::Identity))203 .context("failed to decrypt, wrong key?")?;204205 let mut decrypted = Vec::new();206 decryptor207 .read_to_end(&mut decrypted)208 .context("failed to decrypt")?;209 // secret.secret210 std::io::stdout().lock().write_all(&decrypted)?;211 }212 Secrets::UpdateShared {213 name,214 machines,215 mut add_machines,216 mut remove_machines,217 prefer_identities,218 } => {219 let mut data = config.data_mut();220 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {221 bail!("no operation");222 }223224 let Some(mut secret) = data.shared_secrets.get_mut(&name) else {225 bail!("no shared secret {name}");226 };227 if secret.secret.secret.is_empty() {228 bail!("no secret");229 }230231 let initial_machines = secret.owners.clone();232 let mut target_machines = secret.owners.clone();233234 // ensure!(machines.is_some() || !add_machines.is_empty() || )235 if let Some(machines) = machines {236 ensure!(237 add_machines.is_empty() && remove_machines.is_empty(),238 "can't combine --machines and --add-machines/--remove-machines"239 );240 let target = initial_machines.iter().collect::<HashSet<_>>();241 let source = machines.iter().collect::<HashSet<_>>();242 for removed in target.difference(&source) {243 remove_machines.push((*removed).clone());244 }245 for added in source.difference(&target) {246 add_machines.push((*added).clone());247 }248 }249250 for machine in &remove_machines {251 let mut removed = false;252 while let Some(pos) = target_machines.iter().position(|m| m == machine) {253 target_machines.swap_remove(pos);254 removed = true;255 }256 if !removed {257 bail!("secret is not enabled for {machine}");258 }259 }260 for machine in &add_machines {261 if target_machines.iter().any(|m| m == machine) {262 warn!("secret is already added to {machine}");263 }264 }265 if remove_machines.is_empty() {266 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");267 }268 if target_machines.is_empty() {269 info!("no machines left for secret, removing it");270 data.shared_secrets.remove(&name);271 return Ok(());272 }273274 let identity_holder = if !prefer_identities.is_empty() {275 prefer_identities276 .iter()277 .find(|i| initial_machines.iter().any(|s| s == *i))278 } else {279 secret.owners.first()280 };281 let Some(identity_holder) = identity_holder else {282 bail!("no available holder found");283 };284 let target_recipients = futures::stream::iter(&target_machines)285 .flat_map(|m| futures::stream::once(config.recipient(m)))286 .collect::<Vec<_>>()287 .await288 .into_iter()289 .map(|v| v.map(|v| Box::new(v) as Box<dyn age::Recipient + Send>))290 .collect::<Result<Vec<_>>>()?;291292 let identity = config.identity(identity_holder).await?;293 let decryptor = Decryptor::new(Cursor::new(&secret.secret.secret))?;294 let decryptor = match decryptor {295 Decryptor::Recipients(r) => r,296 Decryptor::Passphrase(_) => bail!("should be recipients"),297 };298 let mut decryptor = decryptor299 .decrypt(iter::once(&identity as &dyn age::Identity))300 .context("failed to decrypt, wrong key?")?;301302 let mut decrypted = Vec::new();303 decryptor304 .read_to_end(&mut decrypted)305 .context("failed to decrypt")?;306307 let mut encrypted = vec![];308 let mut encryptor = Encryptor::with_recipients(target_recipients)309 .expect("recipients provided")310 .wrap_output(&mut encrypted)?;311 io::copy(&mut Cursor::new(decrypted), &mut encryptor)?;312 encryptor.finish()?;313314 secret.secret.secret = encrypted;315 }316 Secrets::Regenerate => {317 // config.data_mut().shared_secrets318 {319 let expected_shared_set =320 config.shared_config_attr_names("sharedSecrets").await?;321 let expected_shared_set = expected_shared_set.iter().collect::<HashSet<_>>();322 let shared_set = config.data();323 let shared_set = shared_set.shared_secrets.keys().collect::<HashSet<_>>();324 for removed in expected_shared_set.difference(&shared_set) {325 warn!("secret needs to be generated: {removed}")326 }327 }328 let mut to_remove = Vec::new();329 for (name, data) in &config.data().shared_secrets {330 let expected_owners: Vec<String> = config331 .shared_config_attr(&format!("sharedSecrets.\"{name}\".expectedOwners"))332 .await?;333 if expected_owners.is_empty() {334 warn!("secret was removed from fleet config: {name}, removing from data");335 to_remove.push(name.to_string());336 continue;337 }338 let set = data.owners.iter().collect::<HashSet<_>>();339 let expected_set = expected_owners.iter().collect::<HashSet<_>>();340 if set != expected_set {341 warn!("reconfiguring owners for {name}");342 }343 }344 for k in to_remove {345 config.data_mut().shared_secrets.remove(&k);346 }347 }348 }349 Ok(())350 }351}1use crate::{2 fleetdata::{FleetSecret, FleetSharedSecret},3 host::Config,4};5use age::{Decryptor, Encryptor};6use anyhow::{bail, ensure, Context, Result};7use clap::Parser;8use futures::{StreamExt, TryStreamExt};9use std::{10 collections::HashSet,11 io::{self, Cursor, Read, Write},12 iter,13 path::PathBuf,14};15use tokio::fs::read_to_string;16use tracing::{info, warn};1718#[derive(Parser)]19pub enum Secrets {20 /// Force load keys for all defined hosts21 ForceKeys,22 /// Add secret, data should be provided in stdin23 AddShared {24 /// Secret name25 name: String,26 /// Secret owners27 machines: Vec<String>,28 /// Override secret if already present29 #[clap(long)]30 force: bool,31 #[clap(long)]32 public: Option<String>,33 #[clap(long)]34 public_file: Option<PathBuf>,35 },36 /// Add secret, data should be provided in stdin37 Add {38 /// Secret name39 name: String,40 /// Secret owners41 machine: String,42 /// Override secret if already present43 #[clap(long)]44 force: bool,45 #[clap(long)]46 public: Option<String>,47 #[clap(long)]48 public_file: Option<PathBuf>,49 },50 /// Read secret from remote host, requires sudo on said host51 Read {52 name: String,53 machine: String,54 #[clap(long)]55 plaintext: bool,56 },57 UpdateShared {58 name: String,5960 #[clap(long)]61 machines: Option<Vec<String>>,6263 #[clap(long)]64 add_machines: Vec<String>,65 #[clap(long)]66 remove_machines: Vec<String>,6768 /// Which host should we use to decrypt69 #[clap(long)]70 prefer_identities: Vec<String>,71 },72 Regenerate {73 /// Which host should we use to decrypt, in case if reencryption is required, without74 /// regeneration75 #[clap(long)]76 prefer_identities: Vec<String>,77 },78}7980impl Secrets {81 pub async fn run(self, config: &Config) -> Result<()> {82 match self {83 Secrets::ForceKeys => {84 for host in config.list_hosts().await? {85 if config.should_skip(&host) {86 continue;87 }88 config.key(&host).await?;89 }90 }91 Secrets::AddShared {92 machines,93 name,94 force,95 public,96 public_file,97 } => {98 let recipients = futures::stream::iter(machines.iter())99 .then(|m| config.recipient(m))100 .try_collect::<Vec<_>>()101 .await?;102103 let secret = {104 let mut input = vec![];105 io::stdin().read_to_end(&mut input)?;106107 if input.is_empty() {108 input109 } else {110 let mut encrypted = vec![];111 let recipients = recipients112 .iter()113 .cloned()114 .map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)115 .collect();116 let mut encryptor = age::Encryptor::with_recipients(recipients)117 .expect("recipients provided")118 .wrap_output(&mut encrypted)?;119 io::copy(&mut Cursor::new(input), &mut encryptor)?;120 encryptor.finish()?;121 encrypted122 }123 };124125 if config.has_shared(&name) && !force {126 bail!("secret already defined");127 }128 config.replace_shared(129 name,130 FleetSharedSecret {131 owners: machines,132 secret: FleetSecret {133 expire_at: None,134 secret,135 public: match (public, public_file) {136 (Some(v), None) => Some(v),137 (None, Some(v)) => Some(read_to_string(v).await?),138 (Some(_), Some(_)) => {139 bail!("only public or public_file should be set")140 }141 (None, None) => None,142 },143 },144 },145 );146 }147 Secrets::Add {148 machine,149 name,150 force,151 public,152 public_file,153 } => {154 let recipient = config.recipient(&machine).await?;155156 let secret = {157 let mut input = vec![];158 io::stdin().read_to_end(&mut input)?;159 if input.is_empty() {160 bail!("no data provided")161 }162163 let mut encrypted = vec![];164 let recipient = Box::new(recipient) as Box<dyn age::Recipient + Send>;165 let mut encryptor = age::Encryptor::with_recipients(vec![recipient])166 .expect("recipients provided")167 .wrap_output(&mut encrypted)?;168 io::copy(&mut Cursor::new(input), &mut encryptor)?;169 encryptor.finish()?;170 encrypted171 };172173 if config.has_secret(&machine, &name) && !force {174 bail!("secret already defined");175 }176 config.insert_secret(177 &machine,178 name,179 FleetSecret {180 expire_at: None,181 secret,182 public: match (public, public_file) {183 (Some(v), None) => Some(v),184 (None, Some(v)) => Some(std::fs::read_to_string(v)?),185 (Some(_), Some(_)) => bail!("only public or public_file should be set"),186 (None, None) => None,187 },188 },189 );190 }191 // TODO: Instead of using sudo, decode secret on remote machine192 #[allow(clippy::await_holding_refcell_ref)]193 Secrets::Read {194 name,195 machine,196 plaintext,197 } => {198 let secret = config.host_secret(&machine, &name)?;199 if secret.secret.is_empty() {200 bail!("no secret {name}");201 }202 let data = config.decrypt_on_host(&machine, secret.secret).await?;203 if plaintext {204 let s = String::from_utf8(data).context("output is not utf8")?;205 print!("{s}");206 } else {207 println!("{}", z85::encode(&data));208 }209 }210 Secrets::UpdateShared {211 name,212 machines,213 mut add_machines,214 mut remove_machines,215 prefer_identities,216 } => {217 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {218 bail!("no operation");219 }220221 let mut secret = config.shared_secret(&name)?;222 if secret.secret.secret.is_empty() {223 bail!("no secret");224 }225226 let initial_machines = secret.owners.clone();227 let mut target_machines = secret.owners.clone();228 info!("Currently encrypted for {initial_machines:?}");229230 // ensure!(machines.is_some() || !add_machines.is_empty() || )231 if let Some(machines) = machines {232 ensure!(233 add_machines.is_empty() && remove_machines.is_empty(),234 "can't combine --machines and --add-machines/--remove-machines"235 );236 let target = initial_machines.iter().collect::<HashSet<_>>();237 let source = machines.iter().collect::<HashSet<_>>();238 for removed in target.difference(&source) {239 remove_machines.push((*removed).clone());240 }241 for added in source.difference(&target) {242 add_machines.push((*added).clone());243 }244 }245246 for machine in &remove_machines {247 let mut removed = false;248 while let Some(pos) = target_machines.iter().position(|m| m == machine) {249 target_machines.swap_remove(pos);250 removed = true;251 }252 if !removed {253 warn!("secret is not enabled for {machine}");254 }255 }256 for machine in &add_machines {257 if target_machines.iter().any(|m| m == machine) {258 warn!("secret is already added to {machine}");259 } else {260 target_machines.push(machine.to_owned());261 }262 }263 if !remove_machines.is_empty() {264 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");265 }266267 if target_machines.is_empty() {268 info!("no machines left for secret, removing it");269 config.remove_shared(&name);270 return Ok(());271 }272273 if target_machines == initial_machines {274 warn!("secret owners are already correct");275 return Ok(());276 }277278 let identity_holder = if !prefer_identities.is_empty() {279 prefer_identities280 .iter()281 .find(|i| initial_machines.iter().any(|s| s == *i))282 } else {283 secret.owners.first()284 };285 let Some(identity_holder) = identity_holder else {286 bail!("no available holder found");287 };288 let target_recipients = futures::stream::iter(&target_machines)289 .then(|m| async { config.key(m).await })290 .collect::<Vec<_>>()291 .await;292 let target_recipients =293 target_recipients.into_iter().collect::<Result<Vec<_>>>()?;294295 let encrypted = config296 .reencrypt_on_host(&identity_holder, secret.secret.secret, target_recipients)297 .await?;298299 secret.owners = target_machines;300 secret.secret.secret = encrypted;301 config.replace_shared(name, secret);302 }303 Secrets::Regenerate { prefer_identities } => {304 {305 let expected_shared_set =306 config.shared_config_attr_names("sharedSecrets").await?;307 let expected_shared_set = expected_shared_set.iter().collect::<HashSet<_>>();308 let shared_set = config.list_shared();309 let shared_set = shared_set.iter().collect::<HashSet<_>>();310 for removed in expected_shared_set.difference(&shared_set) {311 warn!("secret needs to be generated: {removed}")312 }313 }314 let mut to_remove = Vec::new();315 for name in &config.list_shared() {316 let mut data = config.shared_secret(name)?;317 let expected_owners: Vec<String> = config318 .shared_config_attr(&format!("sharedSecrets.\"{name}\".expectedOwners"))319 .await?;320 if expected_owners.is_empty() {321 warn!("secret was removed from fleet config: {name}, removing from data");322 to_remove.push(name.to_string());323 continue;324 }325 let set = data.owners.iter().collect::<HashSet<_>>();326 let expected_set = expected_owners.iter().collect::<HashSet<_>>();327 let should_remove = set.difference(&expected_set).next().is_some();328 if set != expected_set {329 warn!("reconfiguring owners for {name}");330 let generator: Option<String> = config331 .shared_config_attr(&format!("sharedSecrets.\"{name}\".generator"))332 .await?;333 // TODO: if !.owner_dependent334 if let Some(str) = generator {335 todo!("regenerate")336 } else {337 if should_remove {338 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");339 }340341 let identity_holder = if !prefer_identities.is_empty() {342 prefer_identities343 .iter()344 .find(|i| data.owners.iter().any(|s| s == *i))345 } else {346 data.owners.first()347 };348 let Some(identity_holder) = identity_holder else {349 bail!("no available holder found");350 };351352 let target_recipients = futures::stream::iter(&expected_owners)353 .then(|m| async { config.key(m).await })354 .collect::<Vec<_>>()355 .await;356 let target_recipients =357 target_recipients.into_iter().collect::<Result<Vec<_>>>()?;358359 let encrypted = config360 .reencrypt_on_host(361 &identity_holder,362 data.secret.secret,363 target_recipients,364 )365 .await?;366367 data.secret.secret = encrypted;368 data.owners = expected_owners;369 config.replace_shared(name.to_owned(), data);370 }371 }372 }373 for k in to_remove {374 config.remove_shared(&k);375 }376 }377 }378 Ok(())379 }380}cmds/fleet/src/fleetdata.rsdiffbeforeafterboth--- 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<String, BTreeMap<String, FleetSecret>>,
}
-#[derive(Serialize, Deserialize)]
+#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
+#[must_use]
pub struct FleetSharedSecret {
pub owners: Vec<String>,
#[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<TempDir> {
let data_str = fs::read_to_string("fleet.nix").await?;
cmds/fleet/src/host.rsdiffbeforeafterboth--- 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<FleetData> {
+ pub(super) fn data(&self) -> Ref<FleetData> {
self.data.borrow()
}
- pub fn data_mut(&self) -> RefMut<FleetData> {
+ pub(super) fn data_mut(&self) -> RefMut<FleetData> {
self.data.borrow_mut()
}
+ pub fn list_shared(&self) -> Vec<String> {
+ 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<String> {
+ 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<u8>) -> Result<Vec<u8>>{
+ 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<u8>, targets: Vec<String>) -> Result<Vec<u8>>{
+ 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<FleetSecret> {
+ 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<FleetSharedSecret> {
+ 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)?;
cmds/fleet/src/keys.rsdiffbeforeafterboth--- 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<age::ssh::Identity> {
- 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<age::ssh::Recipient> {
let key = self.key(host).await?;
age::ssh::Recipient::from_str(&key).map_err(|e| anyhow!("parse recipient error: {:?}", e))
cmds/install-secrets/src/main.rsdiffbeforeafterboth--- 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<u8>);
+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<Self, Self::Err> {
+ z85::decode(s).map(Self)
+ }
+}
+impl<'de> Deserialize<'de> for SecretWrapper {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ 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<String>,
+ },
+ /// 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<Vec<u8>>,
+ secret: Option<SecretWrapper>,
public: Option<String>,
public_path: PathBuf,
@@ -36,21 +80,45 @@
stable_secret_path: PathBuf,
}
-fn from_z85<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
-where
- D: Deserializer<'de>,
-{
- use serde::de::Error;
- if let Some(v) = <Option<String>>::deserialize(deserializer)? {
- Ok(Some(
- z85::decode(v).map_err(|err| Error::custom(err.to_string()))?,
- ))
- } else {
- Ok(None)
- }
+type Data = HashMap<String, DataItem>;
+
+fn decrypt(input: &SecretWrapper, identity: &dyn Identity) -> Result<Vec<u8>> {
+ 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<String>) -> Result<SecretWrapper> {
+ let recipients = targets
+ .into_iter()
+ .map(|t| {
+ SshRecipient::from_str(&t).map_err(|e| anyhow!("failed to parse recipient: {e:?}"))
+ })
+ .collect::<Result<Vec<SshRecipient>>>()?;
+ let recipients = recipients
+ .into_iter()
+ .map(|v| Box::new(v) as Box<dyn Recipient + Send>)
+ .collect::<Vec<_>>();
+ 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<String, DataItem>;
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<SshIdentity> {
+ 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(())
+ }
+ }
+}
modules/fleet/secrets.nixdiffbeforeafterboth--- 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;
nixos/secrets.nixdiffbeforeafterboth--- 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}
'';
};
}