From d21d492fbada9f13696a3043c401d85128870202 Mon Sep 17 00:00:00 2001 From: oluceps Date: Sat, 9 Nov 2024 17:12:31 +0800 Subject: [PATCH 1/3] + implement template --- README.md | 7 +- module/default.nix | 9 +- module/template.nix | 114 ++++++++++++++++++++++++ src/cmd/deploy.rs | 117 ++++++++++++++++++------ src/helper/mod.rs | 1 + src/helper/secret_buf.rs | 13 +++ src/helper/stored.rs | 19 ++-- src/helper/template.rs | 186 +++++++++++++++++++++++++++++++++++++++ src/profile.rs | 45 ++++++++++ 9 files changed, 470 insertions(+), 41 deletions(-) create mode 100644 module/template.nix create mode 100644 src/helper/template.rs diff --git a/README.md b/README.md index d27025d..293250c 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,12 @@ Secret management for NixOS. -Highly inspired by agenix-rekey. Based on rust age crate. +Highly inspired by agenix-rekey and sops-nix. Based on rust age crate. -+ AGE Support Only -+ PIV Card (Yubikey) Support + Age Plugin Compatible ++ Support identity with passphase ++ Support PIV Card (Yubikey) ++ Support Template + No Bash ## Setup diff --git a/module/default.nix b/module/default.nix index eda138a..890c449 100644 --- a/module/default.nix +++ b/module/default.nix @@ -227,14 +227,15 @@ let Group of the decrypted secret. ''; }; - symlink = mkEnableOption "symlinking secrets to their destination" // { + symlink = mkEnableOption "symlinking secrets to destination" // { default = true; }; }; }); - in { + imports = [ ./template.nix ]; + options.vaultix = { package = mkOption { defaultText = literalMD "`packages.vaultix` from this flake"; }; @@ -254,7 +255,11 @@ in Attrset of secrets. ''; }; + }; + options.vaultix-debug = mkOption { + type = types.unspecified; + default = cfg; }; config = diff --git a/module/template.nix b/module/template.nix new file mode 100644 index 0000000..8d1a823 --- /dev/null +++ b/module/template.nix @@ -0,0 +1,114 @@ +{ + config, + lib, + ... +}: +# this part basically inherit from +# https://github.com/Mic92/sops-nix/tree/60e1bce1999f126e3b16ef45f89f72f0c3f8d16f/modules/sops/templates +let + inherit (lib) + mkOption + mkEnableOption + mkDefault + mapAttrs + types + literalExpression + mkIf + ; + inherit (config.users) users; + + cfg = config.vaultix; + + templateType = types.submodule (submod: { + options = { + content = mkOption { + type = types.str; + default = ""; + defaultText = literalExpression ""; + description = '' + Content of the template + ''; + }; + name = mkOption { + type = types.str; + default = submod.config._module.args.name; + defaultText = literalExpression "submod.config._module.args.name"; + description = '' + Name of the file used in {option}`vaultix.settings.decryptedDir` + ''; + }; + path = mkOption { + type = types.str; + default = "${cfg.settings.decryptedDir}/${submod.config.name}"; + defaultText = literalExpression '' + "''${cfg.settings.decryptedDir}/''${config.name}" + ''; + description = '' + Path where the built template is installed. + ''; + }; + mode = mkOption { + type = types.str; + default = "0400"; + description = '' + Permissions mode of the built template in a format understood by chmod. + ''; + }; + owner = mkOption { + type = types.str; + default = "0"; + description = '' + User of the built template. + ''; + }; + group = mkOption { + type = types.str; + default = users.${submod.config.owner}.group or "0"; + defaultText = literalExpression '' + users.''${config.owner}.group or "0" + ''; + description = '' + Group of the built template. + ''; + }; + symlink = mkEnableOption "symlinking template to destination" // { + default = true; + }; + }; + }); + +in +{ + options.vaultix = { + + templates = mkOption { + type = types.attrsOf templateType; + default = { }; + description = '' + Attrset of templates. + ''; + }; + + placeholder = mkOption { + type = types.attrsOf ( + types.mkOptionType { + name = "coercibleToString"; + description = "value that can be coerced to string"; + check = lib.strings.isConvertibleWithToString; + merge = lib.mergeEqualOption; + } + ); + default = { }; + visible = false; + description = '' + Identical with the attribute name of secrets, NOTICE this if you + defined the `name` in secrets submodule. + ''; + }; + }; + config = mkIf (config.vaultix.templates != { }) { + vaultix.placeholder = mapAttrs ( + n: _: mkDefault "{{ ${builtins.hashString "sha256" n} }}" + ) cfg.secrets; + }; +} diff --git a/src/cmd/deploy.rs b/src/cmd/deploy.rs index ed4e9c8..70ad744 100644 --- a/src/cmd/deploy.rs +++ b/src/cmd/deploy.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, fs::{self, OpenOptions, Permissions, ReadDir}, io::{ErrorKind, Write}, os::unix::fs::PermissionsExt, @@ -10,10 +11,10 @@ use std::{ use crate::{ helper::{ self, - secret_buf::{HostEnc, SecBuf}, + secret_buf::{Plain, SecBuf}, stored::{InStore, SecMap, SecPath}, }, - profile::{HostKey, Profile}, + profile::{self, HostKey, Profile}, }; use age::{ssh, x25519, Recipient}; @@ -33,6 +34,34 @@ impl HostKey { } const KEY_TYPE: &str = "ed25519"; + +fn deploy_to_fs( + ctx: SecBuf, + item: impl crate::profile::DeployFactor, + generation_count: usize, + target_dir_ordered: PathBuf, +) -> Result<()> { + info!("{} -> generation {}", item.get_name(), generation_count); + let mut the_file = { + let mut p = target_dir_ordered.clone(); + p.push(item.get_name().clone()); + + let mode = helper::parse_permission::parse_octal_string(item.get_mode()) + .map_err(|e| eyre!("parse octal permission err: {}", e))?; + let permissions = Permissions::from_mode(mode); + + let file = OpenOptions::new().create(true).write(true).open(p)?; + + file.set_permissions(permissions)?; + + helper::set_owner_group::set_owner_and_group(&file, item.get_owner(), item.get_group())?; + + file + }; + the_file.write_all(ctx.buf_ref())?; + Ok(()) +} + impl Profile { pub fn get_decrypted_mount_point_path(&self) -> String { self.settings.decrypted_mount_point.to_string() @@ -149,15 +178,17 @@ impl Profile { extract secrets to `/run/vaultix.d/$num` and link to `/run/vaultix` */ pub fn deploy(self) -> Result<()> { - let sec_instore_map = SecMap::>::create(&self.secrets) + let host_prv_key = &self.get_host_key_identity()?; + let plain_map: SecMap> = SecMap::>::create(&self.secrets) .renced_stored( self.settings.storage_in_store.clone().into(), self.settings.host_pubkey.as_str(), ) .bake_ctx()? - .inner(); - - trace!("{:?}", sec_instore_map); + .inner() + .into_iter() + .map(|(s, c)| (s, c.decrypt(host_prv_key).expect("err").inner())) + .collect(); let generation_count = self.init_decrypted_mount_point()?; @@ -179,36 +210,66 @@ impl Profile { })? }; - let host_prv_key = &self.get_host_key_identity()?; + // deploy general secrets + plain_map.inner_ref().iter().for_each(|(n, c)| { + let ctx = SecBuf::::new(c.clone()); + deploy_to_fs( + ctx, + *n, + generation_count, + target_extract_dir_with_gen.clone(), + ) + .expect("err"); + }); - sec_instore_map.into_iter().for_each(|(n, c)| { - let ctx = SecBuf::::new(c) - .decrypt(host_prv_key) - .expect("err"); + if self.templates.len() != 0 { + info!("start deploy templates"); + use sha2::{Digest, Sha256}; - info!("{} -> generation {}", n.name, generation_count); - let mut the_file = { - let mut p = target_extract_dir_with_gen.clone(); - p.push(n.name.clone()); + let get_hashed_id = |s: &profile::Secret| -> String { + let mut hasher = Sha256::new(); + hasher.update(s.id.as_str()); + format!("{:X}", hasher.finalize()).to_lowercase() + }; - let mode = helper::parse_permission::parse_octal_string(&n.mode).unwrap(); - let permissions = Permissions::from_mode(mode); + // new map with sha256 hashed secret id str as key, ctx as value + let hashstr_ctx_map: HashMap> = plain_map + .inner_ref() + .iter() + .map(|(k, v)| (get_hashed_id(*k), v)) + .collect(); - let file = OpenOptions::new().create(true).write(true).open(p).unwrap(); + self.templates.clone().iter().for_each(|(_, t)| { + // TODO: + // parse content -> [hash] - file.set_permissions(permissions).unwrap(); + let mut raw_template = t.content.clone(); - helper::set_owner_group::set_owner_and_group(&file, &n.owner, &n.group) - .expect("good report"); + let hashstrs_of_it = t.parse_hash_str_list().expect("parse template"); - file - }; - the_file - .write_all(ctx.buf_ref()) - .expect("write decrypted file error") - }); + hashstr_ctx_map + .iter() + .filter(|(k, _)| hashstrs_of_it.contains(*k)) + .for_each(|(k, v)| { + // render + trace!("template before process: {}", raw_template); + raw_template = raw_template.replace( + format!("{{{{ {} }}}}", k).as_str(), + String::from_utf8_lossy(v).to_string().as_str(), + ); + trace!("processed template: {}", raw_template); + }); + + deploy_to_fs( + SecBuf::::new(raw_template.into_bytes()), + t, + generation_count, + target_extract_dir_with_gen.clone(), + ) + .expect("extract template to target generation") + }); + } - let _ = fs::remove_file(self.get_decrypt_dir_path()); // link back to /run/vaultix if std::os::unix::fs::symlink(target_extract_dir_with_gen, self.get_decrypt_dir_path()) .wrap_err("create symlink error") diff --git a/src/helper/mod.rs b/src/helper/mod.rs index 459f21b..72b73fd 100644 --- a/src/helper/mod.rs +++ b/src/helper/mod.rs @@ -4,3 +4,4 @@ pub mod parse_permission; pub mod secret_buf; pub mod set_owner_group; pub mod stored; +pub mod template; diff --git a/src/helper/secret_buf.rs b/src/helper/secret_buf.rs index 328a448..2678edf 100644 --- a/src/helper/secret_buf.rs +++ b/src/helper/secret_buf.rs @@ -11,6 +11,7 @@ pub struct HostEnc; #[derive(Debug, Clone)] pub struct Plain; +#[derive(Debug, Clone)] pub struct SecBuf { buf: Vec, _marker: PhantomData, @@ -23,6 +24,9 @@ impl SecBuf { _marker: PhantomData, } } + pub fn inner(self) -> Vec { + self.buf + } } use eyre::Result; @@ -44,6 +48,15 @@ impl SecBuf { } } +impl From> for SecBuf { + fn from(value: Vec) -> Self { + Self { + buf: value, + _marker: PhantomData, + } + } +} + impl SecBuf { pub fn renc(&self, ident: &dyn Identity, recips: Rc) -> Result> { self.decrypt(ident).and_then(|d| d.encrypt(vec![recips])) diff --git a/src/helper/stored.rs b/src/helper/stored.rs index d7c8061..3254a2f 100644 --- a/src/helper/stored.rs +++ b/src/helper/stored.rs @@ -18,6 +18,8 @@ use crate::{ use eyre::{eyre, Result}; use std::marker::PhantomData; +use super::secret_buf::HostEnc; + #[derive(Debug, Clone)] pub struct SecPath, T> { pub path: P, @@ -91,7 +93,7 @@ macro_rules! impl_from_iterator_for_secmap { )* }; } -impl_from_iterator_for_secmap!(Vec, blake3::Hash, UniPath); +impl_from_iterator_for_secmap!(Vec, blake3::Hash, UniPath, SecBuf); macro_rules! impl_into_secmap_for_themap { ($($t:ty),*) => { @@ -121,13 +123,6 @@ impl<'a, T> SecMap<'a, T> { } impl<'a, T> SecMap<'a, SecPath> { - /// read secret file - pub fn bake_ctx(self) -> Result>> { - self.inner() - .into_iter() - .map(|(k, v)| v.read_buffer().and_then(|b| Ok((k, b)))) - .try_collect::>>() - } fn have(&self, p: &PathBuf) -> bool { for ip in self.inner_ref().values() { if &ip.path == p { @@ -167,6 +162,14 @@ impl<'a> SecMap<'a, SecPBWith> { .collect::>>() .into() } + + /// read secret file + pub fn bake_ctx(self) -> Result>> { + self.inner() + .into_iter() + .map(|(k, v)| v.read_buffer().and_then(|b| Ok((k, SecBuf::from(b))))) + .try_collect::>>() + } } #[derive(Debug, Clone)] diff --git a/src/helper/template.rs b/src/helper/template.rs new file mode 100644 index 0000000..74fa3f4 --- /dev/null +++ b/src/helper/template.rs @@ -0,0 +1,186 @@ +use crate::profile::Template; +use eyre::Result; +use nom::{ + bytes::complete::{is_not, tag, take_while_m_n}, + error::Error, + sequence::delimited, + IResult, +}; + +fn parse_braced_hash(input: &str) -> IResult<&str, &str, Error<&str>> { + delimited( + tag("{{ "), + take_while_m_n(64, 64, |c: char| c.is_ascii_hexdigit()), + tag(" }}"), + )(input) +} + +fn pars<'a>(text: &'a str, res: &mut Vec<&'a str>) { + let (remaining, _) = is_not::<&str, &str, Error<&str>>("{{")(text).expect("here"); + match parse_braced_hash(remaining) { + Ok((remain, hashes)) => { + res.push(hashes); + if !remain.is_empty() { + pars(remain, res); + } + } + Err(_e) => { + // warn!("parse template terminate: {:?}", e); + } + }; +} + +impl Template { + pub fn parse_hash_str_list(&self) -> Result> { + let text = &self.content; + + let mut res = vec![]; + let text = format!(" {}", text); // hack + pars(text.as_str(), &mut res); + Ok(res.into_iter().map(|s| String::from(s)).collect()) + } +} + +// pub struct Template + +#[cfg(test)] +mod tests { + use super::*; + + impl Default for Template { + fn default() -> Self { + let string_default = String::default(); + Template { + name: string_default.clone(), + content: string_default.clone(), + group: string_default.clone(), + mode: string_default.clone(), + owner: string_default.clone(), + path: string_default, + symlink: true, + } + } + } + + #[test] + fn parse_template_single() { + let str = + "here has {{ dcd789434d890685da841b8db8a02b0173b90eac3774109ba9bca1b81440aa93 }} whch should be replaced"; + + let t = Template { + content: String::from(str), + ..Template::default() + }; + assert_eq!( + vec!["dcd789434d890685da841b8db8a02b0173b90eac3774109ba9bca1b81440aa93"], + t.parse_hash_str_list().unwrap() + ) + } + #[test] + fn parse_template_multi() { + let str = "here {{ dcd789434d890685da841b8db8a02b0173b90eac3774109ba9bca1b81440aa93 }} {{ cd789434d890685da841b8db8a02b0173b90eac3774109ba9bca1b81440a2a93 }}"; + + let t = Template { + content: String::from(str), + ..Template::default() + }; + assert_eq!( + vec![ + "dcd789434d890685da841b8db8a02b0173b90eac3774109ba9bca1b81440aa93", + "cd789434d890685da841b8db8a02b0173b90eac3774109ba9bca1b81440a2a93" + ], + t.parse_hash_str_list().unwrap() + ) + } + #[test] + fn parse_template_with_trailing_white() { + let str = "{{ cd789434d890685da841b8db8a02b0173b90eac3774109ba9bca1b81440a2a93 }} "; + + let t = Template { + content: String::from(str), + ..Template::default() + }; + assert_eq!( + vec!["cd789434d890685da841b8db8a02b0173b90eac3774109ba9bca1b81440a2a93",], + t.parse_hash_str_list().unwrap() + ) + } + #[test] + fn parse_template_with_heading_white() { + let str = " {{ cd789434d890685da841b8db8a02b0173b90eac3774109ba9bca1b81440a2a93 }}"; + + let t = Template { + content: String::from(str), + ..Template::default() + }; + assert_eq!( + vec!["cd789434d890685da841b8db8a02b0173b90eac3774109ba9bca1b81440a2a93",], + t.parse_hash_str_list().unwrap() + ) + } + #[test] + fn parse_template_none() { + let str = ""; + + let t = Template { + content: String::from(str), + ..Template::default() + }; + assert!(t.parse_hash_str_list().unwrap().len() == 0) + } + #[test] + fn parse_template_multi_line_truncate() { + let str = r#"some {{ d9cd8155764c3543f10fad8a480d743137466f8d55213c8eaefcd12f06d43a80 + }}"#; + + let t = Template { + content: String::from(str), + ..Template::default() + }; + assert!(t.parse_hash_str_list().unwrap().len() == 0) + } + #[test] + fn parse_template_invalid_length_of_hash() { + let str = r#"some {{ 8155764c3543f10fad8a480d743137466f8d55213c8eaefcd12f06d43a80 }}"#; + + let t = Template { + content: String::from(str), + ..Template::default() + }; + assert!(t.parse_hash_str_list().unwrap().len() == 0) + } + #[test] + fn parse_template_open() { + let str = r#"some {{ 8155764c3543f10fad8a480d743137466f8d55213c8eaefcd12f06d43a80"#; + + let t = Template { + content: String::from(str), + ..Template::default() + }; + assert!(t.parse_hash_str_list().unwrap().len() == 0) + } + #[test] + fn parse_template_whatever() { + let str = r#"some {{ 8155{{764c3543f10fad8a480d743137466f8d55213c8eaefcd12f06d43a\8}}0"#; + + let t = Template { + content: String::from(str), + ..Template::default() + }; + assert!(t.parse_hash_str_list().unwrap().len() == 0) + } + #[test] + fn render() { + let s: String = String::from("{{ hash }}"); + + assert_eq!( + "some", + s.replace(concat!("{{ ", "hash", " }}").trim(), "some") + ); + assert_eq!( + "some", + // holy + s.replace(format!("{{{{ {} }}}}", "hash").trim(), "some") + ); + } +} diff --git a/src/profile.rs b/src/profile.rs index a4b6438..942a4bc 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -3,11 +3,13 @@ use std::collections::HashMap; use serde::Deserialize; pub type SecretSet = HashMap; +pub type TemplateSet = HashMap; #[derive(Debug, Deserialize)] pub struct Profile { pub secrets: SecretSet, pub settings: Settings, + pub templates: TemplateSet, } #[derive(Debug, Deserialize, Clone, Hash, Eq, PartialEq)] @@ -22,6 +24,17 @@ pub struct Secret { pub symlink: bool, } +#[derive(Debug, Deserialize, Clone, Hash, Eq, PartialEq)] +pub struct Template { + pub name: String, + pub content: String, + pub group: String, + pub mode: String, + pub owner: String, + pub path: String, + pub symlink: bool, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Settings { @@ -48,3 +61,35 @@ pub struct HostKey { pub path: String, pub r#type: String, } + +pub trait DeployFactor { + fn get_mode(&self) -> &String; + fn get_owner(&self) -> &String; + fn get_name(&self) -> &String; + fn get_group(&self) -> &String; +} + +macro_rules! impl_deploy_factor { + ($type:ty, { $($method:ident => $field:ident),+ $(,)? }) => { + impl DeployFactor for $type { + $( + fn $method(&self) -> &String { + &self.$field + } + )+ + } + }; +} + +impl_deploy_factor!(&Secret, { + get_mode => mode, + get_owner => owner, + get_name => name, + get_group => group +}); +impl_deploy_factor!(&Template, { + get_mode => mode, + get_owner => owner, + get_name => name, + get_group => group +}); From e9123621e46e9ae7ff32b0019d4ee1f94289eaf6 Mon Sep 17 00:00:00 2001 From: oluceps Date: Sun, 10 Nov 2024 02:20:33 +0800 Subject: [PATCH 2/3] + docs finish work on template --- README.md | 17 ++++++++++++++--- TODO.md | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 293250c..45b1148 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ Secret management for NixOS. Highly inspired by agenix-rekey and sops-nix. Based on rust age crate. + Age Plugin Compatible ++ Support Template + Support identity with passphase + Support PIV Card (Yubikey) -+ Support Template + No Bash ## Setup @@ -81,14 +81,24 @@ Adding nixosModule config: # path = "/some/place"; }; }; + + # the templating function acts the same as sops-nix + templates = { + test = { + name = "template.txt"; + # to be notice that the source secret file may have trailing `\n` + content = "this is a template for testing ${config.vaultix.placeholder.example}"; + }; + } }; } ``` -After this you could reference the decrypted secret path by: +After this you could reference the path by: ``` - = config.vaultix.secrets.example.path; + = config.vaultix.secrets.example.path; + = config.vaultix.templates.test.path; # ... ``` @@ -139,3 +149,4 @@ See [TODO](./TODO.md) + [agenix](https://github.com/ryantm/agenix) + [agenix-rekey](https://github.com/oddlama/agenix-rekey) ++ [sops-nix](https://github.com/Mic92/sops-nix) diff --git a/TODO.md b/TODO.md index 3121767..dc163df 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,4 @@ -- [ ] template +- [x] template - [ ] [edit] or [add] secret with extra encrypt key - [ ] deploy to specified location - [x] check command From e2183590d36e19af3910c77b23683961d571ec27 Mon Sep 17 00:00:00 2001 From: oluceps Date: Sun, 10 Nov 2024 02:56:07 +0800 Subject: [PATCH 3/3] - clean scaffolding --- src/cmd/deploy.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/cmd/deploy.rs b/src/cmd/deploy.rs index 70ad744..bdae80b 100644 --- a/src/cmd/deploy.rs +++ b/src/cmd/deploy.rs @@ -240,11 +240,7 @@ impl Profile { .collect(); self.templates.clone().iter().for_each(|(_, t)| { - // TODO: - // parse content -> [hash] - - let mut raw_template = t.content.clone(); - + let mut template = t.content.clone(); let hashstrs_of_it = t.parse_hash_str_list().expect("parse template"); hashstr_ctx_map @@ -252,16 +248,16 @@ impl Profile { .filter(|(k, _)| hashstrs_of_it.contains(*k)) .for_each(|(k, v)| { // render - trace!("template before process: {}", raw_template); - raw_template = raw_template.replace( + trace!("template before process: {}", template); + template = template.replace( format!("{{{{ {} }}}}", k).as_str(), String::from_utf8_lossy(v).to_string().as_str(), ); - trace!("processed template: {}", raw_template); + trace!("processed template: {}", template); }); deploy_to_fs( - SecBuf::::new(raw_template.into_bytes()), + SecBuf::::new(template.into_bytes()), t, generation_count, target_extract_dir_with_gen.clone(),