Add keyring package for kernel-keyring-backed secret sealing #3

Merged
benvin merged 5 commits from benvin/keyring into main 2026-10-23 23:00:46 +11:00
7 changed files with 612 additions and 1 deletions
+20
View File
@@ -13,6 +13,7 @@ imports it inherits them.
| --- | --- |
| `git.unkin.net/unkin/golib/pg` | Postgres: DSN from the environment, pgxpool construction, read/write splitting across a primary and a replica, and the estate's migration runner. |
| `git.unkin.net/unkin/golib/pg/pgtest` | A throwaway Postgres container for a consumer's own `_test.go` files. Test-only. |
| `git.unkin.net/unkin/golib/keyring` | Encrypts secrets at rest with a key held in the Linux kernel user keyring, scoped to the current boot and uid. |
| `git.unkin.net/unkin/golib/colour` | jq-style JSON and yq-style YAML highlighting for CLI output, preserving the input byte for byte, plus the TTY/`NO_COLOR` check. |
### pg
@@ -185,6 +186,25 @@ func TestSomething(t *testing.T) {
}
```
### keyring
```go
kr, err := keyring.Open("golib:vault-tools") // get-or-create the key
sealed, err := kr.Seal(token)
token, err := kr.Unseal(sealed)
if errors.Is(err, keyring.ErrKeyUnavailable) {
// rebooted or Forget()-ed: log in again
}
```
The key is 32 random bytes stored as a `user` key in the uid's user keyring
(`@u`), so every login shell, terminal and systemd user service of that uid
shares it until reboot. Its permissions grant the uid read access as well as the
possessor, because processes whose session keyring does not link `@u` do not
possess it. Data is AES-256-GCM in a versioned envelope; a missing, revoked or
different key returns `ErrKeyUnavailable`, damaged data returns `ErrCorrupt`.
`Forget` revokes the key. Non-Linux builds return `errors.ErrUnsupported`.
## Consuming
golib is versioned with semver tags and consumed like any Go module. Pin a tag:
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/jackc/pgx/v5 v5.9.2
github.com/testcontainers/testcontainers-go v0.44.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -63,6 +64,5 @@ require (
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
)
+80
View File
@@ -0,0 +1,80 @@
// Package keyring encrypts small secrets at rest with a random key held in the
// Linux kernel user keyring, so the key lives only for the current boot and is
// readable only by the current uid. A missing key (after a reboot or Forget)
// surfaces as ErrKeyUnavailable, which callers treat as "log in again".
package keyring
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"errors"
)
var (
// ErrKeyUnavailable means the key is missing, revoked or not the one the
// data was sealed with.
ErrKeyUnavailable = errors.New("keyring: key unavailable")
// ErrCorrupt means the envelope is malformed or fails authentication.
ErrCorrupt = errors.New("keyring: sealed data corrupt")
)
const (
keySize = 32
version1 = 1
fpSize = 8
nonceSize = 12
headerLen = 1 + fpSize + nonceSize
)
func fingerprint(key []byte) []byte {
sum := sha256.Sum256(key)
return sum[:fpSize]
}
// seal returns version || fingerprint || nonce || AES-256-GCM(plaintext), with
// version and fingerprint authenticated as additional data.
func seal(key, plaintext []byte) ([]byte, error) {
gcm, err := newGCM(key)
if err != nil {
return nil, err
}
out := make([]byte, headerLen, headerLen+len(plaintext)+gcm.Overhead())
out[0] = version1
copy(out[1:], fingerprint(key))
if _, err := rand.Read(out[1+fpSize : headerLen]); err != nil {
return nil, err
}
return gcm.Seal(out, out[1+fpSize:headerLen], plaintext, out[:1+fpSize]), nil
}
func unseal(key, sealed []byte) ([]byte, error) {
if len(sealed) < headerLen || sealed[0] != version1 {
return nil, ErrCorrupt
}
if subtle.ConstantTimeCompare(sealed[1:1+fpSize], fingerprint(key)) != 1 {
return nil, ErrKeyUnavailable
}
gcm, err := newGCM(key)
if err != nil {
return nil, err
}
pt, err := gcm.Open(nil, sealed[1+fpSize:headerLen], sealed[headerLen:], sealed[:1+fpSize])
if err != nil {
return nil, ErrCorrupt
}
return pt, nil
}
func newGCM(key []byte) (cipher.AEAD, error) {
if len(key) != keySize {
return nil, ErrKeyUnavailable
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
+180
View File
@@ -0,0 +1,180 @@
package keyring
import (
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"runtime"
"golang.org/x/sys/unix"
)
// Possessor: all. User (same uid): view, read, write, search, setattr. Granting
// the uid matters because the user keyring is only possessed by processes whose
// session keyring links it, which a systemd user service or a `keyctl session`
// shell does not.
const keyPerm = 0x3f000000 | 0x002f0000
const createAttempts = 5
// Keyring seals and unseals data with the kernel-held key named by its
// description.
type Keyring struct {
desc string
}
// Open returns the keyring for desc, creating its key in the user keyring
// (KEY_SPEC_USER_KEYRING, shared by every session of this uid until reboot) if
// absent.
func Open(desc string) (*Keyring, error) {
if desc == "" {
return nil, errors.New("keyring: empty description")
}
k := &Keyring{desc: desc}
if _, err := k.getOrCreate(); err != nil {
return nil, err
}
return k, nil
}
// Seal encrypts plaintext, recreating the key if it has been dropped.
func (k *Keyring) Seal(plaintext []byte) ([]byte, error) {
key, err := k.getOrCreate()
if err != nil {
return nil, err
}
return seal(key, plaintext)
}
// Unseal decrypts data from Seal. It returns ErrKeyUnavailable when the key is
// gone or differs from the sealing key, and ErrCorrupt when data is damaged.
func (k *Keyring) Unseal(sealed []byte) ([]byte, error) {
_, key, err := k.lookup()
if err != nil {
return nil, err
}
return unseal(key, sealed)
}
// Forget revokes the key and unlinks it from the user keyring, so everything
// sealed with it becomes unreadable. Forgetting an absent key is not an error.
func (k *Keyring) Forget() error {
id, err := unix.KeyctlSearch(unix.KEY_SPEC_USER_KEYRING, "user", k.desc, 0)
if err != nil {
if missing(err) {
return nil
}
return fmt.Errorf("keyring: search %q: %w", k.desc, err)
}
if _, err := unix.KeyctlInt(unix.KEYCTL_REVOKE, id, 0, 0, 0); err != nil {
return fmt.Errorf("keyring: revoke %q: %w", k.desc, err)
}
unlink(id)
return nil
}
// lookup returns the key and its id; the id is non-zero whenever the search
// found a key, even one that turned out unusable.
func (k *Keyring) lookup() (int, []byte, error) {
id, err := unix.KeyctlSearch(unix.KEY_SPEC_USER_KEYRING, "user", k.desc, 0)
if err != nil {
if missing(err) {
return 0, nil, fmt.Errorf("%w: %w", ErrKeyUnavailable, err)
}
return 0, nil, fmt.Errorf("keyring: search %q: %w", k.desc, err)
}
buf := make([]byte, 2*keySize)
n, err := unix.KeyctlBuffer(unix.KEYCTL_READ, id, buf, 0)
if err != nil {
if missing(err) {
return id, nil, fmt.Errorf("%w: %v", ErrKeyUnavailable, err)
}
return id, nil, fmt.Errorf("keyring: read %q: %w", k.desc, err)
}
if n != keySize {
return id, nil, fmt.Errorf("%w: key %q is %d bytes", ErrKeyUnavailable, k.desc, n)
}
return id, buf[:n], nil
}
// getOrCreate converges concurrent first users on one key: create links
// exclusively, so a loser gets EEXIST and re-reads the winner's key.
func (k *Keyring) getOrCreate() ([]byte, error) {
for range createAttempts {
id, key, err := k.lookup()
if !errors.Is(err, ErrKeyUnavailable) {
return key, err
}
// An unusable key of our description blocks the exclusive link.
if id != 0 {
unlink(id)
} else if errors.Is(err, unix.EKEYREVOKED) || errors.Is(err, unix.EKEYEXPIRED) {
purgeDead()
}
key, err = k.create()
if !errors.Is(err, unix.EEXIST) {
return key, err
}
}
return nil, fmt.Errorf("keyring: %q: key creation kept racing", k.desc)
}
// create adds a fresh key to this thread's keyring, which is always possessed,
// so setperm succeeds, then moves it into the user keyring with MOVE_EXCL,
// failing with EEXIST if a key of the same description is already there.
func (k *Keyring) create() ([]byte, error) {
key := make([]byte, keySize)
if _, err := rand.Read(key); err != nil {
return nil, err
}
runtime.LockOSThread()
defer runtime.UnlockOSThread()
id, err := unix.AddKey("user", k.desc, key, unix.KEY_SPEC_THREAD_KEYRING)
if err != nil {
return nil, fmt.Errorf("keyring: add %q: %w", k.desc, err)
}
defer func() { _, _ = unix.KeyctlInt(unix.KEYCTL_UNLINK, id, unix.KEY_SPEC_THREAD_KEYRING, 0, 0) }()
if err := unix.KeyctlSetperm(id, keyPerm); err != nil {
return nil, fmt.Errorf("keyring: setperm %q: %w", k.desc, err)
}
if _, err := unix.KeyctlInt(unix.KEYCTL_MOVE, id, unix.KEY_SPEC_THREAD_KEYRING, unix.KEY_SPEC_USER_KEYRING, unix.KEYCTL_MOVE_EXCL); err != nil {
if errors.Is(err, unix.EEXIST) {
return nil, err
}
return nil, fmt.Errorf("keyring: move %q: %w", k.desc, err)
}
return key, nil
}
// purgeDead unlinks every revoked or expired key in the user keyring: a dead key
// cannot be described, so ours cannot be singled out. Call it only after the
// search hit a dead key of our description.
func purgeDead() {
buf := make([]byte, 256)
for {
n, err := unix.KeyctlBuffer(unix.KEYCTL_READ, unix.KEY_SPEC_USER_KEYRING, buf, 0)
if err != nil {
return
}
if n <= len(buf) {
buf = buf[:n]
break
}
buf = make([]byte, n)
}
for ; len(buf) >= 4; buf = buf[4:] {
id := int(int32(binary.NativeEndian.Uint32(buf)))
if _, err := unix.KeyctlString(unix.KEYCTL_DESCRIBE, id); missing(err) {
unlink(id)
}
}
}
func unlink(id int) {
_, _ = unix.KeyctlInt(unix.KEYCTL_UNLINK, id, unix.KEY_SPEC_USER_KEYRING, 0, 0)
}
func missing(err error) bool {
return errors.Is(err, unix.ENOKEY) || errors.Is(err, unix.EKEYREVOKED) || errors.Is(err, unix.EKEYEXPIRED)
}
+216
View File
@@ -0,0 +1,216 @@
package keyring
import (
"bytes"
"errors"
"fmt"
"os"
"runtime"
"strings"
"sync"
"testing"
"golang.org/x/sys/unix"
)
// open opens a test-unique keyring, skipping outside CI where keyctl is
// unimplemented. Permission errors always fail: they are bugs, not absence.
func open(t *testing.T) *Keyring {
t.Helper()
k, err := Open(fmt.Sprintf("golib-test:%s:%d", t.Name(), os.Getpid()))
if errors.Is(err, unix.ENOSYS) && os.Getenv("CI") == "" {
t.Skipf("kernel keyring unavailable: %v", err)
}
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = k.Forget() })
return k
}
func TestKernelRoundTrip(t *testing.T) {
k := open(t)
sealed, err := k.Seal([]byte("hvs.token"))
if err != nil {
t.Fatal(err)
}
k2, err := Open(k.desc)
if err != nil {
t.Fatal(err)
}
got, err := k2.Unseal(sealed)
if err != nil || string(got) != "hvs.token" {
t.Fatalf("Unseal = %q, %v", got, err)
}
}
func TestKernelKeyPermissions(t *testing.T) {
k := open(t)
id, err := unix.KeyctlSearch(unix.KEY_SPEC_USER_KEYRING, "user", k.desc, 0)
if err != nil {
t.Fatal(err)
}
desc, err := unix.KeyctlString(unix.KEYCTL_DESCRIBE, id)
if err != nil {
t.Fatal(err)
}
if f := strings.Split(desc, ";"); len(f) < 4 || f[3] != "3f2f0000" {
t.Fatalf("describe = %q, want perm 3f2f0000", desc)
}
}
// A process whose session keyring does not link the user keyring (a systemd
// user service, a fresh `keyctl session`) must still read the key.
func TestKernelUnsealWithoutPossession(t *testing.T) {
k := open(t)
sealed, err := k.Seal([]byte("secret"))
if err != nil {
t.Fatal(err)
}
done := make(chan error)
go func() {
runtime.LockOSThread() // never unlocked: the thread exits with its new session
if _, err := unix.KeyctlInt(unix.KEYCTL_JOIN_SESSION_KEYRING, 0, 0, 0, 0); err != nil {
done <- err
return
}
got, err := k.Unseal(sealed)
if err == nil && !bytes.Equal(got, []byte("secret")) {
err = fmt.Errorf("got %q", got)
}
done <- err
}()
if err := <-done; err != nil {
t.Fatal(err)
}
}
func TestKernelForget(t *testing.T) {
k := open(t)
sealed, err := k.Seal([]byte("secret"))
if err != nil {
t.Fatal(err)
}
if err := k.Forget(); err != nil {
t.Fatal(err)
}
if _, err := k.Unseal(sealed); !errors.Is(err, ErrKeyUnavailable) {
t.Fatalf("Unseal after Forget: err = %v, want ErrKeyUnavailable", err)
}
if err := k.Forget(); err != nil {
t.Fatalf("second Forget: %v", err)
}
resealed, err := k.Seal([]byte("again"))
if err != nil {
t.Fatalf("Seal after Forget: %v", err)
}
if _, err := k.Unseal(sealed); !errors.Is(err, ErrKeyUnavailable) {
t.Fatalf("old data under new key: err = %v, want ErrKeyUnavailable", err)
}
if got, err := k.Unseal(resealed); err != nil || string(got) != "again" {
t.Fatalf("Unseal = %q, %v", got, err)
}
}
func TestKernelWrongSizedKeyIsReplaced(t *testing.T) {
k := open(t)
sealed, err := k.Seal([]byte("secret"))
if err != nil {
t.Fatal(err)
}
if _, err := unix.AddKey("user", k.desc, []byte("short"), unix.KEY_SPEC_USER_KEYRING); err != nil {
t.Fatal(err)
}
if _, err := k.Unseal(sealed); !errors.Is(err, ErrKeyUnavailable) {
t.Fatalf("err = %v, want ErrKeyUnavailable", err)
}
if _, err := Open(k.desc); err != nil {
t.Fatal(err)
}
if _, _, err := k.lookup(); err != nil {
t.Fatalf("key not replaced: %v", err)
}
}
// Racing first users must all end up sealing under the one key that stays linked.
func TestKernelConcurrentFirstSeal(t *testing.T) {
open(t) // skips where keyctl is unavailable
const rounds, workers = 20, 8
for r := range rounds {
desc := fmt.Sprintf("golib-test:%s:%d:%d", t.Name(), os.Getpid(), r)
sealed := make([][]byte, workers)
errs := make([]error, workers)
var wg sync.WaitGroup
for w := range workers {
wg.Go(func() {
k, err := Open(desc)
if err == nil {
sealed[w], err = k.Seal([]byte("secret"))
}
errs[w] = err
})
}
wg.Wait()
k := &Keyring{desc: desc}
t.Cleanup(func() { _ = k.Forget() })
for w := range workers {
if errs[w] != nil {
t.Fatalf("round %d worker %d: %v", r, w, errs[w])
}
if got, err := k.Unseal(sealed[w]); err != nil || string(got) != "secret" {
t.Fatalf("round %d worker %d: Unseal = %q, %v", r, w, got, err)
}
}
}
}
// A key revoked behind our back still blocks an exclusive link until unlinked.
func TestKernelExternallyRevokedKeyIsReplaced(t *testing.T) {
k := open(t)
id, _, err := k.lookup()
if err != nil {
t.Fatal(err)
}
if _, err := unix.KeyctlInt(unix.KEYCTL_REVOKE, id, 0, 0, 0); err != nil {
t.Fatal(err)
}
sealed, err := k.Seal([]byte("secret"))
if err != nil {
t.Fatalf("Seal after external revoke: %v", err)
}
if got, err := k.Unseal(sealed); err != nil || string(got) != "secret" {
t.Fatalf("Unseal = %q, %v", got, err)
}
}
// Another application's dead key in the user keyring is not ours to unlink.
func TestKernelForeignDeadKeySurvivesOpen(t *testing.T) {
open(t) // skips where keyctl is unavailable
foreign := &Keyring{desc: fmt.Sprintf("golib-test-foreign:%s:%d", t.Name(), os.Getpid())}
if _, err := foreign.create(); err != nil {
t.Fatal(err)
}
id, _, err := foreign.lookup()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { unlink(id) })
if _, err := unix.KeyctlInt(unix.KEYCTL_REVOKE, id, 0, 0, 0); err != nil {
t.Fatal(err)
}
k, err := Open(fmt.Sprintf("golib-test:%s:%d:fresh", t.Name(), os.Getpid()))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = k.Forget() })
// Unlinking an id that is no longer linked fails with ENOENT.
if _, err := unix.KeyctlInt(unix.KEYCTL_UNLINK, id, unix.KEY_SPEC_USER_KEYRING, 0, 0); err != nil {
t.Fatalf("foreign dead key was unlinked: %v", err)
}
}
func TestOpenEmptyDescription(t *testing.T) {
if _, err := Open(""); err == nil {
t.Fatal("Open(\"\") succeeded")
}
}
+25
View File
@@ -0,0 +1,25 @@
//go:build !linux
package keyring
import (
"errors"
"fmt"
)
var errUnsupported = fmt.Errorf("keyring: kernel keyring requires linux: %w", errors.ErrUnsupported)
// Keyring is a stub: the kernel key retention service is Linux-only.
type Keyring struct{}
// Open always fails: the kernel key retention service is Linux-only.
func Open(string) (*Keyring, error) { return nil, errUnsupported }
// Seal always fails on this platform.
func (*Keyring) Seal([]byte) ([]byte, error) { return nil, errUnsupported }
// Unseal always fails on this platform.
func (*Keyring) Unseal([]byte) ([]byte, error) { return nil, errUnsupported }
// Forget always fails on this platform.
func (*Keyring) Forget() error { return errUnsupported }
+90
View File
@@ -0,0 +1,90 @@
package keyring
import (
"bytes"
"crypto/rand"
"errors"
"testing"
)
func testKey(b byte) []byte { return bytes.Repeat([]byte{b}, keySize) }
func TestSealUnsealRoundTrip(t *testing.T) {
for _, pt := range [][]byte{nil, []byte("hvs.token"), bytes.Repeat([]byte("x"), 4096)} {
sealed, err := seal(testKey(1), pt)
if err != nil {
t.Fatal(err)
}
got, err := unseal(testKey(1), sealed)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, pt) {
t.Fatalf("got %q, want %q", got, pt)
}
}
}
func TestSealUsesFreshNonce(t *testing.T) {
a, _ := seal(testKey(1), []byte("same"))
b, _ := seal(testKey(1), []byte("same"))
if bytes.Equal(a, b) {
t.Fatal("two seals of the same plaintext are identical")
}
}
func TestUnsealWrongKey(t *testing.T) {
sealed, _ := seal(testKey(1), []byte("secret"))
if _, err := unseal(testKey(2), sealed); !errors.Is(err, ErrKeyUnavailable) {
t.Fatalf("err = %v, want ErrKeyUnavailable", err)
}
}
func TestUnsealTampered(t *testing.T) {
sealed, _ := seal(testKey(1), []byte("secret"))
cases := map[string]func([]byte) []byte{
"ciphertext": func(b []byte) []byte { b[len(b)-1] ^= 1; return b },
"nonce": func(b []byte) []byte { b[1+fpSize] ^= 1; return b },
"version": func(b []byte) []byte { b[0] = 2; return b },
"truncated": func(b []byte) []byte { return b[:headerLen-1] },
"empty": func([]byte) []byte { return nil },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
b := mutate(bytes.Clone(sealed))
if _, err := unseal(testKey(1), b); !errors.Is(err, ErrCorrupt) {
t.Fatalf("err = %v, want ErrCorrupt", err)
}
})
}
}
func TestBadKeyLength(t *testing.T) {
if _, err := seal([]byte("short"), []byte("x")); !errors.Is(err, ErrKeyUnavailable) {
t.Fatalf("seal err = %v, want ErrKeyUnavailable", err)
}
short := []byte("short")
sealed, _ := seal(testKey(1), []byte("x"))
copy(sealed[1:], fingerprint(short))
if _, err := unseal(short, sealed); !errors.Is(err, ErrKeyUnavailable) {
t.Fatalf("unseal err = %v, want ErrKeyUnavailable", err)
}
}
// The header must be bound as additional data: a valid GCM ciphertext under a
// genuine header but without that binding is rejected.
func TestUnsealRequiresHeaderAAD(t *testing.T) {
key := testKey(1)
gcm, err := newGCM(key)
if err != nil {
t.Fatal(err)
}
env := append([]byte{version1}, fingerprint(key)...)
nonce := make([]byte, nonceSize)
_, _ = rand.Read(nonce)
env = append(env, nonce...)
env = gcm.Seal(env, nonce, []byte("secret"), nil)
if _, err := unseal(key, env); !errors.Is(err, ErrCorrupt) {
t.Fatalf("err = %v, want ErrCorrupt", err)
}
}