Skip to content

Automattic/a8c-secrets

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

198 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

a8c-secrets

a8c-secrets is a CLI tool for managing encrypted secrets in Automattic mobile and desktop repositories.

It is aimed to make it easy to deal with secret files that are needed for developers and CI to compile the code in the repository with real credentials/secret files (secrets.properties, Secrets.swift, …)

Internally, it uses the age encryption specification to encrypt/decrypt secret files, and offers some additional key features compared to using the official age binary directly:

  • It decrypts all the secrets present in a repository using a single a8c-secrets decrypt command (compared to having to call the official age binary on each file one by one)
  • It automatically manages the public and private keys in the right places on the user's computer (compared to users having to provide the key to the official age command line explicitly)
  • It ensures the secrets are decrypted outside the repository working tree (in ~/.a8c-secrets/<repo@host@org>/), to avoid accidental commits of secrets and reduce access from AI agents running in repo.
  • It provides help messages tailored to our usage of the tool at Automattic (references to Secrets Store, dedicated help messages…)

Install

curl -fsSL https://raw.githubusercontent.com/Automattic/a8c-secrets/main/install.sh | bash

Or install to a custom directory:

curl -fsSL https://raw.githubusercontent.com/Automattic/a8c-secrets/main/install.sh | bash -s -- --prefix ~/.local/bin

Quick start

First-time repo setup (run once by a maintainer):

cd my-repo
a8c-secrets setup init
# Follow the printed instructions (Secret Store for dev + CI, then Buildkite)

Developer onboarding:

cd my-repo
a8c-secrets keys import   # Paste the dev private key from Secret Store when prompted
a8c-secrets decrypt

Daily workflow:

a8c-secrets decrypt          # Get latest secrets after git pull
a8c-secrets edit              # Pick from existing decrypted secrets (terminal only)
a8c-secrets edit config.json  # Edit or create by name; confirm before editor; auto-encrypts on save
a8c-secrets encrypt          # Encrypt any modified files
a8c-secrets status           # Show encryption/decryption status of each secret file for validation
git add .a8c-secrets/        # Commit encrypted changes

Run a8c-secrets manual for a comprehensive guide, or a8c-secrets help <command> for per-command help.

What gets encrypted

Note

The secrets this tool manages are files needed by Automatticians to compile apps locally and by CI — for example secret.properties (Android), Secrets.swift (iOS), API keys, or debug signing identities.

Secrets only needed by CI (e.g. App Store signing identities, Play Store upload keys) should not go through this tool — use Buildkite secret environment variables instead.

File layout

In the repo (committed):              On the developer's machine (never in git):

.a8c-secrets/                          ~/.a8c-secrets/
├── repo-id             canonical id   ├── keys/
├── keys.pub            public keys    │   └── <repo@host@org>.key        private key (0600)
├── secret.json.age     encrypted      └── <repo@host@org>/               decrypted files
└── api-keys.yml.age    encrypted          └── api-keys.yml

The repo-id file holds one line: repo@host@org (lowercase), e.g. wordpress-ios@github.com@automattic. It is written by a8c-secrets setup init using your git origin URL, then committed. Daily commands read this file so local keys and decrypted paths stay stable if origin changes (forks, mirrors).

Design decisions

Why Rust?

Cross-platform binaries (macOS, Linux, Windows) from a single codebase. Existing Buildkite pipeline and team familiarity from the git-conceal project.

Why age as a library, not a CLI subprocess?

Using the age crate eliminates the external dependency — users don't need to install age separately, and there's no PATH injection risk. The crate implements the same age-encryption.org/v1 spec as the Go reference implementation. A trait-based abstraction (CryptoEngine) allows swapping to a subprocess engine if ever needed.

Why decrypt outside the working tree?

Decrypted secrets in ~/.a8c-secrets/<repo@host@org>/ can never be accidentally committed (even a .gitignore typo can't expose them) and are invisible to AI agents restricted to the repo working copy.

Two key pairs per repo (dev + CI)

The dev private key is shared by all developers via Secret Store. The CI private key is stored in Secret Store (with Apps Infrastructure authorized) and injected in CI as a Buildkite secret via A8C_SECRETS_IDENTITY. Which key is yours is determined by public key derivation (matching your private key against keys.pub). Lines starting with # in keys.pub are comments and are ignored when reading recipients (same as age); they are optional for humans only.

Secret Store entry names

Those are human-created. Use the short title and set Username to the full id:

  • dev private key → a8c-secrets - <repo-name> - dev private key (Username: full repo@host@org)
  • CI private key → a8c-secrets - <repo-name> - CI private key (Username: full repo@host@org).

Smart encryption

Since age uses random nonces, encrypting identical content twice produces different ciphertext. The encrypt command decrypts existing .age files in memory and compares byte-for-byte with decrypted plaintext, only re-encrypting when content actually changed. This prevents noisy git diffs. Use --force after key rotation to bypass the smart comparison and re-encrypt files unconditionally.

Flat file structure

No subdirectories inside .a8c-secrets/. Name collisions (e.g. two google-services.json for different modules) are handled with unique flat names like wear-google-services.json.

Secret file names

Each secret is a single filename (e.g. Secrets.swift), not a relative path. The edit, encrypt <file …>, and rm commands reject names that contain path separators, .., or other non-flat syntax so outputs stay under .a8c-secrets/ and ~/.a8c-secrets/<repo@host@org>/.

Memory hygiene

Decrypted file contents are held in zeroize buffers where practical so they are cleared when dropped. In-memory private keys are represented as age::x25519::Identity, which wraps secret material with age’s own zeroizing discipline.

decrypt and orphan plaintext files

If a file still exists under ~/.a8c-secrets/<repo@host@org>/ but its .age was removed from the repository (for example the team deleted a secret from git), decrypt reports these as orphans.

  • If stdin is connected to an interactive terminal and --non-interactive is not set: the tool will prompt before deleting. This is for when a developer runs the command locally, to avoid accidentally removing e.g. a plaintext secret they just added and forgot to encrypt (and commit the .age) first.
  • If stdin is not an interactive terminal, or --non-interactive is set (typical CI): orphan files are removed automatically without prompting. That keeps CI from blocking on a prompt; those environments also typically should not keep extra unencrypted plaintext around.

Use decrypt --non-interactive in CI with A8C_SECRETS_IDENTITY (or a key file on the agent).

Terminals, prompts, and private keys on stdout

  • setup init and keys rotate require stdout connected to a terminal so new private keys are not accidentally written to a file or pipe. keys rotate also needs stdin for its menus and confirmations.
  • setup nuke requires stdout and stdin connected to a terminal (you must see the destructive summary before confirming). rm (without --non-interactive) requires stdin for confirmation prompts.
  • decrypt orphan handling uses stdin for the orphan prompt (unless --non-interactive is set or stdin is not an interactive terminal — see above).
  • edit is for interactive use only: stdin and stdout must be a terminal. Without a file name, you pick from existing decrypted secrets (run decrypt first if none); the picker shows the resolved $EDITOR and trust guidance. With a file name, you confirm editing or creating that secret before $EDITOR runs, with the same trust guidance. New secrets are created with a8c-secrets edit <file>.

Key rotation

On employee offboarding (or when rotating CI’s key), treat age keys (keys.pub / dev & CI identities) and provider/API secrets (what lives inside the encrypted files) as separate work.

What keys rotate does

It refuses to run until a8c-secrets status shows every secret as “in sync” (no encrypted-only, decrypted-only, or modified local copy). Then it updates keys.pub and re-encrypts each .age file from the matching plaintext under ~/.a8c-secrets/<repo@host@org>/, so new ciphertext matches your local decrypted files. If anything is out of sync, fix it with decrypt / encrypt (or remove stray files) and retry.

Recommended order

  1. Revoke or disable old credentials at each provider as soon as your runbook allows (so stolen keys stop working at the API).
  2. Run a8c-secrets keys rotate — interactive: pick the recipient, confirm; the tool prints the new private key and re-encrypts .age files from your in-sync plaintext under ~/.a8c-secrets/<repo@host@org>/.
  3. Update Secret Store / CI (or equivalent) with the new private key; notify the team to keys import when the dev key changed.
  4. Issue new provider credentials if needed, update the decrypted secret files, then a8c-secrets encrypt — usually --force right after a key rotation. Commit keys.pub and .age changes (and any follow-up commits for new secret content).

Why age keys before new secrets in git: if you encrypt and push new API material while keys.pub still includes a recipient who should no longer decrypt, anyone with that old dev key could decrypt that commit. Completing keys rotate first avoids encrypting new secrets to the old audience. Diffs right after rotation reflect new ciphertext for the same plaintext you had locally (age nonces differ each time), not necessarily new provider values — those show up after you change decrypted files and encrypt again.

  1. Team runs a8c-secrets keys import && a8c-secrets decrypt where needed.

Environment variables

Variable Description
A8C_SECRETS_IDENTITY Private key override as AGE-SECRET-KEY-... text, used directly in memory. Intended for CI.
EDITOR Editor for the edit command. Parsed like shell words, so the command may include arguments (e.g. code --wait) or a quoted path with spaces. Default: vi (Unix) or notepad (Windows).

Development

Requires Rust 1.88 or later. This crate uses the Rust 2024 edition and let chains (if … && let …), which stabilized in 1.88.

make help          # Show all targets
make lint          # Run clippy
make test          # Run tests
make build-release # Build release binary

Releasing a new version

  1. Create a release branch from main:
    git checkout main && git pull
    git checkout -b release/x.y.z
  2. Bump the version in Cargo.toml (the only place that needs a manual edit):
    version = "x.y.z"
  3. Run cargo check (or cargo build) to validate the binary compiles and to update Cargo.lock with the new version.
  4. Commit, push, and open a PR against main:
    git add Cargo.toml Cargo.lock
    git commit -m "Bump version to x.y.z"
    git push -u origin release/x.y.z
  5. Get the PR reviewed and merged into main.
  6. Tag the merge commit on main and push the tag:
    git checkout main && git pull
    git tag x.y.z
    git push origin x.y.z
    Buildkite CI will automatically build the release binaries and create the GitHub Release for the pushed tag.

Note

The install.sh script, clap's --version flag, and Cargo.lock all derive the version automatically — Cargo.toml is the single source of truth.

References

About

Tool to manage secret files required for compilation in repos for compiled/packaged apps

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors