Credential classes and the redaction filter¶
Two halves of one question: what grants access, and can the program print it? The census names every class of access-granting value with a ceiling and a leak class; the redaction filter is what keeps any of them out of the program's output. Instance secrets is where a value is stored and how it is verified.
Classes and instances¶
nerthus secrets census lists classes: a session cookie, an enrolment token, a corpus deploy
token, each with its issuer, holder kind, leak class, ceiling and tier. The inventory in
Nerthus.Infra lists instances: where one particular value lives and who can be asked to rotate
it. An inventory row names its class, and nerthus secrets verify refuses the two when they
disagree.
The class list exists because Platform mints values (session cookies, API keys, instance keys,
site data keys) that no census of an issuer's API can see. It is also checked from the consumer's
end: tests/test_secrets_census.py enumerates every credential-shaped name the package reads and
refuses one with no class.
The leak class is not a severity¶
It answers one question: after the value is revoked, is the incident over?
| Leak class | Meaning |
|---|---|
rotate-and-done |
the value only unlocks a live service. Rotate, read the audit, done. |
data-is-out |
the value decrypts or hands over data the holder may already have taken. Rotate and decide about disclosure. |
A corpus deploy token is data-is-out: one git clone is the whole corpus and its history. The
backup age key decrypts every dump ever taken, and a new key takes nothing back. A runner's cache
credential is rotate-and-done and still dangerous, because it can write a cache the next pipeline
executes; that is a severity question, which this field does not answer.
Ceilings¶
A ceiling has four fields: an absolute life, an idle life, a grace window for a rotation with
signatures in flight, and single_use. A session has both clocks, so a laptop left open and a
stolen cookie kept for a month get different answers; Ceiling.expires_at returns the earlier.
nerthus secrets verify refuses a Platform-issued inventory row with no ceiling.
A ceiling in the census is a declaration: the code that mints a class mints against it, and
census.due() is what a rotation reminder asks.
Recipients: one kind per file¶
A sops file has recipients of exactly one kind: a role's instances, one person, or one external consumer. A file encrypted to a role and a person has a rotation with two audiences.
recipients:
dev-site:
key: age1… # the public half; nothing secret is in this file
kind: role-instances
instance_kind: site # site, client or isolated
who: the dev site's instances
sops_files:
ci/lore.yaml:
what: the lore corpus deploy token
recipient_kind: role-instances
per_instance: false # a client instance may be a recipient only when this is true
A client instance is never pushed into; it may be a recipient only of a file marked
per_instance. verify reports sops-file-mixed-kinds, sops-file-wrong-kind,
sops-file-client-recipient, sops-file-unknown-recipient and sops-file-unreadable, each its
own finding. The recipient list is cleartext in every sops file, so this whole rule is checked by a
machine that can decrypt none of them.
tier: root¶
A credential that can mint others lives in a person's keychain or on a hardware key: never in sops,
never on a host, never in CI. verify reports credential-root-in-vault when a tier: root row's
value is in the vault, and does not probe it: using it would be one more act with a credential
in the wrong place.
The redaction filter¶
nerthus.common.redact: three layered defences, each with its blind spot.
- Registered values. Anything that decrypts or reads a secret calls
redact.register(), and every sink masks that exact string; it is the only defence that works on a password. The sops store registers every value it decrypts. Blind: a value nobody registered. - Self-describing shapes. The issuers' prefixes (
glpat-,gldt-,AGE-SECRET-KEY-1, an OpenSSH header with a body,AKIA) and the ones Platform mints (nrth_ak_,nrth_en_,nrth_sc_), read from the census, so a new minted class is redacted from the moment its row exists. Blind: anything merely long and random. - Context. A value under a key whose name says it is a secret, an
AuthorizationorCookieheader,--password <value>on a command line, the userinfo half of a URL. Blind: a secret logged bare beside an innocent name.
A key is secret by its last word. POSTGRES_PASSWORD holds one; password_file holds a path an
operator needs to read. A secret word is also matched as a suffix of the last word, because
PGPASSWORD is one word to any splitter.
There is no bare-entropy rule over free text. A threshold high enough to spare hashes and UUIDs
still masks every age recipient, every base64 envelope in a sops document and every base64 digest
the program prints on purpose. high_entropy_runs() exposes the heuristic for an audit that sweeps
an artefact, where a false positive costs a glance.
The sinks¶
redact.install() runs at the CLI entry point and covers sys.stdout and sys.stderr
(line-buffered, so a value written in two calls is masked whole), every handler on the root logger
(filter and formatter, because a traceback is rendered at format time), sys.excepthook and
threading.excepthook. The suite asserts redact.unattached_handlers() is empty after a real boot.
Typer's pretty traceback is off. rich renders a frame's local variables through a console of its
own, which printed a token held in a local variable; with it off, an uncaught exception reaches
sys.excepthook, which the filter owns.
Environments and files¶
A secret in an environment variable is a secret in docker inspect, in the compose file that set
it, in the shell history of whoever ran it and in every ps on the host.
Exit 0 clean, 2 on a finding, 3 when the input could not be read. It reports a value that is a
credential by its shape, a name that says its value is one (even when empty), and a URL with a
password in it. image:smoke runs it over the built instance image with a planted
POSTGRES_PASSWORD as its positive control.
The package passes no credential on argv: tools/sync.sh names the index and lets uv read the
credential from UV_INDEX_NERTHUS_*, and nerthus db psql, pg_dump and every health query hand
libpq the password as PGPASSWORD rather than inside a URL.
Handling a credential in a session¶
- Do not type it.
nerthus secrets set NAME --file -reads stdin;sopsopens an editor. - If you must, put a space before the command, with
setopt histignorespace(zsh) orHISTCONTROL=ignorespace(bash); neither is on by default. Orunset HISTFILEfor the session. - A token that reached a history file, a session log or a recorded terminal is a token to rotate, not to delete from the file.
- Never
echo $TOKEN, neversops -dto a terminal, neverglab auth status -t. Prove a token works by using it. - A census over an API reads it through a classifier that returns a shape and a length, never the
raw response: GitLab's variables endpoint returns values, and masking is a property of job logs,
not of the API. A gitignored file is a file to
grep -c, never tocat.