configkeychain

package module
v0.2.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 17, 2026 License: MIT Imports: 10 Imported by: 0

README

config-keychain

Keep tokens in the OS keychain instead of a plaintext config file

Go Reference Pipeline Coverage phpboyscout Go toolkit

Part of the phpboyscout Go toolkit — small, framework-free Go modules extracted from go-tool-base. Documented with the parent module at config.go.phpboyscout.uk


A tool that obtains an OAuth token has to put it somewhere. Without a layer that can hold it, the somewhere is the config file — which is how a refresh token ends up on disk in the clear, next to the port number.

This adapter makes the operating system's keychain a config layer, so tokens have somewhere better to go and the core's own rules keep them there.

import (
	"gitlab.com/phpboyscout/go/config"
	_ "gitlab.com/phpboyscout/go/credentials/keychain" // activates the OS keychain
	configkeychain "gitlab.com/phpboyscout/go/config-keychain"
)

store, err := config.NewStore(ctx,
	config.WithEnv("MYAPP"),                                    // overrides
	config.WithFiles(fsys, "config.yaml"),                      // ordinary settings
	config.WithBackend(configkeychain.New(                      // secrets, on top
		configkeychain.Registered(), "myapp", map[string]string{
			"platforms.instagram.access_token": "instagram-access-token",
			"platforms.tiktok.refresh_token":   "tiktok-refresh-token",
		})),
)

What that buys you

Two behaviours fall out of routing the core already does — neither needs a special case:

store.Apply(ctx, config.Set("platforms.instagram.access_token", token))
  • A first token goes into the keychain, even when it holds nothing yet, because the keychain is the highest-precedence writable layer. No pinning, no branching on whether a keychain exists.
  • A token already in the keychain can never be written to the file beneath it. The core refuses a write whose target is not sensitive, so the fallback that used to put secrets on disk now returns config.ErrSensitiveLeak instead.

And a key you did not declare is ordinary configuration: it routes to the file as it always did. The mapping bounds what this backend owns.

The mapping is declared, because a keychain cannot be listed

Every platform keychain offers get, set and delete — and nothing that enumerates. So unlike every other backend in this toolkit, this one cannot discover its own key space. You declare it:

config path                        →  keychain account
platforms.instagram.access_token   →  instagram-access-token

Both sides are explicit rather than derived. A naming convention would compute account names that your existing entries do not have, which would quietly orphan every credential already stored.

A declared key the keychain does not hold contributes nothing, and that is not an error — it is simply the state before a token has been obtained.

It will not hang your application

A locked keychain blocks on an unlock prompt. On a headless host nobody can answer it, and a configuration layer that waits forever is worse than one that fails.

So each call is bounded — DefaultTimeout is 10 seconds, WithTimeout to change it — and the adapter imposes that bound itself rather than trusting the injected backend to honour a context.

configkeychain.New(backend, "myapp", keys,
	configkeychain.WithTimeout(30*time.Second)) // a desktop user may be typing a passphrase

[!IMPORTANT] An unavailable keychain fails the load, returning ErrKeychainUnavailable rather than quietly contributing nothing.

That is deliberate. Contributing nothing is indistinguishable from "no tokens stored yet", which is a perfectly ordinary state — and degrading in silence is exactly what put tokens in plaintext files to begin with. If you would rather carry on without one, check Available() and omit the layer: a decision written in your code, rather than one nobody made.

Writable, unlike every other secrets backend here

The other secrets adapters in this family are read-only, because secrets there are provisioned by a separate, audited process and a config library writing to Vault would be a surprising power to hand it.

A local keychain is not that. It holds a token this application just obtained, on the user's own machine, and refusing to write it would leave you doing what this module exists to prevent.

Two consequences worth knowing:

  • Writes are not atomic. Setting three tokens is three keychain operations; a failure part-way leaves the earlier ones stored, and rollback undoes them best-effort.
  • Conflict detection compares values, not versions, because a keychain has neither a version nor a modification time. It catches another process having changed an entry — the case worth catching — but it cannot tell "changed and changed back", and there is a small window between the check and the write.

go-keyring stays out of your binary

The keychain is reached through credentials.Backend, the interface. This module never imports the go-keyring implementation, so it does not pull go-keyring or godbus into your dependency graph — a test asserts they are absent, and fails if that ever changes.

You activate a real keychain by blank-importing credentials/keychain yourself, exactly as you do today. A build that must not carry session-bus or keychain IPC code simply does not, and the linker drops it. Registered() then resolves to whatever you activated.

What it costs

Modules added 21 — 9 for the config graph, 12 for credentials
Requires config v0.10.0+, credentials v0.2.2+

Both floors are hard rather than preferences. credentials v0.2.2 is the release in which the keychain backend began honouring its context — against v0.2.1 a locked keyring blocks with nothing able to recover it. config v0.10.0 is the release that added BoundedKeySpace, without which the conformance suite demands an invented account name a declared key space has no way to honour.

Install

