secret-manager
Declarative secret management for NixOS — resolve and render age/agenix secrets from shared Nix declarations.
secret-manager is the engine that generates agenix secret modules, manages
.age files in a Nix data repository (a “store”), and pushes decrypted
secrets to Codeberg/Forgejo, Codefloe, and GitHub Actions secret stores.
It was extracted from the canix CLI;
the canix secret command family is a thin shim over this library.
Repository
- Source: https://github.com/caniko/secret-manager
- License: MIT
Installation
From source (Nix)
nix build git+https://github.com/caniko/secret-manager
Or add it as a flake input:
inputs.secret-manager.url = "git+https://github.com/caniko/secret-manager";
From source (Rust)
Requires Rust 1.88 or newer.
git clone https://github.com/caniko/secret-manager.git
cd secret-manager
cargo build --release
The registry package is caniko-secret-manager; installing it provides the
same secret-manager executable:
cargo install caniko-secret-manager --version 0.4.0 --locked
Prerequisites
agenixwithagenix edit,agenix generate, andagenix rekey- age identity files at
age/master-*.pubin the store repository - (for
pushorsync)fjorghauthentication for Codeberg, Codefloe, or GitHub API access
Quick Start
All commands operate on a Nix store repository — the directory that owns
age/secrets/** and the master identity stubs (age/master-*.pub).
List existing secrets
cd /path/to/store
secret-manager list
Add a home-manager passphrase secret
secret-manager hm password --name app-token --env APP_TOKEN --user can
This generates a 32-byte passphrase, creates age/secrets/users/can/app-token.age,
writes the Nix module at home/user/can/repositories/app-token.nix, and splices
it into the default.nix imports.
Decrypt a secret to stdout
secret-manager decrypt age/secrets/users/can/app-token.age
Push a secret to Codeberg Actions
secret-manager push age/secrets/users/can/app-token.age \
--name APP_TOKEN \
--codeberg caniko/my-repo
Adding a Home-Manager Secret
The hm subcommand manages secrets for home-manager profiles. Every secret
targets either a specific user or the shared profile (--shared).
Secret types
| Subcommand | Source | Delivery |
|---|---|---|
ssh | Generated Ed25519 | --file path |
password | Generated passphrase | --env vars or --file |
text | Plaintext from stdin/file | --env vars or bare |
file | Plaintext or existing .age | --file path |
SSH key (file delivery)
secret-manager hm ssh \
--name deploy-key \
--user can \
--file ~/.ssh/deploy-key \
--algorithm ed25519
Passphrase (env vars)
secret-manager hm password \
--name app-token \
--user can \
--env APP_TOKEN \
--length 48
Text from stdin
echo "my-secret-value" | secret-manager hm text \
--env MY_SECRET \
--user can
Bare source (no env wiring)
Use --bare to create only the encrypted source, for consumers that read the
.age file directly (for example a services.secretSync Kubernetes target)
instead of a home-profile env var. --name is required:
echo "my-secret-value" | secret-manager hm text \
--bare \
--name app-token \
--user can
The source is written to age/secrets/users/<user>/<name>.age. Re-running the
same command refuses to overwrite an existing source; use agenix edit to
replace the value.
Shared secrets
Use --shared to store under age/secrets/users/shared/ and optionally
register as shared home-manager env vars:
secret-manager hm text \
--shared \
--env DEEPSEEK_API_KEY
Pass --name explicitly when no env var can provide a default slug.
Adding a NixOS Host Secret
The nixos subcommand manages secrets for NixOS hosts. Each secret targets a
specific host via --host.
Secret types
| Subcommand | Source | Delivery |
|---|---|---|
ssh | Generated Ed25519 | --file path |
password | Generated passphrase | --env + --service or --file |
text | Plaintext from stdin/file | --env + --service |
file | Plaintext or existing .age | --file path |
SSH key
secret-manager nixos ssh \
--name service-key \
--host thething \
--file /run/secrets/service-key
Passphrase (systemd env file)
secret-manager nixos password \
--name vikunja-mailer \
--host thething \
--env VIKUNJA_MAILER_PASSWORD \
--service vikunja \
--length 48
This generates a passphrase and creates a systemd oneshot unit that writes
an environment file consumed by the vikunja.service unit.
Text from stdin
secret-manager nixos text \
--host thething \
--env API_TOKEN \
--service demo
File from plaintext or existing .age
# Encrypt plaintext from stdin
secret-manager nixos file \
--name kubeconfig \
--host thething \
--file /etc/kubeconfig
# Adopt an existing encrypted .age file
secret-manager nixos file \
--name kubeconfig \
--host thething \
--file /etc/kubeconfig \
--from-age ./existing-kubeconfig.age
Adding a Forgejo Secret
The forgejo subcommand manages secrets for Forgejo Actions runners and
Codeberg/Forgejo CI credential stores.
Secret types
| Subcommand | Source | Delivery |
|---|---|---|
ssh | Generated Ed25519 | Runner credential or source only |
gpg-key-pair | Generated or adopted OpenPGP private key | Source only plus public metadata |
password | Generated passphrase | Runner credential |
text | Plaintext from stdin/file | Runner credential |
file | Plaintext or existing .age | Runner credential |
SSH deploy key
Generate an Ed25519 key for a Forgejo Actions runner, optionally inject the public key as a runner credential:
# Source only (no runner credential)
secret-manager forgejo ssh \
--name aur-ssh-key \
--cred AUR_SSH_KEY
# With runner credential injection on instances
secret-manager forgejo ssh \
--name aur-ssh-key \
--cred AUR_SSH_KEY \
--instance codeberg --instance nixTrusted
The public key is printed to stdout and embedded as a Nix comment in the generated module.
GPG key pair
Store only the armored OpenPGP private key as an agenix secret. The command
derives plaintext .asc and .fingerprint files beside the encrypted source.
secret-manager forgejo gpg-key-pair \
--name modde-apt-repo-gpg-key \
--cred modde_apt_repo_gpg_key \
--from-age age/secrets/modules/repos/apt/modde-apt-repo-gpg-key.age
New keys are generated when neither --from-age nor --from-file is passed.
Use --rotate to replace an existing private-key source.
A Nix mkSecret { generator = "gpg-key-pair"; userId = "Name <email>"; … }
passes userId to the generator as age.secrets.<name>.settings.userId. Forges
mark a signature verified only when the key UID carries the committer’s
verified email, so set it for signing identities.
Passphrase credential
secret-manager forgejo password \
--name copr-token \
--cred copr-token \
--instance codeberg \
--length 64
Text credential
echo "my-token" | secret-manager forgejo text \
--cred COPR_TOKEN \
--instance codeberg
The slug defaults from the --cred value (e.g. COPR_TOKEN becomes copr).
File credential
secret-manager forgejo file \
--name copr-token \
--cred COPR_TOKEN \
--instance codeberg \
--from-file ./token.txt
Key rotation
Pass --rotate to force regeneration of an existing SSH key:
secret-manager forgejo ssh \
--name aur-ssh-key \
--cred AUR_SSH_KEY \
--rotate
Importing from Bitwarden
The hm file, nixos file, and forgejo file commands accept --from-rbw
with an exact Bitwarden item UUID. Unlocking belongs to rbw and pinentry; the
bridge never asks for or stores the vault password. Install rbw 1.15 or later
and authenticate it before importing.
secret-manager hm file --shared --name service-token \
--from-rbw 12345678-1234-1234-1234-123456789abc --rbw-field password
The default field is password. Built-ins include username, password,
totp, and notes. Select custom fields with custom:NAME; ambiguous or empty
fields fail. totp returns the stored seed or URI, not a current one-time code.
Build a JSON document from one item snapshot with repeated mappings:
secret-manager hm file --shared --name application-account \
--from-rbw 12345678-1234-1234-1234-123456789abc \
--rbw-map username=username --rbw-map password=password \
--rbw-map totpSecret=totp
Only mapped fields enter the document. JSON escaping is automatic. All mapped fields are required; omit an optional mapping when it is absent. Application adapters own validation of their document and TOTP parameters before encryption. This generic projection preserves field values exactly.
By default rbw uses its encrypted local cache. --rbw-sync explicitly syncs
first; a failed sync prevents importing stale data. --rbw-timeout-seconds
sets a 1–300 second deadline per invocation, including pinentry interaction
(default 120 seconds). Helper output is capped at 1 MiB and a selected document
at 512 KiB. Child diagnostics are suppressed because they may contain secrets.
Encryption policy
Consumers export an agenix-stream-encryptor package using
secret-manager.lib.mkAgenixStreamEncryptor:
packages.agenix-stream-encryptor = secret-manager.lib.mkAgenixStreamEncryptor {
inherit pkgs;
agenixRekey = inputs.agenix-rekey;
userFlake = self;
nixosConfigurations = self.nixosConfigurations;
};
This adapter uses agenix-rekey’s native master-encryption backend, including
master identities, extra encryption recipients and configured age plugins.
Select the same nodes and agePackage as the existing rekey workflow. Unsupported
backend contracts fail during evaluation.
Set SECRET_MANAGER_AGE_ENCRYPTOR to its absolute executable path for the
standalone CLI. Embedders can instead implement StoreEnv::stream_encryptor;
resolve and realize public encryption policy before reading the vault, and
release any Nix evaluation lease before subprocess work.
The bridge uses private stdout/stdin pipes and zeroizing owned credential
buffers, disables core dumps, and writes only ciphertext to a mode-0600
temporary file beside the destination. Installation is atomic and refuses an
existing source by default. Vault contents never enter command arguments, Nix
expressions, or a plaintext temporary file. Scoped staging and agenix rekey
follow encryption; --no-stage and --no-rekey retain their existing meaning.
Inherited agenix auto-staging is disabled; only newly distributed copies are
staged, and existing work in the distribution directory is preserved.
Standalone RBW imports reexecute the original command in a no-swap user scope when needed, then verify the effective kernel limit and acquire a logind sleep inhibitor before vault lookup. The guard remains held through field projection, encryption, installation and rekey; unavailable protection fails before vault effects. Public encryption-policy preparation precedes this boundary.
Rotation and recovery
Repeat the same import with --rbw-rotate to replace an existing source. Rotation
requires an existing binary age v1 file and preserves module wiring. It cannot
add a new home/system/runner target. RBW imports, generated rotations and adapters
using with_store_write_lock share the existing secret-manager kernel lock.
Missing fields, lookup failure and encryption failure leave existing ciphertext
intact. Source changes observed before vault lookup, after acquiring the lock,
or before installation abort replacement. Direct editors and raw agenix calls
do not automatically acquire this lock; concurrent writes through those paths
are outside the cooperative preservation guarantee. Installation is an atomic
rename, not filesystem compare-and-swap. A failed rekey retains the newly
encrypted source: retry staging and agenix rekey -a before deploying. Never
restore the old source after distributing a new one to some consumers.
Library adapters must establish no-swap protection and acquire
protection::CredentialProtection before RbwSource::read(), retain the guard
until plaintext and credential helpers have been dropped, select RbwItem::field(), validate
their own document in memory, then call StreamEncryptor::encrypt_new() or
encrypt_replace(). Use encryption::with_store_write_lock(root, operation) to
serialize installation, staging and rekey with the generic import/rotation path.
Constrain destination paths to the intended store.
Pushing Secrets to CI
The push command decrypts an agenix-encrypted .age file and pushes the
plaintext to Codeberg/Forgejo, Codefloe, and GitHub Actions secret stores. The
plaintext never touches disk — it lives only in process memory.
secret-manager push age/secrets/users/can/app-token.age \
--name APP_TOKEN \
--codeberg caniko/my-repo \
--codefloe caniko/my-repo \
--github my-org/other-repo
Decrypt-only
To print a decrypted secret to stdout (for piping into other tools):
secret-manager decrypt age/secrets/users/can/app-token.age
Diagnostics and hardware-key prompts stay on stderr.
Authentication
- Codeberg: the
fjauth store at${XDG_DATA_HOME:-$HOME/.local/share}/forgejo-cli/keys.json(set up withfj auth login --host codeberg.orgorfj auth add-key <user>). - Codefloe: the same
fjauth store (set up withfj -H codefloe.com auth loginorfj -H codefloe.com auth add-key <user>). - GitHub: requires
ghauthenticated.
Identity resolution
Identities are resolved in precedence order:
- Explicit
--identityflags (repeatable). - The
SECRET_MANAGER_AGE_IDENTITIESenvironment variable — a colon-separated list of identity paths, mirroring the DNS flow’sCANIX_DNS_AGE_IDENTITIEScontract. - Master identity stubs discovered from
age/master-*.pub/age/master_*.pubin the store root.
Relative paths resolve against the store root.
secret-manager decrypt secret.age --identity age/my-key.pub
# or, once per shell:
export SECRET_MANAGER_AGE_IDENTITIES=age/master_nitro3c_identity.pub:age/master_nitro3_2_identity.pub
secret-manager sync
WireGuard Key Generation
Generate a WireGuard keypair, store the private key as an agenix secret, and print the public key to stdout:
secret-manager wg-keygen age/secrets/hosts/myhost/wireguard-private.age
Requires wg (WireGuard tools) on PATH.
Legacy workflows
Rauthy env-file bootstrap
The rauthy-env command bootstraps a complete Rauthy OIDC provider
environment file:
secret-manager rauthy-env age/secrets/hosts/thething/rauthy-env.age
This generates:
- Encryption keys (via
rauthy generate-enc-key) - Cluster secrets (via
rauthy generate-secrets) - An Argon2id password hash (interactive prompt)
- A bootstrap API key secret
Gerrit cookies
Encrypt Gerrit .gitcookies into an agenix secret:
secret-manager gerrit-cookies ~/.gitcookies age/secrets/gerrit-cookies.age
Cookie lines are normalized (commas → tabs) before encryption.
The Store
A “store” is the Nix data repository that owns the agenix secret tree. secret-manager operates on a store; it does not host secrets itself.
Store layout
<store-root>/
flake.nix
age/
master-identity.pub # Master identity stubs for decryption
master-yubikey.pub
secrets/
users/
can/
app-token.age # Per-user encrypted secrets
shared/
deepseek.age # Shared profile secrets
hosts/
thething/
vikunja-mailer.age # Per-host encrypted secrets
modules/
forgejo-runner/
copr-token.age # Module-scoped secrets
rekeyed/ # Rekeyed per-host copies (managed by agenix)
Discovery
The store root is discovered at runtime:
$SECRET_MANAGER_STOREenvironment variable, if set- Walking up from the current directory to the nearest
flake.nix
Master identities
age/master-*.pub files are the trust anchors for decryption. They are
typically hardware-key (age plugin) identity stubs — rage prompts on the
controlling terminal when decryption is needed.
Target Stacks
Every secret targets a stack that determines where the Nix module is generated and how the secret is delivered to consumers.
Home stack
Targets home-manager profiles — per-user or shared. Secrets can be delivered as environment variables or files on the home filesystem.
| Field | Purpose |
|---|---|
user | Home-manager user profile |
env | Shell environment variable names |
file | File path within the home profile |
Generated module path: home/user/<user>/repositories/<slug>.nix
Secret path: age/secrets/users/<user>/<slug>.age
System stack
Targets NixOS hosts. Secrets can be delivered as systemd environment files or files on the host filesystem.
| Field | Purpose |
|---|---|
host | NixOS host name |
env | Environment variable names |
services | Systemd services receiving the env file |
file | File path on the host |
Generated module path: root/hosts/<host>/server/<slug>.nix
Secret path: age/secrets/hosts/<host>/<slug>.age
Forgejo stack
Targets Forgejo Actions runner credentials. Secrets can be delivered as runner credentials or source-only (e.g., SSH keys without credential binding).
| Field | Purpose |
|---|---|
credential | Credential name in the runner store |
instances | Runner instances receiving the credential |
Generated module path: root/modules/server/forgejo-runner-secrets/<slug>.nix
Secret path: age/secrets/modules/forgejo-runner/<slug>.age
Source-only secrets
When a secret has no delivery target (e.g., a shared SSH key stored for
manual use), the engine still generates the .age file and optionally
its Nix module, but does not register delivery targets.
Nix Declarations
The engine generates Nix modules that call mkSecret to declare each
secret. These modules are consumed by the NixOS/home-manager
configuration that calls agenix.
mkSecret
The core function that declares a single agenix secret:
canixLib.mkSecret {
name = "can-app-token";
stack = "home";
source = secrets.user "can" "app-token";
targets.home.env = ["APP_TOKEN"];
}
Parameters
| Parameter | Description |
|---|---|
name | agenix secret name (maps to age file identity) |
stack | "home", "system", or "forgejo" |
source | Path expression resolving to the .age file |
generator | Optional: "passphrase" or "ssh-key" |
length | Passphrase length (32, 48, or 64) |
algorithm | SSH key algorithm ("ed25519" or "rsa") |
targets | Delivery target configuration (stack-specific) |
owner | File owner (for system file delivery) |
group | File group |
mode | File mode |
Target shapes
Home env delivery:
targets.home.env = ["APP_TOKEN" "API_KEY"];
Home file delivery:
targets.home.file = {
path = "~/.ssh/deploy-key";
mode = "600";
};
System env delivery:
targets.system.env = {
vars = ["VIKUNJA_MAILER_PASSWORD"];
services = ["vikunja"];
};
System file delivery:
targets.system.file = {
path = "/run/secrets/vikunja-mailer";
owner = "vikunja";
mode = "0400";
};
Forgejo credential delivery:
targets.forgejo.credential = {
name = "copr-token";
instances = ["codeberg"];
};
mkSharedSecret
A convenience wrapper that produces a secret declaration for all three stacks from a single source file. Each stack is optional:
canixLib.mkSharedSecret {
source = ./age/secrets/shared-token.age;
generator = "passphrase";
length = 48;
home = {
targets.home.env = ["SHARED_TOKEN"];
};
system = {
targets.system.env = {
vars = ["SHARED_TOKEN"];
services = ["demo"];
};
};
}
Source expressions
The source parameter resolves the .age file path:
# User-scoped secret
secrets.user "can" "app-token"
# Host-scoped secret
secrets.host "thething" "vikunja-mailer"
# Module-scoped secret
secrets.module "forgejo-runner/copr-token"
Generators
lib.mkGenerators {rotateCommand ? "secret-manager rotate"} returns the
agenix-rekey generators behind the names mkSecret emits
(strong-passphrase, strong-passphrase-48, strong-passphrase-64,
ssh-ed25519-pub, ssh-rsa-pub, gpg-key-pair) plus minisign-key-pair.
Register them on every agenix module that evaluates these secrets:
age.generators = secret-manager.lib.mkGenerators {};
Passphrases avoid every character special to a systemd EnvironmentFile or a
shell export. minisign-key-pair depends on exactly one password secret and
encrypts the key with it; rotateCommand is quoted in its error when that
password has not been generated yet.
Sync Targets
secret-manager can push encrypted values to Codeberg/Forgejo, Codefloe, and
GitHub Actions secret stores and to Kubernetes Secrets, and plaintext
non-secret values to Actions variables.
Sync targets are declared declaratively via Nix module options — the
services.secretSync namespace — rather than passed as ad-hoc CLI flags.
A host must opt in with enable = true; its targets are then collected into
a JSON document that secret-manager sync reads.
secret-manager sync keeps a store-local TOML state file at
<secret-store>/.secret-manager/sync-state.toml. The state records only
non-secret destination metadata so later syncs can identify stale entries that
the tool previously managed. Stale entries are reported by default and deleted
only when sync --prune is used.
Declaring sync targets
{ ... }: {
imports = [ inputs.secret-manager.nixosModules.default ];
services.secretSync = {
enable = true;
targets = {
"ci-token" = {
secret = "age/secrets/ci-token.age";
name = "CI_TOKEN";
codeberg = ["caniko/my-repo"];
codefloe = ["caniko/my-repo"];
github = ["caniko/my-repo"];
};
"publisher-token" = {
secret = "age/secrets/publisher-token.age";
name = "CRATES_IO_API_TOKEN";
githubEnvironments = [{
repository = "caniko/my-repo";
environment = "simit-crates-io";
}];
};
"org-token" = {
secret = "age/secrets/org-token.age";
name = "ORG_TOKEN";
codebergOrgs = ["caniko"];
};
"public-key" = {
source = "age/secrets/public-key.asc";
name = "PUBLIC_KEY";
codeberg = ["caniko/my-repo" "caniko/other-repo"];
codebergOrgs = ["caniko"];
codebergUser = true;
};
};
};
}
Target fields
githubEnvironments selects environment-scoped GitHub Actions secrets (encrypted
sources) or variables (plaintext sources). Enroll the environment and its branch
protection before syncing. Sync verifies the environment exists before writing
and reads back destination metadata afterward; it never creates an environment or
falls back to repository scope. Values are passed to gh on stdin.
Encrypted sync acquires credential protection before resolving identities or decrypting: it reexecutes the current frontend in a no-swap user scope when needed, replaying the filtered public configuration on stdin with the frontend’s original arguments. The child verifies that swap is disabled in its effective cgroup; an active logind sleep inhibitor must be held through upload, readback, pruning and state commit. Missing protection fails before those effects. Cached plaintext and temporary copies are zeroized when the handler exits. GitHub input helpers use bounded, redacted pipes, scrub credential-debug settings, inherit active inhibitors across exec and terminate/reap their process group on pipe or timeout failure. Public variables and read-only planning do not require a credential inhibitor.
Repository and environment copies have distinct persisted identities. Environment
names are case-insensitive, so a case-only rename does not schedule a destructive
prune. Old repository entries remain stale until an explicit authorized --prune
after the replacement environment upload succeeds. Older binaries reject the new
environment scope in persisted state; they cannot reinterpret it as a repository
entry. Never reuse such a state file with an older Secret Manager.
| Option | Type | Default | Description |
|---|---|---|---|
secret | null or str | null | .age path pushed as an Actions secret |
source | null or str | null | plaintext path pushed as an Actions variable |
name | str | required | Actions secret/variable name |
codeberg | list of str | [] | owner/repo Forgejo repository targets |
codefloe | list of str | [] | owner/repo Codefloe repository targets |
github | list of str | [] | owner/repo GitHub repository targets |
githubEnvironments | list of {repository, environment} | [] | GitHub environment targets |
kubernetes | list of {context, namespace, ...} | [] | Kubernetes Secret targets (secret only) |
codebergOrgs | list of str | [] | Forgejo organization/account-scope targets |
codebergUser | bool | false | Forgejo authenticated-user account target |
host | str | "codeberg.org" | Forge host for codeberg* token lookup |
Each target must set exactly one of secret or source.
Codefloe targets are repository-scoped; GitHub supports repository and environment
destinations. Authenticate Codefloe with
fj -H codefloe.com and GitHub with gh auth login.
Kubernetes Secrets
kubernetes writes a decrypted secret value to a Kubernetes Secret with
kubectl apply --server-side --force-conflicts. Authenticate kubectl for the
named context beforehand; secret-manager never reads kubeconfig itself.
services.secretSync.targets."registry-pull" = {
secret = "age/secrets/registry-pull.age";
name = "REGISTRY_PASSWORD";
kubernetes = [
{
context = "nautilus";
namespace = "syndb";
secret = "registry-pull";
format = "dockerconfigjson";
username = "robot";
registry = "registry.example"; # default https://index.docker.io/v1/
}
{
context = "nautilus";
namespace = "syndb";
secret = "api-token";
format = "opaque";
key = "token"; # default: the target `name`
}
];
};
| Field | Applies to | Description |
|---|---|---|
context | both | kubectl --context; required |
namespace | both | RFC 1123 subdomain; required |
secret | both | Secret name, RFC 1123 subdomain; required |
format | both | opaque or dockerconfigjson; required |
key | opaque | Data key [-._a-zA-Z0-9]+; defaults to the target name |
registry | dockerconfigjson | Registry key in auths; defaults to https://index.docker.io/v1/ |
username | dockerconfigjson | Required; must not contain : |
fieldManager | both | Server-side apply manager; defaults to secret-manager |
opaque stores the value base64-encoded under key with type: Opaque.
dockerconfigjson stores {"auths":{"<registry>":{"username","password","auth"}}}
under .dockerconfigjson with type: kubernetes.io/dockerconfigjson, using the
decrypted value as the password. Invalid names, mixed fields (for example
registry on an opaque destination), plaintext source values and a Secret
declared twice are rejected before any decryption or kubectl call. Server-side
apply by one field manager owns the whole data map, so one Secret has exactly
one declaration. --prune deletes a Secret that is no longer declared.
Leak guarantees
The decrypted value is only ever in zeroized buffers and in the manifest piped to
kubectl on stdin. Specifically:
- It is never in
kubectl’s argv or environment, and-o/--outputand-v/--vare never passed, sokubectlhas nothing verbose to echo. kubectl’s stdout and stderr are captured through the same bounded private pipes as the other credential helpers (1 MiB cap, 90 s deadline, no core dumps, inherited sleep inhibitor, process-group cleanup). Nothing is forwarded to the terminal.- On failure the report is one line of at most 400 bytes. Before it is built,
every occurrence of the plaintext, its JSON-escaped form, its base64, and for
dockerconfigjsontheuser:passwordstring, its base64 and the whole config JSON and its base64 is replaced with[redacted]. Errors from the capture layer pass through the same redaction. - On success the only output is
kubernetes <context>/<namespace>/secret/<name>: applied.--dry-runand--checkprint the destination and the data key names only, and never decrypt. - The sync state file records destination metadata (context, namespace, Secret name, key, registry, username) and never a value.
Redaction is a backstop, not the control: the value is kept out of every channel that could echo it. A value shorter than a few characters makes redaction over-eager; use real credentials.
Relation to mkSecret
Sync targets are separate from mkSecret’s delivery targets
(home env/file, system env/file, forgejo credential). The two concepts
address different concerns:
- mkSecret delivery: how a decrypted secret reaches a consumer on the host (as an environment variable, file, or systemd LoadCredential).
- Sync targets: which external forge repositories, organizations, or authenticated-user account scopes receive a decrypted agenix value as an Actions secret, or a plaintext source file as an Actions variable.
A single agenix secret can have both local delivery and remote sync targets, neither, or either.
Collection contract
The lib.collect function gathers all enabled hosts’ targets into a JSON
document of the shape:
{
"hosts": [
{
"targets": {
"ci-token": {
"secret": "age/secrets/ci-token.age",
"name": "CI_TOKEN",
"codeberg": ["caniko/my-repo"],
"codefloe": ["caniko/my-repo"],
"github": ["caniko/my-repo"],
"codebergOrgs": ["caniko"],
"codebergUser": true,
"host": "codeberg.org"
},
"registry-pull": {
"secret": "age/secrets/registry-pull.age",
"name": "REGISTRY_PASSWORD",
"kubernetes": [
{
"context": "nautilus",
"namespace": "syndb",
"secret": "registry-pull",
"format": "dockerconfigjson",
"username": "robot",
"registry": "registry.example"
}
]
},
"public-key": {
"source": "age/secrets/public-key.asc",
"name": "PUBLIC_KEY",
"codeberg": ["caniko/my-repo"],
"codefloe": [],
"github": [],
"codebergOrgs": ["caniko"],
"codebergUser": false,
"host": "codeberg.org"
}
}
}
]
}
This JSON is the Nix↔Rust contract. The secret-manager binary reads it via
the SyncDocument deserialization type. In normal flake-root use,
secret-manager sync evaluates this collection automatically; --config and
piped stdin remain available for scripts that want to provide the JSON
explicitly.
CLI Reference
secret-manager <COMMAND>
Declarative secret management for Nix store repositories.
Commands
github app — GitHub-issued App credentials
enroll serves a local manifest form and callback on 127.0.0.1. Open the
printed URL in an authenticated GitHub browser, register the app, then authorize
its selected-repository installation. The conversion response is encrypted
before parsing or publication; the CLI never generates an app private key.
secret-manager github app enroll --config review-app.json \
--store /path/to/canix --transaction /private/state/review-app \
--identity age/master_identities/master_nitro3c_identity.pub
The transaction’s parent directory must already exist. The CLI creates the
transaction directory with mode 0700; files use 0600. Keep it outside tracked
source trees. The manifest callback must complete within one hour; the default
listener deadline is 900 seconds (--timeout-seconds, maximum 3500). --port
chooses a loopback port. Retries reuse the original port and policy/store binding.
The public policy is JSON:
{
"version": 1,
"slug": "example-review",
"owner": "example",
"repository": "example/project",
"homepage": "https://github.com/example/project",
"source": "age/secrets/review-app.age",
"recipients": ["age1..."],
"permissions": {
"contents": "read",
"pull_requests": "write",
"checks": "write"
},
"keySecretName": "COMMITPERCLIP_KEY",
"appIdVariable": "COMMITPERCLIP_APP_ID",
"appSlugVariable": "COMMITPERCLIP_APP_SLUG"
}
Use real public age recipients. The app owner must match the repository owner;
permissions must match the declaration, with only GitHub’s automatic
metadata: read permitted additionally. The issued slug must match the declared
slug. If GitHub assigns a different slug, retain the encrypted checkpoint and
import a settings-issued key with a corrected policy in a fresh transaction.
For an already registered app or a settings-issued replacement key:
secret-manager github app import --config review-app.json --store /path/to/canix \
--transaction /private/state/review-app --app-id 12345 --pem-file /private/key.pem
secret-manager github app installation --config review-app.json \
--store /path/to/canix --transaction /private/state/review-app
secret-manager github app publish --config review-app.json --store /path/to/canix \
--transaction /private/state/review-app --identity /private/age-identity
secret-manager github app verify --config review-app.json --store /path/to/canix \
--transaction /private/state/review-app --identity /private/age-identity
import requires an operator-owned private PEM file and verifies its JWT against
GitHub /app. It retains the original file; remove it through your normal private
credential handling after encrypted adoption. App-key generation/revocation is a
GitHub settings operation; this CLI automates adoption and publication.
If an import stops after saving issued.age but before saving its digest, retry
with the same transaction, PEM and age identity. Recovery binds the retained
ciphertext to the pending transaction and checks the original app identity and
key fingerprint before completing adoption. A recorded digest mismatch or an
unbound checkpoint in a completed transaction is refused.
installation prints the authorization URL. With --installation-id, it can
add the declared repository to an existing selected installation using the
operator’s authenticated gh account. It verifies app/installation ownership and
repository administration before mutation, then verifies token scope afterward.
publish verifies the encrypted checkpoint, authenticates the app, checks the
installation, mints an exactly-one-repository token and revokes that verification
token. It writes the encrypted source and adjacent .app-id / .app-slug public
files, then uses gh stdin to set the named repository secret and variables.
Failed token revocation refuses publication or a successful verification receipt.
If verification and revocation both fail, the diagnostic reports both outcomes.
Partial publication is retryable using the same transaction. --replace-key
permits an explicit verified rotation and retains the previous ciphertext in
previous.age. Publication is serialized per source.
The JSON receipt contains IDs, public-key fingerprint, policy/checkpoint/source
hashes, token expiry and successful slot names. It contains no PEM, JWT or token.
Its actionsSlotQualified stays false: GitHub does not return stored Actions
secret plaintext, so the trusted hosted workflow must prove destination use.
verify checks the encrypted source credential, not the installed Actions slot.
If a crash follows manifest exchange, retry enroll with the same transaction:
an encrypted response can be recovered. If no ciphertext exists, the consumed
code has an ambiguous outcome; inspect GitHub settings and import an issued key
in a fresh transaction. Never replay that conversion blindly.
gh must already be authenticated to github.com; age identities use the
existing --identity / SECRET_MANAGER_AGE_IDENTITIES contract. Canix hardware
identity stubs under age/master_identities/ should be passed explicitly.
Declaration of services.secretSync.targets does not schedule execution.
github token — Account tokens through the device flow
Mint a GitHub OAuth token for an account (for example a bot user) and store it as agenix sources without plaintext touching the store:
SECRET_MANAGER_AGE_ENCRYPTOR=$(nix build --no-link --print-out-paths .#agenix-stream-encryptor)/bin/agenix-stream-encryptor \
secret-manager github token --login canikroid \
--scope repo --scope write:gpg_key --scope admin:public_key \
--out age/secrets/users/can/canikroid_github_token.age \
--env-out age/secrets/users/can/canikroid_github_env.age
The command prints a one-time code; authorize it at
https://github.com/login/device in a browser signed in as --login. It then
verifies the token authenticates as that login with every requested scope
(admin:x implies write:x/read:x), encrypts the bare token to --out and
<ENV_NAME>=<token> to --env-out, and exits. Nothing is stored on a login or
scope mismatch.
gh runs in a private temporary config directory under $XDG_RUNTIME_DIR,
removed on exit, so the token never reaches your own gh login or keyring.
Existing sources are refused unless --rotate is given. Stage the sources and
run agenix rekey afterwards.
| Flag | Description |
|---|---|
--login LOGIN | Account the token must belong to |
--scope SCOPE | Requested and required OAuth scope; repeatable |
--out PATH | Encrypted source for the bare token |
--env-out PATH | Optional encrypted ENV_NAME=token source for EnvironmentFile consumers |
--env-name NAME | Variable name for --env-out (default GH_TOKEN) |
--rotate | Replace existing sources |
--encryptor PATH | lib.mkAgenixStreamEncryptor executable (default SECRET_MANAGER_AGE_ENCRYPTOR) |
--timeout-seconds N | Authorization wait, 30–3600 (default 900) |
claude token — Claude Code subscription tokens
Store a long-lived Claude Code token for headless runners
(CLAUDE_CODE_OAUTH_TOKEN) in one step:
SECRET_MANAGER_AGE_ENCRYPTOR=$(nix build --no-link --print-out-paths .#agenix-stream-encryptor)/bin/agenix-stream-encryptor \
secret-manager claude token --out age/secrets/root/hosts/atlas/paperclip_operations_claude_oauth.age
The command runs native claude setup-token and waits up to 15 minutes for
browser authorization. Only browser interaction is needed: both output channels
are captured privately in bounded, zeroizing memory, never printed or saved as
plaintext. No token copy/paste or controlling terminal is required. Native output
must contain one unique, valid sk-ant-oat01- token; changed, ambiguous or
malformed output fails closed without storage. The token is encrypted with the store’s
agenix master policy, the source is staged, and agenix rekey distributes it.
With --secret NAME (the secret’s agenix attribute) only that secret’s
rekeyed copies are staged, so unrelated uncommitted rekeyed changes do not
block it. Tokens last one year; renew with --rotate.
Native terminal framing outside the token paragraph is ignored. Color styling and private display modes are handled without changing token text; editing controls inside token data, incomplete sequences and ambiguous tokens are rejected.
Encryption/rekey may still require security-key or pinentry approval. A native
browser-launch or authorization failure does not fall back to printing a URL or
token. If rekey fails after encryption, retain the encrypted source and retry
agenix rekey -a, rather than minting again. Host deployment remains separate.
| Flag | Description |
|---|---|
--out PATH | Encrypted source for the token |
--rotate | Replace an existing source |
--claude PATH | Claude Code executable (default claude) |
--no-rekey | Skip agenix rekey; run agenix rekey -a before deploying |
--no-stage | Leave staging of the source and rekeyed copies to you |
--secret NAME | Agenix attribute name; stage only its rekeyed copies, ignoring other dirty ones |
--encryptor PATH | lib.mkAgenixStreamEncryptor executable (default SECRET_MANAGER_AGE_ENCRYPTOR) |
gpg publish — Account signing keys
Register an existing armored public key on authenticated forge accounts:
secret-manager gpg publish nomad.asc --codefloe --github --codeberg
| Flag | Description |
|---|---|
--codefloe | Register on the Forgejo account at codefloe.com |
--github | Register on the GitHub account at github.com |
--codeberg | Register on the Forgejo account at codeberg.org |
--forgejo HOST | Add another Forgejo destination; repeatable |
--dry-run | Validate the public key and print destinations without network calls |
--check | Read back registration; fail if any selected account is missing the key |
--expected-fingerprint FINGERPRINT | Require this full fingerprint before contacting a forge |
Choose at least one destination. The command accepts exactly one public primary key in one ASCII-armored block, rejects private-key packets, and inspects it with GnuPG in an isolated temporary keyring. It does not import into your keyring. Relative file paths resolve from the working directory; no secret store is needed.
Forgejo destinations reuse fj authentication in its existing auth store.
Tokens need read:user for listing and write:user for registration. GitHub
reuses gh authentication; classic/OAuth tokens need read:gpg_key and
write:gpg_key, respectively.
Retries are idempotent: an existing key is accepted only after matching its full fingerprint from the API’s public-key packets. Every destination is attempted even if another fails, and incomplete publication exits nonzero. After a failed POST, a read-back checks whether registration succeeded before reporting failure.
Output reports registration, signing capability, email verification, and Forgejo’s key-ownership verification separately. Uploading a key does not perform Forgejo’s proof-of-possession challenge or verify an account email. Codefloe account registration uses its Forgejo API, independently of Crow CI credentials.
hm — Home-manager secrets
Add an agenix secret for a home-manager target.
Subcommands:
| Command | Description |
|---|---|
ssh | Generate an SSH key and expose as a home-manager file |
password | Generate a passphrase and expose as env vars or a file |
text | Encrypt text from editor/stdin/–from-file as env vars |
file | Encrypt a file payload and expose as a home-manager file |
Options (shared):
| Flag | Description |
|---|---|
--name <SLUG> | Secret slug |
--user <USER> | Home-manager user (default: current login user) |
--shared | Store under age/secrets/users/shared/ |
--env <VAR> | Environment variable names |
--file <PATH> | File path within the home profile |
--from-file <PATH> | Read plaintext from file |
--from-age <PATH> | Adopt an existing encrypted .age file |
--algorithm <ALGO> | SSH key algorithm (ed25519, rsa) |
--length <N> | Passphrase length (32, 48, 64) |
--no-rekey | Skip agenix rekey -a |
--no-stage | Skip git add |
nixos — NixOS host secrets
Add an agenix secret for a NixOS host target.
Subcommands:
| Command | Description |
|---|---|
ssh | Generate an SSH key and expose as a NixOS file |
password | Generate a passphrase for service env vars or a file |
text | Encrypt text as service env vars |
file | Encrypt a file payload and expose as a NixOS file |
Options (shared):
| Flag | Description |
|---|---|
--name <SLUG> | Secret slug |
--host <HOST> | NixOS host name |
--env <VAR> | Environment variable names |
--service <NAME> | Systemd service names (required with --env) |
--file <PATH> | File path on the host |
--from-file <PATH> | Read plaintext from file |
--from-age <PATH> | Adopt an existing encrypted .age file |
--algorithm <ALGO> | SSH key algorithm |
--length <N> | Passphrase length |
--no-rekey | Skip agenix rekey -a |
--no-stage | Skip git add |
forgejo — Forgejo Actions secrets
Add an agenix secret for Forgejo Actions or runner credentials.
Subcommands:
| Command | Description |
|---|---|
ssh | Generate or refresh a Forgejo Actions SSH deploy key |
gpg-key-pair | Generate or adopt an OpenPGP private key and derive public metadata |
password | Generate a passphrase as a runner credential |
text | Encrypt text as a runner credential |
file | Encrypt a file payload as a runner credential |
Options (shared):
| Flag | Description |
|---|---|
--name <SLUG> | Secret slug |
--cred <NAME> | Runner credential name |
--instance <INSTANCE> | Runner instance (repeatable) |
--pubkey-var <VAR> | Nix variable name for the generated public key |
--module-dir <DIR> | Module-secret directory for gpg-key-pair sources |
--user-id <UID> | OpenPGP user ID for newly generated keys |
--rotate | Force SSH key rotation |
--from-file <PATH> | Read plaintext from file |
--from-age <PATH> | Adopt an existing encrypted .age file |
--length <N> | Passphrase length |
--no-rekey | Skip agenix rekey -a |
--no-stage | Skip git add |
list — List secrets
List every *.age file tracked in the repository.
secret-manager list
push — Push to CI secret stores
Decrypt an agenix secret and push it to repository Actions secret stores.
secret-manager push <SECRET.age> \
--name <SECRET_NAME> \
--codeberg <OWNER/REPO> \
--codefloe <OWNER/REPO> \
--github <OWNER/REPO> \
--identity <IDENTITY.pub>
sync — Push declared sync targets
Read a collected services.secretSync JSON document and push each declared
target to Codeberg/Forgejo, Codefloe, or GitHub Actions scopes, or to
Kubernetes Secrets. Codeberg/Forgejo targets support repository, organization,
and authenticated-user scopes; Codefloe and GitHub targets are
repository-scoped; Kubernetes targets name a kubectl context, namespace and
Secret (see Sync Targets).
Encrypted secret targets are pushed as Actions secrets; plaintext source
targets are pushed as Actions variables. Kubernetes accepts encrypted secret
targets only.
secret-manager sync --dry-run
secret-manager sync --identity <IDENTITY.pub>
By default, sync discovers the nearest flake root and evaluates that flake’s
secret-manager.lib.collect output over nixosConfigurations. Use --dry-run
to print the planned pushes without decrypting or contacting the forge.
For portable scripts, pass an explicit collected JSON document:
secret-manager sync --config sync-targets.json --identity <IDENTITY.pub>
If --config is omitted and stdin is not a terminal, sync reads the JSON
document from stdin. Pass --no-flake to disable flake auto-detection and
require one of those explicit input paths.
sync records the remote Actions secrets and variables it manages in
<secret-store>/.secret-manager/sync-state.toml by default. Pass
--state <PATH> to use a different state file. Entries that were previously
managed but no longer appear in the collected config are reported as stale.
They are only deleted from their configured forge when --prune is passed.
--target only narrows which targets are pushed: staleness is always judged
against the complete collected config, and --prune cannot be combined with
--target.
decrypt — Decrypt to stdout
Decrypt an agenix secret and print plaintext to stdout.
secret-manager decrypt <SECRET.age> \
--identity <IDENTITY.pub>
wg-keygen — WireGuard keypair
Generate a WireGuard keypair, store the private key as an agenix secret, print the public key.
secret-manager wg-keygen <SECRET.age>
rauthy-env — Rauthy bootstrap
Bootstrap the Rauthy env-file secret end to end.
secret-manager rauthy-env [SECRET.age]
gerrit-cookies — Gerrit cookies
Encrypt a Gerrit .gitcookies payload into an agenix secret.
secret-manager gerrit-cookies <INPUT_FILE> <SECRET.age>
Global flags
| Flag | Description |
|---|---|
-h, --help | Print help |
-V, --version | Print version |
Library API
Schema crate
Registry data types live in a separate, dependency-light crate so data repositories can decode them without the engine:
[dependencies]
secret-manager-schema = { package = "caniko-secret-manager-schema", version = "0.4.7" }
secret_manager_schema::accounts::AccountCatalogue decodes switchable service
accounts: several accounts per credential service, a fleet default, and
per-host and per-user overrides. select(service, host, user) returns the host
override, else the user override, else the default; validate checks that
every default and override names a registered account. The matching Pkl
classes (Account, AccountService, AccountHost, AccountCatalogue) are in
pkl/SecretRegistry.pkl.
Library API
The secret_manager library provides the building blocks used by the CLI.
The registry package is caniko-secret-manager. Use a Cargo package alias to
retain the existing library imports:
[dependencies]
secret-manager = { package = "caniko-secret-manager", version = "0.4.0" }
The minimum supported Rust compiler is 1.88. Public Forgejo authentication types now use Forgejo API 0.11; callers exchanging those types must use the same generation. The Reqwest 0.13 blocking client retains JSON, Rustls, HTTP/2, charset decoding and system proxy discovery. Persisted version-1 sync-state records retain their destination identities across the TOML 1.1 migration, including GitHub environment ownership.
Modules
| Module | Description |
|---|---|
add | CLI argument types and plan-building for all secret types |
env | StoreEnv trait for host validation and user resolution |
gpg | GpgCmd / PublishArgs: validate and publish public account signing keys on Forgejo/GitHub |
io | File I/O, temp dirs, editor integration, validation |
legacy | Legacy workflows (WireGuard, Rauthy, Gerrit cookies) |
plan | Plan execution engine — source prep, module writing, rekey |
push | Decrypt and push secrets to Codeberg/Codefloe/GitHub Actions |
render | Nix module rendering — TargetSpec, render_body, camel |
store | Store discovery, master identity resolution |
age | Re-export of nix_manager_core::age — rage decryption and the shared flags → env → stubs identity resolution |
Core types
Store
pub struct Store {
pub root: PathBuf,
}
Methods:
Store::current_dir()— root at cwdStore::discover()— from$SECRET_MANAGER_STOREor nearestflake.nixstore.master_identities()— sortedage/master-*.pub/age/master_*.pubstubsstore.resolve_identities(flags)— flags →SECRET_MANAGER_AGE_IDENTITIES→ stubs
TargetSpec
pub enum TargetSpec {
Home(HomeTarget),
SharedHome(SharedHomeTarget),
System(SystemTarget),
Forgejo(ForgejoTarget),
}
Each variant encodes the source expression, secret path, module path, and delivery targets.
RenderedModule
The output of rendering a target spec into a Nix module file:
pub struct RenderedModule {
pub path: PathBuf, // Module file path relative to store root
pub body: String, // Nix module content
pub default_nix: PathBuf, // Parent default.nix for import splicing
pub secret_path: PathBuf, // .age file path relative to store root
pub stack: Stack,
}
StoreEnv trait
pub trait StoreEnv {
fn validate_host(&self, host: &str) -> Result<()>;
fn resolve_home_user(&self) -> Result<String>;
}
LocalEnv— permissive implementation for standalone useTestEnv— test implementation (resolves user to"can")
Key functions
render_modules
pub fn render_modules(
targets: &[TargetSpec],
generator: Option<&GeneratorSpec>,
) -> Vec<RenderedModule>
Renders a set of targets into Nix modules by calling mkSecret with the
appropriate source expressions and delivery targets.
render_body
pub fn render_body(target: &TargetSpec, generator: Option<&GeneratorSpec>) -> String
Renders a single target into the body of a Nix module.
run_plan
pub fn run_plan(args: &CommonSourceArgs, plan: AddPlan) -> Result<()>
Executes an AddPlan: validates destinations, prepares secret sources,
writes modules, runs generation or encryption, stages, and rekegs.
camel
pub fn camel(s: &str) -> String
Converts kebab-case and snake_case to CamelCase used for age name derivation.
Example: "nix-trusted" → "NixTrusted", "copr-token" → "CoprToken".
default_forgejo_age_name
pub fn default_forgejo_age_name(instance: &str, slug: &str) -> String
Derives the default agenix secret name for a Forgejo credential.
Example: ("codeberg", "copr-token") → "forgejoRunnerCodebergCoprToken"
default_forgejo_ssh_key_age_name
pub fn default_forgejo_ssh_key_age_name(slug: &str) -> String
Derives the default agenix secret name for a Forgejo SSH key.
Example: "aur-ssh-key" → "forgejoActionsAurSshKey"