fix: Convert ssh keys to age keys
All checks were successful
CI / Format Check (push) Successful in 2s
CI / Flake Check (push) Successful in 1m42s
CI / Evaluate Key Configurations (nix-builder) (push) Successful in 14s
CI / Evaluate Key Configurations (nix-desktop1) (push) Successful in 7s
CI / Evaluate Key Configurations (nix-laptop1) (push) Successful in 8s
CI / Evaluate Artifacts (installer-iso-nix-laptop1) (push) Successful in 20s
CI / Evaluate Artifacts (lxc-nix-builder) (push) Successful in 13s
CI / Build and Publish Documentation (push) Successful in 11s

This commit is contained in:
UGA Innovation Factory
2026-01-30 19:41:34 +00:00
parent 862ae2c864
commit dd19d1488a
12 changed files with 416 additions and 75 deletions

View File

@@ -1,52 +1,193 @@
# ============================================================================
# Agenix Secret Recipients Configuration
# Agenix Secret Recipients Configuration (Auto-Generated)
# ============================================================================
# This file defines which age public keys can decrypt which secrets.
# Used by the ragenix CLI tool for encrypting/editing secrets.
# This file automatically discovers hosts and their public keys from the
# secrets/ directory structure and generates recipient configurations.
#
# Directory structure:
# secrets/{hostname}/*.pub -> SSH/age public keys for that host
# secrets/global/*.pub -> Keys accessible to all hosts
#
# Usage:
# ragenix -e secrets/global/example.age # Edit/create secret
# ragenix -r # Re-key all secrets
#
# To add admin keys for editing secrets, create secrets/admins/*.pub files
# with your personal age public keys (generated with: age-keygen)
let
# ========== System Public Keys (Age Format) ==========
# Convert SSH host keys to age format:
# ssh-to-age < secrets/{hostname}/ssh_host_ed25519_key.pub
lib = builtins;
# Example (replace with actual age keys):
# nix-builder = "age1...";
# usda-dash = "age1...";
# Helper functions not in builtins
filterAttrs =
pred: set:
lib.listToAttrs (
lib.filter (item: pred item.name item.value) (
lib.map (name: {
inherit name;
value = set.${name};
}) (lib.attrNames set)
)
);
# ========== User Public Keys (for editing secrets) ==========
# These are personal age keys for administrators who need to edit secrets
# Generate with: age-keygen
concatLists = lists: lib.foldl' (acc: list: acc ++ list) [ ] lists;
# Example:
# admin1 = "age1...";
# admin2 = "age1...";
unique =
list:
let
go =
acc: remaining:
if remaining == [ ] then
acc
else if lib.elem (lib.head remaining) acc then
go acc (lib.tail remaining)
else
go (acc ++ [ (lib.head remaining) ]) (lib.tail remaining);
in
go [ ] list;
# ========== Host Groups ==========
allHosts = [
# Add all system keys here
# nix-builder
# usda-dash
];
hasSuffix =
suffix: str:
let
lenStr = lib.stringLength str;
lenSuffix = lib.stringLength suffix;
in
lenStr >= lenSuffix && lib.substring (lenStr - lenSuffix) lenSuffix str == suffix;
admins = [
# Add all admin user keys here
# admin1
# admin2
];
hasPrefix =
prefix: str:
let
lenPrefix = lib.stringLength prefix;
in
lib.stringLength str >= lenPrefix && lib.substring 0 lenPrefix str == prefix;
nameValuePair = name: value: { inherit name value; };
secretsPath = ./.;
# Helper to convert SSH public key content to age public key
sshToAge =
sshPubKey:
let
# This is a simple check - in practice, use ssh-to-age tool
# For now, we'll just use the keys as-is if they look like age keys
trimmed = lib.replaceStrings [ "\n" ] [ "" ] sshPubKey;
in
if lib.substring 0 4 trimmed == "age1" then trimmed else null; # Will need manual conversion with ssh-to-age
# Read all directories in secrets/
secretDirs = if lib.pathExists secretsPath then lib.readDir secretsPath else { };
# Filter to only directories (excludes files)
isDirectory = name: type: type == "directory";
directories = lib.filter (name: isDirectory name secretDirs.${name}) (lib.attrNames secretDirs);
# Read public keys from a directory and convert to age format
readHostKeys =
dirName:
let
dirPath = secretsPath + "/${dirName}";
files = if lib.pathExists dirPath then lib.readDir dirPath else { };
# Prefer .age.pub files (pre-converted), fall back to .pub files
agePubFiles = filterAttrs (name: type: type == "regular" && hasSuffix ".age.pub" name) files;
sshPubFiles = filterAttrs (
name: type: type == "regular" && hasSuffix ".pub" name && !(hasSuffix ".age.pub" name)
) files;
# Read age public keys (already in correct format)
ageKeys = lib.map (
name:
let
content = lib.readFile (dirPath + "/${name}");
# Trim whitespace/newlines
trimmed = lib.replaceStrings [ "\n" " " "\r" "\t" ] [ "" "" "" "" ] content;
in
trimmed
) (lib.attrNames agePubFiles);
# For SSH keys, just include them as-is (user needs to convert with ssh-to-age)
# Or they can run the update-age-keys.sh script
sshKeys =
if (lib.length (lib.attrNames sshPubFiles)) > 0 then
lib.trace "Warning: ${dirName} has unconverted SSH keys. Run secrets/update-age-keys.sh" [ ]
else
[ ];
in
lib.filter (k: k != null && k != "") (ageKeys ++ sshKeys);
# Build host key mappings: { hostname = [ "age1..." "age2..." ]; }
hostKeys = lib.listToAttrs (
lib.map (dir: nameValuePair dir (readHostKeys dir)) (
lib.filter (d: d != "global" && d != "admins") directories
)
);
# Global keys that all hosts can use
globalKeys = if lib.elem "global" directories then readHostKeys "global" else [ ];
# Admin keys for editing secrets
adminKeys = if lib.elem "admins" directories then readHostKeys "admins" else [ ];
# All host keys combined
allHostKeys = concatLists (lib.attrValues hostKeys);
# Find all .age files in the secrets directory
findSecrets =
dir:
let
dirPath = secretsPath + "/${dir}";
files = if lib.pathExists dirPath then lib.readDir dirPath else { };
ageFiles = filterAttrs (name: type: type == "regular" && hasSuffix ".age" name) files;
in
lib.map (name: "secrets/${dir}/${name}") (lib.attrNames ageFiles);
# Generate recipient list for a secret based on its location
getRecipients =
secretPath:
let
# Extract directory name from path: "secrets/nix-builder/foo.age" -> "nix-builder"
pathParts = lib.split "/" secretPath;
dirName = lib.elemAt pathParts 2;
in
if dirName == "global" then
# Global secrets: all hosts + admins
allHostKeys ++ globalKeys ++ adminKeys
else if hostKeys ? ${dirName} then
# Host-specific secrets: that host + global keys + admins
hostKeys.${dirName} ++ globalKeys ++ adminKeys
else
# Fallback: just admins
adminKeys;
# Find all secrets across all directories
allSecrets = concatLists (lib.map findSecrets directories);
# Generate the configuration
secretsConfig = lib.listToAttrs (
lib.map (
secretPath:
let
recipients = getRecipients secretPath;
# Remove duplicates and empty keys
uniqueRecipients = unique (lib.filter (k: k != null && k != "") recipients);
in
nameValuePair secretPath {
publicKeys = uniqueRecipients;
}
) allSecrets
);
in
{
# Global secrets (encrypted for all hosts + admins)
# "secrets/global/example.age".publicKeys = allHosts ++ admins;
# Host-specific secrets (encrypted for specific host + admins)
# "secrets/nix-builder/ssh_host_ed25519_key.age".publicKeys = [ nix-builder ] ++ admins;
# "secrets/usda-dash/ssh_host_ed25519_key.age".publicKeys = [ usda-dash ] ++ admins;
# NOTE: Until you populate the keys above, you can create secrets with:
# age -r <public-key> -o secrets/path/to/secret.age <<< "secret content"
secretsConfig
// {
# Export helper information for debugging
_meta = {
hostKeys = hostKeys;
globalKeys = globalKeys;
adminKeys = adminKeys;
allHostKeys = allHostKeys;
discoveredSecrets = allSecrets;
};
}