node_token_encryption_test.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. package service
  2. import (
  3. "errors"
  4. "strings"
  5. "testing"
  6. "gorm.io/gorm"
  7. "github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
  8. "github.com/mhsanaei/3x-ui/v3/internal/database"
  9. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  10. )
  11. // enableNodeTokenEncryption installs a test keyring and restores off mode so
  12. // the package-global codec cannot leak between tests.
  13. func enableNodeTokenEncryption(t *testing.T) {
  14. t.Helper()
  15. var k [32]byte
  16. for i := range k {
  17. k[i] = byte(i + 1)
  18. }
  19. ring := &nodetoken.Keyring{ActiveID: "t1", Keys: map[string][32]byte{"t1": k}}
  20. codec, err := nodetoken.NewCodec(nodetoken.ModeRequired, ring)
  21. if err != nil {
  22. t.Fatalf("new codec: %v", err)
  23. }
  24. nodetoken.Init(codec)
  25. t.Cleanup(func() {
  26. off, _ := nodetoken.NewCodec(nodetoken.ModeOff, nil)
  27. nodetoken.Init(off)
  28. })
  29. }
  30. func TestNodeToken_CreateNeverInsertsPlaintextTuple(t *testing.T) {
  31. setupConflictDB(t)
  32. enableNodeTokenEncryption(t)
  33. db := database.GetDB()
  34. const callback = "test:no-plaintext-node-insert"
  35. if err := db.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) {
  36. if node, ok := tx.Statement.Dest.(*model.Node); ok && node.ApiToken != "" {
  37. tx.AddError(errors.New("plaintext token reached node INSERT"))
  38. }
  39. }); err != nil {
  40. t.Fatalf("register callback: %v", err)
  41. }
  42. t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
  43. n := &model.Node{Name: "no-plain", Address: "127.0.0.1", Port: 2096, ApiToken: "secret", Enable: true}
  44. if err := (&NodeService{}).Create(n); err != nil {
  45. t.Fatalf("Create: %v", err)
  46. }
  47. if n.ApiToken != "secret" {
  48. t.Fatalf("in-memory token = %q, want plaintext response value", n.ApiToken)
  49. }
  50. }
  51. func rawStoredToken(t *testing.T, id int) string {
  52. t.Helper()
  53. var n model.Node
  54. if err := database.GetDB().Model(model.Node{}).Where("id = ?", id).First(&n).Error; err != nil {
  55. t.Fatalf("raw load: %v", err)
  56. }
  57. return n.ApiToken
  58. }
  59. // Create stores the token encrypted at rest; GetById returns it decrypted.
  60. func TestNodeToken_EncryptedAtRest_PlaintextInMemory(t *testing.T) {
  61. setupConflictDB(t)
  62. enableNodeTokenEncryption(t)
  63. svc := &NodeService{}
  64. n := &model.Node{Name: "enc1", Address: "127.0.0.1", Port: 2096, ApiToken: "super-secret", Enable: true}
  65. if err := svc.Create(n); err != nil {
  66. t.Fatalf("create: %v", err)
  67. }
  68. stored := rawStoredToken(t, n.Id)
  69. if !nodetoken.IsEncrypted(stored) {
  70. t.Fatalf("token at rest is not encrypted: %q", stored)
  71. }
  72. if strings.Contains(stored, "super-secret") {
  73. t.Fatalf("plaintext leaked into stored column: %q", stored)
  74. }
  75. got, err := svc.GetById(n.Id)
  76. if err != nil {
  77. t.Fatalf("get: %v", err)
  78. }
  79. if got.ApiToken != "super-secret" {
  80. t.Fatalf("GetById should return plaintext, got %q", got.ApiToken)
  81. }
  82. }
  83. // A blank token on Update keeps the stored one (the UI doesn't echo secrets).
  84. func TestNodeToken_UpdateBlankKeepsExisting(t *testing.T) {
  85. setupConflictDB(t)
  86. enableNodeTokenEncryption(t)
  87. svc := &NodeService{}
  88. n := &model.Node{Name: "enc2", Address: "127.0.0.1", Port: 2096, ApiToken: "keep-me", Enable: true}
  89. if err := svc.Create(n); err != nil {
  90. t.Fatalf("create: %v", err)
  91. }
  92. before := rawStoredToken(t, n.Id)
  93. // Update with empty token must not wipe or change the stored ciphertext.
  94. upd := &model.Node{Name: "enc2-renamed", Address: "127.0.0.1", Port: 2096, ApiToken: "", Enable: true}
  95. if err := svc.Update(n.Id, upd); err != nil {
  96. t.Fatalf("update: %v", err)
  97. }
  98. if after := rawStoredToken(t, n.Id); after != before {
  99. t.Fatalf("blank-token update changed stored token: %q -> %q", before, after)
  100. }
  101. got, _ := svc.GetById(n.Id)
  102. if got.ApiToken != "keep-me" {
  103. t.Fatalf("token lost after blank update, got %q", got.ApiToken)
  104. }
  105. if got.Name != "enc2-renamed" {
  106. t.Fatalf("other fields should still update, got name %q", got.Name)
  107. }
  108. }
  109. // The migration re-encrypts a legacy plaintext row under the active key (CAS).
  110. func TestNodeToken_MigratePlaintextRows(t *testing.T) {
  111. setupConflictDB(t)
  112. // Insert a legacy plaintext row directly (encryption off at insert time).
  113. db := database.GetDB()
  114. legacy := &model.Node{Name: "legacy", Address: "127.0.0.1", Port: 2096, ApiToken: "legacy-plain", Enable: true}
  115. if err := db.Create(legacy).Error; err != nil {
  116. t.Fatalf("create legacy: %v", err)
  117. }
  118. if rawStoredToken(t, legacy.Id) != "legacy-plain" {
  119. t.Fatal("precondition: legacy row should be plaintext")
  120. }
  121. enableNodeTokenEncryption(t)
  122. changed, _, err := (&NodeService{}).MigrateNodeTokensToActiveKey()
  123. if err != nil {
  124. t.Fatalf("migrate: %v", err)
  125. }
  126. if changed != 1 {
  127. t.Fatalf("expected 1 row re-encrypted, got %d", changed)
  128. }
  129. if stored := rawStoredToken(t, legacy.Id); !nodetoken.IsEncrypted(stored) {
  130. t.Fatalf("legacy row not encrypted after migration: %q", stored)
  131. }
  132. got, _ := (&NodeService{}).GetById(legacy.Id)
  133. if got.ApiToken != "legacy-plain" {
  134. t.Fatalf("migrated token no longer decrypts to original: %q", got.ApiToken)
  135. }
  136. // Idempotent: a second run changes nothing.
  137. changed2, _, _ := (&NodeService{}).MigrateNodeTokensToActiveKey()
  138. if changed2 != 0 {
  139. t.Fatalf("second migration should be a no-op, changed %d", changed2)
  140. }
  141. }
  142. func TestNodeToken_MigrationRejectsCorruptActiveCiphertext(t *testing.T) {
  143. setupConflictDB(t)
  144. enableNodeTokenEncryption(t)
  145. n := &model.Node{Name: "corrupt", Address: "127.0.0.1", Port: 2096, ApiToken: "secret", Enable: true}
  146. if err := (&NodeService{}).Create(n); err != nil {
  147. t.Fatalf("Create: %v", err)
  148. }
  149. stored := rawStoredToken(t, n.Id)
  150. body := strings.LastIndexByte(stored, ':') + 1
  151. replacement := byte('A')
  152. if stored[body] == replacement {
  153. replacement = 'B'
  154. }
  155. corrupt := stored[:body] + string(replacement) + stored[body+1:]
  156. if err := database.GetDB().Model(&model.Node{}).Where("id = ?", n.Id).Update("api_token", corrupt).Error; err != nil {
  157. t.Fatalf("corrupt row: %v", err)
  158. }
  159. if _, _, err := (&NodeService{}).MigrateNodeTokensToActiveKey(); err == nil {
  160. t.Fatal("migration trusted a corrupt active-key ciphertext")
  161. }
  162. }