--- a/cmds/fleet/src/cmds/secrets/mod.rs +++ b/cmds/fleet/src/cmds/secrets/mod.rs @@ -158,7 +158,7 @@ expectations: &Expectations, ) -> Result { let reason = secret_needs_regeneration(&secret.secret, &secret.owners, expectations); - let value = definition.inner(); + let value = definition.definition_value(); let (should_reencrypt, reason) = match reason { Some(RegenerationReason::OwnersAdded(_)) => { @@ -401,7 +401,13 @@ // let owners: Vec = nix_go_json!(secret.expectedOwners); Ok(FleetSharedSecret { managed: Some(true), - secret: generate(config, display_name, secret.inner(), expectations).await?, + secret: generate( + config, + display_name, + secret.definition_value(), + expectations, + ) + .await?, owners: expectations.owners.clone(), }) } @@ -711,7 +717,9 @@ } let definition = config.shared_secret_definition(&name)?; - let expectations = definition.expectations()?; + let expectations = definition + .expectations() + .with_context(|| format!("expectations for shared {name:?}"))?; let updated = maybe_regenerate_shared_secret( &name, @@ -744,7 +752,9 @@ info!("skipping unmanaged secret: {missing}"); continue; } - let expectations = definition.expectations()?; + let expectations = definition + .expectations() + .with_context(|| format!("expectations for shared {missing:?}"))?; info!("generating secret: {missing}"); let shared = generate_shared(config, missing, definition, &expectations) .in_current_span() @@ -768,13 +778,18 @@ .into_iter() .collect::>(); for missing_secret in expected_set.difference(&stored_set) { + let secret = host.secret_definition(missing_secret)?; + if secret.is_shared()? { + continue; + } info!("generating missing secret: {missing_secret}"); - let definition = host.secret_definition(missing_secret)?; - let expectations = definition.expectations()?; + let expectations = secret.expectations().with_context(|| { + format!("expectations for {missing_secret:?} of {:?}", host.name) + })?; let generated = match generate( config, missing_secret, - definition.inner(), + secret.definition_value()?, &expectations, ) .in_current_span() @@ -796,16 +811,19 @@ ) } for known_secret in stored_set.intersection(&expected_set) { + let secret = host.secret_definition(known_secret)?; + if secret.is_shared()? { + continue; + } info!("updating secret: {known_secret}"); let data = config.host_secret(&host.name, known_secret)?; - let definition = host.secret_definition(known_secret)?; - let expectations = definition.expectations()?; + let expectations = secret.expectations()?; if let Some(regen_reason) = data.needs_regeneration(&expectations) { info!("needs regeneration: {regen_reason}"); let generated = match generate( config, known_secret, - definition.inner(), + secret.definition_value()?, &expectations, ) .in_current_span() @@ -828,6 +846,10 @@ } } for removed_secret in stored_set.difference(&expected_set) { + let definition = host.secret_definition(removed_secret)?; + if definition.is_shared()? { + continue; + } info!("removing secret: {removed_secret}"); config.remove_secret(&host.name, removed_secret); } --- a/crates/fleet-base/src/secret.rs +++ b/crates/fleet-base/src/secret.rs @@ -17,20 +17,37 @@ pub struct HostSecretDefinition(pub(crate) String, pub(crate) Value); impl HostSecretDefinition { pub fn is_managed(&self) -> Result { - let value = &self.1; - Ok(!nix_go!(value.generator).is_null()) + let def = self.definition_value()?; + Ok(!nix_go!(def.generator).is_null()) } + pub fn is_shared(&self) -> Result { + let def = self.definition_value()?; + Ok(nix_go_json!(def.shared)) + } pub fn expectations(&self) -> Result { - let value = &self.1; + let def = self.definition_value()?; + let parts = nix_go!(def.parts); + + let mut public_parts = BTreeSet::new(); + let mut private_parts = BTreeSet::new(); + for part in parts.list_fields()? { + if nix_go_json!(parts[&part].encrypted) { + private_parts.insert(part.clone()); + } else { + public_parts.insert(part.clone()); + } + } + Ok(Expectations { owners: BTreeSet::from([self.0.clone()]), - generation_data: nix_go_json!(value.expectedGenerationData), - public_parts: nix_go_json!(value.expectedPublicParts), - private_parts: nix_go_json!(value.expectedPrivateParts), + generation_data: nix_go_json!(def.expectedGenerationData), + public_parts, + private_parts, }) } - pub fn inner(&self) -> Value { - self.1.clone() + pub fn definition_value(&self) -> Result { + let value = &self.1; + Ok(nix_go!(value.definition)) } } @@ -49,7 +66,7 @@ private_parts: nix_go_json!(value.expectedPrivateParts), }) } - pub fn inner(&self) -> Value { + pub fn definition_value(&self) -> Value { self.0.clone() } } --- a/lib/default.nix +++ b/lib/default.nix @@ -57,215 +57,191 @@ inherit (modules) mkFleetDefault mkFleetGeneratorDefault; - secrets = - let - describedGenerator = - generator: {parts ? {}}: - {parts = {};} - // { - __functionArgs = functionArgs generator; - __functor = _: generator; - }; - in - { - inherit describedGenerator; + secrets = { - /** - Generate a random secret password, 32 ascii characters by default + /** + Generate a random secret password, 32 ascii characters by default - Options: - size: generated password length in ascii characters (bytes). - noSymbols: by default, character set includes various special characters ($ , ! + * : ~), and might - not be accepted in some contexts, this option switches charset to just [A-Za-z0-9]. + Options: + size: generated password length in ascii characters (bytes). + noSymbols: by default, character set includes various special characters ($ , ! + * : ~), and might + not be accepted in some contexts, this option switches charset to just [A-Za-z0-9]. - Output: - Resulting secret has only part: secret, which contains encrypted password. - */ - mkPassword = + Output: + Resulting secret has only part: secret, which contains encrypted password. + */ + mkPassword = + { + size ? 32, + }: + ( { - size ? 32, + coreutils, + mkSecretGenerator, }: - describedGenerator - ( - { - coreutils, - mkSecretGenerator, - }: - mkSecretGenerator { - script = '' - mkdir $out - gh generate password -o $out/secret --size ${toString size} - ''; - } - ) - { - parts.secret.encrypted = true; - }; + mkSecretGenerator { + script = '' + mkdir $out + gh generate password -o $out/secret --size ${toString size} + ''; + parts.secret.encrypted = true; + } + ); - /** - Generate a random ed25519 keypair + /** + Generate a random ed25519 keypair - Options: - noEmbedPublic: By default, secret key also embeds public key in itself ("extended" format, 64 bytes) - When noEmbedPublis is enabled - only the private scalar is included. - encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex". + Options: + noEmbedPublic: By default, secret key also embeds public key in itself ("extended" format, 64 bytes) + When noEmbedPublis is enabled - only the private scalar is included. + encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex". - Output: - Resulting secret has two parts: public and secret, where the secret part is encrypted. + Output: + Resulting secret has two parts: public and secret, where the secret part is encrypted. + + This secret format is used by e.g Garage S3 server + */ + mkEd25519 = + { + noEmbedPublic ? false, + encoding ? null, + }: + ( + { mkSecretGenerator }: + mkSecretGenerator { + script = '' + mkdir $out + gh generate ed25519 -p $out/public -s $out/secret \ + ${optionalString noEmbedPublic "--no-embed-public"} \ + ${optionalString (encoding != null) "--encoding=${encoding}"} + ''; + parts.secret.encrypted = true; + parts.public.encrypted = false; + } + ); - This secret format is used by e.g Garage S3 server - */ - mkEd25519 = - { - noEmbedPublic ? false, - encoding ? null, - }: - describedGenerator - ( - { mkSecretGenerator }: - mkSecretGenerator { - script = '' - mkdir $out - gh generate ed25519 -p $out/public -s $out/secret \ - ${optionalString noEmbedPublic "--no-embed-public"} \ - ${optionalString (encoding != null) "--encoding=${encoding}"} - ''; - } - ) - { - parts.secret.encrypted = true; - parts.public.encrypted = false; - }; + /** + Generate a random x25519 keypair - /** - Generate a random x25519 keypair + Options: + encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex". - Options: - encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex". + Output: + Resulting secret has two parts: public and secret, where the secret part is encrypted. - Output: - Resulting secret has two parts: public and secret, where the secret part is encrypted. + This secret format is used by e.g Wireguard VPN for peers (base64-encoded) + */ + mkX25519 = + { + encoding ? null, + }: + ( + { mkSecretGenerator }: + mkSecretGenerator { + script = '' + mkdir $out + gh generate x25519 -p $out/public -s $out/secret \ + ${optionalString (encoding != null) "--encoding=${encoding}"} + ''; - This secret format is used by e.g Wireguard VPN for peers (base64-encoded) - */ - mkX25519 = - { - encoding ? null, - }: - describedGenerator - ( - { mkSecretGenerator }: - mkSecretGenerator { - script = '' - mkdir $out - gh generate x25519 -p $out/public -s $out/secret \ - ${optionalString (encoding != null) "--encoding=${encoding}"} - ''; - } - ) - { - parts.secret.encrypted = true; - parts.public.encrypted = false; - }; + parts.secret.encrypted = true; + parts.public.encrypted = false; + } + ); - /** - Generate a random RSA keypair + /** + Generate a random RSA keypair - Options: - size: RSA key size, 4096 by default + Options: + size: RSA key size, 4096 by default - Output: - Resulting secret has two parts: public and secret, where the secret part is encrypted. - Both parts are PEM encoded. - */ - mkRsa = + Output: + Resulting secret has two parts: public and secret, where the secret part is encrypted. + Both parts are PEM encoded. + */ + mkRsa = + { + size ? 4096, + }: + ( { - size ? 4096, + openssl, + mkSecretGenerator, }: - describedGenerator - ( - { - openssl, - mkSecretGenerator, - }: - mkSecretGenerator { - script = '' - mkdir $out + mkSecretGenerator { + script = '' + mkdir $out + + ${openssl}/bin/openssl genrsa -out rsa_private.key ${toString size} + ${openssl}/bin/openssl rsa -in rsa_private.key -pubout -out rsa_public.key - ${openssl}/bin/openssl genrsa -out rsa_private.key ${toString size} - ${openssl}/bin/openssl rsa -in rsa_private.key -pubout -out rsa_public.key + cat rsa_private.key | gh private -o $out/secret + cat rsa_public.key | gh public -o $out/public + ''; - cat rsa_private.key | gh private -o $out/secret - cat rsa_public.key | gh public -o $out/public - ''; - } - ) - { - parts.secret.encrypted = true; - parts.public.encrypted = false; - }; + parts.secret.encrypted = true; + parts.public.encrypted = false; + } + ); - /** - Generate a random byte sequence + /** + Generate a random byte sequence - Options: - size: generated password length in bytes, 32 by default. - encoding: how the generated bytes should be encoded, "raw" (default), "hex" or "base64" - noNuls: prevent output byte sequence from containing internal \0, useful for some C applications - that can't handle their strings properly. + Options: + size: generated password length in bytes, 32 by default. + encoding: how the generated bytes should be encoded, "raw" (default), "hex" or "base64" + noNuls: prevent output byte sequence from containing internal \0, useful for some C applications + that can't handle their strings properly. - Output: - Resulting secret has only part: secret, which contains encrypted bytes. + Output: + Resulting secret has only part: secret, which contains encrypted bytes. - Might be used for e.g. Wireguard VPN PSK keys (base64-encoded) - */ - mkBytes = - { - count ? 32, - encoding, - noNuls ? false, - }: - describedGenerator - ( - { mkSecretGenerator }: - mkSecretGenerator { - script = '' - mkdir $out - gh generate bytes --count=${toString count} --encoding=${encoding} -o $out/secret \ - ${optionalString noNuls "--no-nuls"} - ''; - } - ) - { - parts.secret.encrypted = true; - }; - /** - Shorthand for `mkBytes`, which defaults to "hex" encoding - */ - mkHexBytes = - { - count ? 32, - }: - mkBytes { - inherit count; - encoding = "hex"; - }; - /** - Shorthand for `mkBytes`, which defaults to "base64" encoding - */ - mkBase64Bytes = - { - count ? 32, - }: - mkBytes { - inherit count; - encoding = "base64"; - }; + Might be used for e.g. Wireguard VPN PSK keys (base64-encoded) + */ + mkBytes = + { + count ? 32, + encoding, + noNuls ? false, + }: + ( + { mkSecretGenerator }: + mkSecretGenerator { + script = '' + mkdir $out + gh generate bytes --count=${toString count} --encoding=${encoding} -o $out/secret \ + ${optionalString noNuls "--no-nuls"} + ''; + parts.secret.encrypted = true; + } + ); + /** + Shorthand for `mkBytes`, which defaults to "hex" encoding + */ + mkHexBytes = + { + count ? 32, + }: + mkBytes { + inherit count; + encoding = "hex"; + }; + /** + Shorthand for `mkBytes`, which defaults to "base64" encoding + */ + mkBase64Bytes = + { + count ? 32, + }: + mkBytes { + inherit count; + encoding = "base64"; + }; - # Wireguard - # mkWireguard = {}: mkX25519 {encoding = "base64";}; - # mkWireguardPsk = {}: mkBase64Bytes {count = 32;}; - }; + # Wireguard + # mkWireguard = {}: mkX25519 {encoding = "base64";}; + # mkWireguardPsk = {}: mkBase64Bytes {count = 32;}; + }; inherit (secrets) mkPassword --- a/modules/nixos/secrets.nix +++ b/modules/nixos/secrets.nix @@ -105,10 +105,14 @@ in { options = { + shared = mkOption { + type = bool; + description = "Was this secret propagated from a shared secret?"; + }; parts = mkOption { type = lazyAttrsOf (secretPartType secretName); description = "Definition of secret parts"; - default = {}; + default = { }; }; generator = mkOption { type = uniq (nullOr (functionTo package)); @@ -137,24 +141,39 @@ default = null; }; }; - config.parts = mkMerge [ - (mkIf (config.generator != null && config.generator ? parts) config.generator.parts) - (mapAttrs (_: _: {}) (removeAttrs (sysConfig.data.secrets.${secretName} or {}) ["shared" "managed"])) - ]; + config = { + shared = (sysConfig.data.secrets.${secretName} or { shared = false; }).shared; + parts = mkMerge [ + (mkIf (config.generator != null) + ( + # Get fake derivation body, in future it should be implemented the same way as in Rust. + lib.callPackageWith ( + pkgs + // { + mkSecretGenerator = pkgs.stdenv.mkDerivation; + mkImpureSecretGenerator = pkgs.stdenv.mkDerivation; + } + ) config.generator { } + ).parts + ) + (mapAttrs (_: _: { }) ( + removeAttrs (sysConfig.data.secrets.${secretName} or { }) [ + "shared" + "managed" + ] + )) + ]; + }; } ); processPart = secretName: partName: part: { inherit (part) path stablePath; raw = config.data.secrets.${secretName}.${partName}.raw; }; - processSecret = - secretName: secret: - { - inherit (secret.definition) group mode owner; - parts = (mapAttrs (processPart secretName) ( - secret.definition.parts - )); - }; + processSecret = secretName: secret: { + inherit (secret.definition) group mode owner; + parts = (mapAttrs (processPart secretName) (secret.definition.parts)); + }; secretsData = (mapAttrs (processSecret) config.secrets); secretsFile = pkgs.writeTextFile { name = "secrets.json"; @@ -174,7 +193,7 @@ secrets = mkOption { type = attrsOf secretType; default = { }; - apply = v: (mapAttrs (_: secret: secret.parts // {definition = secret;}) v); + apply = v: (mapAttrs (_: secret: secret.parts // { definition = secret; }) v); description = "Host-local secrets"; }; system.secretsData = mkOption { --- a/modules/secrets.nix +++ b/modules/secrets.nix @@ -124,6 +124,7 @@ # If set - script will be run on remote machine, otherwise it will be run with fleet project in CWD # (Some secrets-encryption-in-git/managed PKI solution is expected) impureOn ? null, + parts, }: (prev.writeShellScript "impureGenerator.sh" '' #!/bin/sh @@ -151,12 +152,12 @@ '').overrideAttrs (old: { passthru = { - inherit impureOn; + inherit impureOn parts; generatorKind = "impure"; }; }); # Pure generators are disabled for now - mkSecretGenerator = { script }: mkImpureSecretGenerator { inherit script; }; + mkSecretGenerator = { script, parts }: mkImpureSecretGenerator { inherit script parts; }; # TODO: Implement consistent naming # Pure secret generator is supposed to be run entirely by nix, using `__impure` derivation type...