mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-15 15:50:59 +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:
@@ -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 != "" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user