feat(nodes): opt-in encryption at rest for the outbound node API token (#6186)

* node: encrypt outbound bearer token at rest

* fix(nodes): keep bearer tokens encrypted throughout

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
This commit is contained in:
n0ctal
2026-08-15 19:48:50 +05:00
committed by GitHub
parent 8e7fb144ee
commit 1793a9b8b4
9 changed files with 982 additions and 15 deletions
+115
View File
@@ -0,0 +1,115 @@
package nodetoken
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
)
// KeySource loads a startup keyring from a protected file or environment.
// Keys are never accepted on the command line.
type KeySource interface {
Load() (*Keyring, error)
}
// keyFile identifies the active key and all base64-encoded rotation keys.
type keyFile struct {
Active string `json:"active"`
Keys map[string]string `json:"keys"`
}
func parseKeyring(active string, b64keys map[string]string) (*Keyring, error) {
if err := validateKeyID(active); err != nil {
return nil, fmt.Errorf("nodetoken: active key id: %w", err)
}
if active == "" {
return nil, errors.New("nodetoken: key source has no active key id")
}
if len(b64keys) == 0 {
return nil, errors.New("nodetoken: key source has no keys")
}
kr := &Keyring{ActiveID: active, Keys: make(map[string][keyLen]byte, len(b64keys))}
for id, b64 := range b64keys {
if err := validateKeyID(id); err != nil {
return nil, fmt.Errorf("nodetoken: key id %q: %w", id, err)
}
raw, err := decodeKey(b64)
if err != nil {
return nil, fmt.Errorf("nodetoken: key %q: %w", id, err)
}
kr.Keys[id] = raw
}
if _, ok := kr.Keys[active]; !ok {
return nil, fmt.Errorf("nodetoken: active key %q absent from keys", active)
}
return kr, nil
}
func validateKeyID(id string) error {
if id == "" {
return errors.New("must not be empty")
}
if strings.Contains(id, ":") {
return errors.New("must not contain ':'")
}
return nil
}
func decodeKey(b64 string) ([keyLen]byte, error) {
var out [keyLen]byte
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64))
if err != nil {
// tolerate url-safe / unpadded encodings too
if raw2, err2 := base64.RawStdEncoding.DecodeString(strings.TrimSpace(b64)); err2 == nil {
raw = raw2
} else {
return out, fmt.Errorf("base64 decode: %w", err)
}
}
if len(raw) != keyLen {
return out, fmt.Errorf("key must be %d bytes, got %d", keyLen, len(raw))
}
copy(out[:], raw)
return out, nil
}
// FileKeySource accepts only key files that are mode 0600 or stricter.
type FileKeySource struct {
Path string
}
func (f FileKeySource) Load() (*Keyring, error) {
info, err := os.Stat(f.Path)
if err != nil {
return nil, fmt.Errorf("nodetoken: stat key file %s: %w", f.Path, err)
}
if perm := info.Mode().Perm(); perm&0o077 != 0 {
return nil, fmt.Errorf("nodetoken: key file %s has insecure mode %#o (want 0600)", f.Path, perm)
}
data, err := os.ReadFile(f.Path)
if err != nil {
return nil, fmt.Errorf("nodetoken: read key file %s: %w", f.Path, err)
}
var kf keyFile
if err := json.Unmarshal(data, &kf); err != nil {
return nil, fmt.Errorf("nodetoken: parse key file %s: %w", f.Path, err)
}
return parseKeyring(kf.Active, kf.Keys)
}
// EnvKeySource reads a single base64 32-byte key from an environment variable.
// The key id is fixed ("env"); for multi-key rotation prefer a key file.
type EnvKeySource struct {
Var string
}
func (e EnvKeySource) Load() (*Keyring, error) {
v := strings.TrimSpace(os.Getenv(e.Var))
if v == "" {
return nil, fmt.Errorf("nodetoken: env %s is empty", e.Var)
}
return parseKeyring("env", map[string]string{"env": v})
}
+236
View File
@@ -0,0 +1,236 @@
// Package nodetoken encrypts replayable per-node bearer tokens at rest with
// row-bound AES-GCM and versioned key IDs.
package nodetoken
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"strings"
"sync"
)
// Mode is explicit so a missing key cannot silently downgrade encrypted
// deployments to plaintext.
type Mode int
const (
// ModeOff: legacy plaintext operation. Writes store plaintext; an encrypted
// value cannot be interpreted (no key) and is rejected rather than guessed.
ModeOff Mode = iota
// ModeMigration: key required. Reads accept plaintext OR ciphertext; writes
// always produce ciphertext. Used while migrating existing rows.
ModeMigration
// ModeRequired: key required (startup fails if it cannot load). Reads decrypt
// ciphertext (error on failure) and accept any still-unmigrated plaintext;
// writes always produce ciphertext.
ModeRequired
)
const (
encPrefix = "enc:"
encScheme = "enc:v1:"
keyLen = 32 // AES-256
nonceLen = 12 // GCM standard nonce
aadKeyFormat = "nodes/api_token/%d"
)
// ParseMode maps the NODE_TOKEN_ENCRYPTION env value to a Mode.
func ParseMode(s string) (Mode, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "off":
return ModeOff, nil
case "migration":
return ModeMigration, nil
case "required":
return ModeRequired, nil
default:
return ModeOff, fmt.Errorf("nodetoken: unknown NODE_TOKEN_ENCRYPTION %q (want off|migration|required)", s)
}
}
// Keyring holds the active write key and previous decryption keys.
type Keyring struct {
ActiveID string
Keys map[string][keyLen]byte
}
func (kr *Keyring) active() ([keyLen]byte, error) {
k, ok := kr.Keys[kr.ActiveID]
if !ok {
return [keyLen]byte{}, fmt.Errorf("nodetoken: active key %q not in keyring", kr.ActiveID)
}
return k, nil
}
// Codec encrypts/decrypts node tokens under a fixed policy and keyring.
type Codec struct {
mode Mode
ring *Keyring // nil only in ModeOff
}
// NewCodec requires an active key outside ModeOff.
func NewCodec(mode Mode, ring *Keyring) (*Codec, error) {
if mode == ModeOff {
return &Codec{mode: ModeOff}, nil
}
if ring == nil || len(ring.Keys) == 0 {
return nil, errors.New("nodetoken: encryption mode requires a key, but none was loaded")
}
if _, err := ring.active(); err != nil {
return nil, err
}
return &Codec{mode: mode, ring: ring}, nil
}
// Enabled reports whether the codec writes ciphertext (mode != off).
func (c *Codec) Enabled() bool { return c.mode != ModeOff }
func aad(nodeID int) []byte { return []byte(fmt.Sprintf(aadKeyFormat, nodeID)) }
// IsEncrypted reports whether a stored value is in this package's ciphertext form.
func IsEncrypted(stored string) bool { return strings.HasPrefix(stored, encPrefix) }
// Encrypt returns plaintext in ModeOff or row-bound enc:v1 ciphertext otherwise.
// Empty and already-valid encrypted values remain unchanged.
func (c *Codec) Encrypt(nodeID int, plaintext string) (string, error) {
if c.mode == ModeOff || plaintext == "" {
return plaintext, nil
}
if IsEncrypted(plaintext) {
// Validate it actually decrypts for this node; if so keep verbatim.
if _, err := c.Decrypt(nodeID, plaintext); err != nil {
return "", fmt.Errorf("nodetoken: refusing to store undecryptable ciphertext: %w", err)
}
return plaintext, nil
}
key, err := c.ring.active()
if err != nil {
return "", err
}
gcm, err := newGCM(key)
if err != nil {
return "", err
}
nonce := make([]byte, nonceLen)
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ct := gcm.Seal(nil, nonce, []byte(plaintext), aad(nodeID))
blob := append(nonce, ct...)
return encScheme + c.ring.ActiveID + ":" + base64.RawURLEncoding.EncodeToString(blob), nil
}
// Decrypt passes legacy plaintext through; enc: values must authenticate and
// are never reinterpreted as plaintext after an error.
func (c *Codec) Decrypt(nodeID int, stored string) (string, error) {
if c.mode == ModeOff {
return stored, nil
}
if !IsEncrypted(stored) {
return stored, nil
}
rest, ok := strings.CutPrefix(stored, encScheme)
if !ok {
return "", fmt.Errorf("nodetoken: unsupported ciphertext scheme in %q", firstN(stored, 12))
}
keyID, b64, ok := strings.Cut(rest, ":")
if !ok || keyID == "" {
return "", errors.New("nodetoken: malformed ciphertext (missing key id)")
}
if c.ring == nil {
return "", errors.New("nodetoken: encrypted token encountered but encryption is disabled (no key)")
}
key, ok := c.ring.Keys[keyID]
if !ok {
return "", fmt.Errorf("nodetoken: no key %q in keyring to decrypt token", keyID)
}
blob, err := base64.RawURLEncoding.DecodeString(b64)
if err != nil {
return "", fmt.Errorf("nodetoken: base64 decode: %w", err)
}
if len(blob) < nonceLen {
return "", errors.New("nodetoken: ciphertext too short")
}
gcm, err := newGCM(key)
if err != nil {
return "", err
}
pt, err := gcm.Open(nil, blob[:nonceLen], blob[nonceLen:], aad(nodeID))
if err != nil {
return "", fmt.Errorf("nodetoken: authentication failed for node %d: %w", nodeID, err)
}
return string(pt), nil
}
// ActiveKeyID returns the id new writes use (empty in ModeOff).
func (c *Codec) ActiveKeyID() string {
if c.ring == nil {
return ""
}
return c.ring.ActiveID
}
// EncryptedWithActive reports whether migration can skip a ciphertext row.
func (c *Codec) EncryptedWithActive(stored string) bool {
if c.ring == nil || !IsEncrypted(stored) {
return false
}
rest, ok := strings.CutPrefix(stored, encScheme)
if !ok {
return false
}
keyID, _, ok := strings.Cut(rest, ":")
return ok && keyID == c.ring.ActiveID
}
func newGCM(key [keyLen]byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key[:])
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func firstN(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
// --- package singleton, initialized once at startup ---
var (
mu sync.RWMutex
current *Codec
)
// Init installs the process-wide codec. Call once during startup after building
// the keyring; in ModeOff a nil keyring is fine.
func Init(c *Codec) {
mu.Lock()
defer mu.Unlock()
current = c
}
// get returns the installed codec, or a permissive ModeOff codec if Init was
// never called (e.g. unit tests / sqlite dev) so callers never nil-panic.
func get() *Codec {
mu.RLock()
c := current
mu.RUnlock()
if c == nil {
return &Codec{mode: ModeOff}
}
return c
}
// Encrypt/Decrypt/Enabled operate on the process-wide codec.
func Encrypt(nodeID int, plaintext string) (string, error) { return get().Encrypt(nodeID, plaintext) }
func Decrypt(nodeID int, stored string) (string, error) { return get().Decrypt(nodeID, stored) }
func Enabled() bool { return get().Enabled() }
func Active() *Codec { return get() }
+226
View File
@@ -0,0 +1,226 @@
package nodetoken
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func testRing(t *testing.T, activeID string, ids ...string) *Keyring {
t.Helper()
kr := &Keyring{ActiveID: activeID, Keys: map[string][keyLen]byte{}}
for _, id := range ids {
var k [keyLen]byte
for i := range k {
k[i] = byte(i) + id[len(id)-1] // deterministic and distinct for k1/k2
}
kr.Keys[id] = k
}
return kr
}
func TestRoundTrip(t *testing.T) {
c, err := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
if err != nil {
t.Fatal(err)
}
enc, err := c.Encrypt(7, "s3cret-token")
if err != nil {
t.Fatal(err)
}
if !IsEncrypted(enc) || !strings.HasPrefix(enc, "enc:v1:k1:") {
t.Fatalf("unexpected ciphertext form: %q", enc)
}
pt, err := c.Decrypt(7, enc)
if err != nil {
t.Fatal(err)
}
if pt != "s3cret-token" {
t.Fatalf("round-trip mismatch: %q", pt)
}
}
func TestAADBindsToNode(t *testing.T) {
c, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
enc, _ := c.Encrypt(7, "tok")
// Decrypting under a different node id must fail (ciphertext bound to row).
if _, err := c.Decrypt(8, enc); err == nil {
t.Fatal("expected AAD mismatch error decrypting under wrong node id")
}
}
func TestNonceIsRandom(t *testing.T) {
c, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
a, _ := c.Encrypt(1, "same")
b, _ := c.Encrypt(1, "same")
if a == b {
t.Fatal("two encryptions of the same value produced identical ciphertext (nonce reuse)")
}
}
func TestPlaintextPassThrough(t *testing.T) {
// ModeOff: encrypt is a no-op, decrypt returns plaintext.
c, _ := NewCodec(ModeOff, nil)
enc, err := c.Encrypt(1, "plain")
if err != nil || enc != "plain" {
t.Fatalf("off-mode encrypt should be no-op, got %q err=%v", enc, err)
}
// A legacy plaintext row decrypts (passes through) in any mode.
c2, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
if pt, err := c2.Decrypt(1, "legacy-plain"); err != nil || pt != "legacy-plain" {
t.Fatalf("legacy plaintext should pass through, got %q err=%v", pt, err)
}
}
func TestEncryptedNeverFallsBackToPlaintext(t *testing.T) {
c, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
enc, _ := c.Encrypt(1, "tok")
// Corrupt the ciphertext body — must error, never return raw bytes.
bad := enc[:len(enc)-2] + "AA"
if _, err := c.Decrypt(1, bad); err == nil {
t.Fatal("corrupted ciphertext must fail, not fall back to plaintext")
}
// Unknown key id must error.
c2, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
other := strings.Replace(enc, "enc:v1:k1:", "enc:v1:zz:", 1)
if _, err := c2.Decrypt(1, other); err == nil {
t.Fatal("unknown key id must fail")
}
}
func TestEncryptionMarkerPassesThroughWhenDisabled(t *testing.T) {
c, _ := NewCodec(ModeOff, nil)
stored := "enc:v1:not-ciphertext"
if got, err := c.Decrypt(1, stored); err != nil || got != stored {
t.Fatalf("off-mode changed a legacy token: got %q err=%v", got, err)
}
}
func TestParseKeyringRejectsDelimiterInKeyID(t *testing.T) {
key := base64.StdEncoding.EncodeToString(make([]byte, keyLen))
for _, tc := range []struct {
name, active string
keys map[string]string
}{
{"active delimiter", "region:k1", map[string]string{"region:k1": key}},
{"key delimiter", "k1", map[string]string{"k1": key, "old:k0": key}},
{"empty key", "k1", map[string]string{"k1": key, "": key}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := parseKeyring(tc.active, tc.keys); err == nil {
t.Fatal("invalid key id was accepted")
}
})
}
}
func TestEncryptRoundTripSafe(t *testing.T) {
// Re-submitting stored ciphertext (UI round-trip) must not double-encrypt.
c, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
enc, _ := c.Encrypt(5, "tok")
again, err := c.Encrypt(5, enc)
if err != nil {
t.Fatal(err)
}
if again != enc {
t.Fatal("re-encrypting stored ciphertext changed it (double-encrypt)")
}
if pt, _ := c.Decrypt(5, again); pt != "tok" {
t.Fatalf("round-trip-safe encrypt corrupted token: %q", pt)
}
}
func TestEmptyTokenNeverEncrypted(t *testing.T) {
c, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1"))
if v, _ := c.Encrypt(1, ""); v != "" {
t.Fatalf("empty token must stay empty, got %q", v)
}
}
func TestRotation(t *testing.T) {
// k2 active, k1 retained. Old-key value still decrypts; new writes use k2.
ring := testRing(t, "k2", "k1", "k2")
if ring.Keys["k1"] == ring.Keys["k2"] {
t.Fatal("rotation fixture keys k1 and k2 are identical")
}
c, _ := NewCodec(ModeRequired, ring)
// produce a k1 value via a codec whose active is k1
c1, _ := NewCodec(ModeRequired, testRing(t, "k1", "k1", "k2"))
old, _ := c1.Encrypt(3, "tok")
if pt, err := c.Decrypt(3, old); err != nil || pt != "tok" {
t.Fatalf("retained old key must decrypt, got %q err=%v", pt, err)
}
if c.EncryptedWithActive(old) {
t.Fatal("k1 value should not count as encrypted-with-active(k2)")
}
neu, _ := c.Encrypt(3, "tok")
if !c.EncryptedWithActive(neu) {
t.Fatal("new write should be encrypted with active key")
}
}
func TestNewCodecRequiresKey(t *testing.T) {
if _, err := NewCodec(ModeRequired, nil); err == nil {
t.Fatal("required mode without a key must fail (fail-closed)")
}
if _, err := NewCodec(ModeMigration, &Keyring{ActiveID: "x", Keys: nil}); err == nil {
t.Fatal("migration mode with empty keyring must fail")
}
}
func TestParseMode(t *testing.T) {
for in, want := range map[string]Mode{"": ModeOff, "off": ModeOff, "Migration": ModeMigration, "REQUIRED": ModeRequired} {
if m, err := ParseMode(in); err != nil || m != want {
t.Fatalf("ParseMode(%q)=%v err=%v, want %v", in, m, err, want)
}
}
if _, err := ParseMode("bogus"); err == nil {
t.Fatal("unknown mode must error")
}
}
func TestFileKeySourceRejectsLoosePerms(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "k.json")
key := make([]byte, keyLen)
body, _ := json.Marshal(keyFile{Active: "k1", Keys: map[string]string{"k1": base64.StdEncoding.EncodeToString(key)}})
if err := os.WriteFile(p, body, 0o644); err != nil {
t.Fatal(err)
}
if _, err := (FileKeySource{Path: p}).Load(); err == nil {
t.Fatal("0644 key file must be rejected")
}
if err := os.Chmod(p, 0o600); err != nil {
t.Fatal(err)
}
kr, err := (FileKeySource{Path: p}).Load()
if err != nil {
t.Fatalf("0600 key file should load: %v", err)
}
if kr.ActiveID != "k1" || len(kr.Keys) != 1 {
t.Fatalf("unexpected keyring %+v", kr)
}
}
func TestEnvKeySource(t *testing.T) {
key := make([]byte, keyLen)
for i := range key {
key[i] = byte(i)
}
t.Setenv("XUI_NODE_TOKEN_KEY_TEST", base64.StdEncoding.EncodeToString(key))
kr, err := (EnvKeySource{Var: "XUI_NODE_TOKEN_KEY_TEST"}).Load()
if err != nil {
t.Fatal(err)
}
if kr.ActiveID != "env" {
t.Fatalf("env key id should be 'env', got %q", kr.ActiveID)
}
c, _ := NewCodec(ModeRequired, kr)
enc, _ := c.Encrypt(1, "x")
if pt, _ := c.Decrypt(1, enc); pt != "x" {
t.Fatal("env-sourced key failed round trip")
}
}