mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/mihomo-party-org/clash-party.git
synced 2026-09-20 08:03:39 +08:00
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>
90 lines
3.1 KiB
JavaScript
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}`)
|