M1-09: Secret provider and bothy secrets¶
Status: ready | Depends on: M1-07 | Milestone: M1
Goal¶
After this ticket every secret the server uses is read through internal/platform/secrets from a
reference <scheme>:<path> (drivers file and env), cached for secrets.cache_ttl, held in a
type that can never be printed, and picked up again after a rotation without a restart.
bothy secrets init prepares the per-service secret directories of the Compose stack
idempotently, and bothy secrets export writes an age-encrypted escrow bundle. The database
password (M1-12) and the telemetry pseudonym key (M1-10) build on this; SEC-SEC-1 to SEC-SEC-3,
SEC-T11 and SEC-T12 become tests.
Read first¶
docs/spec/16-operations.md: "Configuration and secrets" with all four subsections ("Configuration (ADR 0011)", "Secret provider (ADR 0011)", "Secrets in the Compose stack", "Escrow"), "Roles" under "Database", "Configuration values of this chapter" (secrets.dir,secrets.cache_ttl).docs/spec/19-security.md: "Logs without secrets and without content" (SEC-LOG-1), "Secrets" (SEC-SEC-1 to SEC-SEC-5), "Tests" (SEC-T11, SEC-T12).docs/spec/02-architecture.md: "Commands of the binary", "Code layout", "Configuration loading", "Failure of a dependency" (row secret provider), "Module register as the source", "go-arch-lint".docs/spec/03-data-model.md: "Principles", item 9 (secrets only as hash orsecret_ref).docs/operations/configuration.md: "Naming rule and formats" (secret_ref), "Secrets" (the catalog table you implement and keep equal).docs/operations/disaster-recovery.md"Case B: host lost" (bundle files are adopted bysecrets-init),docs/operations/backup-restore.md(escrow rows).docs/adr/0011-12-factor-configuration-and-secret-provider.md.docs/spec/18-release.md"Server images" (you fix the image user there).docs/observability/signals.md"Backup, audit, telemetry" (you add one metric row).infra/compose/compose.yaml: servicesecrets-initand everysubpathmount.docs/PLAN.md: "Way of working per ticket", the M1 row M1-09 and the contradiction W3.- Code you build on:
services/server/internal/platform/configandcmd/bothy(M1-07, read their READMEs first),internal/kernel/clockandinternal/kernel/errs(M1-05),services/server/architecture/modules.yamland its generator (M1-05). - No API operation, data model table or action id is touched by this ticket.
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: write the failing test, show it red, then make it green, then clean up.
- No en dash or em dash characters in any file you write (the
text-ruleshook rejects them). - White label: no company, person, host or domain names except
example.com, people Alex, Sam, Robin; no paths under a user home directory; example credentials containEXAMPLE. - Configuration only through named keys listed in
docs/operations/configuration.md; prose and comments name the key, never its number. - SEC-SEC-1: the file driver reads only files with mode
0400or0600owned by the service user; anything else aborts the start (test SEC-T11). - SEC-SEC-2 and SEC-T12:
config printand diagnostics never show a secret, not even truncated. - SEC-SEC-3 and SEC-LOG-1: secrets never appear in logs, errors, events or telemetry; in code
they have their own type that always renders as
[redacted]. - 16 "Secret provider": references
<scheme>:<path>; schemesfileandenvin M1; driver interfaceGet(ref) -> (value, version), optionalPut; values cached at mostsecrets.cache_ttl; a missing*_refvalue meansfile:<secrets.dir>/<name>. - 02 "Failure of a dependency": the start aborts when a secret cannot be read; a failed rotation keeps the old value and raises an alarm signal.
- 16 "Secrets in the Compose stack" and "Escrow":
secrets initcreates missing secrets, one subdirectory per service, never overwrites an existing generated secret, prints fingerprints and never values;secrets export --age-recipient <public key>writes an age-encrypted bundle.
Scope¶
All Go paths are relative to services/server/.
internal/platform/secrets (the provider)¶
Public interface (names fixed; signatures may add an error where Go needs one, nothing else):
type Ref struct { Scheme string; Path string }withfunc ParseRef(raw, secretsDir string) (Ref, error)andfunc (Ref) String() string.ParseRefreplaces the literal placeholder<secrets.dir>withsecretsDirbefore parsing. Grammar:file:<absolute path>(after expansion the path must be absolute and clean, no..element);env:<NAME>withNAMEmatching^[A-Z_][A-Z0-9_]*$. The schemesazkvandawssmparse but yieldErrUnsupportedScheme(named in 16 as later drivers); any other scheme yieldsErrInvalidRef.type Value struct{ /* unexported []byte */ }withNewValue([]byte) Value,Bytes() []byte(returns a copy),Len() int,Equal(Value) bool(constant time viacrypto/subtle).String,GoString,Format(every verb including%v %+v %#v %s %q %x %X),LogValue(slog.LogValuer),MarshalJSON,MarshalTextall produce exactly[redacted].type Version string: opaque; forfileandenvit is the first 16 hex characters of the SHA-256 of the value, so equal content gives an equal version.type Driver interface { Scheme() string; Get(ctx context.Context, ref Ref) (Value, Version, error) }andtype Writer interface { Driver; Put(ctx context.Context, ref Ref, v Value) (Version, error) }. No M1 driver implementsWriter; the interface exists because 16 namesPutfor later drivers.type Provider structbuilt byfunc NewProvider(opts Options) (*Provider, error);Options{ Dir string; CacheTTL time.Duration; Clock clock.Clock; Logger *slog.Logger; Drivers []Driver }(nilDriversmeansfileandenv). Methods:Get(ctx, Ref) (Value, Version, error)andResolve(ctx, raw string) (Value, Version, error)(parse plus get).- Errors:
ErrInvalidRef,ErrUnsupportedScheme,ErrNotFound,ErrInsecureFile; every error message names the reference and never contains file content or variable values.
internal/platform/secrets/stack (Compose stack preparation)¶
Catalog: the Go form of the table indocs/operations/configuration.md"Secrets"; one entry per row: name or name pattern, origin (generated,supplied), generator, services.func Init(ctx context.Context, opts InitOptions) (Report, error)andfunc Export(ctx context.Context, opts ExportOptions) error, used by the commands below.
Commands in cmd/bothy (use the command framework already present)¶
bothy secrets init --target <dir> [--source <dir>] [--owner <service>=<uid>:<gid> ...] [--adopt-source]bothy secrets export --from <dir> --age-recipient <age1...> [--age-recipient ...] [--output <file>|-] [--armor]secrets initandsecrets exportwork without a configuration file and without a database; they read only their flags (thesecrets-initcontainer has noBOTHY_environment).bothy config check --secrets: additionally resolves every*_refkey through the provider and prints one line per key (okor the error code), never a value.
Changes to internal/platform/config (M1-07 code)¶
- Register
secrets.dir(default/run/bothy/secrets) andsecrets.cache_ttl(default5m) in the schema if M1-07 did not, typed into theConfigfor this package ininternal/wire. - Expand
<secrets.dir>in the default and configured values of every*_refkey. - Unknown-variable rule: an environment variable named by an
env:reference of any effective*_refkey is not an unknownBOTHY_variable (see Design notes D2).
Other files¶
internal/wire: build one*secrets.Providerper process from the typed config.architecture/modules.yaml: registerfilippo.io/age(andgolang.org/x/syncif you usesingleflight) as vendors allowed for the platform component; regenerate.go-arch-lint.ymlwith the M1-05 generator.infra/compose/compose.yamlalready passes--owner server=65532:65532 --owner migrate=65532:65532tosecrets-init(16 "Secrets in the Compose stack" item 4); keep it.- Docs listed under "Definition of done".
Out of scope¶
- The database pool and how it uses
database.password_ref(M1-12). - The telemetry pseudonym (M1-10); this ticket only guarantees the generated key size.
- Signing keys: the server creates them in
secrets.signing_key_dir(ADR 0066, M1-32); they are no catalogue entry. Generators forpgbackrest_tls/(M7-01) and the generateds3_*keys of thes3profile (M3) staypendingin the catalog (D6, configuration.md section 5). - Drivers
azkvandawssm(later milestone). - The server image itself (M1-23; uid and gid
65532are in 18 "Server images"). - A
promtooltest for the alarmBothySecretRefreshFailing(it exists ininfra/observability/prometheus/rules/bothy-alerts.yaml; rule tests come with M1-04).
Boundaries¶
- Work in a dedicated git worktree on branch
feat/m1-09-secret-provider. - Conventional Commits with scope (
feat(secrets): ...,docs(spec): ...) and DCO sign-off (git commit -s); squash to meaningful commits before the merge. - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope above, except
services/server/CHANGELOG.md,docs/DECISIONS.mdand the docs named in "Definition of done". - Follow the host rules supplied by the launcher. Stop only processes you started, by PID.
- Temporary directories only via
mktemp -d(in Go testst.TempDir()), removed at the end. - Never read or print real secrets; tests use generated values containing
EXAMPLEor a random canary. - No network calls except package downloads for pinned modules and tools.
- If a needed piece of the core contract (chapters 00, 03, 04, 05) is missing, stop with
Verdict: BLOCKEDinstead of building a deviation.
Design notes¶
D1. File driver (file:).
os.Stat(follows symlinks, so Kubernetes-style secret links work), then require a regular file, permission bits exactly0400or0600, and owner uid equal toos.Geteuid(); otherwiseErrInsecureFilenaming the reference and the observed mode, never the content. Ownership checks usesyscall.Stat_t; build the check for unix only and fail closed elsewhere.- Missing file:
ErrNotFound. - Content: read whole file; strip exactly one trailing
\n(and a\rdirectly before it). Reason:echo value > fileis the usual way to write a secret and a trailing newline is never part of a password; generated secrets are ASCII without newline, so nothing else changes.
D2. Env driver (env:).
- Reads
os.LookupEnv(NAME)on each uncachedGet; unset isErrNotFound; an empty value isErrNotFoundtoo. - 16 shows
env:BOTHY_SECRET_DB_APP_PASSWORD, but M1-07 aborts on unknownBOTHY_variables. Rule: after loading file and environment, the loader collects the variable names of all effectiveenv:references and excludes exactly those from the unknown-variable check. Any other unknownBOTHY_variable still aborts (16 "Configuration (ADR 0011)" item 3). env:stays allowed but not recommended (16);config checkprints a warning line per key that uses it.
D3. Cache and rotation.
- Entry per reference: value, version, time read.
Getreturns the cached value whileclock.Now() - readAt < secrets.cache_ttl, else re-reads. Concurrent misses for the same reference read once (mutex per entry orsingleflight). - Re-read fails and a cached value exists: return the cached value, log at error level with the
constant message
secret refresh failed, keeping previous valueand attributeerror.type(error code only), increment counterbothy.secrets.refresh.failures(unit{refresh}, no attributes) through the global OpenTelemetry meter (go.opentelemetry.io/otelAPI only; M1-10 installs the provider later, the global delegate forwards). Add this metric as a row todocs/observability/signals.md"Backup, audit, telemetry" (Prometheus namebothy_secrets_refresh_failures_total). - Re-read fails without cached value: return the error (the start aborts, fail fast).
D4. secrets init layout and rules.
- Target layout:
<target>/_store/holds the one canonical copy of every secret (directory0700, files0400, owned by the running user);<target>/<service>/holds the copies a service mounts withsubpath. No service may be named_store. Relative names inside both are the catalog names (db_app_password,oidc/<provider>_client_secret,offsite/...), which is also the layout of--sourceand of the export bundle, so a decrypted bundle placed in the source directory is adopted (disaster recovery "Case B"). - Per catalog entry, reconcile
_storewith--source:
| store | source | generated entry | supplied entry |
|---|---|---|---|
| absent | absent | generate (generated) |
report missing |
| absent | present | copy (adopted) |
copy (adopted) |
| present | absent | keep (kept) |
keep (kept) |
| present | equal | keep (kept) |
keep (kept) |
| present | different | exit 1 naming the entry, unless --adopt-source (then updated) |
replace (updated); the owner's source is the master for supplied secrets |
- A file in
--sourcethat matches no catalog entry: exit 2 naming it (a typo must not be silent, same reason as unknown configuration keys). - Service directories: for each service, the expected set is every stored entry that lists the
service. Write a copy only if it is absent or differs in content, mode or owner; remove files in
the service directory that are not in the expected set. Writes are atomic: temp file in the same
directory,
fsync,chmod,chown,rename,fsyncof the directory. - A second run with unchanged inputs changes nothing: no write, no rename, no mtime change.
- Output on stdout, one line per entry:
<status> <name> <fingerprint>; status is one ofgenerated,adopted,kept,updated,missing,pending; fingerprint issha256:plus the first 12 hex characters of the SHA-256 of the content (for a directory entry: of the sorted list of relative path and content hash pairs);missingandpendinghave no fingerprint. Last line:back up the secrets: bothy secrets export --age-recipient <age public key>. - Exit codes:
0success,1runtime failure or conflict,2usage error (unknown flag, unknown source file, invalid owner, missing owner forserverormigrate).
D5. Generators (crypto/rand only).
token: 32 random bytes, base64url without padding (43 ASCII characters). Used fordb_superuser_password,db_owner_password,db_app_password,db_maint_password,db_monitor_password,pgbackrest_cipher_pass,restic_password,grafana_admin_password.key: 32 random bytes, lowercase hex (64 ASCII characters, so the collector's HMAC key and the server's key both have at least 32 bytes). Used fortoken_pepper,telemetry_pseudonym_key,audit_ip_kekandfile_ticket_key; consumers that need raw key bytes hex-decode the value. (totp_keyfollows its own ticket, M1-39.)
D6. Catalog.
- Every row of
docs/operations/configuration.md"Secrets" is a catalog entry with the same name, origin and services. Supplied directory entries:oidc/(files matching^[a-z0-9_-]+_client_secret$) andoffsite/(any regular files, recursive). - Entries whose generator belongs to another ticket get generator
pendingwith a code comment naming the owning ticket:pgbackrest_tls/(M7-01) and the generated variant ofs3_access_key,s3_secret_key(M3); until thens3_*are handled as supplied.initprintspendingfor them and exits 0 (configuration.md section 5 lists them). Signing keys are no catalogue entry (ADR 0066). - A test parses the Markdown table and fails when name, origin or services differ from the Go catalog in either direction.
D7. Owners and modes of service copies.
--owner <service>=<uid>:<gid>(repeatable): that service's directory is0500and its files0400, owned byuid:gid. Services without--owner: directory0755, files0444, owned by root. Reason: third-party images run as their own users that this repository does not control; isolation between services comes from thesubpathmounts (each container sees only its own directory), and the volume root is0700.serverandmigratemust have an--owner(exit 2 otherwise), because the file driver enforces SEC-SEC-1 for them.- The
bothy-serverimage runs as uid and gid65532(18 "Server images" item 3); M1-23 builds the image with exactly this user. - Running
initas a non-root user is allowed when every--ownerequals the current uid:gid (tests do this); copies for services without--ownerare then owned by the running user instead of root. Any other failedchownis exit 1 with the entry name.
D8. secrets export.
--from <dir>is required and names a_storedirectory (in Compose:docker compose run --rm -T secrets-init secrets export --from /run/bothy/secrets/_store --age-recipient age1... > bundle.age). It needs no configuration file and no database. Writes a POSIX tar stream (relative names, mode bits, no owner names, mtime zero) encrypted withfilippo.io/ageto every--age-recipient(X25519age1...; at least one required, exit 2 otherwise; invalid recipient exit 2).--output -(default) writes to stdout; refuse binary output to a terminal unless--armor(ASCII armor).--output <file>creates the file with mode0600and refuses to overwrite.- Never writes plaintext anywhere; no temp files.
D9. W3 (subcommands). Record the plan recommendation as one entry in docs/DECISIONS.md if it is
not there yet: Ruling L1 is extended by secrets, healthcheck, restore; bothy admin ... in
08 becomes bothy setup bootstrap-owner and bothy setup issue-enrollment-code. Do not edit 08
(M1-37 does).
D10. Process wiring. Provider instances are created only in internal/wire and cmd/bothy;
modules receive values through their typed Config or a narrow interface, never read files or
environment themselves (02 "Configuration loading" item 5).
Tests (write them first)¶
Unit tests in internal/platform/secrets (fake clock from kernel/clock, files in t.TempDir()):
TestParseRef: table with validfile:,env:,<secrets.dir>expansion, relative path,.., empty path, lowercase env name,azkv:(unsupported),foo:(invalid).TestValueNeverPrints: canary insideValue;fmtwith every verb,slogJSON and text handlers,json.Marshal,errorswrapping; output never contains the canary and is[redacted]where a rendering exists.TestFileDriverRejectsInsecureModes(SEC-T11): modes0644,0640,0444,0700; directory; file owned by another uid (skip only this subcase when the test runs as non-root and cannot create one; say so in the report); each yieldsErrInsecureFileand the message has no content.TestFileDriverAccepts0400And0600,TestFileDriverStripsOneTrailingNewline,TestFileDriverFollowsSymlink.TestEnvDriver: set, unset, empty.TestProviderCacheAndRotation: value v1; file changed; beforesecrets.cache_ttlstill v1; after advancing the fake clock v2 with a differentVersion.TestProviderRefreshFailureKeepsOldValue: file removed after first read; after TTL the old value is returned, one error log line with the constant message, counter incremented (manual reader fromgo.opentelemetry.io/otel/sdk/metricinstalled as global meter provider in the test).TestProviderConcurrentMissReadsOnce.
Unit tests in internal/platform/secrets/stack:
TestCatalogMatchesConfigurationDoc: parsesdocs/operations/configuration.md"Secrets".TestInitFreshTarget: every generated entry present in_storeand in each listed service directory with the mode and owner of D7 (owners = current uid:gid);tokenvalues decode to 32 bytes;TestPseudonymKeyAtLeast32Bytes.TestInitSecondRunChangesNothing: snapshot of path, mode, uid, gid, size, mtime and SHA-256 of every file before and after; identical; output has onlykept,missing,pending.TestInitReconcileTable: one subtest per row of the table in D4, including the conflict exit 1 and--adopt-source.TestInitUnknownSourceFile(exit 2),TestInitRequiresOwnerForServerAndMigrate(exit 2),TestInitRemovesStaleServiceCopies,TestInitOutputHasNoValues(canary in a supplied file).TestExportRoundTrip: export with a test identity fromage.GenerateX25519Identity(), decrypt, untar; names and contents equal_store;TestExportRefusesWithoutRecipient,TestExportRefusesTerminalWithoutArmor(inject the terminal check),TestExportOutputFileMode0600AndNoOverwrite.
Tests for configuration and commands:
TestLoaderAllowsEnvVariablesNamedByRefs:BOTHY_DATABASE__PASSWORD_REF=env:BOTHY_SECRET_XplusBOTHY_SECRET_X=...loads; an unrelatedBOTHY_SECRET_Ystill aborts and names it.TestConfigPrintNeverShowsSecrets(SEC-T12): canary in a referenced file and in a referenced env variable; runconfig print --redactedandconfig check --secretsthrough the command entry point with captured stdout and stderr; canary absent, references present.TestConfigCheckSecretsReportsInsecureFile: mode0644file gives a non-zero exit and the error code, no content.
Commands that must pass:
cd services/server && go test -race ./internal/platform/secrets/... ./internal/platform/config/... ./cmd/bothy/...
make arch
make check
Acceptance criteria¶
-
fileandenvdrivers,Providerwith cache and rotation exist with the interfaces above. - A secret file with mode
0644aborts withErrInsecureFile(SEC-T11). - No canary appears in
config print --redacted,config check --secrets, logs or errors (SEC-T12, SEC-LOG-1). - A changed secret file takes effect after
secrets.cache_ttl; a failed refresh keeps the old value, logs once and incrementsbothy.secrets.refresh.failures. - A second
secrets initrun changes nothing (byte, mode, owner and mtime snapshot). - The generated telemetry pseudonym key has at least 32 bytes.
- The export bundle decrypts with a test identity and equals
_store. - The Go catalog equals the table in
docs/operations/configuration.md"Secrets". -
env:references do not trip the unknown-variable check; other unknownBOTHY_variables do. -
make archandmake checkare green; Go coverage of the module stays at or above the thresholdquality.coverage.go_min_percent.
Definition of done¶
- Code and tests as above; doc comment on every exported symbol.
internal/platform/secrets/README.mdandinternal/platform/secrets/stack/README.md: purpose, interfaces, reference grammar, file rules, cache, init table, owners, export, what is pending.- The spec already states D2 (16 5.1 item 3), D4 and D7 (16 5.3), the uid (18), the metric
(signals.md) and the alarm. Still to write in the same change: 16 "Secret provider" (D1 newline
rule, D3 cache), "Escrow" (D8 usage line);
docs/spec/02-architecture.md"Commands of the binary" (flags ofsecrets init|export,config check --secrets);docs/operations/disaster-recovery.md"Case B" step 2 (--adopt-sourcewhen the volume already exists). docs/DECISIONS.md: one entry each for D1 (newline rule), D8 and D9.- Configuration reference regenerated (
make config-reference, or the M1-08 targetconfig-doc-checkif it exists) without diff. services/server/CHANGELOG.mdentry under "Unreleased".- No ADR needed: ADR 0011 already decides the provider; the rulings above are recorded in DECISIONS.md.
make checkgreen; independent review over the diff and red team afterdocs/quality/red-team-gate.mdwith verdictJArecorded in the report; merge locally into the base branch only if both are green, thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then
commits, files with line counts, test results with counts (including the red run of each new test
before the implementation), what is not proven (for example the foreign-owner subcase when not
run as root), follow-ups that could not be done in scope with the reason. Then write
{{DONE_PATH}} with exit=0 sha=<merge sha> or exit=1 <reason>.