Skip to content

Secrets management

This guide documents secrets management in the infrastructure. Clan vars is the primary secrets system, with legacy sops-nix for supplementary user secrets.

The infrastructure uses clan vars as the primary secrets system, with some legacy sops-nix patterns remaining. See Clan Integration for the complete architectural explanation.

For a learning-oriented walkthrough of setting up secrets from scratch, see the Secrets Setup Tutorial.

StatusToolPurposePlatformsGeneration
PrimaryClan varsSystem and user secretsNixOS (darwin support planned)Automatic (clan vars generate)
Supplementarysops-nixUser secrets (legacy format)All (darwin + NixOS)Manual (age key derivation)

Secrets are split between two systems:

Clan vars handles auto-generated, machine-specific secrets on NixOS hosts. The vars generator creates SSH keys, zerotier IDs, and other secrets that machines need automatically.

sops-nix handles manually-entered user secrets on all platforms. API tokens, personal credentials, and signing keys are created by humans using age encryption. New user secrets can use clan vars; legacy patterns remain functional.

Clan vars is the primary secrets management system. Deployed on NixOS hosts (cinnabar, electrum, galena, scheelite) for system secrets.

  • SSH host keys - Machine identity for SSH
  • Zerotier identities - Network identity for mesh VPN
  • LUKS passphrases - Disk encryption secrets
  • Service credentials - Machine-specific service secrets
Terminal window
# Generate secrets for a machine
clan vars generate cinnabar
# View a specific secret
clan vars get cinnabar ssh.id_ed25519.pub
# Deploy secrets to machine (secrets deploy automatically)
clan machines update cinnabar

Generated secrets are stored encrypted in the vars directory at the repository root:

vars/
├── per-machine/
│ └── cinnabar/
│ ├── openssh/
│ │ ├── ssh.id_ed25519/
│ │ │ └── secret # Private key (encrypted)
│ │ └── ssh.id_ed25519.pub/
│ │ └── value # Public key
│ └── zerotier/
│ └── zerotier-identity-secret/
│ └── secret
└── shared/
└── ... # Shared secrets across machines

Clan vars deploys secrets to /run/secrets/ on NixOS machines during system activation.

Terminal window
# On cinnabar (NixOS)
ls /run/secrets/
# ssh.id_ed25519 zerotier/identity.secret

To rotate clan vars secrets:

Terminal window
# Regenerate secrets for a machine
clan vars generate cinnabar
# Deploy the new secrets
clan machines update cinnabar

Service restart may be required after rotation depending on which secrets changed.

sops-nix handles user-level secrets that require manual creation. Available on all platforms (darwin and NixOS).

  • GitHub tokens - Personal access tokens, signing keys
  • API keys - Anthropic, OpenAI, and other service credentials
  • Personal credentials - User-specific service passwords
  • MCP server secrets - Model Context Protocol authentication

The age private key used by sops-nix is derived from your Bitwarden-managed SSH key using ssh-to-age. This manual bootstrap step is intentional for security.

This infrastructure uses Bitwarden as the authoritative source for SSH keys from which age keys are deterministically derived. SSH keys are stored in Bitwarden as items named sops-{identifier}-ssh (e.g., sops-crs58-ssh, sops-raquel-ssh). Age keys are derived using ssh-to-age, which means the same SSH key always produces the same age key.

Three contexts must have corresponding keys for proper secrets management:

  1. Clan user key in sops/users/{user}/key.json (age public key)
  2. YAML anchor in .sops.yaml (age public key, e.g., &admin-user)
  3. Workstation keyfile at ~/.config/sops/age/keys.txt (age private key)

All three must correspond to the same SSH keypair stored in Bitwarden. The justfile provides automation for maintaining this correspondence.

The infrastructure provides justfile recipes for managing Bitwarden-derived age keys:

Extract and display all age public keys:

Terminal window
just sops-extract-keys

This retrieves all sops-*-ssh items from Bitwarden and displays their corresponding age public keys.

Regenerate workstation keyfile from Bitwarden:

Terminal window
just sops-sync-keys

This extracts your SSH private key from Bitwarden and regenerates ~/.config/sops/age/keys.txt.

