From 1793a9b8b412533bed99b3d973809e452919bc0c Mon Sep 17 00:00:00 2001 From: n0ctal <4c866w5fn9@privaterelay.appleid.com> Date: Sat, 15 Aug 2026 19:48:50 +0500 Subject: [PATCH] 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> --- internal/config/config.go | 21 ++ internal/crypto/nodetoken/keysource.go | 115 +++++++++ internal/crypto/nodetoken/nodetoken.go | 236 ++++++++++++++++++ internal/crypto/nodetoken/nodetoken_test.go | 226 +++++++++++++++++ internal/web/runtime/remote.go | 7 +- internal/web/service/node.go | 126 +++++++++- .../web/service/node_token_encryption_test.go | 180 +++++++++++++ main.go | 74 +++++- main_test.go | 12 + 9 files changed, 982 insertions(+), 15 deletions(-) create mode 100644 internal/crypto/nodetoken/keysource.go create mode 100644 internal/crypto/nodetoken/nodetoken.go create mode 100644 internal/crypto/nodetoken/nodetoken_test.go create mode 100644 internal/web/service/node_token_encryption_test.go create mode 100644 main_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 0f6817b23..b853757d5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -193,6 +193,27 @@ func GetDBDSN() string { 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 // systemd loads via EnvironmentFile) across the supported distro families. func GetEnvFilePaths() []string { diff --git a/internal/crypto/nodetoken/keysource.go b/internal/crypto/nodetoken/keysource.go new file mode 100644 index 000000000..d1398ffbe --- /dev/null +++ b/internal/crypto/nodetoken/keysource.go @@ -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}) +} diff --git a/internal/crypto/nodetoken/nodetoken.go b/internal/crypto/nodetoken/nodetoken.go new file mode 100644 index 000000000..87d993a24 --- /dev/null +++ b/internal/crypto/nodetoken/nodetoken.go @@ -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() } diff --git a/internal/crypto/nodetoken/nodetoken_test.go b/internal/crypto/nodetoken/nodetoken_test.go new file mode 100644 index 000000000..5e1e076b9 --- /dev/null +++ b/internal/crypto/nodetoken/nodetoken_test.go @@ -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") + } +} diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index c1625f39d..be3221718 100644 --- a/internal/web/runtime/remote.go +++ b/internal/web/runtime/remote.go @@ -17,6 +17,7 @@ import ( "sync" "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/logger" "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 } 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") if contentType != "" { diff --git a/internal/web/service/node.go b/internal/web/service/node.go index 8977f4d24..95f02d992 100644 --- a/internal/web/service/node.go +++ b/internal/web/service/node.go @@ -17,6 +17,7 @@ import ( "sync" "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/model" "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 } +// 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) { db := database.GetDB() var nodes []*model.Node @@ -107,6 +125,9 @@ func (s *NodeService) GetAll() ([]*model.Node, error) { if err != nil || len(nodes) == 0 { return nodes, err } + for _, n := range nodes { + decryptToken(n) + } type inboundRow struct { 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 { return nil, err } + decryptToken(n) return n, nil } @@ -429,7 +451,29 @@ func (s *NodeService) Create(n *model.Node) error { return err } 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) { @@ -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 { 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{ "name": in.Name, "remark": in.Remark, @@ -463,7 +516,7 @@ func (s *NodeService) Update(id int, in *model.Node) error { "address": in.Address, "port": in.Port, "base_path": in.BasePath, - "api_token": in.ApiToken, + "api_token": apiToken, "enable": in.Enable, "allow_private_address": in.AllowPrivateAddress, "tls_verify_mode": in.TlsVerifyMode, @@ -508,7 +561,10 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error case req.ClearApiToken: apiToken = "" 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" { 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), "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 } - if dErr := s.MarkNodeDirty(id); dErr != nil { - logger.Warning("mark node dirty after update failed:", dErr) - } if mgr := runtime.GetManager(); mgr != nil { mgr.InvalidateNode(id) } @@ -590,6 +648,53 @@ func (s *NodeService) NodeFromRequestForCertificate(req *NodeMutationRequest) (* 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) { if err := s.normalize(n); err != nil { return nil, err @@ -1128,7 +1233,12 @@ func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) return patch, err } 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") diff --git a/internal/web/service/node_token_encryption_test.go b/internal/web/service/node_token_encryption_test.go new file mode 100644 index 000000000..f9fb0d47c --- /dev/null +++ b/internal/web/service/node_token_encryption_test.go @@ -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") + } +} diff --git a/main.go b/main.go index 27ff0501a..669a3c427 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( _ "unsafe" "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/logger" "github.com/mhsanaei/3x-ui/v3/internal/sub" @@ -35,6 +36,35 @@ import ( // cannot accumulate admin-equivalent credentials that are never revoked. 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. func runWebServer() { 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()) if err != nil { 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. func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) error { err := database.InitDB(config.GetDBPath()) @@ -573,12 +627,7 @@ func main() { oldUsage := flag.Usage flag.Usage = func() { oldUsage() - fmt.Println() - 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") + fmt.Print(commandHelp()) } flag.Parse() @@ -597,6 +646,8 @@ func main() { runWebServer() case "migrate": migrateDb() + case "encrypt-tokens": + encryptNodeTokens() case "migrate-db": if err := migrateDbCmd.Parse(os.Args[2:]); err != nil { fmt.Println(err) @@ -686,3 +737,14 @@ func main() { 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 +` +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 000000000..0d87091c5 --- /dev/null +++ b/main_test.go @@ -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) + } +}