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:
@@ -392,11 +392,30 @@ export class Normalizer {
|
||||
if (this.panelOrigin) out = out.split(this.panelOrigin).join('<panel>');
|
||||
// Learned secrets first (longest raw first so a full token wins over its
|
||||
// own prefixes), including the 16-char prefix shown on the info page.
|
||||
const secrets = [...this.secrets.entries()].sort((a, b) => b[0].length - a[0].length);
|
||||
for (const [raw, placeholder] of secrets) {
|
||||
if (raw.length >= 8) out = out.split(raw).join(placeholder);
|
||||
if (raw.length > 16) out = out.split(raw.slice(0, 16)).join(placeholder);
|
||||
// A base64 secret's prefix truncations (the SS2022 user keys derived
|
||||
// from a customer's 32-byte root key) are the same logical secret, so
|
||||
// their encodings are replaced with the same placeholder.
|
||||
const replacements = [];
|
||||
for (const [raw, placeholder] of this.secrets) {
|
||||
if (raw.length >= 8) replacements.push([raw, placeholder]);
|
||||
if (raw.length > 16) replacements.push([raw.slice(0, 16), placeholder]);
|
||||
if (/^[A-Za-z0-9+/]+={0,2}$/.test(raw) && raw.length % 4 === 0) {
|
||||
const bytes = Buffer.from(raw, 'base64');
|
||||
if (bytes.toString('base64') === raw) {
|
||||
for (const n of [16, 24]) {
|
||||
if (n < bytes.length) replacements.push([bytes.subarray(0, n).toString('base64'), placeholder]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The Surge formatter mangles '=' to '_' inside passwords, so the
|
||||
// mangled variant of every replacement is the same secret too.
|
||||
for (const [raw, placeholder] of [...replacements]) {
|
||||
const mangled = raw.replace(/=/g, '_');
|
||||
if (mangled !== raw) replacements.push([mangled, placeholder]);
|
||||
}
|
||||
replacements.sort((a, b) => b[0].length - a[0].length);
|
||||
for (const [raw, placeholder] of replacements) out = out.split(raw).join(placeholder);
|
||||
for (const [raw, placeholder] of this.hashes) out = out.split(raw).join(placeholder);
|
||||
out = out.replace(PREFIXED_ID_RE, (whole, prefix) => this.#idPlaceholder(prefix, whole));
|
||||
for (const [fragment, placeholder] of this.fragments) out = out.split(fragment).join(placeholder);
|
||||
|
||||
Reference in New Issue
Block a user