diff --git a/.github/instructions/sops-secrets.instructions.md b/.github/instructions/sops-secrets.instructions.md new file mode 100644 index 0000000..3542484 --- /dev/null +++ b/.github/instructions/sops-secrets.instructions.md @@ -0,0 +1,72 @@ +--- +description: "Use when working with SOPS in this repo: editing secrets, adding multiline values, changing recipients, or wiring sops-nix consumers. Captures repo conventions, safe commands, and validation habits." +applyTo: ".sops.yaml, keys/secrets.yaml, modules/hosts/**/secrets.yaml, modules/hosts/**/*.nix, modules/programs/sops.nix, modules/features/**/*.nix, modules/services/**/*.nix" +--- + +# Using SOPS In This Repo + +This repo treats SOPS files as the source of encrypted runtime material, and Nix code as the wiring that exposes those secrets to services. Keep those roles separate: edit secret values with SOPS, consume them through sops-nix paths, and keep recipient policy scoped to the machines or operators that actually need access. + +## Mental Model + +- Secrets live in encrypted YAML files; Nix modules should refer to secret paths, not decrypted values. +- Host-only credentials belong in `modules/hosts//secrets.yaml`. +- Shared credentials belong in `keys/secrets.yaml` only when multiple hosts intentionally consume the same material. +- `.sops.yaml` controls who can decrypt each file; keep those recipient sets narrow and explicit. +- Secret key names are an interface. Rename them only when every consumer changes in the same edit. + +## Editing Secrets + +- Use SOPS tooling for all value changes: `sops `, `edit-secrets`, or `sops set ...`. +- Do not hand-edit `ENC[...]` payloads. That bypasses SOPS and breaks the encrypted document's integrity metadata. +- Keep plaintext out of tracked files. Temporary plaintext files are acceptable only as local working inputs and should be removed after use. +- Prefer `SOPS_EDITOR=nvim sops ` for interactive edits when editor choice matters. + +For large or generated values, avoid putting the secret directly in shell history: + +```bash +sops set --value-file '["parent"]["key"]' /path/to/plaintext-value +``` + +If using inline `sops set`, the value argument must be valid JSON. + +## Multiline Values + +Use YAML literal blocks for private keys, certificates, provisioner keys, and other values where newlines matter: + +```yaml +janus: + ssh_user_ca_key: |- + -----BEGIN OPENSSH PRIVATE KEY----- + ... + -----END OPENSSH PRIVATE KEY----- +``` + +- Use `|-` when the final trailing newline should be stripped. +- Use `|` when the consuming program expects the final trailing newline. +- Avoid folded style (`>`) for keys and certs because it rewrites line breaks. + +## Wiring Secrets Into Nix + +- Consume secrets through `config.sops.secrets."".path` or `config.sops.placeholder` where templating requires it. +- Do not read decrypted secret contents during Nix evaluation. +- Set `owner`, `group`, and `mode` explicitly for non-root services. +- Use `path` or `sops.templates` when a service needs a specific file layout instead of copying secret contents into the store or tracked files. +- If moving a secret between shared and host-local files, update `sops.defaultSopsFile`, `mysops.hostSecretFile`, and every affected `sops.secrets` declaration together. + +## Recipient And Rotation Practices + +- Add dedicated `.sops.yaml` `path_regex` rules for host-local files before broad fallback rules. +- Keep broad fallback rules conservative; do not expand them to grant casual access to every YAML or JSON file. +- Run `sops updatekeys ` after changing recipients so data is rewrapped for the new key set. +- Rotate or migrate in order: update recipients, re-encrypt affected files, update consumers, then validate affected hosts. + +## Validation Habits + +After SOPS or sops-nix changes, check the contract rather than only the syntax: + +1. The expected secrets file is still selected by `sops.defaultSopsFile` or `mysops.hostSecretFile`. +2. `.sops.yaml` has a specific rule for any host-local `secrets.yaml` touched. +3. Secret names in YAML still match the `sops.secrets.""` declarations. +4. Services that need restart or reload behavior have `restartUnits` or `reloadUnits` set. +5. Affected host evaluation/build catches missing secret keys before deployment. diff --git a/modules/hosts/janus/README.md b/modules/hosts/janus/README.md index 4ebe025..b50c291 100644 --- a/modules/hosts/janus/README.md +++ b/modules/hosts/janus/README.md @@ -13,7 +13,6 @@ STEPPATH=/tmp/janus-step-ca-bootstrap/step step ca init --name Janus --dns janus Insert generated runtime CA material into `modules/hosts/janus/secrets.yaml` under `janus`: - `/tmp/janus-step-ca-bootstrap/ca_password.txt` -> `ca_password` -- `/tmp/janus-step-ca-bootstrap/step/certs/intermediate_ca.crt` -> `intermediate_ca_crt` - `/tmp/janus-step-ca-bootstrap/step/secrets/intermediate_ca_key` -> `intermediate_ca_key` - `/tmp/janus-step-ca-bootstrap/step/secrets/ssh_host_ca_key` -> `ssh_host_ca_key` - `/tmp/janus-step-ca-bootstrap/step/secrets/ssh_user_ca_key` -> `ssh_user_ca_key` @@ -27,14 +26,33 @@ If rotating provisioner password, also set: Secret source-of-truth after this split: -- `modules/hosts/janus/secrets.yaml`: Janus runtime CA secrets (`ca_password`, `intermediate_ca_crt`, `intermediate_ca_key`, `ssh_host_ca_key`, `ssh_user_ca_key`, `admin_provisioner_encrypted_key`) +- `modules/hosts/janus/secrets.yaml`: Janus runtime CA secrets (`ca_password`, `intermediate_ca_key`, `ssh_host_ca_key`, `ssh_user_ca_key`, `admin_provisioner_encrypted_key`) - `keys/secrets.yaml`: shared Janus provisioner secret (`janus.admin_jwk`) consumed by `step-ssh-host` across hosts Then update public artifacts in repo from generated output: - `modules/hosts/janus/root_ca.crt` from `/tmp/janus-step-ca-bootstrap/step/certs/root_ca.crt` +- `modules/hosts/janus/intermediate_ca.crt` from `/tmp/janus-step-ca-bootstrap/step/certs/intermediate_ca.crt` (public certificate; intentionally committed, not stored in SOPS) - `modules/hosts/janus/fingerprint` from: ```shell step certificate fingerprint /tmp/janus-step-ca-bootstrap/step/certs/root_ca.crt ``` -- `modules/hosts/janus/ssh_user_ca.pub` from `/tmp/janus-step-ca-bootstrap/step/certs/ssh_user_ca_key.pub` \ No newline at end of file +- `modules/hosts/janus/ssh_user_ca.pub` from `/tmp/janus-step-ca-bootstrap/step/certs/ssh_user_ca_key.pub` + +## Back up the root CA key offline + +The root CA private key is **not** deployed and is never needed by the running CA. +It is only used to sign/rotate intermediates. Store it offline (e.g. 1Password) +before wiping the bootstrap directory, otherwise intermediate rotation becomes +impossible and any future rotation forces a full root rotation (redistributing +`root_ca.crt` + `fingerprint` to every client). + +Back up, then wipe the bootstrap material: + +- `/tmp/janus-step-ca-bootstrap/step/secrets/root_ca_key` -> offline secret store +- `/tmp/janus-step-ca-bootstrap/ca_password.txt` -> offline secret store (root/intermediate key password) + +```shell +shred -u /tmp/janus-step-ca-bootstrap/step/secrets/root_ca_key 2>/dev/null || true +rm -rf /tmp/janus-step-ca-bootstrap +``` \ No newline at end of file diff --git a/modules/hosts/janus/default.nix b/modules/hosts/janus/default.nix index b76a063..f1e4150 100644 --- a/modules/hosts/janus/default.nix +++ b/modules/hosts/janus/default.nix @@ -28,6 +28,7 @@ in }; step-ca = { rootCertPath = ./root_ca.crt; + intermediateCertPath = ./intermediate_ca.crt; dnsNames = [ "${hostname}.john-stream.com" "192.168.1.244" @@ -35,7 +36,6 @@ in secrets = { sopsFile = ./secrets.yaml; caPassword = "janus/ca_password"; - intermediateCrt = "janus/intermediate_ca_crt"; intermediateKey = "janus/intermediate_ca_key"; sshHostCaKey = "janus/ssh_host_ca_key"; sshUserCaKey = "janus/ssh_user_ca_key"; diff --git a/modules/services/step-ca/step-ca.nix b/modules/services/step-ca/step-ca.nix index 5a5576b..75ff1e9 100644 --- a/modules/services/step-ca/step-ca.nix +++ b/modules/services/step-ca/step-ca.nix @@ -7,7 +7,6 @@ caPort = 443; caPasswordPath = (lib.getAttr cfg.secrets.caPassword config.sops.secrets).path; - intermediateCrtPath = (lib.getAttr cfg.secrets.intermediateCrt config.sops.secrets).path; intermediateKeyPath = (lib.getAttr cfg.secrets.intermediateKey config.sops.secrets).path; sshHostCaKeyPath = (lib.getAttr cfg.secrets.sshHostCaKey config.sops.secrets).path; sshUserCaKeyPath = (lib.getAttr cfg.secrets.sshUserCaKey config.sops.secrets).path; @@ -16,7 +15,7 @@ renderedStepCaConfig = builtins.toJSON { root = cfg.rootCertPath; - crt = intermediateCrtPath; + crt = cfg.intermediateCertPath; key = intermediateKeyPath; address = "${caAddress}:${toString caPort}"; dnsNames = cfg.dnsNames; @@ -87,6 +86,10 @@ description = "Path to the Step CA root certificate served by this host."; type = lib.types.path; }; + intermediateCertPath = lib.mkOption { + description = "Path to the Step CA intermediate certificate served by this host. This is public material and does not need to be stored in SOPS."; + type = lib.types.path; + }; dnsNames = lib.mkOption { description = "DNS names and IP SANs advertised by this Step CA instance."; type = with lib.types; listOf str; @@ -100,10 +103,6 @@ description = "SOPS key for the Step CA intermediate password."; type = lib.types.str; }; - intermediateCrt = lib.mkOption { - description = "SOPS key for the Step CA intermediate certificate."; - type = lib.types.str; - }; intermediateKey = lib.mkOption { description = "SOPS key for the Step CA intermediate private key."; type = lib.types.str; @@ -132,13 +131,6 @@ mode = "0400"; restartUnits = [ "step-ca.service" ]; }; - sops.secrets."${cfg.secrets.intermediateCrt}" = { - sopsFile = cfg.secrets.sopsFile; - owner = "step-ca"; - group = "step-ca"; - mode = "0400"; - restartUnits = [ "step-ca.service" ]; - }; sops.secrets."${cfg.secrets.intermediateKey}" = { sopsFile = cfg.secrets.sopsFile; owner = "step-ca"; @@ -184,7 +176,6 @@ intermediatePasswordFile = caPasswordPath; }; - # Keep modules/services/step-ca/ca.json as reference-only; runtime config comes from SOPS template. environment.etc."smallstep/ca.json".source = lib.mkForce config.sops.templates."step-ca-config".path; systemd.services.step-ca.restartTriggers =