Skip to content

secretctl: stores, digests and commands

secretctl is a Go CLI that compares, moves, and hands off credentials without ever printing a value: it turns a claim like “these two systems hold the same credential” into a comparison of keyed digests. This page is the command, source-URI, and exit-code reference; Keeping credentials out of coding-agent transcripts is the guard consumer this tool feeds, and covers why the design exists - the leaks that motivated it, the scanner comparison, the guard layers, and the digest-cache locking protocol.

TL;DR:

  • Five verbs act on one source reference: fp (fingerprint), cmp (compare, exit-coded), set (write), exec (hand the value to a child process only), explain (show how a reference would resolve, no value).
  • Four verbs act on the whole declared registry: sources (what it expands to), digests (a keyed digest per registered value), classify (does this path hold registered material), coverage (what secret-looking files does the registry NOT cover).
  • There is no --show-values flag anywhere in the CLI, and that absence is pinned by a unit test rather than left as an unenforced intention.
  • A remote source (docker:HOST/..., sshenv:HOST/..., uci:HOST/...) is digest-only by construction: the far host extracts and hashes the value, and only <bytes> <hex> ever returns.
registrysources file: globs, excluderesolverlocal in-process |remote: hash on far host |vault clientexpands globssource refscheme:locator#fieldcanonicalisestrip <=1 trailing newlineplaintext, in-processor hex-only from far hostkeyed HMAC-SHA256salt: ephemeral or --salt-filereportfp / cmp / digests / classifyexit code
  1. A source reference names one credential by URI; the registry expands globs into a list of references.
  2. The resolver reads it - in-process for a local scheme, or as a hex digest that a remote host computed and sent back for a remote scheme.
  3. The plaintext is canonicalised (at most one trailing newline stripped) and hashed with a keyed HMAC-SHA256 under the run’s salt.
  4. fp, cmp, digests, and classify render a report from the digests.
  5. The exit code, not the report text, is the contract a caller reads.
You want to knowCommand
Do two sources hold the same valuecmp <src> <src>
What does one source resolve tofp <src>
Write a value into a destination without it passing through stdoutset <dst> --from <src>
Hand a value to a child process onlyexec <src> --as NAME -- cmd
How would this source resolve, without reading itexplain <src>
What does the whole registry expand tosources
A keyed digest of every registered value, for a guarddigests --json
Does this file hold registered materialclassify <path>
What secret-looking files does the registry not covercoverage

Five verbs act on one source reference. fp fingerprints it. cmp compares two or more and is exit-coded on the result. set writes a value into a destination. exec hands a value to a child process’s environment only - never to stdout, a shell variable, or a temp file. explain shows how a reference would resolve (scheme, target, field, whether resolution is local or remote) without reading the value.

Four verbs act on the declared registry rather than one reference. sources lists what the registry expands to, labels only. digests resolves every registered value and emits a keyed digest per value, for a guard to consume. classify tests whether a path holds material the registry has registered. coverage runs a secret-detection scan over the credential-holding directories a user has declared and reports which flagged files the registry does not already cover.

With no arguments, or an unknown command, secretctl prints usage to stderr and exits 2; help, -h, and --help print the same usage to stdout and exit 0.

