2026-05-24 17:04:15 +02:00
|
|
|
# Bootstrap Age Custody
|
|
|
|
|
|
|
|
|
|
Status: draft control-surface contract
|
|
|
|
|
Date: 2026-05-24
|
|
|
|
|
|
|
|
|
|
## Purpose
|
|
|
|
|
|
|
|
|
|
The custodian age keypair is the bootstrap envelope key for NetKingdom security
|
|
|
|
|
setup. It is not a login account and it is not the runtime secret manager.
|
|
|
|
|
|
|
|
|
|
The public key may be stored in the control surface and used by automation to
|
|
|
|
|
encrypt generated bootstrap bundles. The private key is supplied only during an
|
|
|
|
|
explicit unlock/apply or recovery ceremony.
|
|
|
|
|
|
|
|
|
|
## Minimal Secret Model
|
|
|
|
|
|
|
|
|
|
The minimal human-held secret set is:
|
|
|
|
|
|
|
|
|
|
| Secret | Home | Notes |
|
|
|
|
|
| --- | --- | --- |
|
|
|
|
|
| Custodian age private key | Password safe or offline custody packet | Used to decrypt bootstrap bundles for an attended apply/recovery ceremony |
|
|
|
|
|
| Platform-root password | Password safe or offline custody packet | Dedicated custody identity, not day-to-day account |
|
|
|
|
|
| Platform-root OTP/recovery | Authenticator plus offline recovery | Not stored in Git, State Hub, or bootstrap metadata |
|
|
|
|
|
| OpenBao recovery/unseal material | OpenBao custody packet | Created only after custody approval |
|
|
|
|
|
|
|
|
|
|
Everything else should be generated, encrypted to the custodian public key,
|
|
|
|
|
applied to the cluster, then shredded or rotated into OpenBao once OpenBao is
|
|
|
|
|
available.
|
|
|
|
|
|
|
|
|
|
## Trial Versus Custody Mode
|
|
|
|
|
|
|
|
|
|
Trial mode uses throwaway values to document the steps, UI, failure modes, and
|
|
|
|
|
operator instructions. Nothing created in trial mode should be trusted as live
|
|
|
|
|
security material.
|
|
|
|
|
|
|
|
|
|
Custody mode uses real generated secrets. The expected lifecycle is:
|
|
|
|
|
|
|
|
|
|
1. Register the custodian public age recipient in the control surface.
|
|
|
|
|
2. Generate or recover bootstrap secrets.
|
|
|
|
|
3. Encrypt the bundle to the custodian public key.
|
|
|
|
|
4. Apply secrets to the cluster during an attended ceremony.
|
|
|
|
|
5. Shred plaintext `sso-mfa/bootstrap/secrets/`.
|
|
|
|
|
6. Record only non-secret fingerprints, timestamps, and state transitions.
|
|
|
|
|
|
|
|
|
|
## Control Surface Rules
|
|
|
|
|
|
|
|
|
|
The NetKingdom control surface may store:
|
|
|
|
|
|
|
|
|
|
- custodian public age recipient;
|
|
|
|
|
- public-key fingerprint;
|
|
|
|
|
- private-key location reference, such as a password-safe entry label;
|
|
|
|
|
- encrypted bundle path and file count;
|
|
|
|
|
- whether plaintext bootstrap secrets are currently present; and
|
|
|
|
|
- non-secret apply/verification status.
|
|
|
|
|
|
|
|
|
|
It must not store:
|
|
|
|
|
|
|
|
|
|
- `AGE-SECRET-KEY-1...` private key material;
|
|
|
|
|
- generated passwords;
|
|
|
|
|
- OTP seeds;
|
|
|
|
|
- OpenBao root tokens or unseal shares;
|
|
|
|
|
- decrypted bootstrap files; or
|
|
|
|
|
- screenshots of secret output.
|
|
|
|
|
|
|
|
|
|
## Current Tooling Alignment
|
|
|
|
|
|
|
|
|
|
`sso-mfa/bootstrap/encrypt-secrets.sh` should accept an age public recipient
|
|
|
|
|
directly. Encryption does not require the private key.
|
|
|
|
|
|
|
|
|
|
`sso-mfa/bootstrap/decrypt-secrets.sh` requires the private key path and should
|
|
|
|
|
be used only in an explicit unlock/apply ceremony. After apply, plaintext files
|
|
|
|
|
must be shredded.
|
2026-06-14 19:51:05 +02:00
|
|
|
|
|
|
|
|
## Custody Unlock Helper
|
|
|
|
|
|
|
|
|
|
Use `sso-mfa/bootstrap/sops-custody-unlock.sh` when an operator needs to run a
|
|
|
|
|
SOPS-backed drill, recovery, or lockdown command but the private key is only in
|
|
|
|
|
the password safe or offline custody packet.
|
|
|
|
|
|
2026-10-12 10:53:30 +02:00
|
|
|
The helper disables shell tracing before handling input and refuses conflicting input modes.
|
|
|
|
|
Noninteractive callers must select `--from-stdin` or `--from-file` explicitly.
|
|
|
|
|
|
2026-06-14 19:51:05 +02:00
|
|
|
The helper:
|
|
|
|
|
|
2026-10-12 10:53:30 +02:00
|
|
|
- reads the private age key from one explicit source: hidden prompt, stdin, or a source file;
|
2026-06-14 19:51:05 +02:00
|
|
|
- derives the public age recipient with `age-keygen -y`;
|
|
|
|
|
- refuses to continue unless it matches the expected recipient;
|
|
|
|
|
- writes a `0600` temporary `SOPS_AGE_KEY_FILE`;
|
|
|
|
|
- runs the requested command or opens a temporary custody shell; and
|
|
|
|
|
- shreds/removes the temporary key file when the command or shell exits.
|
|
|
|
|
|
|
|
|
|
It must not be used to install the private key permanently on a workstation or
|
|
|
|
|
server. The source of truth remains the password safe/offline custody packet.
|
|
|
|
|
|
|
|
|
|
### Validate Custody Material
|
|
|
|
|
|
|
|
|
|
From the NetKingdom repo, validate the supplied private key against `keys/age.pub`
|
|
|
|
|
without keeping it on disk:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
make sops-custody-check
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Run A One-Shot Recovery Drill
|
|
|
|
|
|
|
|
|
|
For inter-hub:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
make sops-custody-run COMMAND='make -C /home/worsch/inter-hub recovery-drill'
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The helper prompts for the `AGE-SECRET-KEY-1...` line from the password safe or
|
|
|
|
|
offline custody packet, validates it against the registered public recipient,
|
|
|
|
|
sets `SOPS_AGE_KEY_FILE` for the command, and removes the temporary key after
|
|
|
|
|
the drill exits.
|
|
|
|
|
|
|
|
|
|
If using a password-manager CLI, pipe only the private key field:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
op read 'op://Platform/NetKingdom custodian age key/private-key' \
|
|
|
|
|
| sso-mfa/bootstrap/sops-custody-unlock.sh \
|
|
|
|
|
--from-stdin \
|
|
|
|
|
-- make -C /home/worsch/inter-hub recovery-drill
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Open An Incident Shell
|
|
|
|
|
|
|
|
|
|
For multi-step recovery or lockdown work:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
make sops-custody-shell
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Run the required SOPS, recovery, or lockdown commands inside that shell. Exit
|
|
|
|
|
the shell to shred the temporary key file.
|
2026-10-12 09:41:08 +02:00
|
|
|
|
|
|
|
|
## Bootstrap implementation correction — 2026-10-12
|
|
|
|
|
|
|
|
|
|
The compatibility `creds-agent-init` command now encrypts using `keys/age.pub`.
|
|
|
|
|
It does not generate or discover private keys. Live bootstrap requires the
|
|
|
|
|
operator terminal; `--dry-run` is a non-mutating plan. For an encrypted resume,
|
|
|
|
|
use the explicit custody helper with the independently held matching key. The
|
|
|
|
|
bootstrap validates that temporary key before contacting the cluster.
|
|
|
|
|
|
|
|
|
|
The former emergency-bundle export is a non-secret guide. The current encrypted
|
|
|
|
|
ops bundle is handed off by location and SHA256, and a fresh operator
|
|
|
|
|
acknowledgment is required before plaintext cleanup/completion. Old delivery
|
|
|
|
|
flags retain their historical meaning and do not acknowledge a new artifact.
|
|
|
|
|
Generation no longer logs value prefixes and creates private files even under a
|
|
|
|
|
permissive caller umask. These controls do not enforce agent runtime isolation.
|
|
|
|
|
|
|
|
|
|
Recovery verification uses an owner-approved isolated workspace and target. With
|
|
|
|
|
the helper's temporary key, decrypt the exact bundle to a private file, verify
|
|
|
|
|
its expected contents without printing values, then remove decrypted files and
|
|
|
|
|
retain a non-secret receipt. Acknowledgment of a guide is not that verification.
|
|
|
|
|
No live root or password was read or rotated during the source correction.
|
|
|
|
|
NK-WP-0049 tracks remaining native acceptance and other legacy custody callers.
|
2026-10-12 10:53:30 +02:00
|
|
|
|
|
|
|
|
## Direct helper correction — 2026-10-12
|
|
|
|
|
|
|
|
|
|
`encrypt-secrets.sh` defaults to the registered `keys/age.pub`. It accepts a public
|
|
|
|
|
recipient or public-recipient file and rejects private-key files instead of
|
|
|
|
|
loading them to derive metadata. `decrypt-secrets.sh` accepts an explicit key
|
|
|
|
|
argument or the custody helper's `NETKINGDOM_CUSTODY_AGE_KEY_FILE`; it no longer
|
|
|
|
|
searches HOME. Successful decryption publishes a new private output directory
|
|
|
|
|
only after every input decrypts. Failed/empty input removes partial staging;
|
|
|
|
|
existing files, directories and dangling symlinks are never replaced.
|
|
|
|
|
|
|
|
|
|
The custody helper disables shell tracing, rejects repeated/mixed input modes,
|
|
|
|
|
does not echo unknown arguments and clears competing inline/key-command SOPS
|
|
|
|
|
authentication before the child starts. HUP/INT/TERM now retain failure status
|
|
|
|
|
and EXIT cleanup rather than running cleanup and then continuing. Bash may defer
|
|
|
|
|
its trap while a foreground child runs; this is not a process-group supervisor,
|
|
|
|
|
SIGKILL cleanup guarantee or a runtime authority boundary. Temporary key cleanup
|
|
|
|
|
on command return does not certify that an arbitrary child retained no copies.
|
|
|
|
|
|
2026-10-13 21:03:24 +02:00
|
|
|
Remaining concrete caller defects stay in NK-WP-0049-T02, including legacy
|
|
|
|
|
diagnostics, key export and coordinated rotation. Its current checklist records
|
|
|
|
|
repaired callers and outstanding work. Do not treat narrower helper repairs as
|
|
|
|
|
closure of the whole custody surface.
|
2026-10-12 12:08:35 +02:00
|
|
|
|
|
|
|
|
## Make recovery entry points — 2026-10-12
|
|
|
|
|
|
|
|
|
|
`make sops-setup` now validates only `keys/age.pub`. It never copies or generates
|
|
|
|
|
a private key; `creds-init` likewise has no permanent HOME key requirement.
|
|
|
|
|
Existing permanent files are not deleted or represented as remediated copies.
|
|
|
|
|
|
|
|
|
|
`make sops-decrypt FILE=... OUTPUT=...` requires the custody helper's explicit
|
|
|
|
|
`NETKINGDOM_CUSTODY_AGE_KEY_FILE`. The owned private regular key file must match
|
|
|
|
|
the registered public recipient. Output must be a new file in an owned private
|
|
|
|
|
parent outside Git worktrees (including linked-worktree markers and bare repos).
|
|
|
|
|
No destination file/symlink is overwritten. Partial tool output is removed on
|
|
|
|
|
failure; successful publication is atomic and mode 0600. The operator removes
|
|
|
|
|
successful plaintext after the approved operation. This does not promise cleanup
|
|
|
|
|
after SIGKILL, hostile same-UID interference or arbitrary child copies.
|
|
|
|
|
|
|
|
|
|
For the repository's age custody profile, the wrapper accepts SOPS JSON/YAML,
|
|
|
|
|
and rejects non-age recipients, age plugins, key groups, unknown metadata and
|
|
|
|
|
duplicate keys before decryption.
|
|
|
|
|
Other formats/backends require their own reviewed adapter; they are not silently
|
|
|
|
|
sent through a different credential source. YAML input requires PyYAML.
|
|
|
|
|
|
|
|
|
|
SOPS runs with an isolated HOME/XDG configuration and a minimal environment,
|
|
|
|
|
using an inherited descriptor for the explicit key. This prevents SOPS from
|
|
|
|
|
combining it with HOME/SSH keys, inline keys, key commands, proxies or remote key
|
|
|
|
|
services. Raw parser/tool diagnostics and plaintext stdout never enter the
|
|
|
|
|
terminal transcript. This wrapper assumes trusted installed `age`, `age-keygen`
|
|
|
|
|
and `sops` binaries; it is not an OS sandbox or task-authorization grant.
|
|
|
|
|
|
|
|
|
|
Use from the operator's approved custody terminal, with a private directory
|
|
|
|
|
created outside all repositories (for example using `mktemp -d`):
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
# OUTPUT's parent must exist and be owned/private; select a new filename.
|
|
|
|
|
make sops-custody-run COMMAND='make sops-decrypt FILE=secrets/example.yaml OUTPUT=/private/recovery/new-example.yaml'
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The command string carries paths only. Never put a credential in FILE, OUTPUT,
|
|
|
|
|
COMMAND, shell history or Make variables. Tests exercise real SOPS 3.13.3 with
|
|
|
|
|
disposable keys; no production key or encrypted secret was accessed. The test
|
|
|
|
|
binary checksum was checked against the official release checksum; no detached
|
|
|
|
|
signature verification or installed-toolchain promotion is claimed.
|
|
|
|
|
|
|
|
|
|
Source basis: [SOPS age identity loading](https://github.com/getsops/sops/blob/v3.13.3/age/keysource.go)
|
|
|
|
|
loads explicit and ambient identities together, so setting `SOPS_AGE_KEY_FILE`
|
|
|
|
|
alone is insufficient to exclude ambient credentials.
|
2026-10-12 12:54:10 +02:00
|
|
|
|
|
|
|
|
|
2026-10-13 21:03:24 +02:00
|
|
|
## SOPS writes — 2026-10-13
|
|
|
|
|
|
|
|
|
|
The Make wrappers support the registered single-age JSON/YAML profile:
|
|
|
|
|
|
|
|
|
|
- `make sops-encrypt FILE=...` encrypts a private, owned, untracked plaintext
|
|
|
|
|
mapping in place using only `keys/age.pub`. It needs no private key. Tracked
|
|
|
|
|
plaintext is refused and needs incident review; encryption cannot erase history.
|
|
|
|
|
- `make sops-edit FILE=... INPUT=...` replaces content from an owned private
|
|
|
|
|
plaintext file in a private directory outside Git. Use private decryption above
|
|
|
|
|
to prepare that file, edit it in the approved custody environment, then remove
|
|
|
|
|
it after successful replacement. No ambient editor is launched. INPUT must use
|
|
|
|
|
the same JSON/YAML format. The original MAC is verified before replacement and
|
|
|
|
|
its declared field-encryption policy is preserved.
|
|
|
|
|
- `make sops-rotate FILE=...` rotates the data key and re-encrypts values. It does
|
|
|
|
|
**not** migrate recipients or apply changes to `.sops.yaml`. A recipient change
|
|
|
|
|
needs its own reviewed custody migration, independent recipient admission and
|
|
|
|
|
recovery verification.
|
|
|
|
|
|
|
|
|
|
Edit and rotate require the explicit matching temporary custody key. Both refuse
|
|
|
|
|
extra document recipients, even when the registered key can decrypt the file:
|
|
|
|
|
metadata is not authority to rewrap a data key for another recipient. The new
|
|
|
|
|
ciphertext is decrypted for verification with plaintext output discarded.
|
|
|
|
|
Inputs reject symlinks, hardlinks, nonregular files and unsafe modes; key inputs
|
|
|
|
|
also reject symlink ancestors and FIFOs. Parser and tool diagnostics are captured.
|
|
|
|
|
Make passes FILE, INPUT, OUTPUT and custody arguments literally, without evaluating
|
|
|
|
|
Make functions or interpolating them into shell code before the custody handoff.
|
|
|
|
|
The approved COMMAND is still intentionally executed inside that handoff.
|
|
|
|
|
|
|
|
|
|
Ciphertext publication uses private staging, a cooperative file lock and a final
|
|
|
|
|
source identity/version check followed by atomic rename. The check and rename are
|
|
|
|
|
separate operations: this is not protection against hostile concurrent same-UID
|
|
|
|
|
writers or ancestor-directory substitution. Directory fsync failure after rename
|
|
|
|
|
can report failure after publication; inspect state before retrying. SIGKILL may
|
|
|
|
|
leave staging files. Private modes and a temporary key are not credential isolation
|
|
|
|
|
or an authorization grant. Current native recovery acceptance remains NK-WP-0049-T03.
|
|
|
|
|
|
|
|
|
|
Version-specific behavior was exercised with disposable SOPS 3.13.3 and age keys;
|
|
|
|
|
see the [upstream SOPS documentation](https://github.com/getsops/sops/blob/v3.13.3/README.rst).
|
|
|
|
|
|
2026-10-12 12:54:10 +02:00
|
|
|
## Rotation output boundary (2026-10-12 source repair)
|
|
|
|
|
|
|
|
|
|
`creds-rotate.sh` keeps generated values out of stdout/stderr and disables shell
|
|
|
|
|
tracing. The selected plaintext secrets directory and service subdirectory must
|
|
|
|
|
be owned by the operator with no group/other access; `secrets.env` must be an
|
|
|
|
|
owned private regular file with one link and exactly one target assignment.
|
|
|
|
|
Symlink paths, missing keys and duplicate target assignments are refused before
|
|
|
|
|
cluster work. The helper replaces the file atomically with mode 0600 and never
|
|
|
|
|
places the new value in its command arguments. It accepts the generated
|
|
|
|
|
alphanumeric credential format. This is file hygiene, not process isolation.
|
|
|
|
|
|
|
|
|
|
Approved rotations preserve the new candidate in that private service file.
|
|
|
|
|
Transfer it through approved custody, verify real consumer authentication and
|
|
|
|
|
old-value refusal, refresh the encrypted recovery handoff, then remove plaintext.
|
|
|
|
|
A failed apply can leave a new local candidate while some services still use the
|
|
|
|
|
old value. Reconcile that partial state; do not infer completion from file contents.
|
|
|
|
|
Service-command diagnostics are suppressed because they can contain values;
|
|
|
|
|
use approved metadata-only diagnostics to investigate failures.
|
|
|
|
|
|
|
|
|
|
Database rotation stops before local mutation unless exactly one database pod
|
|
|
|
|
is discovered. SQL is sent on stdin with SQL-error failure enabled. Unattended
|
|
|
|
|
LLDAP admin-password rotation is refused because the server-update step is
|
|
|
|
|
manual. The attended path requires acknowledgment of that step before dependent
|
|
|
|
|
Secret updates; cancelling retains the local candidate and requires reconciliation.
|
|
|
|
|
Break-glass Secret creation reads the private file instead of a plaintext argv
|
|
|
|
|
literal and applies server-side without forcing field conflicts. This does not
|
|
|
|
|
remove historical client-side annotations or prove native consumer adoption.
|
|
|
|
|
|
|
|
|
|
The last-rotated timestamp records successful script steps only. No live rotation
|
|
|
|
|
was run for these source tests. CUST-WP-0073-T05 remains founder-deferred. RSA
|
|
|
|
|
rotation recovery/rollback, delegated helper argv/annotation handling and
|
2026-10-12 15:02:15 +02:00
|
|
|
`keycape/create-pi-token.sh` still require source and native acceptance. The
|
|
|
|
|
admin-bootstrap source repair below leaves native MFA and legacy-object revocation open.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Current-profile admin bootstrap (2026-10-12 source repair)
|
|
|
|
|
|
|
|
|
|
The current KeyCape profile does not consume the old Keycloak trigger-admin
|
|
|
|
|
credential. `privacyidea/bootstrap-admin.sh` now creates/verifies only pi-admin
|
|
|
|
|
and its explicitly scoped bootstrap policy; it creates no trigger account, token
|
|
|
|
|
or Kubernetes Secret. It never deletes existing objects. Owner inventory and
|
|
|
|
|
revocation are required for historical credentials, independent of source repair.
|
|
|
|
|
|
|
|
|
|
Input is one private generated service-file assignment, parsed without sourcing
|
|
|
|
|
shell content. The password is sent through kubectl stdin into a version-scoped
|
|
|
|
|
privacyIDEA 3.12.2 adapter. That adapter verifies existing passwords and uses
|
|
|
|
|
insert-only writes for absent account/policy rows. It refuses broad/changed
|
|
|
|
|
legacy policies and existing installations that require policy migration.
|
|
|
|
|
The success receipt proves account/password and scoped-policy state only; MFA,
|
|
|
|
|
effective privileges, deployed-image behavior and consumer acceptance remain
|
|
|
|
|
attended native gates. See the privacyIDEA README for the exact operation scope.
|