nix-seal
nix-seal is a security-first, offline-first secret manager for NixOS,
nix-darwin, and Home Manager. It stores standard age ciphertext in Git, builds a
strict deterministic public policy plan, and activates plaintext only in
restricted runtime directories.
The scale benchmark and its reporting protocol are documented in
docs/benchmarks.md. CI publishes raw, machine-readable
benchmark output with runner metadata; timing numbers are never presented as
environment-independent claims.
Release build, SBOM, checksum, and OIDC attestation controls are documented in
docs/release.md.
Operational recovery, compromise, signer-rotation, cache-loss, and rollback
procedures are documented in docs/runbooks.md.
The target decryption identity is always an absolute out-of-store runtime path;
the Nix modules reject relative paths and /nix/store paths for it.
Before parsing private command input, Unix clients disable new core dumps; Linux clients also mark the process non-dumpable. This is defense in depth, not a replacement for operating-system policy or the documented target-root boundary.
The validated plan.v2 is the single policy authority.
nix-seal plan --target <id> emits a canonical target-specific projection.
Rekey and activation derive recipients, hashes, authorized secret/template sets,
runtime permissions, service actions, and per-secret approval thresholds from
that projection rather than trusting duplicate command-line or Nix options.
Signed artifact v2 manifests bind its hash, so policy substitution fails before
decryption.
Activation phases
Each secret belongs to partitioning, users, activation, or services. The
Rust activation document carries exactly one phase and rejects an artifact or
template from another phase. Templates may reference only secrets from their own
phase, so one phase cannot read a plaintext generation owned by another. The
NixOS enables a root-managed, size-capped tmpfs at /run/nix-seal by default,
with noswap, nosuid, nodev, and noexec. System generations live below
/run/nix-seal/system; embedded Home Manager profiles receive a private
/run/nix-seal/users/<username> subtree. Activation verifies the mount type and
flags from /proc/self/mountinfo before decrypting and fails closed if the
expected mount is absent or changed. Standalone Home Manager cannot create a
root-owned mount, so it uses its session's $XDG_RUNTIME_DIR/nix-seal and
should be paired with an administrator-managed volatile runtime when swap
exposure is unacceptable. noswap prevents tmpfs pages from being written to
swap; it does not override a system's suspend or hibernation policy.
nix-darwin similarly mounts /var/run/nix-seal as a size-capped tmpfs with
nosuid, nodev, and noexec. Its mount and users roots are deliberately
0711 so embedded Home Manager users can traverse to their own private 0700
directory without being able to list the root contents. Activation rejects a
missing mount or unsafe root mode.
NixOS schedules users after specialfs and before account creation,
activation after account creation, and services after the normal nix-seal
activation step. users outputs must remain root:root because user accounts
may not exist yet. The generic module rejects partitioning unless
nixSeal.installerMode = true is explicitly set. Installer mode emits the
public activationSpecs.partitioning document but schedules no normal
activation script: reviewed installer orchestration must carry that document and
its ciphertext-only artifacts over a protected channel, then invoke the internal
nix-seal activate entrypoint with an out-of-store target identity. nix-darwin
currently rejects non-activation phases rather than silently running them at
an unsafe point. Home Manager orders users, activation, and services in
its activation DAG with separate private roots. Integrated NixOS profiles use
the system-created /run/nix-seal/users/<username> subtree; standalone Linux
profiles use $XDG_RUNTIME_DIR/nix-seal. It rejects installer-only
partitioning.
Nix plan front-end
The flake library exposes nixSeal.lib.mkPlan for public Nix metadata. It has a
closed top-level argument set (identities, groups, targets, secrets,
generators, templates, approvalPolicies, and backends), rejects unknown
collections and invalid collection IDs during Nix evaluation, and emits the same
plan.v2.json object consumed by the Rust policy validator. repositoryRoot is
required so Nix can pin every canonical ciphertext by SHA-256:
let
plan = nixSeal.lib.mkPlan {
repositoryRoot = ./.;
identities.admin = {
kind = "administrator";
public = "age1example...";
};
targets.desktop = {
kind = "nixOs";
system = "x86_64-linux";
identity = "admin";
};
};
in
pkgs.writeText "plan.v2.json" plan
For normal NixOS, nix-darwin, and Home Manager use, there is no separate plan
file to maintain: declare policy next to the configuration that consumes it. The
module compiles the same plan itself. Administrator catalogs belong at the flake
level, while each target selects exactly one catalog entry. Import
nix-seal.flakeModules.nix-config-framework when using nix-config-framework;
the adapter passes the catalog through its existing extraSpecialArgs channel.
{
flake.nixSeal = {
administrators.ianhollow = {
identities = {
administrator = { kind = "administrator"; public = "age1..."; };
recovery = { kind = "recovery"; public = "age1..."; };
release = { kind = "signer"; public = "nix-seal-ed25519-v1:..."; };
};
approvalPolicies.release = { threshold = 1; signers = [ "release" ]; };
defaultApprovalPolicy = "release";
};
};
}
Target-local secret names are automatically qualified with the selected
administrator and target scope. For example, a Home Manager target named ianmh
produces the canonical plan ID ianhollow/users/ianmh/nix-access-tokens and
source secrets/ianhollow/users/ianmh/nix-access-tokens.age; callers still use
config.nixSeal.secrets."nix-access-tokens".path. NixOS and nix-darwin use
<administrator>/hosts/{nixos,darwin}/<target>/<secret>. Framework metadata
supplies target names; standalone or unusual configurations can override
nixSeal.targetId and nixSeal.secretScope explicitly.
The following is deliberately all public metadata; the .age source remains
ciphertext.
{
nixSeal = {
enable = true;
administrator = "ianhollow";
repositoryRoot = ../../.;
identityFile = "/etc/nix-seal/target.agekey";
artifactCacheRoot = "/var/lib/nix-seal/cache/v1";
identities = {
target = { kind = "target"; public = "age1..."; };
};
secrets."service-token" = {
administrators = [ "administrator" ];
owner = "root";
group = "root";
mode = "0400";
};
};
}
When nixSeal.administrator is omitted, the legacy explicit-identity mode
remains available for migration and unusual layouts. In that mode IDs and
sources are used exactly as declared; scoped targets reject hard-coded IDs from
another administrator.
Secret selectors can select exact targets or groups and filter by target kind,
system, username, configuration, environment, and tags. Non-empty selector
fields are ANDed (values within one field are ORed); tags are all-required, and
the result is unioned with explicit consumers. Selector references are
validated against the target/group graph before recipient derivation.
Collection values remain public metadata; plaintext values, prompts, and private
identities must never be placed in a Nix expression. Nested fields are validated
with the versioned JSON schema and policy rules by nix-seal check. When a TOML
plan is also supplied, the Rust merge is disjoint by collection and ID, so an
overlap is a fatal error rather than a precedence decision.
Start a repository with an empty, valid public plan; this does not generate keys or create secrets and refuses to overwrite an existing file:
nix-seal init
Canonical authoring is plan-directed and reads values only from stdin or an explicit editor transaction:
nix-seal secret create --plan plan.v2.json --secret db/password \
--identity ~/.config/age/keys.txt < password.txt
nix-seal secret edit --plan plan.v2.json --secret db/password \
--identity ~/.config/age/keys.txt --editor /absolute/path/to/editor
nix-seal secret rekey --plan plan.v2.json --secret db/password \
--identity ~/.config/age/keys.txt --json
nix-seal secret rekey --plan plan.v2.json --secret db/password \
--identity ~/.config/age/keys.txt --yes
nix-seal secret delete --plan plan.v2.json --secret db/password --yes
nix-seal rotate --plan plan.v2.json --secret db/password \
--identity ~/.config/age/keys.txt < replacement.txt
nix-seal secret list --plan plan.v2.json --due
create, import, rotate, and edit accept --format json,
--format toml, --format yaml, or --format dotenv to validate a logical
collection before it is encrypted. The original bytes are retained, so
formatting and ordering are not rewritten. Structured input is limited to 64 MiB
and must be valid UTF-8. dotenv validation accepts only unique shell-compatible
KEY=VALUE entries (with an optional export prefix); it does not evaluate
shell syntax. An edit that fails its declared format check never replaces the
existing ciphertext. Each canonical source remains one independent standard age
file. Keep any plaintext input file private and remove it according to your
local storage policy.
For field-level authoring, secret batch accepts a bounded JSON/TOML/YAML or
dotenv collection on stdin and a public nix-seal.collection.v1 mapping. Each
mapped scalar is decoded according to its explicit utf8, base64, or hex
encoding and committed as an independent age ciphertext through one all-or-
recover transaction. --editor /absolute/path/to/editor stages the collection
in a mode-0600 ephemeral workspace before extraction; the editor receives no
ambient environment or shell. Omit --replace for create-only behavior.
{
"schema": "nix-seal.collection.v1",
"entries": [
{ "secret": "service/password", "path": "service.password" },
{ "secret": "service/key", "path": "service.key", "encoding": "hex" }
]
}
The mapping is public metadata only; values, editor responses, and private identities never enter the plan or Nix store. Unknown or missing mapped fields, duplicate keys, unsafe paths, malformed encodings, and any failed ciphertext write abort the complete batch.
The machine-readable mapping schema is available with
nix-seal schema --kind collection and is installed with the flake's generated
schemas.
Generate a native recovery identity with nix-seal key generate. For a
human-held recovery copy, nix-seal key generate --passphrase writes a standard
age scrypt-encrypted identity file after two hidden terminal prompts. The
passphrase is never accepted from argv, stdin, or the environment and is subject
to a minimum length check. Do not use passphrase-protected identity files for
unattended activation; use an age plugin, agent, or hardware-backed identity
instead. On Unix, generated identity files are created relative to no-follow
directory descriptors and are checked for owner-only access, regular-file type,
and a single link. User-owned symlinked ancestry is rejected; root-owned
platform aliases such as macOS /tmp are resolved to their canonical directory
before the final no-follow open.
The plan determines canonical administrator/recovery recipients. Direct mode
additionally includes authorized target recipients and emits a history-exposure
warning. Canonical create, import, edit, rotation, and rekey operations require
an identity declared as an administrator or recovery identity; a target key may
decrypt an authorized delivery artifact but cannot author repository ciphertext.
Every create, import, edit, and rotation is encrypted into a private
same-directory transaction, round-trip decrypted and hashed, then atomically
committed. Editor execution uses no shell, inherits no environment, and runs in
a private ephemeral workspace. secret rekey changes canonical encryption
recipients without changing the application credential; rotate changes the
application credential. secret rekey is dry-run by default and requires
--yes for its atomic same-source replacement. The top-level rekey command is
separate: it creates signed target artifacts in the ciphertext-only cache.
For the default rekeyed delivery, nix-seal rekey decrypts canonical
ciphertext with --identity and produces a separately target-encrypted, signed
cache artifact. For the explicitly advanced direct delivery, the same command
deliberately rejects --identity: it validates and copies the already
target-addressed canonical age ciphertext into a signed cache artifact without
decrypting or re-encrypting it. Its source and artifact hashes are identical.
This preserves masterless operation while retaining activation's manifest and
approval verification; it does not reduce the documented
historical-key-compromise risk.
nix-seal provision applies that same policy to every secret authorized for one
target. It is dry-run-first: without --execute, it validates the complete
artifact set, signing authorization, source headers and hashes, recipient
binding, and required administrator/recovery identity without opening or
changing the cache. With --execute, it creates or verifies the signed
ciphertext-only cache artifacts. A mixed target may supply --identity for its
rekeyed secrets; direct secrets never receive or use that identity.
nix-seal provision --plan plan.v2.json --target host.example --generation 4 \
--signing-key /private/release.signing-key --identity /private/admin.agekey
nix-seal provision --plan plan.v2.json --target host.example --generation 4 \
--signing-key /private/release.signing-key --identity /private/admin.agekey \
--cache-root /var/lib/nix-seal/cache/v1 --execute
Provisioning never transmits plaintext. Use the explicit ciphertext-only cache
export/import flow or nix copy for a remote build or deployment transport.
There is no deployment lock file. The compiled plan.v2 pins each canonical ciphertext's SHA-256 hash, while activation verifies a matching signed bundle directly from the target-local cache. Private identity locations, signing keys, and plaintext never enter the plan or Nix store.
Target-local artifact cache (recommended)
After provisioning, transfer the ciphertext-only artifact to the target and
import it into that target's local cache. This keeps artifacts out of Git, flake
inputs, and the Nix store. A host cache is normally
/var/lib/nix-seal/cache/v1; a Home Manager cache is normally
$XDG_CACHE_HOME/nix-seal/v1.
nix-seal cache export --destination /tmp/nix-seal-cache-export
nix-seal cache import --source /tmp/nix-seal-cache-export --root /var/lib/nix-seal/cache/v1
Each target artifact is a directory containing exactly ciphertext.age and
manifest.dsse.json. Configure one cache root; Nix does not read the cache
while evaluating or building the configuration:
{
nixSeal.artifactCacheRoot = "/var/lib/nix-seal/cache/v1";
}
The Rust activation runtime opens this directory with its normal no-follow checks and verifies the signed manifest, target binding, and hashes before it decrypts anything. Keep the cache scoped: import host artifacts only into the host cache and user artifacts only into the owning user's cache.
Reboot and repository lifecycle
An activated NixOS, nix-darwin, or Home Manager generation does not need the
source repository at runtime. Its nix-seal executable, public plan, and
activation specification are retained in the Nix store; the target identity and
its local ciphertext-only artifact cache remain outside it. NixOS installs a
boot-time activation unit, nix-darwin installs a launch daemon, and Home Manager
installs a user service or launch agent. They recreate runtime plaintext after
reboot or login from the target-local artifacts, without consulting Git.
Removing the local artifact cache or target identity prevents future activation
and fails closed; it does not expose plaintext.
Deletion never unlinks canonical ciphertext directly. It requires --yes and
atomically moves the ciphertext into a private, collision-safe
.nix-seal/trash/v1 tombstone containing its public secret ID, original source,
ciphertext hash, and deletion time. The authoritative plan is never rewritten
implicitly, so recovery remains possible and check --deep fails until policy
is intentionally updated or the ciphertext is restored.
Cache garbage collection is explicitly dry-run-first and trusts neither cache names nor unsigned metadata. It recomputes the active plan and target-policy hashes, hashes the canonical source ciphertext through a no-follow descriptor, reconstructs the deterministic artifact address, and checks the current approval threshold before retaining an artifact:
nix-seal cache gc --plan plan.v2.json --repository-root .
nix-seal cache gc --plan plan.v2.json --repository-root . --execute
Any malformed, expired, stale, source-mismatched, target-mismatched, or
untrusted artifact is a deletion candidate. Version 1 generic cache objects do
not have an authenticated plan reference, so they are always candidates. The
command never removes anything without --execute. Both rekeyed and advanced
direct-delivery artifacts are retained only after the same signed manifest,
source-hash, recipient, policy, and target checks succeed.
For air-gapped or remote deployment workflows, cache exchange is an explicit ciphertext-only directory operation. Export refuses to overwrite its destination and atomically publishes only verified generic ciphertext and target-artifact bundles; identities, plaintext, locks, and transactions are excluded. Import revalidates every entry and is idempotent, but rejects same-address conflicts:
nix-seal cache export --root "$XDG_CACHE_HOME/nix-seal/v1" --destination ./nix-seal-cache
nix-seal cache import --source ./nix-seal-cache
The project is in an early, pre-release foundation phase. The current vertical slice provides strict plan parsing and validation, canonical plan hashing, native age X25519 encryption/decryption, signed target artifacts, transactional ciphertext cache writes, authenticated atomic activation, ownership-aware generation changes, activation-time secret templates, post-switch service coordination, isolated standard age-plugin operations, Linux generator network namespace isolation with capability fallback, JSON Schema output, and NixOS/nix-darwin/Home Manager modules. See SPEC.md and ROADMAP.md before relying on it.
The Nix package check requires round-trip interoperability with both the
reference age executable and rage, in both encryption directions. They are
test-only dependencies; the shipped runtime remains Rust and uses its isolated
age adapter.
It also pins C2SP/CCTV's age corpus in flake.lock and runs every supported
unarmored, uncompressed X25519 and parser vector, including expected rejection
and partial-payload cases. Unsupported passphrase, armor, and hybrid-recipient
vectors remain explicitly skipped until their corresponding native adapter
capabilities are implemented. Standard age-plugin recipients and identities are
handled through the isolated worker described in the security section. A bounded
structural preflight rejects malformed recipient stanzas before delegation to
the pinned pre-1.0 age adapter.
Installed documentation
The flake's packages.<system>.documentation output installs the versioned
plan, target-policy, secret-recipients, and activation JSON Schemas,
Bash/Zsh/Fish/Nushell completion definitions generated from the released CLI,
and nix-seal(1). Build it directly with:
nix build github:nix-forge/nix-seal#documentation
Fuzzing
The checked-in fuzz workspace exercises strict public plan.v2,
activation.v2, template-parser, signed-artifact-envelope, age
recipient/identity-parser, and ciphertext-cache state boundaries. It
deserializes untrusted bytes, validates successful documents, checks their
public JSON serialization round trips, renders accepted templates through a
bounded writer, and exercises cache import/export and garbage-collection
reachability; the plan target also derives each target projection. Run the short
sanitizer campaigns locally with a nightly Rust toolchain:
cd fuzz
cargo fuzz run plan-v2 -- -max_total_time=60
cargo fuzz run activation-v2 -- -max_total_time=60
cargo fuzz run template-v1 -- -max_total_time=60
cargo fuzz run artifact-envelope-v1 -- -max_total_time=60
cargo fuzz run identity-v1 -- -max_total_time=60
The CI smoke run catches regressions quickly; sustained parser, cache, runtime, signature, and migration campaigns remain a required 1.0 release gate.
Runtime templates
Public template sources may be stored in the Nix store. Secret values are streamed into a private candidate generation only during activation:
nixSeal.templates."application/config" = {
source = pkgs.writeText "application.conf.template" ''
password={{nix-seal:database-password}}
'';
placeholders.database-password = {
secret = "db/password";
encoding = "utf8";
};
mode = "0400";
restartUnits = [ "my-app.service" ];
};
The reserved grammar is exactly {{nix-seal:name}}, with lowercase stable
placeholder names. Missing, unused, malformed, or undeclared reserved
placeholders fail the whole activation before current changes. utf8 rejects
binary input; explicit base64 and lowercase hex transforms support arbitrary
bytes. Sources, outputs, declaration counts, and secret reads are bounded.
Rendered files use the same owner/group/mode controls, unchanged generation
detection, atomic switch, rollback preservation, and post-switch action protocol
as ordinary secret files.
Compatibility symlinks
Legacy applications that require a fixed path can opt into a compatibility symlink on a secret's runtime policy:
nixSeal.secrets."db/password".compatibilitySymlink = "/run/my-app/database-password";
The link always targets the stable <runtime-root>/current/db/password path,
never a generation directly, so a rollback changes the compatibility view
together with current. Its parent must already exist, be owned by the
activating user, and not be group- or world-writable. A mismatched existing file
or symlink is an error; nix-seal never silently replaces it. The option is
intentionally unavailable for templates and is rejected inside the private
runtime root.
nix-seal check and nix-seal doctor validate every bounded public template
source and its declared placeholders before a deployment attempt. For a
deliberate local render outside activation, use an absolute, existing private
directory and an explicit output file:
nix-seal template render \
--plan plan.v2.json \
--template application/config \
--repository-root . \
--identity /private/administrator.agekey \
--output /private/runtime/application.conf
The command checks that the identity is authorized for every referenced
canonical secret, streams plaintext only into a same-directory staging file,
sets the final file to mode 0600, and atomically creates it. It never prints
the result; replacement requires --replace. The destination must be absolute,
outside /nix/store, owned by the invoking user, and in a directory that is not
group- or world-writable.
Systemd service credentials
NixOS system services and Linux Home Manager user services can receive an activated secret through systemd's per-service credential directory:
nixSeal.secrets."db/password" = {
source = "secrets/services/db-password.age";
serviceCredentials = [
{
unit = "my-app.service";
name = "database-password";
}
];
};
nixSeal.artifactCacheRoot = "/var/lib/nix-seal/cache/v1";
The service reads $CREDENTIALS_DIRECTORY/database-password. A mapping emits
LoadCredential= without putting plaintext in a unit or the Nix store and
automatically adds the service to the changed-generation restart set. NixOS
system services also default to PrivateMounts=true, limiting credential
visibility to the service mount namespace. Credential names may have only
portable filename characters, and a (unit, name) pair can belong to only one
secret. Darwin configurations reject this systemd-only option.
Security status
This code has not received the independent audit required for 1.0. Do not use it for production secrets yet. Report vulnerabilities according to SECURITY.md.
Diagnostics
nix-seal doctor --plan plan.v2.json --repository-root . performs the same deep
public-policy and canonical-ciphertext checks used before deployment, then
reports authenticated and stale cache-artifact counts plus platform/runtime
caveats. An artifact is authenticated only when its current plan, target policy,
source hash, recipient, address, manifest, and approval threshold all verify. It
emits only public metadata and does not decrypt secrets.
Non-usage failures use stable exit categories: 1 operational, 3 policy, 4
cryptographic or approval verification, 5 cache/canonical-storage, and 6
runtime activation. Clap reserves 2 for argument/usage errors.
nix-seal key list --plan plan.v2.json inventories the identities declared by
that validated public plan. It exposes only each stable ID, role, and public
recipient, signer, or plugin reference; it never searches for or reads private
identity files.
Age-plugin recipients and identities use the standard age plugin protocol
through an isolated internal worker. The worker resolves only the declared
age-plugin-* binaries, clears the inherited environment, passes an explicit
allowlist needed by hardware/agent plugins, closes unrelated descriptors,
enforces bounded streaming I/O and a timeout, and terminates the worker process
group on failure. Plugin callbacks are non-interactive in this release, so a
plugin that requires a prompt fails closed. Plugin identities do not expose a
generic public key; authorization prechecks compare the plugin name and the age
stanza decryption remains authoritative.
Approval signer identities may use either the native nix-seal-ed25519-v1:
public-key format or a standard ssh-ed25519 public key. The corresponding
--signing-key file may be a native key or an unencrypted OpenSSH Ed25519
private key. SSH approvals use a standard PEM sshsig, bound to a dedicated
nix-seal namespace and the same DSSE payload as native approvals; public-key
comments do not affect authorization. For private keys held by a compatible
local agent, the file may instead contain
NIX-SEAL-SSH-AGENT-ED25519-v1:ssh-ed25519 ...; nix-seal then requires
SSH_AUTH_SOCK, selects the exact public key in the agent sign request, uses
the standard bounded Unix-agent protocol with a ten-second I/O timeout, and
never reads or persists the private key. Agent use is explicit rather than
inferred from the environment. SSH RSA, ECDSA, FIDO/U2F security-key algorithms,
encrypted OpenSSH files, and interactive agent prompts remain rejected until
their own reviewed protocol paths are implemented.
For TOML-managed plans, nix-seal identity add|remove|rotate updates only the
public TOML source in a same-directory atomic transaction. It validates the
merged Nix/TOML policy before committing and refuses to remove referenced IDs.
Rotation requires --yes and deliberately invalidates old artifacts, so it
reports that rekeying and approval are required. Nix-emitted plan sources are
validation inputs and are never rewritten by these commands.
nix-seal group add|list|remove uses the same transaction path for named
administrator or consumer groups. Group creation requires explicit members;
removal requires --yes and fails while another group or a secret's
administrator/consumer policy still references it.
Built-in generation
nix-seal generate follows the public plan, derives the canonical recipients,
and encrypts the generated value through the normal verified authoring path. The
current Rust-only built-ins are builtin:random, builtin:hex,
builtin:base64, builtin:token, builtin:passphrase, builtin:ssh-ed25519,
builtin:argon2id-password-hash, builtin:wireguard-private-key, and
builtin:uuid. Random, hex, base64, and token generators accept one public
bytes parameter (1–1,048,576; default 32). builtin:token emits unpadded
URL-safe base64 for service-safe tokens; builtin:base64 emits standard padded
base64. builtin:wireguard-private-key generates a clamped 32-byte Curve25519
private scalar in the standard WireGuard base64 format and accepts no
parameters; UUID accepts none. builtin:passphrase uses 12–64 uniformly
selected, hyphen-separated words from an embedded 64-word list (default 16, 96
bits of selection entropy). builtin:argon2id-password-hash accepts exactly one
declared single-line hidden prompt and emits one Argon2id PHC string. It
defaults to 64 MiB, three iterations, one lane, and a 32-byte output; public
bounds are 19–512 MiB, 2–10 iterations, and 16–64 output bytes. The private
prompt value is never put in the plan, arguments, environment, or logs.
builtin:ssh-ed25519 produces one standard unencrypted OpenSSH Ed25519 private
key, which is immediately encrypted through the normal canonical-secret
transaction; its public key is derivable from that secret. Generation is
create-only unless --replace is explicit. Generators may produce multiple
secret outputs and declared public outputs. Every output is validated before any
destination changes; secret outputs are encrypted and round-trip verified,
public outputs are written with mode 0644, and replacement failures restore
the complete prior set. Direct executable generators use an explicit protocol:
executable and every runtimeInputs entry must be under /nix/store;
arguments are literal public values; and the process runs with a cleared
environment, null standard streams, a private workspace, and a bounded timeout.
On Unix it also runs in a dedicated process group, so timeout cleanup terminates
descendants that might otherwise retain staged plaintext. It must write exactly
one regular file named 0, 1, and so on for each declared secret output
beneath $NIX_SEAL_OUTPUT_DIR, plus the same numbered protocol beneath
$NIX_SEAL_PUBLIC_OUTPUT_DIR for declared public outputs. Unlisted files,
links, oversized output, nonzero exits, and timeouts fail the full transaction
without exposing process output. Public destinations are repository-relative,
must not collide with ciphertext sources, and are recorded in the public plan;
built-in generators emit encrypted secret outputs; builtin:ssh-ed25519 may
additionally emit one derived public OpenSSH key, and
builtin:wireguard-private-key may additionally emit one derived public
WireGuard key, without exposing private material.
Private identities, prompt state, generator dependencies, and generator outputs
are permissioned through their already-open descriptors. Generator output paths
are opened with no-follow and single-link checks before mode 0600 is applied,
so a pathname substitution cannot redirect a permission change or the bounded
read to another file.
Set a generator's public validation value when its generated credential must
be replaced after a specific non-secret configuration change. nix-seal records
only the generator ID, output IDs, and validation value in a private local
.nix-seal/generator-state/v1 file. The first matching run creates the outputs;
later matching runs are no-ops, while a changed validation value performs a
transactional replacement. Existing outputs without this state intentionally
require --replace to establish a baseline, preventing an unreviewed metadata
file from silently rotating a credential.
Declared external-generator prompts are non-interactive by default. Supply each
response with
nix-seal generate --prompt-file prompt/id=/absolute/private-file; the response
file must be owned by the invoking user and mode 0600 (or stricter). The CLI
rejects missing or unused prompt files and copies responses only into numbered
files below $NIX_SEAL_PROMPT_DIR in the private workspace. Prompt values never
enter the plan, command arguments, environment, or logs. A prompt marked
persistent = true may be initialized from an explicit --prompt-file; after a
successful generation its response is atomically retained in the owner-only
repository state path .nix-seal/prompt-state/v1/<generator>/<prompt>, and
later runs may use that stored response without passing it again. Nonpersistent
prompts are never retained. Persistent state is plaintext and must be protected
like any other local credential; it is not part of Git, the Nix store, or the
public plan. For an explicitly interactive workflow, pass --interactive.
nix-seal then opens the controlling /dev/tty rather than stdin/stdout, rejects
non-terminal sessions, sanitizes public prompt labels before display, bounds
each response to 1 MiB, masks hidden prompts with terminal settings restored
on all errors, and never places the response in argv, ordinary environment
variables, the plan, or logs. Single-line prompts finish at Enter; multiline
prompts finish with Ctrl-D and preserve entered line endings. Automation should
continue to use private response files, because interactive prompting is never
implicit.
External generators may additionally declare secretDependencies. nix-seal
requires every declared ID to be an existing canonical secret, forbids duplicate
or self-output dependencies, verifies that the supplied identity is an
authorized canonical recipient, then streams each dependency into an owned
0600 file named 0, 1, and so on beneath $NIX_SEAL_SECRET_DIR in declared
order. $NIX_SEAL_SECRET_COUNT is public metadata only. Built-in generators
cannot receive secret dependencies. No undeclared canonical secret is decrypted
for the generator, and the private workspace is removed whether generation
succeeds or fails. If an input is itself produced by another generator, that
producer must be a direct entry in dependencies, making generation order
explicit and checkable. On Linux, nix-seal launches the generator through a Rust
worker that attempts a fresh network namespace before execution. If the kernel
or container denies that operation, nix-seal falls back once to the direct
process-group path and emits a diagnostic warning. macOS and other platforms
emit the same capability warning because network isolation is not available
there; generators and their declared runtime inputs must always be treated as
trusted code.
Set a secret's repositoryOnly policy bit for an intermediary output that must
remain administrator/recovery-encrypted in the repository and cache but must
never be delivered to a target. Policy validation rejects target consumers and
advanced direct delivery for such secrets; this explicit bit avoids relying on
an empty consumer list as a security signal.
Migration inspection
Migration begins with a deliberately non-destructive public inventory. Inspect the stable mapping before touching ciphertext:
nix-seal migrate agenix --directory ./secrets --json
# ragenix uses the same standard age ciphertext inventory format
nix-seal migrate ragenix --directory ./secrets --json
# bulk import is side-by-side and remains dry-run-first
nix-seal migrate agenix --repository-root . --directory legacy/secrets \
--destination secrets/nix-seal --identity /absolute/private/admin.agekey \
--verification-identity /absolute/private/nix-seal-admin.agekey \
--recipient age1admin... --recipient age1recovery... --json
# add --execute only after reviewing every mapping and recipient
nix-seal migrate agenix --repository-root . --directory legacy/secrets \
--destination secrets/nix-seal --identity /absolute/private/admin.agekey \
--verification-identity /absolute/private/nix-seal-admin.agekey \
--recipient age1admin... --recipient age1recovery... --execute
# inspect an evaluated agenix-rekey policy export without decrypting data
nix eval --json .#agenixRekeyMigration > /tmp/agenix-rekey.json
nix-seal migrate agenix-rekey --metadata /tmp/agenix-rekey.json --json
# inspect structured SOPS JSON metadata without decrypting values or invoking SOPS
nix-seal migrate sops-json --directory ./secrets --json
# Convert one SOPS document using only an explicit SOPS binary and private age key file.
nix-seal migrate sops --repository-root . --source legacy/token.yaml \
--destination secrets/token.age --sops /absolute/path/to/sops \
--sops-age-key-file /absolute/private/sops-age-key.txt \
--identity /absolute/private/nix-seal-admin.age --recipient age1... --execute
# inventory Clan's documented per-machine output leaves without reading values
nix-seal migrate clan-vars --directory ./vars/per-machine --json
# after reviewing the mapping, stream values into a side-by-side native age tree
nix-seal migrate clan-vars --directory vars/per-machine \
--repository-root . --destination nix-seal-vars \
--identity /absolute/private/nix-seal-admin.age \
--recipient age1... --execute
# inventory documented Clan Facts public leaves without reading values
nix-seal migrate clan-facts --directory ./machines --json
# after reviewing the mapping, copy public facts side-by-side
nix-seal migrate clan-facts --directory machines --repository-root . \
--destination nix-seal-public --execute
# First inspect the mutation; then add --execute to stream-reencrypt it.
nix-seal migrate ciphertext --source legacy/token.age --destination secrets/token.age \
--identity /absolute/path/to/administrator.age --recipient age1... --json
It validates legacy paths, IDs, and SSH recipient metadata. An explicit
repository-relative destination, private identity, and replacement recipients
enable a side-by-side bulk rekey; --execute is required before ciphertext is
opened, and the source tree remains untouched. Without those import flags it
never decrypts or rewrites legacy files. New plans should use native age
recipients. Existing unencrypted OpenSSH Ed25519/RSA identities are supported
only as a migration compatibility path; encrypted SSH private keys are
deliberately rejected in non-interactive workflows, so convert them to a
reviewed native-age or hardware-backed identity before automated import.
For age-tree, agenix-rekey, and single-file ciphertext migration, --identity
is the legacy source/decryption identity. --verification-identity is optional
and defaults to --identity; when supplied it must be authorized by every
replacement recipient and is used to authenticate the newly written ciphertext.
This explicit split is required when a migration replaces a legacy SSH or age
key with a new administrator or recovery key. Both private identities are opened
only for --execute; dry runs inspect public metadata and paths.
PGP is migration-only and never a native nix-seal encryption backend. Its
dry-run-first bridge requires an absolute GnuPG executable and private,
owner-only GNUPGHOME; execution clears inherited environment variables,
disables option-file and automatic-key lookup behavior, suppresses GnuPG
diagnostics, bounds the plaintext stream, and encrypts directly into a new
native age file:
nix-seal migrate pgp --repository-root . --source legacy/service.pgp \
--destination secrets/service.age --gpg /absolute/path/to/gpg \
--gnupg-home /private/gnupg --identity /private/administrator.agekey \
--recipient age1example
nix-seal migrate pgp --repository-root . --source legacy/service.pgp \
--destination secrets/service.age --gpg /absolute/path/to/gpg \
--gnupg-home /private/gnupg --identity /private/administrator.agekey \
--recipient age1example --execute
The agenix/ragenix adapters recursively inventory only regular *.age files,
validate their age headers, and reject symbolic links or unsafe nesting. Because
recipient and Nix module policy are not recoverable from ciphertext paths, their
reports require an explicit nix-seal recipient mapping before import. Supplying
--destination, --identity, and one or more --recipient values enables a
bulk migration preflight; the identity is not opened until --execute is
present. The destination must be a separate repository-relative tree. Every
source is streamed and round-trip verified into a private staging file before
any destination changes, then all destination files are committed with backup
and rollback behavior. Existing legacy files and configuration are never
rewritten, so the two managers can run side by side during activation and
rollback verification. --replace is explicit and still preserves the legacy
tree. The same flow is available for ragenix because its ciphertext layout is
standard age-compatible.
Public migration compatibility goldens are checked into
crates/nix-seal-cli/tests/fixtures/migrations and exercised through the
released binary. They cover agenix, ragenix, agenix-rekey, SOPS JSON metadata,
Clan Vars, and Clan Facts. The fixtures contain only public metadata,
empty/public leaves, or ciphertext without its private identity; mutation
adapters remain dry-run-first and require separate round-trip tests before a 1.0
migration claim.
For agenix-rekey, expose one public evaluated configuration with
nixSeal.lib.agenixRekeyMigrationExport. The target must declare id, kind
(nixos, darwin, or home-manager), system, recipient, and storageMode
(local or derivation); masterRecipients contains only public master
recipients. Each secret maps to a repository-relative string rekeyFile and may
set intermediary = true. The inventory validates all of those public values,
normalizes recipients, and preserves intermediary secrets as repository-only.
Supplying --destination, --identity, and one or more --recipient values
enables the same dry-run-first, side-by-side bulk rekey flow as agenix/ragenix;
--verification-identity may select the new administrator/recovery identity;
--execute is required before either private identity is opened. Every source
is staged and round-trip verified before any destination changes, while the
legacy tree remains intact for rollback. It does not infer private runtime
configuration or rewrite the legacy ciphertext.
nixSeal.lib.agenixRekeyMigrationExport {
target = {
id = "desktop";
kind = "nixOs";
system = "x86_64-linux";
recipient = "ssh-ed25519 AAAA...";
storageMode = "derivation";
};
masterRecipients = [ "age1..." ];
secrets.service-token.rekeyFile = "secrets/service-token.age";
}
Build a separate, reviewable plan.v2.json with the Nix or TOML frontend before
performing a migration. The plan must name target systems, approval signers,
administrator or recovery recipients, runtime ownership, phases, lifecycle, and
templates explicitly. The default delivery is administrator-backed rekeyed;
advanced direct delivery is intentionally explicit because a stolen target key
can decrypt historical ciphertext addressed to that target.
When one canonical secret serves targets with different local account-group
conventions, runtimeOverrides.<target-id> may replace its public runtime
owner/group/mode for an already-authorized target. The resolved value is part of
that target's signed policy and cannot be changed at activation time.
migrate sops-json is intentionally a metadata-only adapter for SOPS JSON
files. It accepts only bounded regular files, validates the top-level sops
object, MAC/version fields, provider metadata, age recipients, and SOPS key
groups, then reports public provider types. It does not decrypt or authenticate
the document values; structured extraction and SOPS invocation remain an
explicit later migration step. YAML, dotenv, INI, and binary SOPS inputs are not
silently treated as JSON.
migrate sops is the separate mutation path for a single reviewed SOPS
document. It invokes only an absolute, non-symlink SOPS executable with an empty
environment, optionally passing a private SOPS_AGE_KEY_FILE path. Its
plaintext stdout is bounded to 64 MiB and streamed directly into a staged native
age ciphertext; no plaintext file is created. The staged result is round-trip
verified and is committed only after SOPS exits successfully. SOPS diagnostics
are deliberately discarded to avoid leaking values into the invoking terminal;
failure is reported as a redacted status error. A 120-second watchdog terminates
a stalled process. This initial mutation path therefore supports SOPS age
identities explicitly; PGP and cloud/KMS SOPS migrations remain a separately
reviewed extension rather than implicitly inheriting credential environments.
migrate clan-vars recognizes only the documented
vars/per-machine/<machine>/<generator>/<output>/value leaves. It validates the
complete filesystem walk without following links, reports paths and byte counts,
and never reads, decrypts, prints, or passes a value to another process during
inventory. Clan storage backend, secret/public classification, target
authorization, and runtime policy are not encoded by those leaves, so they must
be supplied in a reviewed mapping before import. Supplying --repository-root,
--destination, --identity, and one or more --recipient values enables a
side-by-side import; --execute is required before values are opened. Every
value is streamed into a staged age ciphertext, round-trip verified, and
committed as one recoverable batch while the legacy Vars tree remains unchanged.
migrate clan-facts inventories only documented public
machines/<machine>/facts/<fact> leaves, with link/type and 64 MiB bounds. It
never reads their values during inventory. Supplying a repository-relative
destination enables an explicit side-by-side public import; --execute streams
each leaf through a bounded no-follow file transaction, verifies the complete
batch, and publishes mode-safe outputs while leaving the legacy tree untouched.
Clan secret facts have configurable stores and paths, so they need an explicit
reviewed export instead of filesystem inference.
Development
nix develop
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
nix flake check
Licensed under either Apache-2.0 or MIT, at your option. Contributions require a Developer Certificate of Origin sign-off.