Every source is one URI: scheme:locator[#field]. The # split is on the last # in the string, so a locator that itself contains # still parses correctly. A leading ~/ in a local path expands to the user’s home directory.

Ten schemes exist:

SchemeWhereRegistrableNotes
env:NAMEthis process’s own environmentsingle name only, no globs
dotenv:PATH#KEYa quote-aware KEY=VALUE fileyes, globs and **
sops:PATH#KEYa sops-encrypted file, decrypted in-processyes, same globs as dotenv:which scheme claims a matched file is decided by content, not filename
docker:[HOST/]CONTAINER#VARone container env varyes; docker:HOST/*#* digests every running container on a host in one ssh round triplocal when HOST is omitted, remote (digest-only) when given
bw:ITEM[#FIELD]Bitwarden via the local bw serve HTTP daemonyes, whole item or a name globwritable (set target)
rbw:ITEM[#FIELD]Bitwarden via the rbw CLIyes, whole item or a name globread-only: set is refused, because rbw’s own edit path is an interactive $EDITOR session secretctl cannot drive
keyfile:PATHa whole file as one opaque valueyes, globsTSIG keys, PEM blobs
prompt:[LABEL]a hidden terminal promptno - the only scheme that is not registrablenames an interactive input, not a place a credential already lives
sshenv:HOST/PATH#KEYone key in a remote KEY=VALUE fileyes, no globsremote, digest-only
uci:HOST/CONFIG#SECTION.OPTIONone OpenWrt UCI option over sshyes; #* registers every option in that configremote, digest-only; added after a 2026-09-04 incident where a uci show wireless read over ssh put two wifi keys in a transcript no local store held a digest for

With no #FIELD, bw:ITEM reads the item’s whole notes body, and an empty notes body is an error - there is no fallback to the login password. With a #FIELD, resolution tries, in order: a custom field of that exact name with a non-empty value; if the field name is literally password, the login password if non-empty; a FIELD=value line inside the notes body. An empty custom field of the right name does not shadow a real value in the notes - the walk keeps looking.

rbw:ITEM resolves the same way for the #FIELD case, but its no-#FIELD case differs: empty notes fall back to the login password rather than erroring, and only both being empty is an error. The two extractors were written to match on every other step “so rbw: and bw: digests agree on the same item and field”, per the rbw code’s own comment - but that stated goal does not cover the no-field case, which is exactly where the two diverge.

The registry file (default ~/.config/secretctl/sources, overridable by $SECRETCTL_SOURCES or --sources PATH) is a plain-text list, one entry per line: scheme:pattern[#field]. Blank lines and lines starting with # are ignored; a trailing #comment after a URI is stripped. Local file schemes accept shell globs and a ** recursive segment; env:, sshenv:, and uci: reject globs; docker: allows exactly one glob shape, HOST/*. Loading an empty or missing registry file is a hard error, not a silently-protects-nothing pass.

An exclude NAME [NAME...] line (globs allowed) marks key names that are configuration rather than credentials - TZ, LANG, EMAIL, PUID - and skips them when digesting, even though they live inside a registered store. Since a vault whole-item entry always resolves under the identical key name notes, a bare exclude pattern can only remove every vault item at once or none of them. Since commit 3a0ece7 (2026-09-29), an exclude pattern that itself looks like a scheme prefix (matching ^[A-Za-z][A-Za-z0-9+.-]*:, e.g. rbw:, bw:, dotenv:) is matched against the registry entry’s label instead - exclude rbw:ITEM_NAME#* can then carve one item out of a name glob like rbw:PREFIX_* while leaving its siblings digested. Because exclude can only remove things from being digested, forgetting to add a name costs over-redaction, never a leak.

Whether a glob-matched file is read as sops: or dotenv: is decided by content, not filename: the registry looks for one of four markers (sops_version, ENC[AES256_GCM, "sops":, sops: followed by a newline) in the first 64KB. A sops-encrypted .env renamed to .txt is still read as sops, and a plaintext file literally named .sops is still read as plaintext.

A value that looks like a filesystem path (starts with /, ~/, or ./) is skipped by every whole-store reader, local and remote - it is a location, not a credential, and digesting one would make a guard mask an ordinary path in tool output.

Registered values shorter than --min-len bytes (default 8) are withheld from digests and classify entirely - not digested, just counted - because a keyed digest of a very short value is itself an offline guessing target once an attacker has the salt.

Every digest is a keyed HMAC-SHA2561 computed over the canonicalised value, keyed on the run’s salt. The key material is the salt’s hex string - its ASCII characters, never the raw decoded bytes - because the remote-host script hands that same hex string to openssl dgst -hmac <hex>, which keys on the ASCII characters too. Keying locally on the decoded bytes was a shipped bug, found 2026-08-30: a local digest and a remote digest of the identical credential could never agree, and the tool reported MISMATCH on a Postgres password three independent manual checks confirmed was unchanged.

The default salt is ephemeral: 32 random bytes generated fresh per invocation, so a printed digest is comparable only within that one run and cannot correlate values across two transcripts. --salt-file PATH opts into a persistent salt so digests stay comparable across separate invocations - at the cost that digests recorded under that salt become linkable to each other, and so become sensitive metadata in their own right.

Canonicalisation strips at most one trailing newline (and, only if it immediately precedes that removed newline, one carriage return); every other byte is compared verbatim. Stripping only one newline, not all, is deliberate: it keeps a value with a single newline and a value with two comparing unequal, since a file appended to twice really did change. The exact rule is printed in every fp, cmp, and digests report header, so a MISMATCH is never silently attributable to normalisation.

The printed digest width defaults to 12 hex characters, settable with --width N. A requested width of 1-7 is silently raised to a floor of 8 - at 4 hex characters a per-pair false MATCH has probability 1/65536, judged too coarse to trust - and a width of 0, negative, or above 64 falls back to the 12-character default. digests --json always emits the full 64-character SHA-256 hex regardless of --width, because that output feeds a guard’s own in-memory comparison rather than a human-read table.

--salt-file PATH opens the file with O_CREATE|O_EXCL|O_WRONLY, mode 0600 - refusing to follow a pre-planted symlink at that path, and refusing to clobber a salt a concurrently racing invocation just created. On losing that creation race, the loser reads back and uses the winner’s salt instead of erroring, so two racing invocations stay comparable rather than silently diverging on two different keys. A salt file’s content must be at least 16 characters, or loading it errors.

digests --json --fragments N (N defaults to 8; 0 disables it) also emits, for every local value that looks like an opaque token (letters and digits present, no whitespace or list/URL/DSN punctuation beyond + / = _ - .), a keyed digest of every N-byte sliding window of that value. This exists to catch a credential reassembled at runtime from smaller pieces - the workaround observed once whole-value matching was blocked. Values that are not opaque tokens - a repository-name list, a DSN, a URL, anything with spaces - get zero fragments; emitting them for those produced a live false positive on 2026-09-04, where a guard refused an ordinary command for mentioning two repository names in one sentence.

digests --json --variants (default on) also emits, for every local value, keyed digests of encoded spellings that differ from the raw value: standard base64, standard base64 without padding, URL-safe base64 with and without padding, and two percent-encoded forms.2 This catches the same secret appearing pre-encoded in output - a Basic or Bearer header, a Kubernetes secret manifest, a DSN escaped into a log line - which an exact-token guard would otherwise miss entirely. Remote stores contribute no fragments and no variants, since their plaintext never reaches the local host to window or encode.

Exit codes are not uniform across verbs, and reading one table across two commands is the easy mistake:

Verb012
fpresolved-unresolved
cmpmatchmismatchunresolved
classifypath HOLDS registered materialcleanunresolved
coverageevery flagged file is covereduncovered files founda tool error (e.g. noseyparker missing)
execchild exited 0 (or its own code, passed through)child failed to launchsecretctl’s own argument-parsing error
set / explain / sources / digestssuccess-any error (digests: a registered store failed to resolve, but the rest of the pass is still emitted)

classify’s polarity is the inverse of cmp’s: 0 means “found here” for classify, but “agree with each other” for cmp. exec’s exit code is the child process’s own, passed straight through - 1 only means the child itself failed to launch.

  • No --show-values, anywhere, at any subcommand. The absence is pinned by a unit test rather than left as an unenforced intention. For a human tool an escape-hatch flag behind a stderr warning is defensible; for an agent tool the escape hatch is itself the vulnerability - an agent under pressure to finish a task reaches for whatever flag makes the immediate step easier. A program that genuinely needs the plaintext gets it exclusively through exec, where the value enters a child process’s environment and never a transcript.
  • Remote sources are digest-only by construction, not by convention. The remote resolution path extracts and hashes the value on the far host - a generated openssl dgst -hmac <salt hex> invocation over ssh - and the only things that return to this process are <bytes> <hex> lines. No function signature in the remote path can hand back a plaintext value, so the property cannot be defeated by a future call site forgetting to check a flag.
  • No resolver error string may interpolate a raw value or a buffer that held one, including a wrapped standard-library error. Go’s encoding/hex reports errors like “invalid byte: U+0073 ‘s’”, naming one literal character of whatever a remote host actually sent - so that specific error is deliberately left unwrapped rather than risk echoing more of it. This rule followed a live 2026-08-30 bug where a remote byte-count parse error quoted its offending input verbatim. A user-supplied path is exempt and may still appear in an error, since the user needs it to diagnose the problem.
  • classify names which registered credential a file contains, never what it is. The output names a label, derived purely from the source reference (dotenv:~/x/.env#TOKEN), never the value.

make parity shells out to the real openssl binary and asserts its openssl dgst -sha256 -hmac <hex> output matches the Go-computed HMAC, for five representative value shapes (a short low-entropy password, a 48-character token-shaped string, a value containing spaces, base64 text, and a Postgres connection URL), under both an ephemeral salt and a --salt-file-loaded persistent salt, plus a check that a single trailing newline canonicalises identically on both sides. It exists because a Go-only test suite could not have caught the 2026-08-30 key-derivation bug: both sides of every such assertion would have used the identical wrong key. Both the CI and release workflows run openssl version then make parity as a required, non-skippable step.

make install builds a static, symbol-stripped binary (CGO_ENABLED=0 go build -trimpath -ldflags='-s -w') and installs it to ~/.local/bin/secretctl. The module targets Go 1.26. The only compiled-in dependency is golang.org/x/term, for the hidden prompt: read; openssl must additionally exist on any remote host that is queried, since it computes the digest there; noseyparker is an optional runtime dependency needed only by coverage.

The repo’s Nix flake exposes a package for x86_64-linux and aarch64-linux, built with buildGoModule against a pinned nixpkgs channel and a pinned vendor hash, with the test suite left out of the Nix build (doCheck = false) because it needs environment and registry fixtures a hermetic sandbox does not provide - tests are a CI gate, not a Nix build gate. The optional runtime tools the resolvers shell out to (bw, docker, ssh, rbw, openssl, noseyparker) are deliberately not wrapped into the Nix closure; they resolve from the consuming host’s own PATH, so that host’s own installed versions are what runs.

Pushing a tag matching v* triggers a workflow that re-runs the same vet, test, and parity gate as CI, then cross-compiles two static Linux binaries (amd64 and arm64, both CGO_ENABLED=0), computes a sha256sum checksums file over both, and creates a release with the two binaries plus checksums.txt as assets. Re-pushing an already-released tag reuses the existing release rather than failing.

CI runs on push to main and on pull requests: a test job (gofmt check, go vet ./..., go test ./..., the openssl parity test, then make build) and a separate scan job that downloads a pinned, bare MIT-licensed gitleaks binary and runs it against full git history - a credential-handling tool leaking a credential inside its own repository would be self-refuting. The one deliberate difference between the two mirrored workflow files is that the self-hosted-runner copy drops the GitHub-specific permissions: block, which that runner ignores anyway.

Run against a scratch registry of three synthetic canary values, none of them real credentials: a.env and b.env hold the identical value, c.env holds a different one. Digests below are shown as <hmac> placeholders, since secretctl prints them verbatim even for a synthetic canary.

CommandResult (paraphrased)Exit
secretctl sourceslists 3 expanded stores, one per registry line, labels only0
secretctl fp 'dotenv:a.env#CANARY_TOKEN'<hmac> (22B)0
secretctl cmp 'dotenv:a.env#CANARY_TOKEN' 'dotenv:b.env#CANARY_TOKEN'both rows MATCH <hmac> (22B)0
secretctl cmp 'dotenv:a.env#CANARY_TOKEN' 'dotenv:c.env#CANARY_TOKEN'a row MATCH, c row MISMATCH (different hex, 27B not 22B)1
secretctl cmp 'dotenv:a.env#CANARY_TOKEN' 'dotenv:a.env#NO_SUCH_KEY'second row UNRESOLVED, field not found2
secretctl explain 'dotenv:a.env#CANARY_TOKEN'prints scheme, target, field, and “resolution: local - read into this process and hashed here, never written or printed”0
secretctl classify a.envHOLDS - registered store a.env0
secretctl classify clean.txt (an unrelated file)CLEAN1

This run confirmed three things read from source elsewhere on this page: MATCH, MISMATCH, and UNRESOLVED are the only three outcomes a comparison report names; classify’s exit-code polarity really is the inverse of cmp’s; and the canonicalisation-rule and salt-note headers print on every report, as documented above.

You haveUse
Two systems that should hold the same credentialcmp - read the exit code, not the table
A value a program genuinely needsexec, never a printed value in a script
A dotenv, sops, or keyfile that needs a rotated value written inset, on the destination, not a hand-edited file
A guard or other consumer that needs to know values without reading themdigests --json, resolved on a schedule, cached
A file of unknown provenanceclassify against the registry, and coverage if it might be unregistered
A key name that is configuration, not a credentialexclude it in the registry, with a label-shaped pattern for one vault item among many

Keeping credentials out of coding-agent transcripts covers why this design exists - the two 2026-09-04 leaks, the scanner comparison that ruled out pattern detection, the guard layers built on top of digests --json, and the digest-cache locking protocol three writers share. It measures the registry’s scale in production; this page stays counts-free so the two do not drift against each other.

Rotating a credential with secretctl is the task-sequenced use of set, fp --salt-file and cmp described here.

  1. H. Krawczyk, M. Bellare, R. Canetti, “HMAC: Keyed-Hashing for Message Authentication,” IETF RFC 2104. https://www.rfc-editor.org/rfc/rfc2104 ↩

  2. S. Josefsson, “The Base16, Base32, and Base64 Data Encodings,” IETF RFC 4648. https://www.rfc-editor.org/rfc/rfc4648 ↩