From 839a3c63f94cc35d8f51ac75ff24a2d11ca074f7 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Sun, 24 May 2026 16:32:27 +0200 Subject: [PATCH] feat: keep secrets in sync via age-encrypted master key (#208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store all production secrets encrypted in secrets.age (committed to the repo) using age. Only one secret needs to be in CI: SECRETS_AGE_KEY. When a secret changes locally, update secrets.env and re-run scripts/secrets-encrypt.sh to commit a new secrets.age. CI picks up the updated secrets automatically on the next push — no manual CI variable updates required. Changes: - scripts/secrets-encrypt.sh: encrypt secrets.env → secrets.age - scripts/secrets-decrypt.sh: decrypt secrets.age → GITHUB_ENV (CI) or eval-safe export block (local) - scripts/test_secrets.sh: encrypt/decrypt round-trip tests - secrets.env.example: template documenting all production secret keys - ci/main.go: add CheckSecrets function (runs test_secrets.sh via Dagger), wire into Check(), update Graph(); add age to toolchain apt packages - .forgejo/Dockerfile: add age package - .forgejo/workflows/deploy.yml: replace per-secret CI references with a single "Decrypt production secrets" step using SECRETS_AGE_KEY - flake.nix: add age to dev shell - Taskfile.yml: add check-secrets task, include in check-fast - .gitignore: ignore plaintext secrets.env - DAGGER.md: document Option 5 (encrypted secrets file) as active approach Co-Authored-By: Claude Sonnet 4.6 --- .forgejo/Dockerfile | 1 + .forgejo/workflows/deploy.yml | 74 +++++++++------- .gitignore | 1 + DAGGER.md | 66 ++++++++++++++- Taskfile.yml | 7 +- ci/main.go | 22 ++++- flake.nix | 3 + scripts/secrets-decrypt.sh | 85 +++++++++++++++++++ scripts/secrets-encrypt.sh | 42 ++++++++++ scripts/test_secrets.sh | 153 ++++++++++++++++++++++++++++++++++ secrets.env.example | 28 +++++++ 11 files changed, 448 insertions(+), 34 deletions(-) create mode 100755 scripts/secrets-decrypt.sh create mode 100755 scripts/secrets-encrypt.sh create mode 100755 scripts/test_secrets.sh create mode 100644 secrets.env.example diff --git a/.forgejo/Dockerfile b/.forgejo/Dockerfile index 73d5916..fed065b 100644 --- a/.forgejo/Dockerfile +++ b/.forgejo/Dockerfile @@ -10,6 +10,7 @@ FROM ghcr.io/catthehacker/ubuntu:go-24.04 RUN apt-get update && apt-get install -y --no-install-recommends \ stunnel4 \ netcat-openbsd \ + age \ && rm -rf /var/lib/apt/lists/* # Dagger CLI — pinned to match the engine version on the runner host diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index a7887b0..418db7a 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -65,6 +65,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -75,11 +76,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Run Android Tests on Firebase Test Lab - if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Run Android Tests on Firebase Test Lab + if: env.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' env: - FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }} - FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }} DAGGER_NO_NAG: "1" run: task test-android-firebase @@ -103,6 +108,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -113,12 +119,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Publish Android to Play Store - if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Publish Android to Play Store + if: env.PLAY_STORE_CONFIG_JSON != '' env: - ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} - ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} - PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }} DAGGER_NO_NAG: "1" run: task publish-android @@ -142,6 +151,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -152,15 +162,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Build & Deploy APK to server - if: ${{ secrets.SSH_PRIVATE_KEY != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Build & Deploy APK to server + if: env.SSH_PRIVATE_KEY != '' env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} - SSH_USER: ${{ secrets.SSH_USER }} - SSH_HOST: ${{ secrets.SSH_HOST }} - ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} - ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} DAGGER_NO_NAG: "1" run: task deploy-apk @@ -184,6 +194,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -194,13 +205,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Build & Deploy Linux to server - if: ${{ secrets.SSH_PRIVATE_KEY != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Build & Deploy Linux to server + if: env.SSH_PRIVATE_KEY != '' env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} - SSH_USER: ${{ secrets.SSH_USER }} - SSH_HOST: ${{ secrets.SSH_HOST }} DAGGER_NO_NAG: "1" run: task deploy-linux @@ -226,6 +239,7 @@ jobs: run: | command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } + command -v age >/dev/null 2>&1 || { echo "ERROR: age is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; } dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; } - name: Setup Dagger Remote Engine (via stunnel) @@ -236,13 +250,15 @@ jobs: DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }} run: scripts/setup_dagger_remote.sh - - name: Generate build history and deploy website - if: ${{ secrets.SSH_PRIVATE_KEY != '' }} + - name: Decrypt production secrets + if: ${{ secrets.SECRETS_AGE_KEY != '' }} + env: + SECRETS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY }} + run: scripts/secrets-decrypt.sh + + - name: Generate build history and deploy website + if: env.SSH_PRIVATE_KEY != '' env: - SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} - SSH_USER: ${{ secrets.SSH_USER }} - SSH_HOST: ${{ secrets.SSH_HOST }} DAGGER_NO_NAG: "1" run: task publish-website diff --git a/.gitignore b/.gitignore index de47e6c..7608eac 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ assets/changelog.txt .env.local .envrc .direnv/ +secrets.env # plaintext secrets — encrypted version (secrets.age) is committed # --- Android --- android/.gradle/ diff --git a/DAGGER.md b/DAGGER.md index 5f7f3de..e17cea1 100644 --- a/DAGGER.md +++ b/DAGGER.md @@ -174,10 +174,70 @@ Run a secret manager co-located with the Dagger host. The CI job authenticates w - Vault itself becomes a security-critical single point of failure. - Operational overhead likely disproportionate for a small single-developer project. +### Option 5: Encrypted secrets file (age) — **implemented** + +Store all production secrets in a file (`secrets.env`) that is encrypted with +[age](https://age-encryption.org/) into `secrets.age`. The encrypted file is +committed to the repository. Only the age private key — a single string — is +stored in Codeberg as `SECRETS_AGE_KEY`. Any CI job or developer with the key +can decrypt the file and obtain all secrets. + +**How it works:** + +1. Generate a key pair once: + ```bash + age-keygen -o ~/.config/age/sharedinbox.key + age-keygen -y ~/.config/age/sharedinbox.key > .age-public-key + ``` +2. Copy `secrets.env.example` to `secrets.env`, fill in all values, then encrypt: + ```bash + scripts/secrets-encrypt.sh # reads public key from .age-public-key + git add secrets.age && git commit -m "chore: update encrypted secrets" + ``` +3. Add the private key content as `SECRETS_AGE_KEY` in Codeberg repository secrets. +4. CI jobs call `scripts/secrets-decrypt.sh` (with `SECRETS_AGE_KEY` set) before + any step that needs production credentials. The script writes each variable + to `$GITHUB_ENV` so subsequent steps see them automatically. + +**Keeping local and CI in sync:** +When you rotate a secret locally, update `secrets.env`, re-run +`scripts/secrets-encrypt.sh`, and commit the new `secrets.age`. CI will pick +up the fresh secrets on the next push — no manual CI variable updates needed. + +Multi-line values (SSH keys, certificates) must be stored as a single line +with `\n` escape sequences inside double quotes. Example: +``` +SSH_PRIVATE_KEY="
\n\n