mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 00:01:02 +00:00
1793a9b8b4
* node: encrypt outbound bearer token at rest * fix(nodes): keep bearer tokens encrypted throughout --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
237 lines
6.7 KiB
Go
237 lines
6.7 KiB
Go
// 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() }
|