@twinsearth/w2m-dsh-plugin 1.1.3 → 1.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +105 -0
- package/codex/mcp-servers.toml +1 -1
- package/package.json +11 -4
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +17 -0
- package/src/plugin/account-join.mjs +86 -0
- package/src/plugin/join-degradation.mjs +34 -0
- package/src/plugin/tools.mjs +57 -7
- package/src/relay/admission.mjs +250 -0
- package/src/relay/join-challenge.mjs +92 -0
- package/src/relay/server.mjs +1496 -1426
- package/src/relay/state.mjs +46 -0
- package/src/util/account-identity.mjs +75 -0
- package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
- package/src/plugin/fleet-target.mjs.bak-archive +0 -812
package/src/relay/state.mjs
CHANGED
|
@@ -876,6 +876,31 @@ export class RabbitState {
|
|
|
876
876
|
}
|
|
877
877
|
|
|
878
878
|
/** §2.2 step 3: validate a one-time code. */
|
|
879
|
+
/**
|
|
880
|
+
* The account fingerprint -> public key registry (v1.1.6).
|
|
881
|
+
*
|
|
882
|
+
* Keyed by the FINGERPRINT, not by machine: every machine on one account shares the derived keypair, and the point of
|
|
883
|
+
* the registry is to answer "which key speaks for this account", once, for all of them.
|
|
884
|
+
*
|
|
885
|
+
* @returns {{registered: boolean, reason?: string}}
|
|
886
|
+
*/
|
|
887
|
+
registerAccountKey(fingerprint, publicKeyPem) {
|
|
888
|
+
if (typeof fingerprint !== 'string' || fingerprint === '') return { registered: false, reason: 'BAD_FINGERPRINT' };
|
|
889
|
+
if (typeof publicKeyPem !== 'string' || publicKeyPem === '') return { registered: false, reason: 'BAD_PUBLIC_KEY' };
|
|
890
|
+
this.accountKeys ??= new Map();
|
|
891
|
+
const existing = this.accountKeys.get(fingerprint);
|
|
892
|
+
if (existing !== undefined) {
|
|
893
|
+
return { registered: false, reason: existing === publicKeyPem ? 'ALREADY_REGISTERED' : 'KEY_MISMATCH' };
|
|
894
|
+
}
|
|
895
|
+
this.accountKeys.set(fingerprint, publicKeyPem);
|
|
896
|
+
return { registered: true };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** @returns {string|null} the key that speaks for this account, or null if no machine ever paired for it. */
|
|
900
|
+
accountKeyFor(fingerprint) {
|
|
901
|
+
return this.accountKeys?.get(fingerprint) ?? null;
|
|
902
|
+
}
|
|
903
|
+
|
|
879
904
|
validatePairingCode(code) {
|
|
880
905
|
if (typeof code !== 'string' || code.length === 0) {
|
|
881
906
|
throw new ProtocolError('PAIRING_INVALID', 'pairing_code is required');
|
|
@@ -987,6 +1012,27 @@ export class RabbitState {
|
|
|
987
1012
|
};
|
|
988
1013
|
this.devices.set(machineId, device);
|
|
989
1014
|
this.tokens.set(token, machineId);
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* v1.1.6: register the account's signing key while we have proof of the account.
|
|
1018
|
+
*
|
|
1019
|
+
* A machine that failed the old joining path must later prove the account to the relay. It can only do that against
|
|
1020
|
+
* a key the relay already trusts, and the only moment the relay has real proof ("this machine holds the account
|
|
1021
|
+
* material") is a successful pairing. So this is the trust anchor, and it is written HERE rather than at
|
|
1022
|
+
* registration time to keep the two paths honest: no pairing, no key.
|
|
1023
|
+
*
|
|
1024
|
+
* Idempotent on purpose: the first machine to pair for a fingerprint fixes the key. A second machine claiming the
|
|
1025
|
+
* same fingerprint with a DIFFERENT key is not silently accepted -- the registration is refused and reported, and
|
|
1026
|
+
* changing a key needs the fleet vote the removal flow already provides.
|
|
1027
|
+
*/
|
|
1028
|
+
const accountFingerprint = typeof request.account_fingerprint === 'string' ? request.account_fingerprint : '';
|
|
1029
|
+
const accountPublicKey = typeof request.account_public_key === 'string' ? request.account_public_key : '';
|
|
1030
|
+
const accountKey = accountFingerprint !== '' && accountPublicKey !== ''
|
|
1031
|
+
? this.registerAccountKey(accountFingerprint, accountPublicKey)
|
|
1032
|
+
: null;
|
|
1033
|
+
// Recorded on the device, not just discarded: a second machine claiming the same account with a different key is
|
|
1034
|
+
// refused, and the refusal must be visible in devices.json rather than looking like a successful registration.
|
|
1035
|
+
device.account_key = accountKey;
|
|
990
1036
|
rec.used = true;
|
|
991
1037
|
|
|
992
1038
|
// A group has more than one machine, and a single-use code would leave the
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account's own signing key.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* A fleet key is a **symmetric** secret: it proves "this machine is allowed into the fleet derived from this account",
|
|
7
|
+
* and it is the only account material the system had. That is enough for the old joining path and not enough for the
|
|
8
|
+
* new one -- a machine that failed to pair must *prove the account* to a relay that has never met it, and proof of that
|
|
9
|
+
* kind is a signature.
|
|
10
|
+
*
|
|
11
|
+
* So the account gains an asymmetric identity. It is DERIVED, not generated: the same account material yields the same
|
|
12
|
+
* keypair on every machine, which is what makes "zero configuration" possible at all -- a fresh install can sign with
|
|
13
|
+
* an identity nobody had to copy onto it. The matching public key is registered with the relay when the account's first
|
|
14
|
+
* machine pairs (see the relay's account-key registry), and later machines are verified against it.
|
|
15
|
+
*
|
|
16
|
+
* ## Ed25519 from a seed, without a new dependency
|
|
17
|
+
*
|
|
18
|
+
* Node can import an Ed25519 private key from PKCS#8 DER, and PKCS#8 for Ed25519 is a fixed 16-byte prefix followed by
|
|
19
|
+
* the 32-byte seed. That is the whole construction; no library, and the output is the standard RFC 8032 key, so any
|
|
20
|
+
* other verifier accepts it.
|
|
21
|
+
*
|
|
22
|
+
* The derivation domain-separates with a label, so a seed reused elsewhere cannot accidentally produce this key.
|
|
23
|
+
*/
|
|
24
|
+
import { createHash, createPrivateKey, createPublicKey, sign as signWith, verify as verifyWith } from 'node:crypto';
|
|
25
|
+
|
|
26
|
+
/** PKCS#8 prefix for Ed25519: OID 1.3.101.112 inside a OneAsymmetricKey with the seed as the private key. */
|
|
27
|
+
const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
28
|
+
/** Domain separation: this key exists only for fleet joining. */
|
|
29
|
+
const LABEL = 'w2m-account-identity-v1';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string|Buffer} accountMaterial the account's stable material (never sent anywhere by this function)
|
|
33
|
+
* @returns {{privateKey: import('node:crypto').KeyObject, publicKey: import('node:crypto').KeyObject,
|
|
34
|
+
* publicKeyPem: string, fingerprint: string}}
|
|
35
|
+
*/
|
|
36
|
+
export function deriveAccountKeypair(accountMaterial) {
|
|
37
|
+
if (typeof accountMaterial !== 'string' && !Buffer.isBuffer(accountMaterial)) {
|
|
38
|
+
throw new TypeError('accountMaterial must be a string or a Buffer');
|
|
39
|
+
}
|
|
40
|
+
const material = Buffer.isBuffer(accountMaterial) ? accountMaterial : Buffer.from(accountMaterial, 'utf8');
|
|
41
|
+
if (material.length === 0) throw new TypeError('accountMaterial must not be empty');
|
|
42
|
+
// Derive a 32-byte seed; the label is part of the input so this cannot collide with another use of the same material.
|
|
43
|
+
const seed = createHash('sha256').update(LABEL).update(Buffer.from([0x00])).update(material).digest();
|
|
44
|
+
const privateKey = createPrivateKey({
|
|
45
|
+
key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]),
|
|
46
|
+
format: 'der',
|
|
47
|
+
type: 'pkcs8',
|
|
48
|
+
});
|
|
49
|
+
const publicKey = createPublicKey(privateKey);
|
|
50
|
+
const publicKeyDer = publicKey.export({ type: 'spki', format: 'der' });
|
|
51
|
+
return {
|
|
52
|
+
privateKey,
|
|
53
|
+
publicKey,
|
|
54
|
+
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
|
55
|
+
// The fingerprint IS the public key, identified by hash: a relay can verify a signature against the key it holds
|
|
56
|
+
// and check that the hash matches, with no registration of the account anywhere.
|
|
57
|
+
fingerprint: createHash('sha256').update(publicKeyDer).digest('hex'),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Sign the challenge payload. The relay verifies; nothing here decides policy. */
|
|
62
|
+
export function signChallenge(identity, payload) {
|
|
63
|
+
const data = typeof payload === 'string' ? Buffer.from(payload, 'utf8') : Buffer.from(payload);
|
|
64
|
+
return signWith(null, data, identity.privateKey).toString('base64');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Verify a signature against a PEM public key. Returns a boolean: a thrown error here would be a 500 for a bad login. */
|
|
68
|
+
export function verifyChallenge(publicKeyPem, payload, signatureBase64) {
|
|
69
|
+
const data = typeof payload === 'string' ? Buffer.from(payload, 'utf8') : Buffer.from(payload);
|
|
70
|
+
try {
|
|
71
|
+
return verifyWith(null, data, createPublicKey(publicKeyPem), Buffer.from(String(signatureBase64), 'base64'));
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|