Files
clash-party/scripts/plugin/sign-discovery.mjs
ezequielnick a092eb50c3 feat(plugin): CPX v2 availability hardening (squash of cpx-v2-availability-hardening)
Client-side hardening of the CPX airport plugin, squashed from 46 commits
forked at 89c2bb0e. Behaviour changes:

- Unified operation model (§0.4/0.5): one deadline + one AbortSignal + one
  persistence commit per operation; per-plugin lock → vault lock hierarchy;
  tombstone on delete; every wait (lock, DNS preflight, proxy resolution,
  vault decrypt) is bounded by the same budget.
- Routing: direct/proxy auto-fallback with a pre-send guard; proxied https
  builds its own CONNECT tunnel (an aborted hung CONNECT closes its socket);
  invalid local-proxy ports are refused instead of falling back to :80; the
  core's inbound credentials are carried to the local proxy; NAT64 and
  site-local IPv6 ranges are non-public.
- Gateways: multi-gateway recovery with one rediscovery per operation,
  normalized endpoint paths, signed discovery documents (Ed25519, seq/digest
  accept/align/rollback/equivocation), commit order vault → plugin.yaml.
- Subscriptions: a fetched subscription is validated by the core (mihomo -t)
  against the current override set before it replaces the profile, inside
  the profile write critical section (profile.yaml and override.yaml share
  one write queue); schedule fields are read at write time; the first
  subscription is activated through the real switch flow; profile deletion
  removes the record last so any failure stays retryable.
- Devices: a re-login that replaces a still-valid device records it in the
  vault (staleDevices) and retires it after the login, after later
  successful fetches and on removal; enroll compensation restores the old
  vault and keeps an un-revoked new device for retirement.
- Vault: on Linux the vault is persisted only behind a system secret store
  (backend name + ciphertext-prefix canary); otherwise it stays in memory.
  A cache-miss read releases the caller at the budget while the lock is held
  until the decrypt ends.
- Config caches (plugin.yaml, profile.yaml, override.yaml) can no longer be
  rolled back by a late cold read.
- Reference gateway and provider guides updated (deploy contract, discoveryUrls
  same-origin rule, /revoke after re-login); https-proxy-agent dropped.

Reviewed in a Codex loop (gpt-6, 38 calls): 74 findings, 68 fixed and
verified, 6 invalid, no backlog. Tests: vitest 501, gateway 106.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 22:44:13 +08:00

90 lines
3.1 KiB
JavaScript

// Offline signer for the CPX v2 signed discovery document (integration guide §5a).
//
// Usage:
// node scripts/plugin/sign-discovery.mjs <payload.json> [--seed-file <path>] [--out <envelope.txt>]
//
// The Ed25519 seed (32 raw bytes, standard base64) is read from --seed-file or, when omitted, from
// stdin — never from the command line. Keep the seed offline; the gateway process only needs the
// resulting envelope file (DISCOVERY_SIGNED_FILE). Prints "<payloadB64>.<sigB64>" and the public key.
//
// The payload file is parsed and re-serialized compactly; the signature covers exactly those bytes
// (prefix "CPX2-DISCOVERY\0" || payloadBytes). No canonicalization is needed on either side.
import { readFileSync, writeFileSync } from 'fs'
import { createPrivateKey, createPublicKey, sign } from 'crypto'
import { validateDiscoveryPayload } from '../../deploy/gateway/src/discovery.mjs'
const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex')
const PREFIX = Buffer.from('CPX2-DISCOVERY\u0000', 'utf-8')
const MAX_PAYLOAD_BYTES = 4096
function die(msg) {
console.error(msg)
process.exit(1)
}
function keyFromSeed(seed) {
return createPrivateKey({
key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]),
format: 'der',
type: 'pkcs8'
})
}
function signDiscovery(payloadObject, seed) {
const payloadBytes = Buffer.from(JSON.stringify(payloadObject), 'utf-8')
if (payloadBytes.length > MAX_PAYLOAD_BYTES) {
throw new Error(`payload is ${payloadBytes.length} bytes; the limit is ${MAX_PAYLOAD_BYTES}`)
}
const priv = keyFromSeed(seed)
const sig = sign(null, Buffer.concat([PREFIX, payloadBytes]), priv)
const pub = Buffer.from(createPublicKey(priv).export({ format: 'jwk' }).x, 'base64url')
return {
signed: `${payloadBytes.toString('base64')}.${sig.toString('base64')}`,
pubKeyB64: pub.toString('base64')
}
}
const args = process.argv.slice(2)
let payloadPath
let seedFile
let outFile
for (let i = 0; i < args.length; i++) {
const a = args[i]
if (a === '--seed-file') seedFile = args[++i]
else if (a === '--out') outFile = args[++i]
else if (!payloadPath) payloadPath = a
else die(`unexpected argument: ${a}`)
}
if (!payloadPath) {
die('Usage: node sign-discovery.mjs <payload.json> [--seed-file <path>] [--out <envelope.txt>]')
}
const seedText = (seedFile ? readFileSync(seedFile, 'utf-8') : readFileSync(0, 'utf-8')).trim()
const seed = Buffer.from(seedText, 'base64')
if (seed.length !== 32 || seed.toString('base64') !== seedText) {
die('seed must be 32 raw bytes in standard base64 (with padding)')
}
let payload
try {
payload = JSON.parse(readFileSync(payloadPath, 'utf-8'))
} catch (e) {
die(`cannot parse ${payloadPath}: ${e.message}`)
}
// Same field rules as the client (discovery-sig.ts): fail here rather than after publication.
try {
payload = validateDiscoveryPayload(payload)
} catch (e) {
die(e.message)
}
let result
try {
result = signDiscovery(payload, seed)
} catch (e) {
die(e.message)
}
if (outFile) writeFileSync(outFile, result.signed + '\n')
console.log(result.signed)
console.error(`public key (providerPubKey): ${result.pubKeyB64}`)