Skip to content

0043 — A keychain backend for phpboyscout/go/config

Status: DRAFT — NOT SCHEDULED. Shaping questions Q1/Q2 deferred with Matt 2026-07-26 until this is picked up. Not a prerequisite for anything: 0042 must work without it (the machine where the secret problem bites has no working keychain). This is the tidier long-term resolution model, and a candidate sibling module to config-afero. Date: 2026-07-26 Relates: 0042 (where platform secrets live — the immediate fix) · 0001 §4.2 (token storage) · gitlab.com/phpboyscout/go/config (the Backend seam) · gitlab.com/phpboyscout/go/config-afero (the adapter-module precedent).


1. Problem

keryx resolves every platform secret through a hand-rolled ladder in pkg/oauth/store.go:

func (s Store) Resolve(ctx, r config.Reader) string {
    // 1. os.Getenv(s.EnvVar)
    // 2. keychain.Backend{}.Retrieve(s.KeychainService, s.KeychainAccount)
    // 3. r.GetString(s.ConfigKey)
}

with a mirrored Save choosing keychain-else-config. Five platforms each declare an oauth.Store triple (EnvVar, KeychainService/Account, ConfigKey) and the ladder is re-walked per lookup.

This works, but it sits outside the config system rather than inside it, and that has costs:

  • Two precedence models. go/config already merges layers in a defined order with provenance (Origin/Explain). The oauth ladder is a second, private ordering that nothing can introspect — keryx config cannot say where a token came from, because config never saw it.
  • The blind spot 0042 exists to fix. Because resolution takes whichever config.Reader it is handed, handing it a project store silently loses the global layer. A backend inside the Store could not have that bug: layers are the Store's own business.
  • Writes are bespoke. Save re-implements "keychain if available, else this file", where the Store already knows how to route a write to a writable layer.
  • Per-secret ceremony. Every new platform re-declares the triple.

2. Direction

go/config exposes a first-class Backend seam — WithBackend(b Backend), where a backend has an ID(), a Load() returning Layers in precedence order, and (for writes) the WritableBackend half that the Store finds again by matching a layer's Source.Name against the backend's ID().

That is precisely the shape a keychain wants. So: a config-keychain sibling module contributing an OS-keychain layer, exactly as config-afero contributes a filesystem:

store, err := config.NewStore(ctx,
    config.WithFiles(config.OS, "/etc/keryx.yaml", "~/.keryx/config.yaml"),
    config.WithBackend(configkeychain.New("keryx", configkeychain.Keys(
        "platforms.instagram.access_token",
        "platforms.tiktok.refresh_token",
    ))),
    config.WithEnv("KERYX"),
)

Resolution then becomes ordinary config precedence — env over keychain over files — with provenance for free, and Set on a keychain-backed key routing to the keychain because that is where its writable layer lives.

3. Design sketch

3.1 What the backend contributes

  • ID() → a stable name (e.g. keychain:keryx) used for write routing.
  • Load()one layer, Source{Kind: SourceKeychain (new) or SourceFile-like, Name: ID(), Writable: true}, whose Values are the configured keys that the keychain currently holds. Absent keys are simply absent — a keychain miss is not an error, it is a layer that does not supply that key.
  • WritableBackendSet(path, value) stores under the service/account derived from the path; delete removes the entry.

3.2 Capability honesty (the config-afero lesson)

config-afero's doc comment is explicit that the valuable part of an adapter is claiming only the capabilities the underlying thing actually has — it returns one of four types so config never takes a path the filesystem cannot honour.

The same discipline applies here, and matters more:

  • A keychain may be absent or hostile. On a headless box the secret service can be missing, or — as on this dev server — present enough to connect and then block indefinitely (see the dev-server-no-keychain note; Available() hangs on godbus ReadMessage). The backend must therefore treat availability as a probe with a timeout, and degrade to contributing no layer rather than hanging the Store's Load. A config system that can hang on construction is worse than no adapter.
  • Writability is conditional. If the keychain is unavailable, the backend must not claim a writable layer, or writes will route into a black hole.

3.3 Secrets and the rest of config

Two properties worth designing for deliberately:

  • Values must never be echoed. Anything that prints config (keryx config, Explain, diagnostics) must be able to show provenance — "from the keychain" — without the value. This probably wants a Sensitive marker on the layer or key, which is useful beyond keychains (it would let a file-sourced token be redacted in the same way).
  • A keychain layer is not serialisable. Any "write the effective config out" path must not materialise keychain values into a file — the exact accident 0042 is cleaning up.

4. Open questions

Deferred with Matt, 2026-07-26 — this spec is not scheduled, so the shaping questions wait until it is. Recorded now so the thinking is not lost:

  • Q1 — Where does this module live? → DEFERRED. Sibling module gitlab.com/phpboyscout/go/config-keychain (matching config-afero), or inside go/config proper. Leaning sibling, on the config-afero precedent.
  • Q2 — Which keychain implementation? → DEFERRED. Wrap GTB's keychain.Backend (used today by pkg/oauth), or depend directly so go/config need not depend on GTB. The config-afero doc comment argues for the latter — it avoids obliging consumers to import a dependency chosen for them — but that is a call for whoever picks this up.

Still open: - Q3 — Does Sensitive belong in go/config itself? Redaction is not keychain-specific. If it lands in the core, the adapter just sets it. - Q4 — Scope of adoption in keryx. Once the backend exists, does pkg/oauth's ladder get deleted in favour of plain config lookups (the point of the exercise), or kept as a compatibility path? Deleting it is the prize; it is also the riskiest part, and wants its own slice. - Q5 — Priority. This is quality-of-implementation, not capability: nothing a user can do changes. It competes with the deferred backlog. Worth doing before or after the remaining 0041 fast-follows?

5. Relationship to 0042 — the same socket

0042 contributes the user-scoped ~/.keryx/accounts.yaml as a secret layer through this same Backend seam, explicitly as a stopgap. That a dedicated credentials file is what gets superseded — rather than secret keys scattered through general config — is part of why 0042 shapes it that way (0042 D6). So this spec is additive, not a rewrite: the keychain backend is registered at higher precedence and the file layer becomes the fallback beneath it.

env var  →  [keychain backend: 0043]  →  project config  →  [accounts.yaml backend: 0042]

Nothing at a call site changes, there is never a second resolution model to reconcile, and on a machine with no keychain the 0042 layer keeps working exactly as before — which is the whole point of shaping it this way now.

6. Non-goals

  • Unblocking 0042. Stated again because it is the likeliest misreading: the keychain is unavailable on the machine that has the problem, so this adapter would fall through to exactly the same config layer. 0042 stands alone.
  • Changing where keryx keeps secrets. User scope, decided in 0042 D1, is unaffected — this changes how they are reached, not whose they are.

7. Test plan (sketch)

  • Unit — the backend contributes a layer only for keys the keychain holds; an unavailable keychain contributes no layer and claims no writability; a probe that would block is abandoned within the timeout.
  • Precedence — env beats keychain beats file, via the Store's ordinary merging, with Explain naming the keychain layer.
  • Redaction — no path that renders config emits a keychain value.
  • Integration (env-gated, GTB pattern) — against a real secret service on a desktop; skipped where unavailable, which is most CI.