diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..5483ed6 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"de029a55-31d3-41c9-b62a-0fa17789202c","pid":31042,"procStart":"9453895","acquiredAt":1779827176270} \ No newline at end of file diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..e7ba0d8 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,14 @@ +# sops creation rules. Recipients: +# host — cc-ci's age key, derived from its ed25519 SSH host key (ssh-to-age). +# Used at activation to decrypt into /run/secrets (sops-nix, age.sshKeyPaths). +# master — off-box recovery/admin key; private half lives ONLY on the build host at +# /srv/cc-ci/.sops/master-age.txt (never in this repo). Lets us re-key if cc-ci is lost. +keys: + - &host age1h90utdztfc23kx8ewrtrtk80mnddvrf8pg4ppej55rwwwupzhfvqhmp3qa + - &master age1cmk26t9e30ls8594s8txgmf2exenydmntfxqpcd3qdqm3ru2lpnqpdkdz9 +creation_rules: + - path_regex: secrets/.*\.(yaml|json|env)$ + key_groups: + - age: + - *host + - *master diff --git a/BACKLOG.md b/BACKLOG.md index 4ea21fc..a2fac85 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -8,9 +8,10 @@ Two single-writer sections (§6.1): Builder edits only `## Build backlog`; Adver ### M0 — Foundations - [x] Author flake.nix (NixOS host cc-ci) + hosts/cc-ci/{configuration,hardware}.nix from baseline - [x] Deploy mechanism decision + first rebuild from repo (DECISIONS.md) — switch --flake on host -- [ ] sops-nix wiring: host age key, secrets/secrets.yaml, decrypt a test secret on host -- [ ] Gate: M0 — `ssh cc-ci 'systemctl is-system-running'` healthy after rebuild from repo (base - rebuild verified healthy 2026-05-26; will CLAIM gate once sops test-secret also lands) +- [x] sops-nix wiring: host age key (from ssh host key) + master recovery key; secrets/secrets.yaml; + decrypt a test secret on host → /run/secrets/test_secret (0400 root) verified +- [x] Gate: M0 — `ssh cc-ci 'systemctl is-system-running'` healthy after rebuild from repo + → CLAIMED 2026-05-26, awaiting Adversary (see STATUS.md) ### M1 — Swarm + abra target - [ ] Docker + single-node swarm via Nix diff --git a/DECISIONS.md b/DECISIONS.md index 4049f12..340c2dd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -25,7 +25,12 @@ Architecture decisions and dead-ends. One line of rationale each. (§0, §8) is a true no-op-then-base. Bump deliberately, never drift. - **Webhook scope:** default per-repo via enroll script. - **Drone runner type:** default exec (must drive host abra). -- **Secret tool:** default sops-nix. +- **Secret tool — SETTLED (M0):** sops-nix. cc-ci decrypts at activation using its **ed25519 SSH + host key** as the age identity (`sops.age.sshKeyPaths`), so no extra key file to manage on the box. + Recipients in `/.sops.yaml`: the host age key (`age1h90ut…`, from ssh-to-age) + an off-box + **master recovery key** (`age1cmk26t…`; private half only at `/srv/cc-ci/.sops/master-age.txt` on + the build host, never in the repo) for re-keying if cc-ci is lost. Encrypt new secrets by writing + plaintext into `secrets/.yaml` then `sops -e -i` (run inside the repo so `.sops.yaml` is found). - **D10 recipe set:** lock six early. Candidates favouring already-mirrored: custom-html (simple), cryptpad (stateful no-DB), keycloak (SSO/DB), matrix-synapse (DB+media), lasuite-docs (multi+S3), bluesky-pds (TLS-passthrough) — covers all five categories. Confirm during M4–M6.5. diff --git a/JOURNAL.md b/JOURNAL.md index a9e715b..6babd15 100644 --- a/JOURNAL.md +++ b/JOURNAL.md @@ -58,3 +58,42 @@ baseline; networking is up. Clean up by choosing one stack later. **Next:** sops-nix wiring (host age key from ssh host key + a decrypt-a-test-secret proof), then CLAIM the M0 gate for the Adversary. + +## 2026-05-26 — M0: sops-nix wiring + decrypt-a-test-secret (M0 COMPLETE, gate CLAIMED) + +**Keys:** +- Host age recipient from ssh host key: `ssh cc-ci 'nix run nixpkgs#ssh-to-age -- -i + /etc/ssh/ssh_host_ed25519_key.pub'` → `age1h90utdztfc23kx8ewrtrtk80mnddvrf8pg4ppej55rwwwupzhfvqhmp3qa`. +- Master recovery key generated on host (`age-keygen`), public `age1cmk26t…`; private moved off-box + to `/srv/cc-ci/.sops/master-age.txt` (mode 600) and `shred`-ded from the host. Never in repo. + +**Files:** `.sops.yaml` (both recipients, rule `secrets/.*\.(yaml|json|env)$`); `modules/secrets.nix` +(`sops.age.sshKeyPaths=[/etc/ssh/ssh_host_ed25519_key]`, `secrets.test_secret={}`); flake gains +`sops-nix` input + `sops-nix.nixosModules.sops`; configuration.nix imports the module. + +**sops-nix version pin (dead-end avoided):** master sops-nix wants `buildGo125Module` (Go 1.25), +absent in pinned nixpkgs 24.11 → eval error. Pinned sops-nix to `77c423a…` (2025-06-17, last using +plain `buildGoModule`). Verified the file at that rev uses `buildGoModule`. Build then OK. + +**Encrypt test secret:** on host, `printf 'test_secret: cc-ci-m0-' > secrets/secrets.yaml` +then `nix run nixpkgs#sops -- --encrypt --in-place secrets/secrets.yaml` (run inside repo so +`.sops.yaml` resolves) → rc=0, two age recipients in the file. + +**Build + switch (commands + output):** +- `nixos-rebuild build --flake .#cc-ci` → `BUILD EXIT 0` (built sops-install-secrets w/ Go 1.23.8). +- `systemd-run --unit=ccci-rebuild2 ... nixos-rebuild switch --flake /root/cc-ci#cc-ci` → + `Result=success ExecMainStatus=0`. + +**Gate verification (M0):** +- `systemctl is-system-running` → `running`; `systemctl --failed` → none. +- `ls -la /run/secrets/test_secret` → `-r-------- 1 root root 41` ; `stat` → `root:root 400`. +- `head -c9` → `cc-ci-m0-` (matches generated value), `wc -c` → 41 (9 + 32 hex). Decrypt path proven. +- Pulled encrypted `secrets/secrets.yaml` + `flake.lock` back to clone; `grep cc-ci-m0 secrets.yaml` + → no plaintext leak; lock inputs = nixpkgs, sops-nix. + +**Gate handshake:** set `Gate: M0 — CLAIMED, awaiting Adversary` in STATUS.md. REVIEW.md still empty +(no Adversary activity yet). Per §6.1 liveness I won't idle-block: I keep M0 claimed and proceed +with M1 (independent infra build), without advancing to M2 until M0 shows PASS. + +**Next:** M1 — Docker + single-node swarm via Nix (modules/swarm.nix), then Traefik (file provider +→ /var/lib/ci-certs/live/) + abra, then a by-hand HTTPS deploy/teardown of a trivial recipe. diff --git a/STATUS.md b/STATUS.md index f3a45a3..c5ac297 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,11 +1,15 @@ # STATUS — cc-ci Builder -**Phase:** M0 — Foundations -**In-flight:** Base flake config deployed + verified. Next M0 task: sops-nix + decrypt a test secret. -**Last updated:** 2026-05-26 (M0 base config live) +**Phase:** M0 → M1. M0 complete & CLAIMED; starting M1 (swarm + Traefik + abra) while awaiting verdict. +**In-flight:** M1 — Docker + single-node swarm via Nix (first M1 task). +**Last updated:** 2026-05-26 (M0 claimed) ## Gates -- (none claimed yet — M0 gate pends sops wiring) +- **Gate: M0 — CLAIMED, awaiting Adversary** (2026-05-26). Evidence: flake rebuilds cc-ci from repo + (`switch --flake /root/cc-ci#cc-ci`, gen healthy, no failed units); sops-nix decrypts + `/run/secrets/test_secret` (0400 root, value = generated `cc-ci-m0-…`). Repro: clone repo, sync to + host, `nixos-rebuild switch --flake .#cc-ci`, then `systemctl is-system-running` + check the secret. + Per §6.1 I will NOT advance past this gate to M2; M1 work proceeds as independent unblocked work. ## Blocked - (none) diff --git a/flake.lock b/flake.lock index 8eeab1b..143b18c 100644 --- a/flake.lock +++ b/flake.lock @@ -18,7 +18,29 @@ }, "root": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "sops-nix": "sops-nix" + } + }, + "sops-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1750119275, + "narHash": "sha256-Rr7Pooz9zQbhdVxux16h7URa6mA80Pb/G07T4lHvh0M=", + "owner": "Mic92", + "repo": "sops-nix", + "rev": "77c423a03b9b2b79709ea2cb63336312e78b72e2", + "type": "github" + }, + "original": { + "owner": "Mic92", + "repo": "sops-nix", + "rev": "77c423a03b9b2b79709ea2cb63336312e78b72e2", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index a8fddfb..37a7c5d 100644 --- a/flake.nix +++ b/flake.nix @@ -5,9 +5,14 @@ # Pinned to the exact revision cc-ci already runs, so the first rebuild from # this repo is a true no-op-then-base (M0). Bump deliberately, not drift. nixpkgs.url = "github:NixOS/nixpkgs/50ab793786d9de88ee30ec4e4c24fb4236fc2674"; + + # Pinned to a commit that still uses plain `buildGoModule` — sops-nix master moved to + # `buildGo125Module` (Go 1.25), which our pinned nixpkgs 24.11 (2025-06-30) does not have. + sops-nix.url = "github:Mic92/sops-nix/77c423a03b9b2b79709ea2cb63336312e78b72e2"; + sops-nix.inputs.nixpkgs.follows = "nixpkgs"; }; - outputs = { self, nixpkgs }: + outputs = { self, nixpkgs, sops-nix }: let system = "x86_64-linux"; pkgs = nixpkgs.legacyPackages.${system}; @@ -15,7 +20,10 @@ { nixosConfigurations.cc-ci = nixpkgs.lib.nixosSystem { inherit system; - modules = [ ./hosts/cc-ci/configuration.nix ]; + modules = [ + sops-nix.nixosModules.sops + ./hosts/cc-ci/configuration.nix + ]; }; # Devshell for working on the harness/bridge locally. diff --git a/hosts/cc-ci/configuration.nix b/hosts/cc-ci/configuration.nix index ab5605c..caee011 100644 --- a/hosts/cc-ci/configuration.nix +++ b/hosts/cc-ci/configuration.nix @@ -5,6 +5,7 @@ { imports = [ ./hardware.nix + ../../modules/secrets.nix ]; # --- Tailscale (ACCESS-CRITICAL: do not break, this is the only route in) --- diff --git a/modules/secrets.nix b/modules/secrets.nix new file mode 100644 index 0000000..a6dbb83 --- /dev/null +++ b/modules/secrets.nix @@ -0,0 +1,18 @@ +# sops-nix wiring (D6 infra secrets). cc-ci decrypts secrets at activation using its own +# ed25519 SSH host key as the age identity (no separate key file to manage on the box). +# Encrypted material lives in ../secrets/*.yaml, committed and readable only by recipients +# listed in /.sops.yaml (host key + off-box master recovery key). +{ ... }: +{ + sops = { + defaultSopsFile = ../secrets/secrets.yaml; + # Decrypt using the host's SSH host key (converted to an age identity by sops-nix). + age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ]; + # Do not also look for a GPG key. + gnupg.sshKeyPaths = [ ]; + + # M0 proof secret — confirms the decrypt path works end to end. Real infra secrets + # (Drone RPC, webhook HMAC, OAuth, registry creds) are added in their milestones. + secrets.test_secret = { }; + }; +} diff --git a/secrets/.gitkeep b/secrets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml new file mode 100644 index 0000000..2a36a86 --- /dev/null +++ b/secrets/secrets.yaml @@ -0,0 +1,30 @@ +test_secret: ENC[AES256_GCM,data:VOxNiRyeSQQPKeF2PUK9AtezhzX+Hdm9ji5ZYm+gNd2NJ+wwXc67En8=,iv:Bn1oQeBN98E2/To1KRAw3wsLUF0/HsQFBm8s28L5aqo=,tag:KPS5Y+25Elf3alSF2H6npw==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age1h90utdztfc23kx8ewrtrtk80mnddvrf8pg4ppej55rwwwupzhfvqhmp3qa + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoRzN4Q0Eramhkdk8xbmNn + NGRwclpaT3YvR24yemJxQjF1N001cVI3MXdjCk9wUE5QTE1UNmxTa1BWalk1V1hS + M1o2elpOVHNGcWdyWVI1RHdUZi96M0UKLS0tIGpaRDNNa1ZuRWkybitPTlM5ajVK + VjNldWh1Q3lTc2lCNlE4aGNmaVNOT1kKMhsp/z5LbEyAezDHTodL2vS3L/wlNOc0 + xiDLBYX1AJSiT8DOBvSMSZFj+ygsNL8GBYABjC0Ioar1PIK/KI00oA== + -----END AGE ENCRYPTED FILE----- + - recipient: age1cmk26t9e30ls8594s8txgmf2exenydmntfxqpcd3qdqm3ru2lpnqpdkdz9 + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwTk9uQ3JSeEFidEF6U2FU + Mjk1bVYvNTdYaDJjL0g1ZGlZdXkyR3VUREJrClV5S283elRFbXZkUXNiQkoyYWwr + ZUkxV1UyRE1xcW5DSTNVbEk1dXEyWmcKLS0tIFo5SUdhaVVqYUpZUGQxVGQvR2s0 + a2RKRWVaTGhNb0d3TFlnL2NtejZOaEUK2dQaAzlYk4Z7aBej77cO4Ug9Afkka6wg + G1SumwxX0wMocpgz4WhDUPkBC66uWlaR3u1AWzwpzRseuwAZ94gAxA== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-05-26T20:32:25Z" + mac: ENC[AES256_GCM,data:cfMw8ILys0FXVXiRUbsZ1Jjz+S7X+0Q+DI4oyWNEP8Ka/iZeLkF1NjYbcdOVpqTuiF2gGde2uUjFRy3GtXOUYn8u8Sf2Ve1D1QpJ+8IiDCcaPUjxOxAKd3yGgDi4mm5EWxg+DerlugEj5hwlxWWBdj0I7XDnL2RPANN+yA9Ypko=,iv:hewvYjUmDfEg0PfUMaY0+SZuLy8sAR++d2Dlzm5Yr4w=,tag:nHqjRCHHM98Hzdv1HYHr3A==,type:str] + pgp: [] + unencrypted_suffix: _unencrypted + version: 3.9.4