@twinsearth/w2m-dsh-plugin 0.5.2 → 0.5.4

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.
@@ -20,6 +20,15 @@
20
20
  * * **Bounded.** Candidate count, address shape and TTL are all validated, because
21
21
  * this is the one place where a peer supplies an address another peer will send
22
22
  * UDP datagrams to.
23
+ * * **The static public key is a *distribution* channel, not an authority (v0.6.0).** A machine
24
+ * announces its own X25519 public key with its candidates, and a peer reads it back — which is
25
+ * what removes the `[Peer]` block nobody wants to fill in by hand. What the relay can and cannot
26
+ * do here is worth stating plainly: it can substitute a key, because a peer learns the key from
27
+ * the relay's answer (or from the dispatcher's offer, which the relay also carries verbatim). It
28
+ * cannot *use* a substituted key, because a handshake needs the matching private half, so a
29
+ * substituted key produces a refusal rather than a silent interception. §8 of
30
+ * `docs/P2P-CRYPTO.md` names the cheap improvement — a key-change notice — and leaves it for
31
+ * stage 2; until then a changed key is visible as `P2P_CRYPTO_BAD_MAC` rather than as silence.
23
32
  */
24
33
 
25
34
  /** Most candidates a single machine may announce. Enough for several interfaces, not for a scan. */
@@ -31,6 +40,33 @@ export const DEFAULT_TTL_MS = 60_000;
31
40
  /** Upper bound a caller may request, so an announcement cannot be pinned indefinitely. */
32
41
  export const MAX_TTL_MS = 10 * 60_000;
33
42
 
43
+ /**
44
+ * Bytes in an X25519 public key (v0.6.0).
45
+ *
46
+ * The key itself is base64, so this is what the validator decodes to. Kept as a literal rather than
47
+ * imported from `src/agent/crypto.mjs` for the same reason `P2P_MODES` is duplicated in
48
+ * `state.mjs`: the relay is a standalone process that must not depend on an agent module, and the
49
+ * value is a wire fact two independently deployed ends have to agree on.
50
+ */
51
+ export const STATIC_KEY_BYTES = 32;
52
+
53
+ /**
54
+ * Is `value` a usable static public key?
55
+ *
56
+ * Strict about the encoding, because a key that "almost" decodes is the input to a MAC that fails for
57
+ * a reason indistinguishable from an attack. `Buffer.from(x, 'base64')` ignores characters it does
58
+ * not recognise, so the decode is only trusted after re-encoding it and getting the same string back.
59
+ *
60
+ * @param {unknown} value
61
+ * @returns {boolean}
62
+ */
63
+ export function isStaticKey(value) {
64
+ if (typeof value !== 'string' || value === '') return false;
65
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return false;
66
+ const decoded = Buffer.from(value, 'base64');
67
+ return decoded.length === STATIC_KEY_BYTES && decoded.toString('base64') === value;
68
+ }
69
+
34
70
  const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
35
71
 
36
72
  /**
@@ -112,10 +148,26 @@ export class PeerRegistry {
112
148
  const ttlMs = Math.max(1000, Math.min(requestedTtl, MAX_TTL_MS));
113
149
  const now = this.nowMs();
114
150
 
151
+ // v0.6.0: the machine's static public key, when it sent one. Validated here because this is the
152
+ // one place a peer supplies a value another peer will use to authenticate it — a malformed key
153
+ // stored and served would make a handshake fail with a MAC error that looks exactly like an
154
+ // attack. Absent (a v0.5.x machine, or one with no key) stays absent rather than becoming `null`:
155
+ // "this machine has no key" and "this machine's key is unknown" must not be the same value.
156
+ let staticKey = null;
157
+ if (body.static_key !== undefined && body.static_key !== null) {
158
+ if (!isStaticKey(body.static_key)) {
159
+ throw Object.assign(new Error('static_key is not a 32-byte base64 X25519 public key'), {
160
+ code: 'BAD_STATIC_KEY',
161
+ });
162
+ }
163
+ staticKey = body.static_key;
164
+ }
165
+
115
166
  const entry = {
116
167
  machine_id: machineId,
117
168
  candidates,
118
169
  nat: { mapping: typeof body.nat?.mapping === 'string' ? body.nat.mapping : null },
170
+ static_key: staticKey,
119
171
  announced_at_ms: now,
120
172
  expires_at_ms: now + ttlMs,
121
173
  };
@@ -124,6 +176,7 @@ export class PeerRegistry {
124
176
  return {
125
177
  machine_id: machineId,
126
178
  candidates,
179
+ ...(staticKey === null ? {} : { static_key: staticKey }),
127
180
  expires_at: new Date(entry.expires_at_ms).toISOString(),
128
181
  ttl_ms: ttlMs,
129
182
  };
@@ -148,6 +201,9 @@ export class PeerRegistry {
148
201
  machine_id: entry.machine_id,
149
202
  candidates: entry.candidates.map((c) => ({ ...c })),
150
203
  nat: { ...entry.nat },
204
+ ...(entry.static_key === null || entry.static_key === undefined
205
+ ? {}
206
+ : { static_key: entry.static_key }),
151
207
  announced_at: new Date(entry.announced_at_ms).toISOString(),
152
208
  expires_at: new Date(entry.expires_at_ms).toISOString(),
153
209
  };