From e4604707a606e329429c7e220e7a449926f0ee82 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 23 Oct 2026 21:56:33 +1100 Subject: [PATCH 1/4] Add keyring package for kernel-keyring-backed secret sealing --- README.md | 20 +++++ go.mod | 2 +- keyring/keyring.go | 86 ++++++++++++++++++++++ keyring/keyring_linux.go | 127 ++++++++++++++++++++++++++++++++ keyring/keyring_linux_test.go | 135 ++++++++++++++++++++++++++++++++++ keyring/keyring_other.go | 22 ++++++ keyring/keyring_test.go | 71 ++++++++++++++++++ 7 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 keyring/keyring.go create mode 100644 keyring/keyring_linux.go create mode 100644 keyring/keyring_linux_test.go create mode 100644 keyring/keyring_other.go create mode 100644 keyring/keyring_test.go diff --git a/README.md b/README.md index a9fe51f..fea300f 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/go.mod b/go.mod index 05682a4..7973ba9 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/keyring/keyring.go b/keyring/keyring.go new file mode 100644 index 0000000..cce6522 --- /dev/null +++ b/keyring/keyring.go @@ -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) +} diff --git a/keyring/keyring_linux.go b/keyring/keyring_linux.go new file mode 100644 index 0000000..cca08e1 --- /dev/null +++ b/keyring/keyring_linux.go @@ -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) +} diff --git a/keyring/keyring_linux_test.go b/keyring/keyring_linux_test.go new file mode 100644 index 0000000..0d9fd72 --- /dev/null +++ b/keyring/keyring_linux_test.go @@ -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") + } +} diff --git a/keyring/keyring_other.go b/keyring/keyring_other.go new file mode 100644 index 0000000..6779f29 --- /dev/null +++ b/keyring/keyring_other.go @@ -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 } diff --git a/keyring/keyring_test.go b/keyring/keyring_test.go new file mode 100644 index 0000000..bbf2bc5 --- /dev/null +++ b/keyring/keyring_test.go @@ -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) + } +} -- 2.47.3 From b636617f1bf921e3aa3061545849bf5939574cda Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 23 Oct 2026 22:24:38 +1100 Subject: [PATCH 2/4] Link keyring keys exclusively so concurrent first users share one key --- Makefile | 4 +- keyring/keyring.go | 6 -- keyring/keyring_linux.go | 108 +++++++++++++++++++++++++--------- keyring/keyring_linux_test.go | 54 ++++++++++++++++- keyring/keyring_other.go | 3 + keyring/keyring_test.go | 19 ++++++ 6 files changed, 158 insertions(+), 36 deletions(-) diff --git a/Makefile b/Makefile index 37ad41d..dfebb48 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,9 @@ COVER_MIN := 90 # Packages excluded from the coverage gate: pgtest exists only to be imported # from other repos' _test.go files, and is itself exercised by the # container-backed integration tests, which the gate deliberately does not run. -COVER_EXCLUDE := /pg/pgtest/ +# keyring_linux.go is the keyctl syscall layer; its tests skip wherever seccomp +# blocks keyctl (CI containers), so it cannot count towards the gate. +COVER_EXCLUDE := /pg/pgtest/|/keyring/keyring_linux.go .PHONY: all build test test-all cover vet fmt lint tidy clean pre-commit patch minor major _tag diff --git a/keyring/keyring.go b/keyring/keyring.go index cce6522..4c58f8a 100644 --- a/keyring/keyring.go +++ b/keyring/keyring.go @@ -29,12 +29,6 @@ const ( 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] diff --git a/keyring/keyring_linux.go b/keyring/keyring_linux.go index cca08e1..511e387 100644 --- a/keyring/keyring_linux.go +++ b/keyring/keyring_linux.go @@ -2,6 +2,7 @@ package keyring import ( "crypto/rand" + "encoding/binary" "errors" "fmt" "runtime" @@ -15,6 +16,14 @@ import ( // 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. @@ -41,15 +50,15 @@ func (k *Keyring) Seal(plaintext []byte) ([]byte, error) { // 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() + _, key, err := k.lookup() 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. +// 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 { @@ -61,65 +70,108 @@ func (k *Keyring) Forget() error { 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 } -func (k *Keyring) get() ([]byte, error) { +// 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 nil, fmt.Errorf("%w: %v", ErrKeyUnavailable, err) + return 0, nil, fmt.Errorf("%w: %v", ErrKeyUnavailable, err) } - return nil, fmt.Errorf("keyring: search %q: %w", k.desc, 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 nil, fmt.Errorf("%w: %v", ErrKeyUnavailable, err) + return id, nil, fmt.Errorf("%w: %v", ErrKeyUnavailable, err) } - return nil, fmt.Errorf("keyring: read %q: %w", k.desc, err) + return id, 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 id, nil, fmt.Errorf("%w: key %q is %d bytes", ErrKeyUnavailable, k.desc, n) } - return buf[:n], nil + 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) { - key, err := k.get() - if !errors.Is(err, ErrKeyUnavailable) { - return key, err + 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 { + purgeDead() + } + key, err = k.create() + if !errors.Is(err, unix.EEXIST) { + 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() + return nil, fmt.Errorf("keyring: %q: key creation kept racing", k.desc) } -// 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 { +// 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 err + 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 fmt.Errorf("keyring: add %q: %w", k.desc, err) + 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 fmt.Errorf("keyring: setperm %q: %w", k.desc, err) + return nil, 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) + 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 nil + return key, nil +} + +// purgeDead unlinks revoked and expired keys from the user keyring. Their +// description can no longer be read, so all of them go; they are inert. +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 { diff --git a/keyring/keyring_linux_test.go b/keyring/keyring_linux_test.go index 0d9fd72..aa8bd5d 100644 --- a/keyring/keyring_linux_test.go +++ b/keyring/keyring_linux_test.go @@ -7,6 +7,7 @@ import ( "os" "runtime" "strings" + "sync" "testing" "golang.org/x/sys/unix" @@ -123,11 +124,62 @@ func TestKernelWrongSizedKeyIsReplaced(t *testing.T) { if _, err := Open(k.desc); err != nil { t.Fatal(err) } - if _, err := k.get(); err != nil { + 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 blocked + 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) + } +} + func TestOpenEmptyDescription(t *testing.T) { if _, err := Open(""); err == nil { t.Fatal("Open(\"\") succeeded") diff --git a/keyring/keyring_other.go b/keyring/keyring_other.go index 6779f29..597133b 100644 --- a/keyring/keyring_other.go +++ b/keyring/keyring_other.go @@ -9,6 +9,9 @@ import ( 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 } diff --git a/keyring/keyring_test.go b/keyring/keyring_test.go index bbf2bc5..627fadf 100644 --- a/keyring/keyring_test.go +++ b/keyring/keyring_test.go @@ -2,6 +2,7 @@ package keyring import ( "bytes" + "crypto/rand" "errors" "testing" ) @@ -69,3 +70,21 @@ func TestBadKeyLength(t *testing.T) { 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) + } +} -- 2.47.3 From 319ae21bc5379e83d5b16fe5e34fc03522b62740 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 23 Oct 2026 22:30:59 +1100 Subject: [PATCH 3/4] Purge dead keys only when ours is dead; gate keyring_linux.go coverage --- Makefile | 4 +--- keyring/keyring_linux.go | 9 +++++---- keyring/keyring_linux_test.go | 27 ++++++++++++++++++++++++++- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index dfebb48..37ad41d 100644 --- a/Makefile +++ b/Makefile @@ -8,9 +8,7 @@ COVER_MIN := 90 # Packages excluded from the coverage gate: pgtest exists only to be imported # from other repos' _test.go files, and is itself exercised by the # container-backed integration tests, which the gate deliberately does not run. -# keyring_linux.go is the keyctl syscall layer; its tests skip wherever seccomp -# blocks keyctl (CI containers), so it cannot count towards the gate. -COVER_EXCLUDE := /pg/pgtest/|/keyring/keyring_linux.go +COVER_EXCLUDE := /pg/pgtest/ .PHONY: all build test test-all cover vet fmt lint tidy clean pre-commit patch minor major _tag diff --git a/keyring/keyring_linux.go b/keyring/keyring_linux.go index 511e387..c535796 100644 --- a/keyring/keyring_linux.go +++ b/keyring/keyring_linux.go @@ -80,7 +80,7 @@ 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: %v", ErrKeyUnavailable, err) + return 0, nil, fmt.Errorf("%w: %w", ErrKeyUnavailable, err) } return 0, nil, fmt.Errorf("keyring: search %q: %w", k.desc, err) } @@ -109,7 +109,7 @@ func (k *Keyring) getOrCreate() ([]byte, error) { // An unusable key of our description blocks the exclusive link. if id != 0 { unlink(id) - } else { + } else if errors.Is(err, unix.EKEYREVOKED) || errors.Is(err, unix.EKEYEXPIRED) { purgeDead() } key, err = k.create() @@ -147,8 +147,9 @@ func (k *Keyring) create() ([]byte, error) { return key, nil } -// purgeDead unlinks revoked and expired keys from the user keyring. Their -// description can no longer be read, so all of them go; they are inert. +// 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 { diff --git a/keyring/keyring_linux_test.go b/keyring/keyring_linux_test.go index aa8bd5d..7f2bbb5 100644 --- a/keyring/keyring_linux_test.go +++ b/keyring/keyring_linux_test.go @@ -114,7 +114,10 @@ func TestKernelForget(t *testing.T) { func TestKernelWrongSizedKeyIsReplaced(t *testing.T) { k := open(t) - sealed, _ := k.Seal([]byte("secret")) + 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) } @@ -180,6 +183,28 @@ func TestKernelExternallyRevokedKeyIsReplaced(t *testing.T) { } } +// 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 blocked + id, err := unix.AddKey("user", fmt.Sprintf("golib-test-foreign:%s:%d", t.Name(), os.Getpid()), []byte("x"), unix.KEY_SPEC_USER_KEYRING) + 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") -- 2.47.3 From 581993fe834c5e5f3b611a64afbfc79c41bda91d Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Fri, 23 Oct 2026 22:46:05 +1100 Subject: [PATCH 4/4] Fail kernel keyring tests on permission errors; possess the foreign test key --- keyring/keyring_linux_test.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/keyring/keyring_linux_test.go b/keyring/keyring_linux_test.go index 7f2bbb5..d50b03d 100644 --- a/keyring/keyring_linux_test.go +++ b/keyring/keyring_linux_test.go @@ -13,12 +13,12 @@ import ( "golang.org/x/sys/unix" ) -// open opens a test-unique keyring, skipping where the container's seccomp -// profile blocks the keyctl syscalls. +// 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) || errors.Is(err, unix.EPERM) || errors.Is(err, unix.EACCES) { + if errors.Is(err, unix.ENOSYS) && os.Getenv("CI") == "" { t.Skipf("kernel keyring unavailable: %v", err) } if err != nil { @@ -134,7 +134,7 @@ func TestKernelWrongSizedKeyIsReplaced(t *testing.T) { // 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 blocked + 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) @@ -185,8 +185,12 @@ func TestKernelExternallyRevokedKeyIsReplaced(t *testing.T) { // 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 blocked - id, err := unix.AddKey("user", fmt.Sprintf("golib-test-foreign:%s:%d", t.Name(), os.Getpid()), []byte("x"), unix.KEY_SPEC_USER_KEYRING) + 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) } -- 2.47.3