Add keyring package for kernel-keyring-backed secret sealing
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user