Files
3x-ui/internal/crypto/nodetoken/keysource.go
T
n0ctal 1793a9b8b4 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>
2026-08-15 16:48:50 +02:00

116 lines
3.2 KiB
Go

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})
}