feat: credentials at rest — per-secret encrypted files #25

Closed
opened 2026-03-28 15:57:13 +00:00 by dev-bot · 0 comments
Collaborator

Prerequisite for #24 (vault-gated actions).

Problem

Forge tokens are in plaintext .env. Future vault secrets (CLAWHUB_TOKEN, GITHUB_TOKEN, deploy keys) need secure storage. The vault gate (#24) needs to launch containers with specific credentials — not all of them, just the ones an action needs.

Design: per-secret files, not a megafile

Instead of one .env.vault.enc containing everything, store each secret as an individual file:

secrets/
├── CLAWHUB_TOKEN.enc
├── GITHUB_TOKEN.enc
└── DEPLOY_KEY_STAGING.enc

When disinto vault-run launches a container for an action that needs CLAWHUB_TOKEN, it mounts only secrets/CLAWHUB_TOKEN.enc, decrypts it, and injects it as an env var. The container never sees GITHUB_TOKEN.

This maps cleanly to the vault proposal: the proposal says "secrets": ["CLAWHUB_TOKEN"] and vault-run knows exactly which files to mount.

How SOPS + age works

age generates a key pair:

  • Private key: ~/.config/sops/age/keys.txt (on disk, chmod 600, not in repo)
  • Public key: in .sops.yaml (committed — tells SOPS who can decrypt)

SOPS encrypts values, not keys. You can see which secret a file contains, just not its value.

The private key must be on disk for unattended decryption (factory restarts without human). Same trade-off as SSH keys. Protected by filesystem permissions.

When to set up

Not required at init. disinto init works without SOPS/age — stores forge tokens in plaintext .env with a warning. The user runs disinto secrets migrate when ready. This keeps the barrier to entry low.

Sequence:

  1. disinto init → plaintext .env (works immediately)
  2. User installs age + SOPS when ready
  3. disinto secrets migrate → encrypts .env.env.enc, deletes plaintext
  4. User adds vault secrets: disinto secrets add CLAWHUB_TOKEN → prompts for value → writes secrets/CLAWHUB_TOKEN.enc

Impact on stack stop/start

  • disinto up: env.sh detects .env.enc, runs sops -d in memory, exports vars. No plaintext on disk.
  • disinto down: nothing changes. Encrypted files stay.
  • Container restart: entrypoint sources env.sh which decrypts. Age private key mounted read-only.
  • New machine: needs the age private key backed up separately. Without it, encrypted files are unreadable.

Compose changes

Mount age key into agents container:

- ${HOME}/.config/sops/age:/home/agent/.config/sops/age:ro

Implementation

  1. Install age + SOPS in Dockerfile (and document host install)
  2. disinto secrets add <NAME> — prompts for value, encrypts to secrets/<NAME>.enc
  3. disinto secrets migrate — encrypts existing .env.env.enc
  4. disinto vault-run — reads proposal's secrets list, mounts only those files, decrypts at container start
  5. Update env.sh to handle .env.enc (already scaffolded)

What stays plaintext

  • .sops.yaml (public key only)
  • docker-compose.yml (var references, no values)
  • projects/*.toml (no secrets)
  • Proposal JSONs in ops repo (secret names, not values)

Acceptance criteria

  • Per-secret encrypted files in secrets/ directory
  • disinto secrets add/migrate commands work
  • disinto vault-run mounts only requested secrets
  • Stack stop/start works with encrypted secrets
  • Age private key documented as backup requirement
  • Not required at init — graceful fallback to plaintext with warning
Prerequisite for #24 (vault-gated actions). ## Problem Forge tokens are in plaintext `.env`. Future vault secrets (CLAWHUB_TOKEN, GITHUB_TOKEN, deploy keys) need secure storage. The vault gate (#24) needs to launch containers with specific credentials — not all of them, just the ones an action needs. ## Design: per-secret files, not a megafile Instead of one `.env.vault.enc` containing everything, store each secret as an individual file: ``` secrets/ ├── CLAWHUB_TOKEN.enc ├── GITHUB_TOKEN.enc └── DEPLOY_KEY_STAGING.enc ``` When `disinto vault-run` launches a container for an action that needs `CLAWHUB_TOKEN`, it mounts only `secrets/CLAWHUB_TOKEN.enc`, decrypts it, and injects it as an env var. The container never sees `GITHUB_TOKEN`. This maps cleanly to the vault proposal: the proposal says `"secrets": ["CLAWHUB_TOKEN"]` and vault-run knows exactly which files to mount. ## How SOPS + age works **age** generates a key pair: - Private key: `~/.config/sops/age/keys.txt` (on disk, `chmod 600`, not in repo) - Public key: in `.sops.yaml` (committed — tells SOPS who can decrypt) **SOPS** encrypts values, not keys. You can see which secret a file contains, just not its value. The private key must be on disk for unattended decryption (factory restarts without human). Same trade-off as SSH keys. Protected by filesystem permissions. ## When to set up **Not required at init.** `disinto init` works without SOPS/age — stores forge tokens in plaintext `.env` with a warning. The user runs `disinto secrets migrate` when ready. This keeps the barrier to entry low. Sequence: 1. `disinto init` → plaintext `.env` (works immediately) 2. User installs age + SOPS when ready 3. `disinto secrets migrate` → encrypts `.env` → `.env.enc`, deletes plaintext 4. User adds vault secrets: `disinto secrets add CLAWHUB_TOKEN` → prompts for value → writes `secrets/CLAWHUB_TOKEN.enc` ## Impact on stack stop/start - **`disinto up`**: `env.sh` detects `.env.enc`, runs `sops -d` in memory, exports vars. No plaintext on disk. - **`disinto down`**: nothing changes. Encrypted files stay. - **Container restart**: entrypoint sources `env.sh` which decrypts. Age private key mounted read-only. - **New machine**: needs the age private key backed up separately. Without it, encrypted files are unreadable. ## Compose changes Mount age key into agents container: ```yaml - ${HOME}/.config/sops/age:/home/agent/.config/sops/age:ro ``` ## Implementation 1. Install age + SOPS in Dockerfile (and document host install) 2. `disinto secrets add <NAME>` — prompts for value, encrypts to `secrets/<NAME>.enc` 3. `disinto secrets migrate` — encrypts existing `.env` → `.env.enc` 4. `disinto vault-run` — reads proposal's `secrets` list, mounts only those files, decrypts at container start 5. Update `env.sh` to handle `.env.enc` (already scaffolded) ## What stays plaintext - `.sops.yaml` (public key only) - `docker-compose.yml` (var references, no values) - `projects/*.toml` (no secrets) - Proposal JSONs in ops repo (secret names, not values) ## Acceptance criteria - [ ] Per-secret encrypted files in `secrets/` directory - [ ] `disinto secrets add/migrate` commands work - [ ] `disinto vault-run` mounts only requested secrets - [ ] Stack stop/start works with encrypted secrets - [ ] Age private key documented as backup requirement - [ ] Not required at init — graceful fallback to plaintext with warning
dev-bot changed title from feat: install SOPS + age for credentials at rest to feat: credentials at rest — per-secret encrypted files 2026-03-28 16:12:52 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: johba/disinto#25
No description provided.