Add keyring package for kernel-keyring-backed secret sealing
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

This commit is contained in:
2026-10-23 21:56:33 +11:00
parent da23ba9f4a
commit e4604707a6
7 changed files with 462 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. |
### pg
@@ -184,6 +185,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
)
require (
@@ -61,7 +62,6 @@ 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
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+86
View File
@@ -0,0 +1,86 @@
// 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
)
// Keyring seals and unseals data with the kernel-held key named by its
// description.
type Keyring struct {
desc string
}
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)
}
+127
View File
@@ -0,0 +1,127 @@
package keyring
import (
"crypto/rand"
"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
// 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.get()
if err != nil {
return nil, err
}
return unseal(key, sealed)
}
// Forget revokes the key, 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)
}
return nil
}
func (k *Keyring) get() ([]byte, error) {
id, err := unix.KeyctlSearch(unix.KEY_SPEC_USER_KEYRING, "user", k.desc, 0)
if err != nil {
if missing(err) {
return nil, fmt.Errorf("%w: %v", ErrKeyUnavailable, err)
}
return 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 nil, fmt.Errorf("%w: %v", ErrKeyUnavailable, err)
}
return nil, fmt.Errorf("keyring: read %q: %w", k.desc, err)
}
if n != keySize {
return nil, fmt.Errorf("%w: key %q is %d bytes", ErrKeyUnavailable, k.desc, n)
}
return buf[:n], nil
}
func (k *Keyring) getOrCreate() ([]byte, error) {
key, err := k.get()
if !errors.Is(err, ErrKeyUnavailable) {
return key, err
}
if err := k.create(); err != nil {
return nil, err
}
// Re-read so concurrent creators converge on whichever key was linked last.
return k.get()
}
// create adds the key to this thread's keyring, which is always possessed, so
// setperm succeeds; linking it into the user keyring then replaces any stale
// key of the same description.
func (k *Keyring) create() error {
key := make([]byte, keySize)
if _, err := rand.Read(key); err != nil {
return err
}
runtime.LockOSThread()
defer runtime.UnlockOSThread()
id, err := unix.AddKey("user", k.desc, key, unix.KEY_SPEC_THREAD_KEYRING)
if err != nil {
return 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 fmt.Errorf("keyring: setperm %q: %w", k.desc, err)
}
if _, err := unix.KeyctlInt(unix.KEYCTL_LINK, id, unix.KEY_SPEC_USER_KEYRING, 0, 0); err != nil {
return fmt.Errorf("keyring: link %q: %w", k.desc, err)
}
return nil
}
func missing(err error) bool {
return errors.Is(err, unix.ENOKEY) || errors.Is(err, unix.EKEYREVOKED) || errors.Is(err, unix.EKEYEXPIRED)
}
+135
View File
@@ -0,0 +1,135 @@
package keyring
import (
"bytes"
"errors"
"fmt"
"os"
"runtime"
"strings"
"testing"
"golang.org/x/sys/unix"
)
// open opens a test-unique keyring, skipping where the container's seccomp
// profile blocks the keyctl syscalls.
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) || errors.Is(err, unix.EPERM) || errors.Is(err, unix.EACCES) {
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, _ := k.Seal([]byte("secret"))
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.get(); err != nil {
t.Fatalf("key not replaced: %v", err)
}
}
func TestOpenEmptyDescription(t *testing.T) {
if _, err := Open(""); err == nil {
t.Fatal("Open(\"\") succeeded")
}
}
+22
View File
@@ -0,0 +1,22 @@
//go:build !linux
package keyring
import (
"errors"
"fmt"
)
var errUnsupported = fmt.Errorf("keyring: kernel keyring requires linux: %w", errors.ErrUnsupported)
// 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 }
+71
View File
@@ -0,0 +1,71 @@
package keyring
import (
"bytes"
"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)
}
}