@twinsearth/w2m-dsh-plugin 1.1.3 → 1.1.14

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.
@@ -22,6 +22,8 @@ import {
22
22
  RabbitState,
23
23
  rfc3339,
24
24
  } from './state.mjs';
25
+ import { createJoinChallenges } from './join-challenge.mjs';
26
+ import { createAdmission } from './admission.mjs';
25
27
  import { Persistence } from './persistence.mjs';
26
28
  import { PeerRegistry } from './peers.mjs';
27
29
  import { aggregateTask, buildReport } from './report.mjs';
@@ -733,6 +735,25 @@ export class RelayServer {
733
735
  return sendJson(res, 200, result);
734
736
  }
735
737
 
738
+ /* ---- v1.1.6: reachable WITHOUT a device token ---- */
739
+ this.joinChallenges ??= createJoinChallenges({
740
+ now: () => this.state.nowMs(),
741
+ publicKeyFor: (fingerprint) => this.state.accountKeyFor(fingerprint),
742
+ });
743
+ if (method === 'GET' && path === '/v1/fleet/admission/challenge') {
744
+ const issued = this.joinChallenges.issue(url.searchParams.get('fingerprint') ?? '', url.searchParams.get('machine_id') ?? '');
745
+ if (issued.ok !== true) throw new ProtocolError('BAD_REQUEST', 'challenge refused: ' + issued.code, { code: issued.code });
746
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...issued });
747
+ }
748
+ if (method === 'POST' && path === '/v1/fleet/admission/request') {
749
+ const body = takeJson();
750
+ this.admission ??= createAdmission({ now: () => this.state.nowMs() });
751
+ const proof = this.joinChallenges.consume({ challenge_id: body.challenge_id, machine_id: body.machine_id, signature: body.signature });
752
+ if (proof.ok !== true) throw new ProtocolError('UNAUTHORIZED', 'join proof refused: ' + proof.code, { code: proof.code });
753
+ const result = this.admission.requestAdmission({ machine_id: body.machine_id, machine_name: body.machine_name ?? null, account_fingerprint: proof.fingerprint });
754
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
755
+ }
756
+
736
757
  /* ---- everything below requires `Authorization: Bearer <device_token>` ---- */
737
758
  const token = bearerToken(req);
738
759
  const device = token ? this.state.authenticate(token) : null;
@@ -889,6 +910,11 @@ export class RelayServer {
889
910
  // v0.3.9: P2P candidate rendezvous. `/announce` is checked before the read route
890
911
  // so that a machine literally named "announce" cannot shadow it.
891
912
  const peerReadMatch = /^\/v1\/peer\/([^/]+)$/.exec(path);
913
+ /* ---- v1.1.6 admission ---- */
914
+ const admissionDecideMatch = /^\/v1\/fleet\/admission\/([^/]+)\/decide$/.exec(path);
915
+ const admissionOneMatch = /^\/v1\/fleet\/admission\/([^/]+)$/.exec(path);
916
+ const memberRemoveMatch = /^\/v1\/fleet\/members\/([^/]+)\/remove$/.exec(path);
917
+ const removalVoteMatch = /^\/v1\/fleet\/removals\/([^/]+)\/vote$/.exec(path);
892
918
 
893
919
  /* ---- v0.3.9 P2P signalling ---- */
894
920
  if (method === 'POST' && path === '/v1/peer/announce') {
@@ -967,6 +993,50 @@ export class RelayServer {
967
993
  });
968
994
  }
969
995
 
996
+
997
+ /* ---- v1.1.6 admission: the caller is always `device.machine_id`, never a body field ---- */
998
+ this.admission ??= createAdmission({ now: () => this.state.nowMs() });
999
+
1000
+ if (method === 'GET' && path === '/v1/fleet/admission/pending') {
1001
+ return sendJson(res, 200, {
1002
+ protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(),
1003
+ pending: this.admission.pendingFor(),
1004
+ });
1005
+ }
1006
+
1007
+ if (method === 'POST' && admissionDecideMatch) {
1008
+ const body = takeJson();
1009
+ const result = this.admission.decide(decodeURIComponent(admissionDecideMatch[1]), {
1010
+ decision: body.decision, decided_by: device.machine_id, auto: body.auto === true,
1011
+ });
1012
+ if (result.ok !== true) throw new ProtocolError('BAD_REQUEST', 'admission decision refused: ' + result.code, result);
1013
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
1014
+ }
1015
+
1016
+ if (method === 'GET' && admissionOneMatch) {
1017
+ const request = this.admission.getRequest(decodeURIComponent(admissionOneMatch[1]));
1018
+ if (request === null) throw new ProtocolError('NOT_FOUND', 'unknown admission request');
1019
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), request });
1020
+ }
1021
+
1022
+ if (method === 'POST' && memberRemoveMatch) {
1023
+ const body = takeJson();
1024
+ const result = this.admission.remove(decodeURIComponent(memberRemoveMatch[1]), {
1025
+ requested_by: device.machine_id, reason: typeof body.reason === 'string' ? body.reason : null,
1026
+ });
1027
+ if (result.ok !== true) throw new ProtocolError('BAD_REQUEST', 'removal refused: ' + result.code, result);
1028
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
1029
+ }
1030
+
1031
+ if (method === 'POST' && removalVoteMatch) {
1032
+ const body = takeJson();
1033
+ const result = this.admission.vote(decodeURIComponent(removalVoteMatch[1]), {
1034
+ machine_id: device.machine_id, vote: body.vote,
1035
+ });
1036
+ if (result.ok !== true) throw new ProtocolError('BAD_REQUEST', 'vote refused: ' + result.code, result);
1037
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
1038
+ }
1039
+
970
1040
  throw new ProtocolError('NOT_FOUND', `no route for ${method} ${path}`, { method, path });
971
1041
  }
972
1042
 
@@ -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
+ }