Validate three-context correspondence:

Terminal window
just sops-validate-correspondences

This checks that clan user keys, .sops.yaml anchors, and workstation keyfiles all correspond to the same Bitwarden SSH keys.

Full key rotation workflow:

Terminal window
just sops-rotate

This orchestrates the complete key rotation process including validation, re-encryption, and deployment.

Proper secrets management requires consistency across three distinct contexts:

Context 1: Clan user key (sops/users/{user}/key.json) This file contains the age public key for the user’s clan identity. It is used by clan commands and must match the user’s Bitwarden SSH key.

Context 2: YAML anchor (.sops.yaml) The age public key is referenced as a YAML anchor (e.g., &admin-user) in creation rules. This controls which keys can decrypt specific secrets files.

Context 3: Workstation keyfile (~/.config/sops/age/keys.txt) The age private key must exist on the workstation to decrypt secrets during configuration builds. This is generated from the SSH private key in Bitwarden.

Validation example:

Terminal window
# Extract public key from clan user
cat sops/users/crs58/key.json | jq -r '.publickey'
# Extract public key from .sops.yaml
grep "admin-user" .sops.yaml | awk '{print $3}'
# Derive public key from workstation keyfile
age-keygen -y ~/.config/sops/age/keys.txt
# All three outputs must match for proper operation

The just sops-validate-correspondences recipe automates this verification.

ToolPurposeInstallation
bwBitwarden CLI for SSH key retrievalnix-shell -p bitwarden-cli
ssh-to-ageDerive age keys from SSH keysnix-shell -p ssh-to-age
sopsEncrypt/decrypt secrets filesnix-shell -p sops
ageAge encryption (for verification)nix-shell -p age

Step 1: Unlock Bitwarden vault

Terminal window
# Login to Bitwarden CLI (if not already logged in)
bw login
# Unlock vault and set session
export BW_SESSION=$(bw unlock --raw)

Step 2: Derive age keys from Bitwarden SSH key

Terminal window
# Derive age public key (for .sops.yaml and clan user)
age_pub=$(bw get item "sops-myuser-ssh" | jq -r '.login.password' | ssh-to-age)
# Derive age private key (for workstation keyfile)
age_priv=$(bw get item "sops-myuser-ssh" | jq -r '.notes' | ssh-to-age -private-key)
# Validate format (age public keys are 63 characters starting with 'age1')
[[ $age_pub =~ ^age1[a-z0-9]{58}$ ]] && echo "Valid public key format"
# Validate private key format
[[ $age_priv =~ ^AGE-SECRET-KEY- ]] && echo "Valid private key format"

Step 3: Deploy age private key to workstation

Terminal window
# Create sops directory if it doesn't exist
mkdir -p ~/.config/sops/age
# Write age private key to workstation keyfile
echo "$age_priv" > ~/.config/sops/age/keys.txt
# Set restrictive permissions
chmod 600 ~/.config/sops/age/keys.txt
# Verify age key exists
cat ~/.config/sops/age/keys.txt | head -1
# Should show: AGE-SECRET-KEY-...

Step 4: Update clan user and .sops.yaml with public key

Terminal window
# Add public key to .sops.yaml as YAML anchor
# Edit .sops.yaml and add line like:
# - &myuser age1abc...xyz
# Create or update clan user key file
mkdir -p sops/users/myuser
echo "{\"publickey\": \"$age_pub\"}" > sops/users/myuser/key.json
# Lock Bitwarden vault
bw lock

Step 5: Validate three-context correspondence

Terminal window
# Use justfile validation recipe
just sops-validate-correspondences
# Or validate manually (see "Three-context validation" above)

Darwin laptops (stibnite, blackphos, rosegold, argentum): Bitwarden Desktop can serve as an SSH agent, allowing age keys to be derived on-demand without storing SSH private keys on disk. The workstation keyfile (~/.config/sops/age/keys.txt) must still be manually created using the bootstrap procedure.

NixOS servers (cinnabar, electrum, galena, scheelite): No GUI available, so use bw CLI to extract SSH keys and derive age keys. Deploy the age private key to ~/.config/sops/age/keys.txt before running configuration builds that require secrets decryption.

