mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-02 16:37:14 +00:00
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:
@@ -193,6 +193,27 @@ func GetDBDSN() string {
|
|||||||
return strings.TrimSpace(os.Getenv("XUI_DB_DSN"))
|
return strings.TrimSpace(os.Getenv("XUI_DB_DSN"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNodeTokenEncryptionMode returns off, migration, or required. Explicit
|
||||||
|
// policy prevents a missing key from silently downgrading encrypted storage.
|
||||||
|
func GetNodeTokenEncryptionMode() string {
|
||||||
|
return strings.TrimSpace(os.Getenv("NODE_TOKEN_ENCRYPTION"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeTokenKeyFile returns the mode-0600 keyring path, configurable through
|
||||||
|
// XUI_NODE_TOKEN_KEY_FILE.
|
||||||
|
func GetNodeTokenKeyFile() string {
|
||||||
|
if p := strings.TrimSpace(os.Getenv("XUI_NODE_TOKEN_KEY_FILE")); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return "/etc/x-ui/node_token_key.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeTokenKeyEnv returns the name of the env var holding a single base64
|
||||||
|
// 32-byte node-token key (secondary to the key file). Empty value => unused.
|
||||||
|
func GetNodeTokenKeyEnv() string {
|
||||||
|
return "XUI_NODE_TOKEN_KEY"
|
||||||
|
}
|
||||||
|
|
||||||
// GetEnvFilePaths returns the candidate service environment file paths (the file
|
// GetEnvFilePaths returns the candidate service environment file paths (the file
|
||||||
// systemd loads via EnvironmentFile) across the supported distro families.
|
// systemd loads via EnvironmentFile) across the supported distro families.
|
||||||
func GetEnvFilePaths() []string {
|
func GetEnvFilePaths() []string {
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||||
@@ -229,7 +230,11 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if r.node.ApiToken != "" {
|
if r.node.ApiToken != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+r.node.ApiToken)
|
token, err := nodetoken.Decrypt(r.node.Id, r.node.ApiToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypt node token: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
}
|
}
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
if contentType != "" {
|
if contentType != "" {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
@@ -100,6 +101,23 @@ func (s *NodeService) FetchCertFingerprint(ctx context.Context, n *model.Node) (
|
|||||||
return base64.StdEncoding.EncodeToString(sum[:]), nil
|
return base64.StdEncoding.EncodeToString(sum[:]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decryptToken exposes plaintext to callers. Failures blank only this token
|
||||||
|
// and surface through LastError instead of dropping the node row.
|
||||||
|
func decryptToken(n *model.Node) {
|
||||||
|
if n == nil || n.ApiToken == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pt, err := nodetoken.Decrypt(n.Id, n.ApiToken)
|
||||||
|
if err != nil {
|
||||||
|
n.ApiToken = ""
|
||||||
|
if n.LastError == "" {
|
||||||
|
n.LastError = "token decrypt failed: " + err.Error()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n.ApiToken = pt
|
||||||
|
}
|
||||||
|
|
||||||
func (s *NodeService) GetAll() ([]*model.Node, error) {
|
func (s *NodeService) GetAll() ([]*model.Node, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var nodes []*model.Node
|
var nodes []*model.Node
|
||||||
@@ -107,6 +125,9 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
|
|||||||
if err != nil || len(nodes) == 0 {
|
if err != nil || len(nodes) == 0 {
|
||||||
return nodes, err
|
return nodes, err
|
||||||
}
|
}
|
||||||
|
for _, n := range nodes {
|
||||||
|
decryptToken(n)
|
||||||
|
}
|
||||||
|
|
||||||
type inboundRow struct {
|
type inboundRow struct {
|
||||||
Id int
|
Id int
|
||||||
@@ -333,6 +354,7 @@ func (s *NodeService) GetById(id int) (*model.Node, error) {
|
|||||||
if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil {
|
if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
decryptToken(n)
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +451,29 @@ func (s *NodeService) Create(n *model.Node) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
return db.Create(n).Error
|
if !nodetoken.Enabled() {
|
||||||
|
return db.Create(n).Error
|
||||||
|
}
|
||||||
|
plaintext := n.ApiToken
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// The id-bound ciphertext can only be produced after insertion. Never put
|
||||||
|
// plaintext in the initial tuple: PostgreSQL WAL would retain it.
|
||||||
|
n.ApiToken = ""
|
||||||
|
defer func() { n.ApiToken = plaintext }()
|
||||||
|
if err := tx.Create(n).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
enc, err := nodetoken.Encrypt(n.Id, plaintext)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if enc == plaintext {
|
||||||
|
return nil // off-mode / empty token: nothing to rewrite
|
||||||
|
}
|
||||||
|
// DB column gets ciphertext; the in-memory struct keeps plaintext so the
|
||||||
|
// create response echoes the same usable value GetById would return.
|
||||||
|
return tx.Model(model.Node{}).Where("id = ?", n.Id).Update("api_token", enc).Error
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *NodeService) CreateFromRequest(req *NodeMutationRequest) (*NodeView, error) {
|
func (s *NodeService) CreateFromRequest(req *NodeMutationRequest) (*NodeView, error) {
|
||||||
@@ -456,6 +500,15 @@ func (s *NodeService) Update(id int, in *model.Node) error {
|
|||||||
if err := db.Where("id = ?", id).First(existing).Error; err != nil {
|
if err := db.Where("id = ?", id).First(existing).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Blank means keep the hidden stored token; non-blank values are encrypted.
|
||||||
|
apiToken := existing.ApiToken
|
||||||
|
if in.ApiToken != "" {
|
||||||
|
enc, eerr := nodetoken.Encrypt(id, in.ApiToken)
|
||||||
|
if eerr != nil {
|
||||||
|
return eerr
|
||||||
|
}
|
||||||
|
apiToken = enc
|
||||||
|
}
|
||||||
updates := map[string]any{
|
updates := map[string]any{
|
||||||
"name": in.Name,
|
"name": in.Name,
|
||||||
"remark": in.Remark,
|
"remark": in.Remark,
|
||||||
@@ -463,7 +516,7 @@ func (s *NodeService) Update(id int, in *model.Node) error {
|
|||||||
"address": in.Address,
|
"address": in.Address,
|
||||||
"port": in.Port,
|
"port": in.Port,
|
||||||
"base_path": in.BasePath,
|
"base_path": in.BasePath,
|
||||||
"api_token": in.ApiToken,
|
"api_token": apiToken,
|
||||||
"enable": in.Enable,
|
"enable": in.Enable,
|
||||||
"allow_private_address": in.AllowPrivateAddress,
|
"allow_private_address": in.AllowPrivateAddress,
|
||||||
"tls_verify_mode": in.TlsVerifyMode,
|
"tls_verify_mode": in.TlsVerifyMode,
|
||||||
@@ -508,7 +561,10 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
|
|||||||
case req.ClearApiToken:
|
case req.ClearApiToken:
|
||||||
apiToken = ""
|
apiToken = ""
|
||||||
case req.ApiToken != nil:
|
case req.ApiToken != nil:
|
||||||
apiToken = *req.ApiToken
|
apiToken, err = nodetoken.Encrypt(id, *req.ApiToken)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" {
|
if apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" {
|
||||||
return common.NewError("apiToken is required unless mtls is enabled")
|
return common.NewError("apiToken is required unless mtls is enabled")
|
||||||
@@ -529,12 +585,14 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
|
|||||||
"inbound_tags": string(inboundTagsJSON),
|
"inbound_tags": string(inboundTagsJSON),
|
||||||
"outbound_tag": in.OutboundTag,
|
"outbound_tag": in.OutboundTag,
|
||||||
}
|
}
|
||||||
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.MarkNodeDirtyTx(tx, id)
|
||||||
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if dErr := s.MarkNodeDirty(id); dErr != nil {
|
|
||||||
logger.Warning("mark node dirty after update failed:", dErr)
|
|
||||||
}
|
|
||||||
if mgr := runtime.GetManager(); mgr != nil {
|
if mgr := runtime.GetManager(); mgr != nil {
|
||||||
mgr.InvalidateNode(id)
|
mgr.InvalidateNode(id)
|
||||||
}
|
}
|
||||||
@@ -590,6 +648,53 @@ func (s *NodeService) NodeFromRequestForCertificate(req *NodeMutationRequest) (*
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MigrateNodeTokensToActiveKey uses compare-and-swap to avoid clobbering live
|
||||||
|
// changes. Current-key rows are skipped; changed and skipped counts are returned.
|
||||||
|
func (s *NodeService) MigrateNodeTokensToActiveKey() (int, int, error) {
|
||||||
|
codec := nodetoken.Active()
|
||||||
|
if !codec.Enabled() {
|
||||||
|
return 0, 0, errors.New("node-token encryption is off; set NODE_TOKEN_ENCRYPTION=migration|required and a key first")
|
||||||
|
}
|
||||||
|
db := database.GetDB()
|
||||||
|
var nodes []*model.Node
|
||||||
|
if err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error; err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
changed, skipped := 0, 0
|
||||||
|
for _, n := range nodes {
|
||||||
|
old := n.ApiToken
|
||||||
|
if old == "" {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if codec.EncryptedWithActive(old) {
|
||||||
|
if _, err := codec.Decrypt(n.Id, old); err != nil {
|
||||||
|
return changed, skipped, fmt.Errorf("node %d validate active ciphertext: %w", n.Id, err)
|
||||||
|
}
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
plain, err := codec.Decrypt(n.Id, old) // plaintext passes through; old-key ciphertext is decrypted
|
||||||
|
if err != nil {
|
||||||
|
return changed, skipped, fmt.Errorf("node %d decrypt: %w", n.Id, err)
|
||||||
|
}
|
||||||
|
enc, err := codec.Encrypt(n.Id, plain)
|
||||||
|
if err != nil {
|
||||||
|
return changed, skipped, fmt.Errorf("node %d encrypt: %w", n.Id, err)
|
||||||
|
}
|
||||||
|
res := db.Model(model.Node{}).Where("id = ? AND api_token = ?", n.Id, old).Update("api_token", enc)
|
||||||
|
if res.Error != nil {
|
||||||
|
return changed, skipped, res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 1 {
|
||||||
|
changed++
|
||||||
|
} else {
|
||||||
|
skipped++ // raced with a live update; a later run handles it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed, skipped, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node) ([]runtime.RemoteInboundOption, error) {
|
func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node) ([]runtime.RemoteInboundOption, error) {
|
||||||
if err := s.normalize(n); err != nil {
|
if err := s.normalize(n); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1128,7 +1233,12 @@ func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string)
|
|||||||
return patch, err
|
return patch, err
|
||||||
}
|
}
|
||||||
if n.ApiToken != "" {
|
if n.ApiToken != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+n.ApiToken)
|
token, derr := nodetoken.Decrypt(n.Id, n.ApiToken)
|
||||||
|
if derr != nil {
|
||||||
|
patch.LastError = derr.Error()
|
||||||
|
return patch, derr
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
}
|
}
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// enableNodeTokenEncryption installs a test keyring and restores off mode so
|
||||||
|
// the package-global codec cannot leak between tests.
|
||||||
|
func enableNodeTokenEncryption(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
var k [32]byte
|
||||||
|
for i := range k {
|
||||||
|
k[i] = byte(i + 1)
|
||||||
|
}
|
||||||
|
ring := &nodetoken.Keyring{ActiveID: "t1", Keys: map[string][32]byte{"t1": k}}
|
||||||
|
codec, err := nodetoken.NewCodec(nodetoken.ModeRequired, ring)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new codec: %v", err)
|
||||||
|
}
|
||||||
|
nodetoken.Init(codec)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
off, _ := nodetoken.NewCodec(nodetoken.ModeOff, nil)
|
||||||
|
nodetoken.Init(off)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNodeToken_CreateNeverInsertsPlaintextTuple(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
enableNodeTokenEncryption(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
const callback = "test:no-plaintext-node-insert"
|
||||||
|
if err := db.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) {
|
||||||
|
if node, ok := tx.Statement.Dest.(*model.Node); ok && node.ApiToken != "" {
|
||||||
|
tx.AddError(errors.New("plaintext token reached node INSERT"))
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("register callback: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
|
||||||
|
|
||||||
|
n := &model.Node{Name: "no-plain", Address: "127.0.0.1", Port: 2096, ApiToken: "secret", Enable: true}
|
||||||
|
if err := (&NodeService{}).Create(n); err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
if n.ApiToken != "secret" {
|
||||||
|
t.Fatalf("in-memory token = %q, want plaintext response value", n.ApiToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rawStoredToken(t *testing.T, id int) string {
|
||||||
|
t.Helper()
|
||||||
|
var n model.Node
|
||||||
|
if err := database.GetDB().Model(model.Node{}).Where("id = ?", id).First(&n).Error; err != nil {
|
||||||
|
t.Fatalf("raw load: %v", err)
|
||||||
|
}
|
||||||
|
return n.ApiToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stores the token encrypted at rest; GetById returns it decrypted.
|
||||||
|
func TestNodeToken_EncryptedAtRest_PlaintextInMemory(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
enableNodeTokenEncryption(t)
|
||||||
|
svc := &NodeService{}
|
||||||
|
|
||||||
|
n := &model.Node{Name: "enc1", Address: "127.0.0.1", Port: 2096, ApiToken: "super-secret", Enable: true}
|
||||||
|
if err := svc.Create(n); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored := rawStoredToken(t, n.Id)
|
||||||
|
if !nodetoken.IsEncrypted(stored) {
|
||||||
|
t.Fatalf("token at rest is not encrypted: %q", stored)
|
||||||
|
}
|
||||||
|
if strings.Contains(stored, "super-secret") {
|
||||||
|
t.Fatalf("plaintext leaked into stored column: %q", stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := svc.GetById(n.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.ApiToken != "super-secret" {
|
||||||
|
t.Fatalf("GetById should return plaintext, got %q", got.ApiToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A blank token on Update keeps the stored one (the UI doesn't echo secrets).
|
||||||
|
func TestNodeToken_UpdateBlankKeepsExisting(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
enableNodeTokenEncryption(t)
|
||||||
|
svc := &NodeService{}
|
||||||
|
|
||||||
|
n := &model.Node{Name: "enc2", Address: "127.0.0.1", Port: 2096, ApiToken: "keep-me", Enable: true}
|
||||||
|
if err := svc.Create(n); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
before := rawStoredToken(t, n.Id)
|
||||||
|
|
||||||
|
// Update with empty token must not wipe or change the stored ciphertext.
|
||||||
|
upd := &model.Node{Name: "enc2-renamed", Address: "127.0.0.1", Port: 2096, ApiToken: "", Enable: true}
|
||||||
|
if err := svc.Update(n.Id, upd); err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
if after := rawStoredToken(t, n.Id); after != before {
|
||||||
|
t.Fatalf("blank-token update changed stored token: %q -> %q", before, after)
|
||||||
|
}
|
||||||
|
got, _ := svc.GetById(n.Id)
|
||||||
|
if got.ApiToken != "keep-me" {
|
||||||
|
t.Fatalf("token lost after blank update, got %q", got.ApiToken)
|
||||||
|
}
|
||||||
|
if got.Name != "enc2-renamed" {
|
||||||
|
t.Fatalf("other fields should still update, got name %q", got.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The migration re-encrypts a legacy plaintext row under the active key (CAS).
|
||||||
|
func TestNodeToken_MigratePlaintextRows(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
// Insert a legacy plaintext row directly (encryption off at insert time).
|
||||||
|
db := database.GetDB()
|
||||||
|
legacy := &model.Node{Name: "legacy", Address: "127.0.0.1", Port: 2096, ApiToken: "legacy-plain", Enable: true}
|
||||||
|
if err := db.Create(legacy).Error; err != nil {
|
||||||
|
t.Fatalf("create legacy: %v", err)
|
||||||
|
}
|
||||||
|
if rawStoredToken(t, legacy.Id) != "legacy-plain" {
|
||||||
|
t.Fatal("precondition: legacy row should be plaintext")
|
||||||
|
}
|
||||||
|
|
||||||
|
enableNodeTokenEncryption(t)
|
||||||
|
changed, _, err := (&NodeService{}).MigrateNodeTokensToActiveKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
if changed != 1 {
|
||||||
|
t.Fatalf("expected 1 row re-encrypted, got %d", changed)
|
||||||
|
}
|
||||||
|
if stored := rawStoredToken(t, legacy.Id); !nodetoken.IsEncrypted(stored) {
|
||||||
|
t.Fatalf("legacy row not encrypted after migration: %q", stored)
|
||||||
|
}
|
||||||
|
got, _ := (&NodeService{}).GetById(legacy.Id)
|
||||||
|
if got.ApiToken != "legacy-plain" {
|
||||||
|
t.Fatalf("migrated token no longer decrypts to original: %q", got.ApiToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: a second run changes nothing.
|
||||||
|
changed2, _, _ := (&NodeService{}).MigrateNodeTokensToActiveKey()
|
||||||
|
if changed2 != 0 {
|
||||||
|
t.Fatalf("second migration should be a no-op, changed %d", changed2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNodeToken_MigrationRejectsCorruptActiveCiphertext(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
enableNodeTokenEncryption(t)
|
||||||
|
n := &model.Node{Name: "corrupt", Address: "127.0.0.1", Port: 2096, ApiToken: "secret", Enable: true}
|
||||||
|
if err := (&NodeService{}).Create(n); err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
stored := rawStoredToken(t, n.Id)
|
||||||
|
body := strings.LastIndexByte(stored, ':') + 1
|
||||||
|
replacement := byte('A')
|
||||||
|
if stored[body] == replacement {
|
||||||
|
replacement = 'B'
|
||||||
|
}
|
||||||
|
corrupt := stored[:body] + string(replacement) + stored[body+1:]
|
||||||
|
if err := database.GetDB().Model(&model.Node{}).Where("id = ?", n.Id).Update("api_token", corrupt).Error; err != nil {
|
||||||
|
t.Fatalf("corrupt row: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := (&NodeService{}).MigrateNodeTokensToActiveKey(); err == nil {
|
||||||
|
t.Fatal("migration trusted a corrupt active-key ciphertext")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
_ "unsafe"
|
_ "unsafe"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/sub"
|
"github.com/mhsanaei/3x-ui/v3/internal/sub"
|
||||||
@@ -35,6 +36,35 @@ import (
|
|||||||
// cannot accumulate admin-equivalent credentials that are never revoked.
|
// cannot accumulate admin-equivalent credentials that are never revoked.
|
||||||
const cliFallbackTokenName = "cli-fallback"
|
const cliFallbackTokenName = "cli-fallback"
|
||||||
|
|
||||||
|
// initNodeTokenCrypto loads the process codec, preferring the key file over
|
||||||
|
// the environment and failing closed when an enabled policy lacks a key.
|
||||||
|
func initNodeTokenCrypto() error {
|
||||||
|
mode, err := nodetoken.ParseMode(config.GetNodeTokenEncryptionMode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mode == nodetoken.ModeOff {
|
||||||
|
c, _ := nodetoken.NewCodec(nodetoken.ModeOff, nil)
|
||||||
|
nodetoken.Init(c)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ring, ferr := (nodetoken.FileKeySource{Path: config.GetNodeTokenKeyFile()}).Load()
|
||||||
|
if ferr != nil {
|
||||||
|
var eerr error
|
||||||
|
if ring, eerr = (nodetoken.EnvKeySource{Var: config.GetNodeTokenKeyEnv()}).Load(); eerr != nil {
|
||||||
|
return fmt.Errorf("load node-token key: file: %w; env: %w", ferr, eerr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c, err := nodetoken.NewCodec(mode, ring)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nodetoken.Init(c)
|
||||||
|
// The CLI runs before package logger initialization, so use log.Printf.
|
||||||
|
log.Printf("node-token encryption enabled (mode=%s, active-key=%s)", config.GetNodeTokenEncryptionMode(), c.ActiveKeyID())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// runWebServer initializes and starts the web server for the 3x-ui panel.
|
// runWebServer initializes and starts the web server for the 3x-ui panel.
|
||||||
func runWebServer() {
|
func runWebServer() {
|
||||||
log.Printf("Starting %v %v", config.GetName(), config.GetPanelVersion())
|
log.Printf("Starting %v %v", config.GetName(), config.GetPanelVersion())
|
||||||
@@ -69,6 +99,10 @@ func runWebServer() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := initNodeTokenCrypto(); err != nil {
|
||||||
|
log.Fatalf("Error initializing node-token encryption: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB(config.GetDBPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error initializing database: %v", err)
|
log.Fatalf("Error initializing database: %v", err)
|
||||||
@@ -303,6 +337,26 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encryptNodeTokens re-encrypts stored tokens after enablement or rotation.
|
||||||
|
// It requires migration|required mode and a configured key.
|
||||||
|
func encryptNodeTokens() {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
if err := initNodeTokenCrypto(); err != nil {
|
||||||
|
fmt.Println("node-token encryption init failed:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := database.InitDB(config.GetDBPath()); err != nil {
|
||||||
|
fmt.Println("database initialization failed:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
changed, skipped, err := (&service.NodeService{}).MigrateNodeTokensToActiveKey()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("token migration failed:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("node-token migration complete: %d re-encrypted, %d already current/skipped\n", changed, skipped)
|
||||||
|
}
|
||||||
|
|
||||||
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
|
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
|
||||||
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) error {
|
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) error {
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB(config.GetDBPath())
|
||||||
@@ -573,12 +627,7 @@ func main() {
|
|||||||
oldUsage := flag.Usage
|
oldUsage := flag.Usage
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
oldUsage()
|
oldUsage()
|
||||||
fmt.Println()
|
fmt.Print(commandHelp())
|
||||||
fmt.Println("Commands:")
|
|
||||||
fmt.Println(" run run web panel")
|
|
||||||
fmt.Println(" migrate migrate from other/old x-ui")
|
|
||||||
fmt.Println(" migrate-db SQLite <-> .dump (--dump/--restore) or copy into PostgreSQL (--dsn)")
|
|
||||||
fmt.Println(" setting set settings")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -597,6 +646,8 @@ func main() {
|
|||||||
runWebServer()
|
runWebServer()
|
||||||
case "migrate":
|
case "migrate":
|
||||||
migrateDb()
|
migrateDb()
|
||||||
|
case "encrypt-tokens":
|
||||||
|
encryptNodeTokens()
|
||||||
case "migrate-db":
|
case "migrate-db":
|
||||||
if err := migrateDbCmd.Parse(os.Args[2:]); err != nil {
|
if err := migrateDbCmd.Parse(os.Args[2:]); err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
@@ -686,3 +737,14 @@ func main() {
|
|||||||
settingCmd.Usage()
|
settingCmd.Usage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func commandHelp() string {
|
||||||
|
return `
|
||||||
|
Commands:
|
||||||
|
run run web panel
|
||||||
|
migrate migrate from other/old x-ui
|
||||||
|
migrate-db SQLite <-> .dump (--dump/--restore) or copy into PostgreSQL (--dsn)
|
||||||
|
encrypt-tokens encrypt node bearer tokens with the configured active key
|
||||||
|
setting set settings
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommandHelpListsEncryptTokens(t *testing.T) {
|
||||||
|
if help := commandHelp(); !strings.Contains(help, "encrypt-tokens") {
|
||||||
|
t.Fatalf("command help omits encrypt-tokens:\n%s", help)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user