go get gitlab.com/phpboyscout/go/config-keychain

Documentation

Full documentation lives with the parent module at config.go.phpboyscout.uk. The Go API reference is on pkg.go.dev.

Licence

MIT

Documentation

Overview

Package configkeychain contributes secrets from the operating system's keychain as a first-class config layer — and, unusually for a secrets backend, accepts writes into it.

It exists because tokens keep ending up in plaintext config files. A tool that obtains an OAuth token has to put it somewhere, and without a layer that can hold it the fallback is the config file, which is how a refresh token comes to sit on disk in the clear next to the port number.

With a keychain layer above the file, two things follow from routing the core already does: a first-ever token is written INTO the keychain, because that is the highest-precedence writable layer; and a token the keychain already holds can never be written to the file beneath, because the core refuses a write whose target is not sensitive. What was a silent fallback becomes either a correct write or a refusal.

A keychain cannot be enumerated — the platform APIs offer get, set and delete and nothing that lists — so the key set is declared rather than discovered. See New.

The keychain is reached through credentials.Backend, the interface, never the go-keyring implementation. That keeps the keyring and dbus dependencies out of a consumer's graph unless they blank-import credentials/keychain themselves, which is the property that package's split exists to preserve.

See the config-keychain spec for the decisions behind all of this.

Index

Constants

View Source
const DefaultTimeout = 10 * time.Second

DefaultTimeout bounds a single keychain operation.

A keychain call is milliseconds when it works and unbounded when it does not: a locked collection waits on an unlock prompt, which on a headless host nobody can answer. credentials honours a context, but a Load on a startup path usually carries no deadline of its own, so this adapter imposes one rather than letting a configuration layer hang an application.

Ten seconds is generous for a working keychain and short enough that a stuck one looks like a failure rather than a hang. A desktop consumer expecting a human to type a passphrase should raise it with WithTimeout.

View Source
const SourceKind = config.SourceKind("keychain")

SourceKind is what a layer from this backend reports as, so provenance can name the keychain as the origin of a value — and so that an empty keychain does not present as a file. See [backend.SourceKind].

Variables

View Source
var ErrKeychainUnavailable = errors.New("configkeychain: the keychain is unavailable")

ErrKeychainUnavailable is returned by Load when the injected backend reports that it cannot serve requests at all.

It is deliberately not an absent source. Contributing nothing would be indistinguishable from "no tokens stored yet", which is an ordinary state, and silently degrading is precisely what put tokens in plaintext files to begin with. A consumer that would rather carry on can catch this and omit the layer — a decision written in their code rather than one nobody made.

View Source
var ErrUndeclaredKey = errors.New("configkeychain: no keychain account declared for this key")

ErrUndeclaredKey is returned when a write targets a path this backend was not given an account for.

The mapping is the whole key space here, because a keychain cannot be enumerated: there is no account to write to unless the consumer named one. The alternative — inventing an account name from the path — is the naming convention D3 rejects, and it would put a secret somewhere no read would ever look for it.

View Source
var ErrUnwritableValue = errors.New("configkeychain: value cannot be written to a keychain entry")

ErrUnwritableValue is returned when a Set carries something a keychain cannot hold. Entries are strings; a map or slice has no representation here.

Functions

func New

func New(backend credentials.Backend, service string, keys map[string]string, opts ...Option) config.Backend

New returns a backend contributing the declared secrets as one config layer.

keys maps a config path to the keychain account holding it, all under one service name:

configkeychain.New(backend, "keryx", map[string]string{
	"platforms.instagram.access_token": "instagram-access-token",
})

The mapping is declared rather than discovered because a keychain cannot be enumerated, and it is explicit in both directions rather than derived from the path: a naming convention would compute account names that existing entries do not have, making every already-stored credential unreadable.

backend is credentials.Backend — a fake in tests, and in production the keychain implementation the consumer activates by blank-importing credentials/keychain.

func Registered

func Registered() credentials.Backend

Registered returns a credentials.Backend backed by whatever the consumer activated in the credentials registry.

It exists because the two packages have different shapes. credentials is built around a blank import — importing credentials/keychain registers the OS backend, and the package-level Store, Retrieve and Delete delegate to whatever is registered — while this adapter takes a Backend value so its unit suite can inject a fake. Without a bridge, a consumer would have to import the keychain implementation to get a value, which is exactly what drags go-keyring and godbus into their dependency graph whether they wanted it or not.

So the usual production wiring is:

import _ "gitlab.com/phpboyscout/go/credentials/keychain" // activate

config.WithBackend(configkeychain.New(configkeychain.Registered(), "myapp", keys))

A build that must not carry keychain IPC simply omits the blank import, and Registered then reports unavailable — which Load turns into ErrKeychainUnavailable rather than silence.

Types

type Option

type Option func(*backend)

Option configures a backend.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout bounds each keychain operation. The default is DefaultTimeout.

Raise it where a human may be asked to unlock a keychain interactively; lower it on a service that must fail fast.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL