keychain

package module
v1.2.0 Latest Latest
Warning

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

Go to latest
Published: Sep 3, 2026 License: BSD-3-Clause Imports: 7 Imported by: 0

README

keychain

CI Go Reference

A cgo-free Go library for storing secrets in the operating system's native secret store — macOS Keychain, Linux/*BSD Secret Service, Windows Credential Manager — behind one small interface.

It is a zalando/go-keyring successor. On macOS it calls the Security.framework API directly instead of shelling out to /usr/bin/security (no subprocess, no secret on a command line), and on Windows it removes the 2560-byte credential-blob cap by chunking transparently — all with no cgo and no interactive prompt for a daemon. A multi-KB secret round-trips on every platform.

Status

All three backends have shipped: macOS (native Security.framework, with an optional /usr/bin/security delegation — see macOS access), Windows (Credential Manager via inline advapi32, with transparent chunking past the 2560-byte blob cap), and Linux/*BSD (freedesktop Secret Service over D-Bus). Every backend is exercised by a gated integration job on its own OS runner. The public API is stable.

Install

go get github.com/lexfrei/keychain

Usage

package main

import (
	"errors"
	"fmt"

	"github.com/lexfrei/keychain"
)

func main() {
	if err := keychain.Set("myapp", "alice", []byte("s3cr3t")); err != nil {
		panic(err)
	}

	secret, err := keychain.Get("myapp", "alice")
	switch {
	case errors.Is(err, keychain.ErrNotFound):
		fmt.Println("no such item")
	case err != nil:
		panic(err)
	default:
		fmt.Printf("got %d bytes\n", len(secret))
	}

	if err := keychain.Delete("myapp", "alice"); err != nil {
		panic(err)
	}
}

Errors

Get and Delete distinguish the cases a caller needs to branch on, all testable with errors.Is:

  • keychain.ErrNotFound — no such item (Get only; Delete of an absent item is a nil no-op).
  • keychain.ErrInvalidKey — an empty service or account.
  • keychain.ErrLocked — the store is locked and would need an interactive unlock (a locked Linux collection; a macOS read that would prompt).
  • keychain.ErrUnavailable — no reachable store (no Linux session bus or default collection; the macOS Security framework failed to load) — a signal to fall back rather than retry.
  • keychain.ErrAccessDenied — macOS only: the item exists but this process's code identity is denied by the access partition (typically an unsigned binary reading its own item after a rebuild) — fall back to WithSecurityCLI or another store.
  • keychain.ErrUnsupported — a platform with no backend.

Every other error carries the platform detail in its message.

Configuration and logging

The package-level functions use a silent default. For a logger or a non-default access mode, build a Keychain with New and call its methods:

logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
kc := keychain.New(keychain.WithLogger(logger))

_ = kc.Set("myapp", "alice", []byte("s3cr3t"))

The library emits nothing unless you pass WithLogger. Debug lines record the backend, the lookup key, and payload length — never the secret value.

Access-control model per platform

Platform Store Default read scope Rebuild-safe Size limit
macOS Keychain (Security API) trust-all ACL (opt: current-app) same binary or stable Team ID none (API)
Linux/*BSD Secret Service (D-Bus) user session collection yes none
Windows Credential Manager current user yes 2560 B → chunked

AccessMode only changes behaviour on macOS. Linux and Windows secrets are user-scoped, and every mode there behaves like TrustAll.

macOS: rebuild-stability and code signing

macOS gates each keychain item by an access partition tied to the creating binary's code identity. A process of the same identity reads without a prompt across restarts and rebuilds — this holds for the same binary and for any binary code-signed with the same stable Apple Team ID. An unsigned or ad-hoc-signed binary (the default go build output) is bound to its cdhash, which changes on every rebuild, so a rebuilt copy can no longer read what it stored. There is no OS mechanism to make an item readable by every app of the user; the partition exists precisely to prevent that.

If you cannot sign with a stable Team ID and still need an unsigned binary to keep access across rebuilds — or to share an item with another app — opt into the /usr/bin/security delegation:

kc := keychain.New(keychain.WithSecurityCLI())

Items written this way live in the stable apple-tool partition and stay readable across rebuilds and apps — which also means any process of the user reads them without a prompt, and that is the trade. The value is handed to the tool on standard input, so it does not appear in the process list. security's own usage text agrees: "Use of the -p or -w options is insecure." Two cases still go on the command line, where the value is briefly visible to the same user in ps and bounded by ARG_MAX: a secret whose base64 does not fit the roughly 4 KB line the tool reads per command (about 3 KB of secret, less the length of the service and account), and a service or account holding a newline, which one line cannot carry. A truncated secret would be the worse outcome. The same tool buffer is what limits go-keyring on macOS, which guards the identical command with ErrSetDataTooBig. A key holding a NUL works on neither route, as it never did. Use it consistently — an item written with it must also be read with it; mixing the two paths on one item returns a garbled or missing value, not an error. It is a no-op on Linux and Windows.

Linux/*BSD: Secret Service availability

The backend talks to the freedesktop Secret Service (gnome-keyring / KWallet) over the session D-Bus, so it needs a session bus and an unlocked default collection. On a bare server or container with no secret-service provider — or a locked collection that would need an interactive unlock — every operation returns a clear error rather than hanging, and no prompt is ever shown. A daemon that must run in such an environment should detect the error and fall back to another store (for example a plaintext file behind an explicit opt-in). DragonFly BSD is not covered — its D-Bus library does not build there — and reports ErrUnsupported.

Security

Every backend protects data-at-rest and never writes a plaintext file itself. On Linux and Windows an item is readable, without a prompt, by any process of the same user — the deliberate trade for a headless daemon. On macOS the reader must additionally match the item's access partition (see macOS access). None of the backends defends against code already running as that user. See the package documentation for the full threat model.

Known limitations

  • macOS, unsigned binaries. The native backend is rebuild-stable only for the same binary or a stable Apple Team ID. An unsigned or ad-hoc-signed binary — the default go build output — loses access to its own items after a rebuild. WithSecurityCLI works around it, at the cost of an item every process of the user can read; a secret over about 3 KB, or a key holding a newline, additionally goes on the command line, briefly visible to the same user in ps. There is no cgo-free way to make a native item readable by an arbitrary process; the OS partition mechanism prevents that by design.
  • Linux/*BSD provider and OS coverage. The integration contract runs in CI against gnome-keyring on Linux, FreeBSD, OpenBSD, and NetBSD. The backend calls only the standard org.freedesktop.secrets interface, so any compliant provider — gnome-keyring, KWallet, KeePassXC — should work, but only gnome-keyring is exercised in CI (KWallet and KeePassXC prompt for an interactive unlock that is impractical to drive headless). DragonFly is unsupported — its D-Bus library does not build there. The backend needs a session bus and an existing default collection — a fresh headless keyring must be provisioned out of band.
  • macOS relies on deprecated ACL APIs. The trust-all ACL uses SecAccess/SecACL calls Apple deprecated (but has not removed) for the legacy keychain. If a future macOS drops them, Set falls back to a per-app ACL.
  • Operations are serialized. A process-wide mutex serializes every store call, which keeps the read-modify-write paths simple and is ample for a daemon, but is not built for high-throughput concurrent access.
  • Not yet battle-tested. v1.x is API-stable and passes the full behavioural contract against each real store in CI, but it has no production mileage yet; validate it against your own store before relying on it.

License

BSD-3-Clause. See LICENSE.

Documentation

Overview

Package keychain stores and retrieves secrets in the operating system's native secret store — the macOS Keychain, the Linux/*BSD Secret Service, or the Windows Credential Manager — behind one small, cgo-free interface.

It is a successor to zalando/go-keyring that removes go-keyring's two hard limits: the ~4 KB macOS command-line cap and the 2560-byte Windows credential-blob cap. Secrets of any size round-trip on every platform (Windows chunks transparently under the hood). There is no cgo on any platform, so callers cross-compile freely, and a rebuilt binary keeps access to what it stored — the properties a headless, frequently-rebuilt daemon needs.

Usage

The package-level Set, Get, and Delete cover the common case with a silent default configuration. For a logger or a non-default access mode, construct a Keychain with New and call its methods.

Semantics

Set is an upsert. Get returns the exact bytes previously stored, or ErrNotFound. Delete is idempotent — removing an absent item is not an error. Service and account together form the lookup key; neither may be empty. An empty secret is allowed and is distinct from an absent item.

Logging

The library is silent by default. Pass WithLogger a *slog.Logger to trace, at debug level, which backend ran and how an operation resolved. The secret value is never logged.

Security

Every backend protects data-at-rest and never writes a plaintext file itself. On Linux and Windows an item is readable, without a prompt, by any process of the same user — the deliberate trade for a headless daemon. None of the backends defends against code already executing as that user.

macOS access

macOS additionally gates each keychain item by an access partition keyed to the creating binary's code identity. A process of the same identity reads without a prompt across restarts and rebuilds: this holds for the same binary and for any binary code-signed with the same stable Apple Team ID. An unsigned or ad-hoc-signed binary — the default go build output — is bound to its cdhash, which changes on every rebuild, so a rebuilt copy can no longer read what it stored. WithSecurityCLI is the opt-in escape hatch for that case; a stable Team ID signature needs nothing extra.

Example

Store, read, then remove a secret with the package-level API.

package main

import (
	"fmt"

	"github.com/lexfrei/keychain"
)

func main() {
	err := keychain.Set("myapp", "alice", []byte("s3cr3t"))
	if err != nil {
		fmt.Println("set:", err)

		return
	}

	secret, err := keychain.Get("myapp", "alice")
	if err != nil {
		fmt.Println("get:", err)

		return
	}

	fmt.Printf("stored %d bytes\n", len(secret))

	err = keychain.Delete("myapp", "alice")
	if err != nil {
		fmt.Println("delete:", err)
	}
}

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrAccessDenied = errors.New("keychain: access denied by the store's ACL or partition")

ErrAccessDenied wraps a macOS denial where the item exists but this process is not permitted to read it — the access partition or ACL rejected it, which an unlock cannot fix. The common cause is an unsigned or ad-hoc-signed binary reading its own item after a rebuild changed its code identity; such a caller should fall back to WithSecurityCLI or another store. It does not occur on Linux or Windows. Test for it with errors.Is.

View Source
var ErrInvalidKey = errors.New("keychain: service and account must both be non-empty")

ErrInvalidKey is returned when service or account is empty. The two together form the lookup key, so neither may be blank.

View Source
var ErrLocked = errors.New("keychain: store is locked and needs an interactive unlock")

ErrLocked wraps an error from a store that is locked and would need an interactive unlock to proceed — a headless caller cannot answer the prompt. It occurs on the Linux Secret Service when the collection is locked, and on macOS when a read would need a keychain prompt (errSecInteractionNotAllowed). Test for it with errors.Is; the wrapped error carries the platform detail.

View Source
var ErrNotFound = errors.New("keychain: item not found")

ErrNotFound is returned by Get when no item matches the service and account.

View Source
var ErrUnavailable = errors.New("keychain: secret store is unavailable")

ErrUnavailable wraps an error from a store that is not reachable at all: on Linux, no session bus or no default collection; on macOS, the Security framework failed to load. It signals the caller to fall back to another store rather than retry. Test for it with errors.Is.

View Source
var ErrUnsupported = errors.New("keychain: platform not supported")

ErrUnsupported is returned on a platform whose backend is not implemented.

Functions

func Delete

func Delete(service, account string) error

Delete removes the item under service and account; a missing item is not an error.

func Get

func Get(service, account string) ([]byte, error)

Get returns the secret stored under service and account, or ErrNotFound.

Example

Branch on the typed errors with errors.Is instead of matching a message.

package main

import (
	"errors"
	"fmt"

	"github.com/lexfrei/keychain"
)

func main() {
	secret, err := keychain.Get("myapp", "alice")

	switch {
	case errors.Is(err, keychain.ErrNotFound):
		fmt.Println("no such item")
	case errors.Is(err, keychain.ErrLocked):
		fmt.Println("unlock the store and retry")
	case errors.Is(err, keychain.ErrUnavailable):
		fmt.Println("no secret store available; fall back")
	case errors.Is(err, keychain.ErrAccessDenied):
		fmt.Println("this build cannot read the item; sign it or use WithSecurityCLI")
	case err != nil:
		fmt.Println("error:", err)
	default:
		fmt.Printf("got %d bytes\n", len(secret))
	}
}

func Set

func Set(service, account string, secret []byte, opts ...Option) error

Set stores secret under service and account using the default configuration.

Types

type AccessMode

type AccessMode int

AccessMode controls read access. It is only meaningful on macOS; on Linux and Windows secrets are user-scoped and every mode behaves like TrustAll.

const (
	// TrustAll lets any process of the same user read without a prompt. It
	// matches security -A, go-keyring, and the Linux and Windows default:
	// rebuild-safe and daemon-friendly. It protects data-at-rest, not against
	// code already running as the user. This is the default.
	TrustAll AccessMode = iota

	// TrustCurrentApp, on macOS only, lets only the creating binary read
	// silently; other apps are prompted. Stronger, but a rebuilt binary loses
	// access.
	TrustCurrentApp
)

type Keychain

type Keychain struct {
	// contains filtered or unexported fields
}

Keychain is a handle to the OS secret store with a fixed configuration (a logger and a default access mode). Construct one with New; the package-level Set, Get, and Delete delegate to a default, silent instance.

func New

func New(opts ...Option) *Keychain

New returns a Keychain configured by opts. Without WithLogger it is silent.

Example

Construct a Keychain that traces at debug level; the library is silent otherwise, and never logs the secret value.

package main

import (
	"fmt"
	"log/slog"
	"os"

	"github.com/lexfrei/keychain"
)

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
	kc := keychain.New(keychain.WithLogger(logger))

	err := kc.Set("myapp", "alice", []byte("s3cr3t"))
	if err != nil {
		fmt.Println("set:", err)
	}
}

func (*Keychain) Delete

func (k *Keychain) Delete(service, account string) error

Delete removes the item under service and account. A missing item is not an error: Delete is idempotent.

func (*Keychain) Get

func (k *Keychain) Get(service, account string) ([]byte, error)

Get returns the secret stored under service and account, or ErrNotFound.

func (*Keychain) Set

func (k *Keychain) Set(service, account string, secret []byte, opts ...Option) error

Set stores secret under service and account, replacing any existing value. Per-call opts (for example WithLabel) override the Keychain's configuration for this call only.

type Option

type Option func(*config)

Option configures a Keychain (via New) or a single Set call.

func WithAccessMode

func WithAccessMode(mode AccessMode) Option

WithAccessMode selects the macOS read-access ACL. It defaults to TrustAll and is a no-op on Linux and Windows.

func WithLabel

func WithLabel(label string) Option

WithLabel sets a human-readable label where the store supports one — the macOS Keychain item label. It is ignored on Linux and Windows.

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger routes debug-level tracing to logger. Without it the library is silent. The secret value is never logged, only its length and the lookup key.

func WithSecurityCLI

func WithSecurityCLI() Option

WithSecurityCLI routes macOS operations through the /usr/bin/security tool instead of the native Security.framework API. It exists for one case: an unsigned or ad-hoc-signed binary that must read its own secret again after a rebuild, or share it with another application of the same user.

The native default binds an item to its creator's code identity (its cdhash), which changes on every rebuild, so a rebuilt unsigned binary can no longer read what it stored. Items written through /usr/bin/security instead live in the stable "apple-tool" access partition and stay readable across rebuilds and across apps. The trade is that any process of the user can then read the item without a prompt.

The value reaches the tool on its standard input, so it does not appear in this process's argument list. security(1) says the same thing about the alternative in its own usage text: "Use of the -p or -w options is insecure."

Two cases still use the argument list, where the value is briefly visible to the same user in ps: a secret whose base64 does not fit the roughly 4 KB line the tool reads per command (about 3 KB of secret, less the length of the service and account), and a service or account holding a newline, which one line cannot carry. Exposure there is the lesser fault against storing a truncated secret. The same tool buffer is what limits go-keyring on macOS, which guards the identical command with ErrSetDataTooBig. A service or account holding a NUL works on neither route, as it never did: Go rejects such an argument before the process starts.

It is a no-op on Linux and Windows, whose stores are user-scoped with no such partitioning. A binary code-signed with a stable Apple Team ID does not need it: the native default is already rebuild-stable for same-team readers.

Use it consistently: an item written with it must also be read with it. The two paths store the value differently, so mixing them on one item does not error — the value comes back garbled or missing. WithLabel and WithAccessMode have no effect in this mode.

Directories

Path Synopsis
internal
cf
Package cf is a minimal, cgo-free CoreFoundation binding for the darwin keychain backend.
Package cf is a minimal, cgo-free CoreFoundation binding for the darwin keychain backend.
chunk
Package chunk stores an arbitrary-size secret across several fixed-capacity credential blobs, hiding the Windows Credential Manager per-blob byte cap behind a header-plus-chunks layout.
Package chunk stores an arbitrary-size secret across several fixed-capacity credential blobs, hiding the Windows Credential Manager per-blob byte cap behind a header-plus-chunks layout.
secitem
Package secitem is the cgo-free Security.framework binding for the darwin keychain backend.
Package secitem is the cgo-free Security.framework binding for the darwin keychain backend.
winapi
Package winapi is a dependency-free Windows Credential Manager binding: inline advapi32 CredWriteW/CredReadW/CredDeleteW/CredFree calls through the standard library's syscall package, with no golang.org/x/sys and no third-party wrapper.
Package winapi is a dependency-free Windows Credential Manager binding: inline advapi32 CredWriteW/CredReadW/CredDeleteW/CredFree calls through the standard library's syscall package, with no golang.org/x/sys and no third-party wrapper.

Jump to

Keyboard shortcuts

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