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 decryptcommand (compared to having to call the officialagebinary 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
agecommand 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…)
curl -fsSL https://raw.githubusercontent.com/Automattic/a8c-secrets/main/install.sh | bashOr install to a custom directory:
curl -fsSL https://raw.githubusercontent.com/Automattic/a8c-secrets/main/install.sh | bash -s -- --prefix ~/.local/binFirst-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 decryptDaily 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 changesRun a8c-secrets manual for a comprehensive guide, or a8c-secrets help <command> for per-command help.
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.
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).
Cross-platform binaries (macOS, Linux, Windows) from a single codebase. Existing Buildkite pipeline and team familiarity from the git-conceal project.
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.
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.
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.
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: fullrepo@host@org) - CI private key →
a8c-secrets - <repo-name> - CI private key(Username: fullrepo@host@org).
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.
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.
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>/.
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.
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-interactiveis 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-interactiveis 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).
setup initandkeys rotaterequire stdout connected to a terminal so new private keys are not accidentally written to a file or pipe.keys rotatealso needs stdin for its menus and confirmations.setup nukerequires stdout and stdin connected to a terminal (you must see the destructive summary before confirming).rm(without--non-interactive) requires stdin for confirmation prompts.decryptorphan handling uses stdin for the orphan prompt (unless--non-interactiveis set or stdin is not an interactive terminal — see above).editis for interactive use only: stdin and stdout must be a terminal. Without a file name, you pick from existing decrypted secrets (rundecryptfirst if none); the picker shows the resolved$EDITORand trust guidance. With a file name, you confirm editing or creating that secret before$EDITORruns, with the same trust guidance. New secrets are created witha8c-secrets edit <file>.
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.
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.
- Revoke or disable old credentials at each provider as soon as your runbook allows (so stolen keys stop working at the API).
- Run
a8c-secrets keys rotate— interactive: pick the recipient, confirm; the tool prints the new private key and re-encrypts.agefiles from your in-sync plaintext under~/.a8c-secrets/<repo@host@org>/. - Update Secret Store / CI (or equivalent) with the new private key; notify the team to
keys importwhen the dev key changed. - Issue new provider credentials if needed, update the decrypted secret files, then
a8c-secrets encrypt— usually--forceright after a key rotation. Commitkeys.puband.agechanges (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.
- Team runs
a8c-secrets keys import && a8c-secrets decryptwhere needed.
| 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). |
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- Create a release branch from
main:git checkout main && git pull git checkout -b release/x.y.z - Bump the version in
Cargo.toml(the only place that needs a manual edit):version = "x.y.z"
- Run
cargo check(orcargo build) to validate the binary compiles and to updateCargo.lockwith the new version. - 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 - Get the PR reviewed and merged into
main. - Tag the merge commit on
mainand push the tag:Buildkite CI will automatically build the release binaries and create the GitHub Release for the pushed tag.git checkout main && git pull git tag x.y.z git push origin x.y.z
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.