CI/CD environments: Store the age private key as a repository secret (e.g., SOPS_AGE_KEY in GitHub Actions). The CI runner exports this as SOPS_AGE_KEY_FILE environment variable for sops to use during builds.

The manual bootstrap is intentional for security:

  1. SSH keys remain in Bitwarden - Not stored in nix store or git
  2. Age keys derived locally - Private key material never transmitted
  3. User controls bootstrap - Each user manages their own key derivation
  4. Defense in depth - Compromising the nix config doesn’t expose private keys
  5. Deterministic derivation - Same SSH key always produces same age key, enabling validation

After generating your age public key, add it to .sops.yaml:

keys:
# User keys (legacy sops-nix)
- &crs58-stibnite age1abc...xyz
- &raquel-blackphos age1def...uvw
creation_rules:
- path_regex: secrets/users/crs58\.sops\.yaml$
key_groups:
- age:
- *crs58-stibnite
- path_regex: secrets/users/raquel\.sops\.yaml$
key_groups:
- age:
- *raquel-blackphos
Terminal window
# Create new secrets file
sops secrets/users/crs58.sops.yaml
# Edit existing secrets
sops secrets/users/crs58.sops.yaml
# View decrypted secrets (read-only)
sops -d secrets/users/crs58.sops.yaml

Example secrets file structure:

secrets/users/crs58.sops.yaml
github-signing-key: |
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
github-token: ghp_xxxxxxxxxxxxxxxxxxxx
anthropic-api-key: sk-ant-xxxxxxxxxxxxxxxx
ssh-public-key: ssh-ed25519 AAAA... crs58@stibnite

Reference secrets in home-manager modules using sops.secrets:

modules/home/users/crs58/default.nix
{ config, inputs, ... }:
{
sops.secrets = {
"users/crs58/github-signing-key" = {
sopsFile = "${inputs.self}/secrets/users/crs58.sops.yaml";
};
"users/crs58/github-token" = {
sopsFile = "${inputs.self}/secrets/users/crs58.sops.yaml";
};
};
}

Use secrets in configuration:

# Git signing key
programs.git.signing.key = config.sops.secrets."users/crs58/github-signing-key".path;
# Environment variable from secret
home.sessionVariables = {
ANTHROPIC_API_KEY = "$(cat ${config.sops.secrets."users/crs58/anthropic-api-key".path})";
};

sops-nix decrypts secrets during home-manager activation to:

Terminal window
# Check decrypted secrets location
ls ~/.config/sops-nix/secrets/
# Secrets are symlinked from this location
readlink -f ~/.config/sops-nix/secrets/users-crs58-github-token

To rotate sops-nix secrets:

Terminal window
# 1. Edit the encrypted secrets file
sops secrets/users/crs58.sops.yaml
# 2. Update the secret value
# 3. Save and exit (sops re-encrypts automatically)
# 4. Rebuild configuration to deploy
darwin-rebuild switch --flake .#stibnite # darwin
# OR
clan machines update cinnabar # NixOS (includes home-manager)

When adding a new machine or user that needs access to existing secrets:

Terminal window
# 1. Add public key to .sops.yaml
# Edit .sops.yaml and add the new key anchor
# 2. Update all affected secrets files with the new key
sops updatekeys secrets/users/crs58.sops.yaml
# 3. Commit the updated .sops.yaml and re-encrypted files

Darwin (stibnite, blackphos, rosegold, argentum)

Section titled “Darwin (stibnite, blackphos, rosegold, argentum)”

Darwin hosts use sops-nix for user-level secrets. Clan vars support for darwin is planned but not yet implemented.

Secrets workflow:

  1. Bootstrap age key from Bitwarden SSH key (see Age key bootstrap)
  2. Create user secrets file: sops secrets/users/<username>.sops.yaml
  3. Configure home-manager sops module
  4. Deploy: darwin-rebuild switch --flake .#<hostname>

Secrets deployment:

  • Automatic during home-manager activation
  • Location: ~/.config/sops-nix/secrets/
  • No system-level secrets (no /run/secrets/)

NixOS (cinnabar, electrum, galena, scheelite)

Section titled “NixOS (cinnabar, electrum, galena, scheelite)”

NixOS hosts use clan vars for system secrets and sops-nix for user secrets.

Clan vars workflow (system secrets):

Terminal window
# Generate machine secrets
clan vars generate cinnabar
# Deploy (includes secrets)
clan machines update cinnabar

sops-nix workflow (user secrets):

Same as darwin: bootstrap age key, create sops secrets, configure home-manager.

Secrets deployment:

  • Clan vars: /run/secrets/ (system activation)
  • sops-nix: ~/.config/sops-nix/secrets/ (home-manager activation)
AspectDarwinNixOS
Clan varsPlanned (not yet implemented)clan vars generate, /run/secrets/
sops-nixAge key + home-managerAge key + home-manager
SSH host keysManual or existingClan vars generated
Zerotier identityHomebrew generatesClan vars generated
User API keyssops-nixsops-nix
Deploymentdarwin-rebuild switchclan machines update

For user-level secrets (legacy sops-nix):

Terminal window
# 1. Edit or create secrets file
sops secrets/users/<username>.sops.yaml
# 2. Add the new secret
# my-new-secret: secret-value-here
# 3. Reference in home-manager module
# sops.secrets."users/<username>/my-new-secret" = { ... };
# 4. Deploy configuration
darwin-rebuild switch --flake .#<hostname>

For system-level secrets (clan vars - NixOS only):

System secrets are typically auto-generated by clan vars. For custom system secrets, use clan vars generators or add to the machine’s vars configuration.

Terminal window
# Open in editor (decrypts → edit → re-encrypts)
sops secrets/users/crs58.sops.yaml
# Make changes and save
# sops handles encryption automatically
Terminal window
# View decrypted content
sops -d secrets/users/crs58.sops.yaml
# View specific secret (requires jq)
sops -d secrets/users/crs58.sops.yaml | yq '.github-token'
Terminal window
# Check file is encrypted (should show sops metadata)
head secrets/users/crs58.sops.yaml
# Expected: sops: section with mac, lastmodified, etc.

Vars not generating:

Terminal window
# Verify machine is registered in clan
clan machines list
# Check vars generator configuration
cat modules/clan/machines.nix
# Regenerate vars
clan vars generate <hostname>

Secrets not appearing at /run/secrets/:

Terminal window
# Verify deployment
clan machines update <hostname>
# Check systemd service
systemctl status sops-nix
# Check secrets directory permissions
ls -la /run/secrets/

Permission denied on secrets:

Terminal window
# Check owner/group on secret
ls -la /run/secrets/<secret-name>
# Verify user is in correct group
groups $(whoami)

Cannot decrypt sops file:

Terminal window
# Verify age key exists
cat ~/.config/sops/age/keys.txt | head -1
# Should show: AGE-SECRET-KEY-...
# Check your public key is in .sops.yaml
age-keygen -y ~/.config/sops/age/keys.txt
# Compare output with keys in .sops.yaml
# Test decryption explicitly
SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt sops -d secrets/users/<user>.sops.yaml

Age key not found:

Terminal window
# Check key file location
ls -la ~/.config/sops/age/keys.txt
# If missing, re-bootstrap from Bitwarden (see Age key bootstrap workflow)

sops.secrets not appearing in home-manager:

Terminal window
# Verify sops module is imported
# Check home-manager configuration includes sops-nix
# Verify sopsFile path is correct
# Path should be: "${inputs.self}/secrets/users/<username>.sops.yaml"
# Rebuild and check for errors
darwin-rebuild switch --flake .#<hostname> 2>&1 | grep -i sops

Secret path mismatch:

Terminal window
# Check actual decrypted secret location
ls ~/.config/sops-nix/secrets/
# Secret names use dashes instead of slashes
# users/crs58/github-token → users-crs58-github-token

“could not decrypt data key”:

  • Your age public key is not in the creation rules for this file
  • Solution: Add key to .sops.yaml and run sops updatekeys <file>

“no key could be found”:

  • Age key file missing or empty
  • Solution: Re-bootstrap age key from Bitwarden

“MAC mismatch”:

  • File was modified without proper re-encryption
  • Solution: Re-encrypt the file: sops -e -i <file>