feat: customer-level credentials shared across all nodes (item 2)
One credential set per customer (UUID + 32-byte root relay key + shortId)
lives on the customer record; node bindings no longer store credentials.
SS2022 user keys derive deterministically from the root key by method
length (16/24/32-byte prefix), so a single password serves every protocol,
matching 3x-ui's per-client credential model. Reality public keys stay a
node property and are shown per binding in the credentials pane.
- store: load-time convergence — first binding's set wins; a converged
password that is not a 32-byte root key is regenerated (uuid/shortId
preserved); bindings are stripped; idempotent, covered by tests
- deploy: the orchestrator stamps the customer's set onto a build copy;
subscriptions resolve the same set; the derivation applies in both the
inbound builder and the URI builder (verified live: Alice's N1 link is
byte-identical to the pre-change seed expectation, her N2 user key is
exactly the root key's 16-byte prefix)
- API: GET /api/customers/:id/credentials returns {credentials, bindings};
regenerate is now POST /api/customers/:id/credentials/regenerate and
rotates the whole set, rebuilding every deployed node of the customer
- UI: credentials pane shows the single shared set plus per-binding
node/protocol/Reality-public-key rows
- golden subscriptions fixture regenerated under one shared set (RF and
SS entries byte-identical to before; five entries audited by hand)
- golden baseline re-captured for the new shapes; difftest normalizer now
treats a base64 root key's prefix-truncation encodings and the Surge
'='-mangled variants as the same secret
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
// Tests for the customer-level credential model (round-2 item 2):
|
||||
// store-load convergence from legacy per-binding credentials, the
|
||||
// deterministic password rule, and the SS2022 user-key derivation.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrateCustomerCredentialsConvergesFirstBinding(t *testing.T) {
|
||||
d := &Data{
|
||||
Customers: []*Customer{{ID: "cus_1", Name: "Legacy"}},
|
||||
NodeClients: []*NodeClient{
|
||||
{ID: "ncl_1", NodeID: "nod_a", CustomerID: "cus_1", Credentials: &Credentials{
|
||||
ClientID: "11111111-2222-3333-4444-000000000001",
|
||||
// A 32-byte root key: must be kept verbatim.
|
||||
RelayPassword: base64.StdEncoding.EncodeToString(make([]byte, 32)),
|
||||
ShortID: "abcd0001ef01",
|
||||
}},
|
||||
{ID: "ncl_2", NodeID: "nod_b", CustomerID: "cus_1", Credentials: &Credentials{
|
||||
ClientID: "22222222-2222-3333-4444-000000000002", RelayPassword: "other", ShortID: "ffff0002ef01",
|
||||
}},
|
||||
},
|
||||
}
|
||||
if !MigrateCustomerCredentials(d) {
|
||||
t.Fatalf("migration reported no change")
|
||||
}
|
||||
creds := d.Customers[0].Credentials
|
||||
if creds == nil || creds.ClientID != "11111111-2222-3333-4444-000000000001" || creds.ShortID != "abcd0001ef01" {
|
||||
t.Fatalf("converged set is not the first binding's: %+v", creds)
|
||||
}
|
||||
if creds.RelayPassword != base64.StdEncoding.EncodeToString(make([]byte, 32)) {
|
||||
t.Fatalf("32-byte root key must be kept, got %q", creds.RelayPassword)
|
||||
}
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.Credentials != nil {
|
||||
t.Fatalf("binding %s still carries credentials", nc.ID)
|
||||
}
|
||||
}
|
||||
if MigrateCustomerCredentials(d) {
|
||||
t.Fatalf("migration must be idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateCustomerCredentialsRegeneratesShortPasswordOnly(t *testing.T) {
|
||||
d := &Data{
|
||||
Customers: []*Customer{{ID: "cus_1"}},
|
||||
NodeClients: []*NodeClient{
|
||||
{ID: "ncl_1", NodeID: "nod_a", CustomerID: "cus_1", Credentials: &Credentials{
|
||||
ClientID: "11111111-2222-3333-4444-000000000001",
|
||||
// 16-byte key (an old SS2022-aes128 binding set): too short
|
||||
// to derive a 32-byte user key from.
|
||||
RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==",
|
||||
ShortID: "abcd0001ef01",
|
||||
}},
|
||||
},
|
||||
}
|
||||
MigrateCustomerCredentials(d)
|
||||
creds := d.Customers[0].Credentials
|
||||
if creds.ClientID != "11111111-2222-3333-4444-000000000001" || creds.ShortID != "abcd0001ef01" {
|
||||
t.Fatalf("clientId/shortId must be preserved: %+v", creds)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(creds.RelayPassword)
|
||||
if err != nil || len(raw) != 32 {
|
||||
t.Fatalf("password must be regenerated as a 32-byte root key, got %q", creds.RelayPassword)
|
||||
}
|
||||
if creds.RelayPassword == "FRYXGBkaGxwdHh8gISIjJA==" {
|
||||
t.Fatalf("short password must not survive convergence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateCustomerCredentialsFreshForBindingless(t *testing.T) {
|
||||
d := &Data{Customers: []*Customer{{ID: "cus_new"}}}
|
||||
if !MigrateCustomerCredentials(d) {
|
||||
t.Fatalf("expected a change")
|
||||
}
|
||||
creds := d.Customers[0].Credentials
|
||||
if creds == nil || creds.ClientID == "" || creds.ShortID == "" {
|
||||
t.Fatalf("fresh set incomplete: %+v", creds)
|
||||
}
|
||||
if raw, err := base64.StdEncoding.DecodeString(creds.RelayPassword); err != nil || len(raw) != 32 {
|
||||
t.Fatalf("fresh set password is not a 32-byte root key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveRelayPassword(t *testing.T) {
|
||||
root := make([]byte, 32)
|
||||
for i := range root {
|
||||
root[i] = byte(i)
|
||||
}
|
||||
rootB64 := base64.StdEncoding.EncodeToString(root)
|
||||
// aes128 (16-byte method): deterministic prefix truncation.
|
||||
if got := EffectiveRelayPassword("shadowsocks-2022-aes128", rootB64); got != base64.StdEncoding.EncodeToString(root[:16]) {
|
||||
t.Fatalf("aes128 derivation wrong: %q", got)
|
||||
}
|
||||
// aes256: the root key itself.
|
||||
if got := EffectiveRelayPassword("shadowsocks-2022-aes256", rootB64); got != rootB64 {
|
||||
t.Fatalf("aes256 derivation wrong: %q", got)
|
||||
}
|
||||
// Legacy methods and other protocols: verbatim.
|
||||
if got := EffectiveRelayPassword("shadowsocks-aes256-gcm", rootB64); got != rootB64 {
|
||||
t.Fatalf("legacy SS must be verbatim")
|
||||
}
|
||||
if got := EffectiveRelayPassword("hysteria2", "plain-password"); got != "plain-password" {
|
||||
t.Fatalf("non-SS must be verbatim")
|
||||
}
|
||||
// An exact-length legacy key passes through unchanged (identity).
|
||||
k16 := "FRYXGBkaGxwdHh8gISIjJA=="
|
||||
if got := EffectiveRelayPassword("shadowsocks-2022-aes128", k16); got != k16 {
|
||||
t.Fatalf("16-byte key on aes128 must be identity, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCustomerCredentialsShape(t *testing.T) {
|
||||
c := NewCustomerCredentials()
|
||||
if c.ClientID == "" || c.ShortID == "" {
|
||||
t.Fatalf("incomplete set: %+v", c)
|
||||
}
|
||||
if raw, err := base64.StdEncoding.DecodeString(c.RelayPassword); err != nil || len(raw) != 32 {
|
||||
t.Fatalf("relay password must be a base64 32-byte root key")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user